From f1449285baff1ab844782b1ac887afc5ac8ab8bd Mon Sep 17 00:00:00 2001 From: ske087 Date: Wed, 13 May 2026 16:36:17 +0300 Subject: [PATCH] feat: WMT client versioning, release management and force-update playbook - api/wmt.py: add GET /api/wmt/client/version and GET /api/wmt/client/download endpoints; rewrite submit_update_request with dedup logic - web/wmt.py: add releases, releases_upload, releases_delete, releases_build routes; build-from-folder excludes hidden/data/venv/pyc files - web/main.py: admin per-device delete route; clear-device-logs route; pass devices list to admin template - templates/wmt/releases.html: new release management page (current release info, upload form, build-from-folder card) - templates/admin.html: replace nuclear clear-devices with clear-logs + per-device delete table - templates/base.html: add Client Releases nav link in WMT sidebar section - templates/ansible/execute.html: add Update WMT Code playbook card - ansible/playbooks/update_wmt_code.yml: rsync WMT_project to clients excluding data/; backs up app.py; restarts wmt service - ansible_service.py: register update_wmt_code description - .gitignore: whitelist update_wmt_code.yml --- .gitignore | 1 + ansible/playbooks/update_wmt_code.yml | 306 ++++++++++++++++++++++++++ app/api/wmt.py | 128 +++++++++-- app/services/ansible_service.py | 1 + app/web/main.py | 49 ++++- app/web/wmt.py | 201 ++++++++++++++++- templates/admin.html | 117 ++++++++-- templates/ansible/execute.html | 13 +- templates/base.html | 6 + templates/wmt/releases.html | 196 +++++++++++++++++ 10 files changed, 978 insertions(+), 40 deletions(-) create mode 100644 ansible/playbooks/update_wmt_code.yml create mode 100644 templates/wmt/releases.html diff --git a/.gitignore b/.gitignore index 4d54272..b28d1fe 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ ansible/playbooks/*.yml !ansible/playbooks/restart_service.yml !ansible/playbooks/migrate_to_wmt.yml !ansible/playbooks/Update_Rest_WMT_client.yml +!ansible/playbooks/update_wmt_code.yml # VS Code .vscode/ diff --git a/ansible/playbooks/update_wmt_code.yml b/ansible/playbooks/update_wmt_code.yml new file mode 100644 index 0000000..dfb8142 --- /dev/null +++ b/ansible/playbooks/update_wmt_code.yml @@ -0,0 +1,306 @@ +--- +# Update WMT client code from the controller's WMT_project folder +# ────────────────────────────────────────────────────────────────────────── +# Use this for devices that have not yet received the HTTP auto-update, +# or whenever you need to force a code push from the server. +# +# What this playbook does: +# 1. Ensure WMT directory exists on the target +# 2. Back up the current app.py as app.py.bak. +# 3. Copy everything from /home/pi/Desktop/WMT_project/ on the CONTROLLER +# → /home/pi/Desktop/WMT/ on the TARGET +# The data/ directory on the target is fully preserved (never touched) +# 4. Fix file ownership +# 5. Restart the wmt systemd service +# +# The data/ directory (config.txt, idmasa.txt, tag.txt, log.txt, device_info.txt) +# is intentionally excluded — device-specific settings stay intact. +# +# Run via: Ansible > Playbooks > "Update WMT Code" +# ────────────────────────────────────────────────────────────────────────── + +- name: Update WMT client code from WMT_project folder + hosts: all + gather_facts: false + become: false + + vars: + controller_src: /home/pi/Desktop/WMT_project/ + wmt_dir: /home/pi/Desktop/WMT + + tasks: + + # ── 1. Ensure WMT directory exists ──────────────────────────────────── + - name: Ensure WMT directory exists on target + file: + path: "{{ wmt_dir }}" + state: directory + owner: pi + group: pi + mode: '0755' + + # ── 2. Back up current app.py ───────────────────────────────────────── + - name: Read first line of current app.py (for backup filename) + shell: head -1 {{ wmt_dir }}/app.py 2>/dev/null || echo "unknown" + register: local_first_line + changed_when: false + ignore_errors: true + + - name: Extract local version number + set_fact: + local_version: >- + {{ local_first_line.stdout + | regex_search('version\s+([\d.]+)', '\1') + | first | default('old') }} + + - name: Back up current app.py + copy: + src: "{{ wmt_dir }}/app.py" + dest: "{{ wmt_dir }}/app.py.bak.{{ local_version }}" + remote_src: true + owner: pi + group: pi + mode: preserve + ignore_errors: true + + - name: Show backup info + debug: + msg: "Backed up app.py v{{ local_version }} → app.py.bak.{{ local_version }}" + + # ── 3. Snapshot data/ before copy (audit) ──────────────────────────── + - name: List current data/ files (audit) + shell: ls -1 {{ wmt_dir }}/data/ 2>/dev/null || echo "(empty or missing)" + register: data_files_before + changed_when: false + + - name: Show data/ files that will be preserved + debug: + msg: "data/ contents (will NOT be changed): {{ data_files_before.stdout_lines }}" + + # ── 4. Sync WMT_project → WMT on target, excluding data/ ───────────── + # synchronize uses rsync under the hood; delegate_to pushes + # from the controller to the target. + - name: Sync WMT_project to target (exclude data/ and junk files) + synchronize: + src: "{{ controller_src }}" + dest: "{{ wmt_dir }}/" + recursive: true + delete: false + checksum: true + rsync_opts: + - "--exclude=data/" + - "--exclude=.git/" + - "--exclude=.gitignore" + - "--exclude=__pycache__/" + - "--exclude=*.pyc" + - "--exclude=*.pyo" + - "--exclude=*.log" + - "--exclude=*.bak" + - "--exclude=venv/" + - "--exclude=.venv/" + - "--exclude=node_modules/" + - "--exclude=.*" + register: sync_result + + - name: Show sync summary + debug: + msg: "Sync completed. Changed: {{ sync_result.changed }}" + + # ── 5. Fix ownership ────────────────────────────────────────────────── + - name: Set correct ownership on WMT directory + become: true + file: + path: "{{ wmt_dir }}" + owner: pi + group: pi + recurse: true + + # ── 6. Verify data/ is still intact ────────────────────────────────── + - name: List data/ files after update (verification) + shell: ls -1 {{ wmt_dir }}/data/ 2>/dev/null || echo "(empty)" + register: data_files_after + changed_when: false + + - name: Show data/ contents after update (should match before) + debug: + msg: "{{ data_files_after.stdout_lines }}" + + # ── 7. Restart WMT service ──────────────────────────────────────────── + - name: Restart WMT systemd service + become: true + systemd: + name: wmt + state: restarted + enabled: true + register: service_result + ignore_errors: true + + - name: Show service state + debug: + msg: "WMT service state: {{ service_result.status.ActiveState | default('unknown') }}" + when: service_result is not failed + + - name: Warn if service restart failed + debug: + msg: "WARNING: wmt service restart failed – the device may need a manual reboot." + when: service_result is failed + + vars: + wmt_dir: /home/pi/Desktop/WMT + tmp_zip: /tmp/wmt_update.zip + # Controller address – override on CLI with -e "server_url=http://..." + server_url: "http://{{ hostvars[inventory_hostname]['ansible_host'] | default(ansible_host) | regex_replace('\\d+\\.\\d+$', '10.76.157.1') }}" + + tasks: + + # ── 0. Resolve server URL ───────────────────────────────────────────── + # The monitoring server address is read from the device's own config.txt + # so we don't have to hard-code it here. + - name: Read server_host from WMT config.txt + shell: | + grep -E '^\s*server_host\s*=' {{ wmt_dir }}/data/config.txt 2>/dev/null \ + | head -1 | awk -F'=' '{print $2}' | tr -d ' \r\n' + register: cfg_server_host + changed_when: false + ignore_errors: true + + - name: Read server_port from WMT config.txt + shell: | + grep -E '^\s*server_port\s*=' {{ wmt_dir }}/data/config.txt 2>/dev/null \ + | head -1 | awk -F'=' '{print $2}' | tr -d ' \r\n' + register: cfg_server_port + changed_when: false + ignore_errors: true + + - name: Set monitoring server base URL + set_fact: + monitoring_base: "http://{{ cfg_server_host.stdout | default('rpi-ansible') }}:{{ cfg_server_port.stdout | default('5000') }}" + + - name: Show resolved server URL + debug: + msg: "Monitoring server: {{ monitoring_base }}" + + # ── 1. Check latest version on server ──────────────────────────────── + - name: Query latest WMT version from monitoring server + uri: + url: "{{ monitoring_base }}/api/wmt/client/version" + method: GET + return_content: true + timeout: 15 + register: version_response + ignore_errors: true + + - name: Show server version info + debug: + msg: "Server release: v{{ version_response.json.version | default('unknown') }} ({{ version_response.json.filename | default('n/a') }})" + when: version_response is not failed + + - name: Fail if server version endpoint unreachable + fail: + msg: "Cannot reach {{ monitoring_base }}/api/wmt/client/version – is the server running?" + when: version_response is failed + + # ── 2. Get current local version ───────────────────────────────────── + - name: Read first line of local app.py + shell: head -1 {{ wmt_dir }}/app.py 2>/dev/null || echo "unknown" + register: local_first_line + changed_when: false + + - name: Extract local version number + set_fact: + local_version: "{{ local_first_line.stdout | regex_search('version\\s+([\\d.]+)', '\\1') | first | default('0') }}" + + - name: Show local version + debug: + msg: "Local version: {{ local_version }} | Server version: {{ version_response.json.version }}" + + # ── 3. Ensure WMT directory exists ─────────────────────────────────── + - name: Ensure WMT directory exists + file: + path: "{{ wmt_dir }}" + state: directory + owner: pi + group: pi + mode: '0755' + + # ── 4. Download release zip ─────────────────────────────────────────── + - name: Download WMT release zip from monitoring server + get_url: + url: "{{ monitoring_base }}/api/wmt/client/download" + dest: "{{ tmp_zip }}" + force: true + timeout: 120 + mode: '0644' + + # ── 5. Back up current app.py ───────────────────────────────────────── + - name: Back up current app.py + copy: + src: "{{ wmt_dir }}/app.py" + dest: "{{ wmt_dir }}/app.py.bak.{{ local_version }}" + remote_src: true + owner: pi + group: pi + mode: preserve + ignore_errors: true + + # ── 6. Extract zip – skip data/ directory ───────────────────────────── + - name: Extract WMT release zip (preserving data/ directory) + shell: | + cd {{ wmt_dir }} + python3 - <<'EOF' + import zipfile, os, sys + zip_path = "{{ tmp_zip }}" + dest = "{{ wmt_dir }}" + skipped = 0 + extracted = 0 + with zipfile.ZipFile(zip_path, 'r') as zf: + for member in zf.infolist(): + p = member.filename.replace('\\', '/') + if p.startswith('data/') or p == 'data': + skipped += 1 + continue + zf.extract(member, dest) + extracted += 1 + print(f"Extracted {extracted} files, skipped {skipped} data/ entries") + EOF + register: extract_result + changed_when: true + + - name: Show extraction result + debug: + msg: "{{ extract_result.stdout }}" + + # ── 7. Fix ownership ────────────────────────────────────────────────── + - name: Set correct ownership on WMT directory + become: true + file: + path: "{{ wmt_dir }}" + owner: pi + group: pi + recurse: true + + # ── 8. Clean up temp zip ────────────────────────────────────────────── + - name: Remove temporary zip file + file: + path: "{{ tmp_zip }}" + state: absent + + # ── 9. Restart WMT service ──────────────────────────────────────────── + - name: Restart WMT systemd service + become: true + systemd: + name: wmt + state: restarted + enabled: true + register: service_result + ignore_errors: true + + - name: Show service restart result + debug: + msg: "Service state: {{ service_result.status.ActiveState | default('unknown') }}" + when: service_result is not failed + + - name: Warn if service restart failed + debug: + msg: "WARNING: wmt service restart failed – the device may need a manual reboot." + when: service_result is failed diff --git a/app/api/wmt.py b/app/api/wmt.py index d61039d..a618ae1 100644 --- a/app/api/wmt.py +++ b/app/api/wmt.py @@ -2,8 +2,10 @@ WMT (Workstation Management Terminal) configuration API Handles config distribution and device update requests from WMT clients. """ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, send_file from datetime import datetime +from pathlib import Path +import json from app.models import WMTGlobalConfig, Device, WMTUpdateRequest from config.database_config import get_db import logging @@ -125,7 +127,11 @@ def get_device_config(mac_address): @wmt_api_bp.route('/config/update_request', methods=['POST']) def submit_update_request(): """ - WMT client sends current device info as an update request for admin approval. + WMT client sends current device info. Three outcomes: + + 1. Device not registered → create pending WMTUpdateRequest for admin approval. + 2. Device registered, info UNCHANGED → update last_seen only; no request created. + 3. Device registered, info CHANGED → create/refresh a pending WMTUpdateRequest. Expected JSON: { @@ -133,6 +139,7 @@ def submit_update_request(): "device_name": "Masa-01", "hostname": "rpi-masa01", "device_ip": "192.168.1.100", + "card_presence": "enable", // optional – applied directly, no approval "client_config_mtime": "2026-04-22T09:30:00" // optional } """ @@ -144,31 +151,126 @@ def submit_update_request(): if not mac: return jsonify({'error': 'mac_address is required'}), 400 + proposed_name = (data.get('device_name') or '').strip() + proposed_hostname = (data.get('hostname') or '').strip() + proposed_ip = (data.get('device_ip') or '').strip() + card_presence = data.get('card_presence') + + def _eq(a, b): + """Compare two values treating None and '' as equivalent.""" + return (a or '') == (b or '') + try: with get_db().get_session() as session: device = session.query(Device).filter_by(mac_address=mac).first() + # Always update last_seen and card_presence (no approval needed) + if device: + device.last_seen = datetime.utcnow() + if card_presence in ('enable', 'disable'): + device.card_presence = card_presence + + # --- Determine whether the proposed info differs from the server record --- + if device: + info_changed = not ( + _eq(proposed_name, device.nume_masa) and + _eq(proposed_hostname, device.hostname) and + _eq(proposed_ip, device.device_ip) + ) + else: + # Unknown device – always needs admin attention + info_changed = True + + if not info_changed: + # Heartbeat only – nothing for admin to review + logger.debug(f'WMT heartbeat from {mac} – info unchanged, last_seen updated') + return jsonify({'status': 'no_change', 'message': 'Device info matches server record'}), 200 + + # --- Info changed (or device unknown): avoid duplicate pending requests --- + existing = ( + session.query(WMTUpdateRequest) + .filter_by(mac_address=mac, status='pending') + .order_by(WMTUpdateRequest.submitted_at.desc()) + .first() + ) + if existing and ( + _eq(existing.proposed_device_name, proposed_name) and + _eq(existing.proposed_hostname, proposed_hostname) and + _eq(existing.proposed_device_ip, proposed_ip) + ): + # Identical pending request already exists – just refresh its timestamp + existing.submitted_at = datetime.utcnow() + logger.debug(f'WMT duplicate pending request from {mac} – timestamp refreshed') + return jsonify({'status': 'pending', 'message': 'Update request already pending admin review'}), 200 + + # Create a new update request req = WMTUpdateRequest( mac_address=mac, device_id=device.id if device else None, - proposed_device_name=data.get('device_name'), - proposed_hostname=data.get('hostname'), - proposed_device_ip=data.get('device_ip'), + proposed_device_name=proposed_name or None, + proposed_hostname=proposed_hostname or None, + proposed_device_ip=proposed_ip or None, client_config_mtime=data.get('client_config_mtime'), submitted_at=datetime.utcnow(), status='pending', ) session.add(req) - # Update device last_seen - if device: - device.last_seen = datetime.utcnow() - # card_presence is a device capability flag – update directly (no approval needed) - if data.get('card_presence') in ('enable', 'disable'): - device.card_presence = data['card_presence'] - - logger.info(f'WMT update request received from {mac}') + reason = 'new device' if not device else 'info changed' + logger.info(f'WMT update request created for {mac} ({reason})') return jsonify({'status': 'received', 'message': 'Update request queued for admin review'}), 201 except Exception as e: logger.error(f'Error saving WMT update request from {mac}: {e}') return jsonify({'error': str(e)}), 500 + + +# --------------------------------------------------------------------------- +# WMT client auto-update endpoints +# --------------------------------------------------------------------------- + +WMT_RELEASES_DIR = Path('data/wmt_releases') + + +@wmt_api_bp.route('/client/version', methods=['GET']) +def get_client_version(): + """ + Returns the latest available WMT client version metadata. + Client calls this to decide if it needs to update. + + Response: { "version": "3.0", "notes": "...", "uploaded_at": "...", "filename": "wmt_v3.0.zip" } + """ + meta_path = WMT_RELEASES_DIR / 'latest.json' + if not meta_path.exists(): + return jsonify({'error': 'No release available'}), 404 + try: + meta = json.loads(meta_path.read_text()) + return jsonify(meta), 200 + except Exception as e: + logger.error(f'Error reading WMT release metadata: {e}') + return jsonify({'error': str(e)}), 500 + + +@wmt_api_bp.route('/client/download', methods=['GET']) +def download_client_release(): + """ + Streams the latest WMT client release zip to the requesting device. + Client downloads this when its version is older than the server version. + """ + meta_path = WMT_RELEASES_DIR / 'latest.json' + if not meta_path.exists(): + return jsonify({'error': 'No release available'}), 404 + try: + meta = json.loads(meta_path.read_text()) + zip_path = WMT_RELEASES_DIR / meta['filename'] + if not zip_path.exists(): + return jsonify({'error': f'Release file not found: {meta["filename"]}'}), 404 + logger.info(f'WMT client downloading release {meta["version"]} from {request.remote_addr}') + return send_file( + str(zip_path.resolve()), + mimetype='application/zip', + as_attachment=True, + download_name=meta['filename'], + ) + except Exception as e: + logger.error(f'Error serving WMT release: {e}') + return jsonify({'error': str(e)}), 500 diff --git a/app/services/ansible_service.py b/app/services/ansible_service.py index 7576094..703e678 100644 --- a/app/services/ansible_service.py +++ b/app/services/ansible_service.py @@ -737,6 +737,7 @@ class AnsibleService: 'system_health': 'Check system health and monitoring status', 'maintenance_mode': 'Put devices in maintenance mode', 'distribute_ssh_keys': 'Push server public key to all devices using password auth', + 'update_wmt_code': 'Push WMT_project code to clients from controller (preserves data/)', } return descriptions.get(playbook_name, f'Execute {playbook_name} playbook') diff --git a/app/web/main.py b/app/web/main.py index 2da02d4..2c9aa55 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -516,7 +516,18 @@ def admin(): logging.error(f'Admin inventory stats error: {e}') stats['inventory_hosts'] = '?' stats['inventory_groups_yaml'] = '?' - return render_template('admin.html', stats=stats) + # Pass registered devices for per-device delete UI + devices = [] + try: + with get_db().get_session() as session: + devices = [ + {'id': d.id, 'hostname': d.hostname, 'nume_masa': d.nume_masa, + 'mac_address': d.mac_address, 'device_ip': d.device_ip} + for d in session.query(Device).order_by(Device.nume_masa).all() + ] + except Exception as e: + logging.error(f'Admin device list error: {e}') + return render_template('admin.html', stats=stats, devices=devices) @main_bp.route('/admin/clear/logs', methods=['POST']) @@ -532,9 +543,43 @@ def admin_clear_logs(): return jsonify({'success': False, 'error': str(e)}), 500 +@main_bp.route('/admin/clear/device-logs', methods=['POST']) +def admin_clear_device_logs(): + """Delete all log entries from the database (devices stay intact).""" + try: + with get_db().get_session() as session: + count = session.query(LogEntry).delete() + session.commit() + return jsonify({'success': True, 'deleted': count}) + except Exception as e: + logging.error(f'Admin clear device logs error: {e}') + return jsonify({'success': False, 'error': str(e)}), 500 + + +@main_bp.route('/admin/delete/device/', methods=['POST']) +def admin_delete_device(device_id): + """Delete a single registered device and its log entries.""" + try: + with get_db().get_session() as session: + device = session.query(Device).filter_by(id=device_id).first() + if not device: + return jsonify({'success': False, 'error': 'Device not found'}), 404 + name = device.nume_masa or device.hostname + session.execute(text('DELETE FROM device_inventory_groups WHERE device_id = :id'), {'id': device_id}) + session.query(LogEntry).filter_by(device_id=device_id).delete() + session.query(WMTUpdateRequest).filter_by(device_id=device_id).delete() + session.delete(device) + session.commit() + logging.info(f'Admin deleted device {device_id} ({name})') + return jsonify({'success': True, 'name': name}) + except Exception as e: + logging.error(f'Admin delete device {device_id} error: {e}') + return jsonify({'success': False, 'error': str(e)}), 500 + + @main_bp.route('/admin/clear/devices', methods=['POST']) def admin_clear_devices(): - """Delete all devices (and their log entries) from the database.""" + """Delete ALL devices (and their log entries) from the database.""" try: with get_db().get_session() as session: session.execute(text('DELETE FROM device_inventory_groups')) diff --git a/app/web/wmt.py b/app/web/wmt.py index aef6757..5312f72 100644 --- a/app/web/wmt.py +++ b/app/web/wmt.py @@ -3,8 +3,12 @@ WMT management web routes – global settings, device registry, update requests. """ import csv import io -from flask import Blueprint, render_template, request, redirect, url_for, flash, Response -from datetime import datetime +import json +import re +import zipfile +from pathlib import Path +from flask import Blueprint, render_template, request, redirect, url_for, flash, Response, jsonify +from datetime import datetime, timezone from app.models import WMTGlobalConfig, Device, WMTUpdateRequest from config.database_config import get_db import logging @@ -363,3 +367,196 @@ def device_delete(device_id): logger.error(f'WMT device_delete error: {e}') flash(f'Error: {e}', 'error') return redirect(url_for('wmt_web.devices')) + + +# --------------------------------------------------------------------------- +# WMT Client Release Management +# --------------------------------------------------------------------------- + +WMT_RELEASES_DIR = Path('data/wmt_releases') + + +def _read_release_meta(): + meta_path = WMT_RELEASES_DIR / 'latest.json' + if meta_path.exists(): + try: + return json.loads(meta_path.read_text()) + except Exception: + pass + return None + + +@wmt_web_bp.route('/releases') +def releases(): + """WMT client release management page.""" + meta = _read_release_meta() + zip_size = None + if meta: + zip_path = WMT_RELEASES_DIR / meta.get('filename', '') + if zip_path.exists(): + zip_size = zip_path.stat().st_size + return render_template('wmt/releases.html', meta=meta, zip_size=zip_size) + + +@wmt_web_bp.route('/releases/upload', methods=['POST']) +def releases_upload(): + """Upload a new WMT client release zip and set it as latest.""" + f = request.files.get('release_zip') + version_str = request.form.get('version', '').strip() + notes = request.form.get('notes', '').strip() + + if not f or not f.filename.endswith('.zip'): + flash('Please upload a .zip file.', 'error') + return redirect(url_for('wmt_web.releases')) + + if not version_str: + flash('Version is required.', 'error') + return redirect(url_for('wmt_web.releases')) + + # Validate version format (digits and dots) + if not re.match(r'^\d+(\.\d+)*$', version_str): + flash('Version must be in numeric format (e.g. 3.0 or 3.1.2).', 'error') + return redirect(url_for('wmt_web.releases')) + + try: + WMT_RELEASES_DIR.mkdir(parents=True, exist_ok=True) + filename = f'wmt_v{version_str}.zip' + zip_path = WMT_RELEASES_DIR / filename + + # Validate the zip contains app.py + data = f.read() + try: + with zipfile.ZipFile(io.BytesIO(data)) as zf: + names = zf.namelist() + if 'app.py' not in names: + flash('Invalid release: zip must contain app.py at the root.', 'error') + return redirect(url_for('wmt_web.releases')) + except zipfile.BadZipFile: + flash('Uploaded file is not a valid zip archive.', 'error') + return redirect(url_for('wmt_web.releases')) + + zip_path.write_bytes(data) + + meta = { + 'version': version_str, + 'notes': notes, + 'uploaded_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S'), + 'filename': filename, + } + (WMT_RELEASES_DIR / 'latest.json').write_text(json.dumps(meta, indent=2)) + + logger.info(f'New WMT release uploaded: v{version_str} ({filename})') + flash(f'Release v{version_str} uploaded and set as latest.', 'success') + except Exception as e: + logger.error(f'WMT release upload error: {e}') + flash(f'Upload error: {e}', 'error') + + return redirect(url_for('wmt_web.releases')) + + +@wmt_web_bp.route('/releases/delete', methods=['POST']) +def releases_delete(): + """Remove the current release zip and metadata.""" + try: + meta = _read_release_meta() + if meta: + zip_path = WMT_RELEASES_DIR / meta.get('filename', '') + if zip_path.exists(): + zip_path.unlink() + meta_path = WMT_RELEASES_DIR / 'latest.json' + if meta_path.exists(): + meta_path.unlink() + flash('Release deleted.', 'success') + except Exception as e: + logger.error(f'WMT release delete error: {e}') + flash(f'Error: {e}', 'error') + return redirect(url_for('wmt_web.releases')) + + +# Patterns excluded when building a release from a local folder +_BUILD_EXCLUDE_DIRS = {'.git', '__pycache__', 'data', 'venv', '.venv', 'node_modules'} +_BUILD_EXCLUDE_EXTS = {'.pyc', '.pyo', '.bak', '.log', '.swp'} + + +def _should_exclude(rel_parts): + """Return True if this path should be left out of the release zip.""" + # Any hidden segment (starts with '.') + for part in rel_parts: + if part.startswith('.'): + return True + # Top-level or nested dir exclusion + if rel_parts[0] in _BUILD_EXCLUDE_DIRS: + return True + # Extension exclusion + if Path(rel_parts[-1]).suffix.lower() in _BUILD_EXCLUDE_EXTS: + return True + return False + + +@wmt_web_bp.route('/releases/build', methods=['POST']) +def releases_build(): + """ + Build a release zip directly from a folder on this server, + excluding hidden files, __pycache__, data/, venv/, etc. + """ + folder_path = request.form.get('folder_path', '/home/pi/Desktop/WMT').strip() + version_str = request.form.get('version', '').strip() + notes = request.form.get('notes', '').strip() + + if not version_str: + flash('Version is required.', 'error') + return redirect(url_for('wmt_web.releases')) + if not re.match(r'^\d+(\.\d+)*$', version_str): + flash('Version must be in numeric format (e.g. 3.0).', 'error') + return redirect(url_for('wmt_web.releases')) + + src = Path(folder_path) + if not src.is_dir(): + flash(f'Folder not found: {folder_path}', 'error') + return redirect(url_for('wmt_web.releases')) + + try: + buf = io.BytesIO() + file_count = 0 + has_app_py = False + + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + for abs_path in sorted(src.rglob('*')): + if not abs_path.is_file(): + continue + rel = abs_path.relative_to(src) + parts = rel.parts + if _should_exclude(parts): + continue + arcname = str(rel) + zf.write(abs_path, arcname) + file_count += 1 + if arcname == 'app.py': + has_app_py = True + + if not has_app_py: + flash(f'No app.py found in {folder_path} — cannot create release.', 'error') + return redirect(url_for('wmt_web.releases')) + + WMT_RELEASES_DIR.mkdir(parents=True, exist_ok=True) + filename = f'wmt_v{version_str}.zip' + zip_path = WMT_RELEASES_DIR / filename + zip_path.write_bytes(buf.getvalue()) + + meta = { + 'version': version_str, + 'notes': notes, + 'uploaded_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S'), + 'filename': filename, + } + (WMT_RELEASES_DIR / 'latest.json').write_text(json.dumps(meta, indent=2)) + + size_kb = len(buf.getvalue()) // 1024 + logger.info(f'WMT release built from {folder_path}: v{version_str}, {file_count} files, {size_kb} KB') + flash(f'Release v{version_str} built from {folder_path} ({file_count} files, {size_kb} KB).', 'success') + + except Exception as e: + logger.error(f'WMT release build error: {e}') + flash(f'Build error: {e}', 'error') + + return redirect(url_for('wmt_web.releases')) diff --git a/templates/admin.html b/templates/admin.html index 6ee79c5..db91478 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -93,30 +93,77 @@ - +
-
+
-
Clear Device Database
+
Clear Device Logs

- Deletes all devices and their associated log entries from the database. - Devices will re-register automatically when they next check in. + Deletes all log entries from the database. + Registered devices are not affected and will continue logging automatically.

-
+
- Currently {{ stats.get('devices', '?') }} devices - and {{ stats.get('logs', '?') }} log entries. + Currently {{ stats.get('logs', '?') }} log entries.
-
+ +
+
+
+
Registered Device Registry
+ {{ devices|length }} device(s) +
+
+ {% if devices %} +
+ + + + + + + + + + + + {% for d in devices %} + + + + + + + + {% endfor %} + +
Work PlaceHostnameMACIPDelete
{{ d.nume_masa or '—' }}{{ d.hostname or '—' }}{{ d.mac_address or '—' }}{{ d.device_ip or '—' }} + +
+
+ {% else %} +
+
+ No registered devices. +
+ {% endif %} +
+
+
+
@@ -180,16 +227,17 @@