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
This commit is contained in:
ske087
2026-05-13 16:36:17 +03:00
parent ccad5c1201
commit f1449285ba
10 changed files with 978 additions and 40 deletions
+1
View File
@@ -37,6 +37,7 @@ ansible/playbooks/*.yml
!ansible/playbooks/restart_service.yml !ansible/playbooks/restart_service.yml
!ansible/playbooks/migrate_to_wmt.yml !ansible/playbooks/migrate_to_wmt.yml
!ansible/playbooks/Update_Rest_WMT_client.yml !ansible/playbooks/Update_Rest_WMT_client.yml
!ansible/playbooks/update_wmt_code.yml
# VS Code # VS Code
.vscode/ .vscode/
+306
View File
@@ -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.<version>
# 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
+115 -13
View File
@@ -2,8 +2,10 @@
WMT (Workstation Management Terminal) configuration API WMT (Workstation Management Terminal) configuration API
Handles config distribution and device update requests from WMT clients. 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 datetime import datetime
from pathlib import Path
import json
from app.models import WMTGlobalConfig, Device, WMTUpdateRequest from app.models import WMTGlobalConfig, Device, WMTUpdateRequest
from config.database_config import get_db from config.database_config import get_db
import logging import logging
@@ -125,7 +127,11 @@ def get_device_config(mac_address):
@wmt_api_bp.route('/config/update_request', methods=['POST']) @wmt_api_bp.route('/config/update_request', methods=['POST'])
def submit_update_request(): 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: Expected JSON:
{ {
@@ -133,6 +139,7 @@ def submit_update_request():
"device_name": "Masa-01", "device_name": "Masa-01",
"hostname": "rpi-masa01", "hostname": "rpi-masa01",
"device_ip": "192.168.1.100", "device_ip": "192.168.1.100",
"card_presence": "enable", // optional applied directly, no approval
"client_config_mtime": "2026-04-22T09:30:00" // optional "client_config_mtime": "2026-04-22T09:30:00" // optional
} }
""" """
@@ -144,31 +151,126 @@ def submit_update_request():
if not mac: if not mac:
return jsonify({'error': 'mac_address is required'}), 400 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: try:
with get_db().get_session() as session: with get_db().get_session() as session:
device = session.query(Device).filter_by(mac_address=mac).first() 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( req = WMTUpdateRequest(
mac_address=mac, mac_address=mac,
device_id=device.id if device else None, device_id=device.id if device else None,
proposed_device_name=data.get('device_name'), proposed_device_name=proposed_name or None,
proposed_hostname=data.get('hostname'), proposed_hostname=proposed_hostname or None,
proposed_device_ip=data.get('device_ip'), proposed_device_ip=proposed_ip or None,
client_config_mtime=data.get('client_config_mtime'), client_config_mtime=data.get('client_config_mtime'),
submitted_at=datetime.utcnow(), submitted_at=datetime.utcnow(),
status='pending', status='pending',
) )
session.add(req) session.add(req)
# Update device last_seen reason = 'new device' if not device else 'info changed'
if device: logger.info(f'WMT update request created for {mac} ({reason})')
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}')
return jsonify({'status': 'received', 'message': 'Update request queued for admin review'}), 201 return jsonify({'status': 'received', 'message': 'Update request queued for admin review'}), 201
except Exception as e: except Exception as e:
logger.error(f'Error saving WMT update request from {mac}: {e}') logger.error(f'Error saving WMT update request from {mac}: {e}')
return jsonify({'error': str(e)}), 500 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
+1
View File
@@ -737,6 +737,7 @@ class AnsibleService:
'system_health': 'Check system health and monitoring status', 'system_health': 'Check system health and monitoring status',
'maintenance_mode': 'Put devices in maintenance mode', 'maintenance_mode': 'Put devices in maintenance mode',
'distribute_ssh_keys': 'Push server public key to all devices using password auth', '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') return descriptions.get(playbook_name, f'Execute {playbook_name} playbook')
+47 -2
View File
@@ -516,7 +516,18 @@ def admin():
logging.error(f'Admin inventory stats error: {e}') logging.error(f'Admin inventory stats error: {e}')
stats['inventory_hosts'] = '?' stats['inventory_hosts'] = '?'
stats['inventory_groups_yaml'] = '?' 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']) @main_bp.route('/admin/clear/logs', methods=['POST'])
@@ -532,9 +543,43 @@ def admin_clear_logs():
return jsonify({'success': False, 'error': str(e)}), 500 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/<int:device_id>', 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']) @main_bp.route('/admin/clear/devices', methods=['POST'])
def admin_clear_devices(): 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: try:
with get_db().get_session() as session: with get_db().get_session() as session:
session.execute(text('DELETE FROM device_inventory_groups')) session.execute(text('DELETE FROM device_inventory_groups'))
+199 -2
View File
@@ -3,8 +3,12 @@ WMT management web routes global settings, device registry, update requests.
""" """
import csv import csv
import io import io
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response import json
from datetime import datetime 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 app.models import WMTGlobalConfig, Device, WMTUpdateRequest
from config.database_config import get_db from config.database_config import get_db
import logging import logging
@@ -363,3 +367,196 @@ def device_delete(device_id):
logger.error(f'WMT device_delete error: {e}') logger.error(f'WMT device_delete error: {e}')
flash(f'Error: {e}', 'error') flash(f'Error: {e}', 'error')
return redirect(url_for('wmt_web.devices')) 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'))
+95 -22
View File
@@ -93,30 +93,77 @@
</div> </div>
</div> </div>
<!-- Clear Devices --> <!-- Clear Device Logs -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card danger-card h-100"> <div class="card warning-card h-100">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"><i class="fas fa-server me-2"></i>Clear Device Database</h5> <h5 class="mb-0"><i class="fas fa-file-alt me-2"></i>Clear Device Logs</h5>
</div> </div>
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">
<p class="text-muted flex-grow-1"> <p class="text-muted flex-grow-1">
Deletes <strong>all devices</strong> and their associated log entries from the database. Deletes <strong>all log entries</strong> from the database.
Devices will re-register automatically when they next check in. Registered devices are <strong>not affected</strong> and will continue logging automatically.
</p> </p>
<div class="alert alert-danger py-2 mb-3"> <div class="alert alert-warning py-2 mb-3">
<i class="fas fa-exclamation-triangle me-1"></i> <i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-devices">{{ stats.get('devices', '?') }}</strong> devices Currently <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
and <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
</div> </div>
<button class="btn btn-danger w-100" <button class="btn btn-warning w-100"
onclick="runAction('clear-devices', 'Delete ALL devices and their logs? This cannot be undone.')"> onclick="runAction('clear-device-logs', 'Delete ALL device log entries? Devices stay registered. This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear All Devices <i class="fas fa-trash me-2"></i>Clear Device Logs
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Registered Device Registry -->
<div class="col-md-6">
<div class="card danger-card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-server me-2"></i>Registered Device Registry</h5>
<span class="badge bg-light text-danger">{{ devices|length }} device(s)</span>
</div>
<div class="card-body p-0">
{% if devices %}
<div class="table-responsive" style="max-height:320px;overflow-y:auto;">
<table class="table table-sm table-hover mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Work Place</th>
<th>Hostname</th>
<th>MAC</th>
<th>IP</th>
<th class="text-end">Delete</th>
</tr>
</thead>
<tbody id="device-registry-body">
{% for d in devices %}
<tr id="device-row-{{ d.id }}">
<td>{{ d.nume_masa or '—' }}</td>
<td>{{ d.hostname or '—' }}</td>
<td><code>{{ d.mac_address or '—' }}</code></td>
<td>{{ d.device_ip or '—' }}</td>
<td class="text-end">
<button class="btn btn-danger btn-sm"
onclick="deleteDevice({{ d.id }}, '{{ (d.nume_masa or d.hostname)|e }}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="fas fa-check-circle fa-2x mb-2 text-success"></i><br>
No registered devices.
</div>
{% endif %}
</div>
</div>
</div>
<!-- Clear Ansible Inventory --> <!-- Clear Ansible Inventory -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card danger-card h-100"> <div class="card danger-card h-100">
@@ -180,16 +227,17 @@
<script> <script>
const ENDPOINTS = { const ENDPOINTS = {
'clear-logs': '{{ url_for("main.admin_clear_logs") }}', 'clear-logs': '{{ url_for("main.admin_clear_logs") }}',
'clear-devices': '{{ url_for("main.admin_clear_devices") }}', 'clear-device-logs': '{{ url_for("main.admin_clear_device_logs") }}',
'clear-inventory': '{{ url_for("main.admin_clear_inventory") }}', 'clear-inventory': '{{ url_for("main.admin_clear_inventory") }}',
'clear-wmt': '{{ url_for("main.admin_clear_wmt") }}' 'clear-wmt': '{{ url_for("main.admin_clear_wmt") }}'
}; };
function runAction(action, confirmMsg) { function runAction(action, confirmMsg) {
if (!confirm(confirmMsg)) return; if (!confirm(confirmMsg)) return;
const btn = event.currentTarget; const btn = event.currentTarget;
btn.disabled = true; btn.disabled = true;
const origLabel = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Working…'; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Working…';
fetch(ENDPOINTS[action], { fetch(ENDPOINTS[action], {
@@ -206,19 +254,44 @@ function runAction(action, confirmMsg) {
} }
}) })
.catch(err => showToast('danger', 'Network error: ' + err)) .catch(err => showToast('danger', 'Network error: ' + err))
.finally(() => { .finally(() => { btn.disabled = false; btn.innerHTML = origLabel; });
}
function deleteDevice(deviceId, deviceName) {
if (!confirm(`Delete device "${deviceName}" and all its logs and WMT requests?\n\nThis cannot be undone.`)) return;
const btn = event.currentTarget;
btn.disabled = true;
const origLabel = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
fetch(`/admin/delete/device/${deviceId}`, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
// Remove row from table without page reload
const row = document.getElementById(`device-row-${deviceId}`);
if (row) row.remove();
showToast('success', `Device "${data.name}" deleted.`);
refreshStats();
} else {
showToast('danger', 'Error: ' + (data.error || 'Unknown'));
btn.disabled = false;
btn.innerHTML = origLabel;
}
})
.catch(err => {
showToast('danger', 'Network error: ' + err);
btn.disabled = false; btn.disabled = false;
btn.innerHTML = btn.innerHTML.replace(/<span.*?><\/span>/, '<i class="fas fa-trash me-2"></i>'); btn.innerHTML = origLabel;
// Re-render button label properly
btn.innerHTML = '<i class="fas fa-trash me-2"></i>' + btn.textContent.trim();
}); });
} }
function buildMessage(action, data) { function buildMessage(action, data) {
if (action === 'clear-logs') if (action === 'clear-logs' || action === 'clear-device-logs')
return `Deleted ${data.deleted} log entries.`; return `Deleted ${data.deleted} log entries.`;
if (action === 'clear-devices')
return `Deleted ${data.deleted_devices} devices and ${data.deleted_logs} log entries.`;
if (action === 'clear-inventory') if (action === 'clear-inventory')
return `Inventory reset. ${data.groups_deleted} group(s) removed.`; return `Inventory reset. ${data.groups_deleted} group(s) removed.`;
if (action === 'clear-wmt') if (action === 'clear-wmt')
+12 -1
View File
@@ -76,7 +76,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card playbook-card mb-3" data-name="distribute_ssh_keys" onclick="selectPlaybook('distribute_ssh_keys')"> <div class="card playbook-card mb-2" data-name="distribute_ssh_keys" onclick="selectPlaybook('distribute_ssh_keys')">
<div class="card-body py-2"> <div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
@@ -87,6 +87,17 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card playbook-card mb-3" data-name="update_wmt_code" onclick="selectPlaybook('update_wmt_code')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-danger"><i class="fas fa-code-branch me-1"></i>Update WMT Code</h6>
<p class="small text-muted mb-0">Force-push latest WMT release to clients that missed auto-update (preserves data/)</p>
</div>
<span class="badge bg-danger ms-2 flex-shrink-0">Built-in</span>
</div>
</div>
</div>
<h6 class="text-muted fw-bold mb-2 small text-uppercase">Custom Playbooks</h6> <h6 class="text-muted fw-bold mb-2 small text-uppercase">Custom Playbooks</h6>
<select class="form-select" id="customPlaybook"> <select class="form-select" id="customPlaybook">
+6
View File
@@ -255,6 +255,12 @@
{% if pending_wmt_count > 0 %}<span class="badge bg-danger ms-1">{{ pending_wmt_count }}</span>{% endif %} {% if pending_wmt_count > 0 %}<span class="badge bg-danger ms-1">{{ pending_wmt_count }}</span>{% endif %}
</a> </a>
</li> </li>
<li class="nav-item">
<a href="{{ url_for('wmt_web.releases') }}" class="nav-link {% if request.endpoint == 'wmt_web.releases' %}active{% endif %}">
<i class="fas fa-box-open"></i>
Client Releases
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a href="{{ url_for('wmt_web.devices') }}" class="nav-link {% if request.endpoint in ['wmt_web.devices','wmt_web.device_new','wmt_web.device_edit'] %}active{% endif %}"> <a href="{{ url_for('wmt_web.devices') }}" class="nav-link {% if request.endpoint in ['wmt_web.devices','wmt_web.device_new','wmt_web.device_edit'] %}active{% endif %}">
<i class="fas fa-desktop"></i> <i class="fas fa-desktop"></i>
+196
View File
@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}WMT Client Releases {{ app_name }}{% endblock %}
{% block page_title %}WMT Client Release Management{% endblock %}
{% block content %}
<div class="row g-4">
<!-- Current release info -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-box-open me-2"></i>Current Release
</div>
<div class="card-body">
{% if meta %}
<table class="table table-sm mb-3">
<tr><th style="width:130px">Version</th>
<td><span class="badge bg-primary fs-6">v{{ meta.version }}</span></td></tr>
<tr><th>File</th>
<td><code>{{ meta.filename }}</code></td></tr>
<tr><th>Size</th>
<td>{% if zip_size %}{{ (zip_size / 1024 / 1024) | round(2) }} MB{% else %}<span class="text-danger">file missing!</span>{% endif %}</td></tr>
<tr><th>Uploaded</th>
<td>{{ meta.uploaded_at }}</td></tr>
<tr><th>Notes</th>
<td>{{ meta.notes or '—' }}</td></tr>
</table>
<div class="d-flex gap-2">
<a href="{{ url_for('wmt_api.download_client_release') }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-download me-1"></i>Download zip
</a>
<form method="post" action="{{ url_for('wmt_web.releases_delete') }}"
onsubmit="return confirm('Delete release v{{ meta.version }}? Clients will not find an update until you upload a new one.')">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="fas fa-trash me-1"></i>Delete release
</button>
</form>
</div>
{% else %}
<div class="text-center text-muted py-4">
<i class="fas fa-inbox fa-3x mb-3 d-block"></i>
No release uploaded yet.<br>
<small>Upload a zip below to enable client auto-update.</small>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Upload new release -->
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-upload me-2"></i>Upload New Release
</div>
<div class="card-body">
<form method="post" action="{{ url_for('wmt_web.releases_upload') }}"
enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label fw-semibold">Version <span class="text-danger">*</span></label>
<input type="text" name="version" class="form-control" placeholder="e.g. 3.0"
pattern="\d+(\.\d+)*" required>
<div class="form-text">Numeric only, e.g. <code>3.0</code> or <code>3.1.2</code>.
Must be higher than the current version to trigger client updates.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Release Notes</label>
<textarea name="notes" class="form-control" rows="3"
placeholder="What changed in this version?"></textarea>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Release Zip <span class="text-danger">*</span></label>
<input type="file" name="release_zip" class="form-control" accept=".zip" required>
<div class="form-text">
Must contain <code>app.py</code> at the zip root.<br>
Include <code>dependency_utils.py</code>, <code>config.py</code>,
<code>Files/reposytory/</code>, and <code>Files/Screen.html</code> for a full release.
</div>
</div>
<div class="alert alert-info py-2">
<i class="fas fa-info-circle me-1"></i>
Uploading will <strong>replace</strong> the current release immediately.
All WMT clients will download the new version on their next 5-minute sync cycle.
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>Upload &amp; Set as Latest
</button>
</form>
</div>
</div>
</div>
<!-- Build from local folder -->
<div class="col-12">
<div class="card border-success">
<div class="card-header bg-success bg-opacity-10 text-success">
<i class="fas fa-folder-open me-2"></i>Build Release from Server Folder
</div>
<div class="card-body">
<p class="text-muted mb-3">
Package the WMT source folder that lives on <em>this</em> server into a clean release zip.
Hidden files (<code>.git</code>, <code>.gitignore</code>, <code>.lgd-nfy0</code>, …),
<code>__pycache__</code>, <code>data/</code>, <code>venv/</code> and compiled
<code>.pyc</code> / <code>.log</code> files are automatically excluded.
</p>
<form method="post" action="{{ url_for('wmt_web.releases_build') }}">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label fw-semibold">Source Folder <span class="text-danger">*</span></label>
<input type="text" name="folder_path" class="form-control font-monospace"
value="/home/pi/Desktop/WMT" required>
<div class="form-text">Absolute path to the WMT folder on this server.</div>
</div>
<div class="col-md-2">
<label class="form-label fw-semibold">Version <span class="text-danger">*</span></label>
<input type="text" name="version" class="form-control" placeholder="e.g. 3.1"
pattern="\d+(\.\d+)*" required>
</div>
<div class="col-md-5">
<label class="form-label fw-semibold">Release Notes</label>
<input type="text" name="notes" class="form-control"
placeholder="Short description of changes">
</div>
</div>
<div class="mt-3 p-2 bg-light rounded border small text-muted">
<strong>Excluded automatically:</strong>
<code>.git/</code> &nbsp;
<code>.gitignore</code> &nbsp;
<code>.lgd-*</code> &nbsp;
any hidden file/folder &nbsp;·&nbsp;
<code>__pycache__/</code> &nbsp;
<code>*.pyc</code> &nbsp;
<code>*.log</code> &nbsp;
<code>*.bak</code> &nbsp;·&nbsp;
<code>data/</code> &nbsp;
<code>venv/</code>
</div>
<button type="submit" class="btn btn-success mt-3"
onclick="return confirm('Build a new release from the specified folder?')">
<i class="fas fa-hammer me-2"></i>Build &amp; Set as Latest
</button>
</form>
</div>
</div>
</div>
<!-- How it works -->
<div class="col-12">
<div class="card">
<div class="card-header bg-light">
<i class="fas fa-info-circle me-2"></i>How Auto-Update Works
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3 text-center">
<div class="fs-1 text-primary mb-1">1</div>
<strong>Client checks version</strong><br>
<small class="text-muted">Every 5 min, client calls
<code>GET /api/wmt/client/version</code>
and compares server version with its own (from line 1 of app.py)</small>
</div>
<div class="col-md-3 text-center">
<div class="fs-1 text-primary mb-1">2</div>
<strong>Download if outdated</strong><br>
<small class="text-muted">If server version &gt; local version,
client downloads <code>GET /api/wmt/client/download</code></small>
</div>
<div class="col-md-3 text-center">
<div class="fs-1 text-primary mb-1">3</div>
<strong>Apply &amp; back up</strong><br>
<small class="text-muted">Zip is extracted over the WMT folder.
Old <code>app.py</code> is backed up as <code>app.py.bak.&lt;version&gt;</code></small>
</div>
<div class="col-md-3 text-center">
<div class="fs-1 text-primary mb-1">4</div>
<strong>Service restart</strong><br>
<small class="text-muted">Client calls <code>sudo systemctl restart wmt</code>
(or reboots) to start the new code</small>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}