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:
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
+47
-2
@@ -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/<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'])
|
||||
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'))
|
||||
|
||||
+199
-2
@@ -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'))
|
||||
|
||||
+92
-19
@@ -93,30 +93,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear Devices -->
|
||||
<!-- Clear Device Logs -->
|
||||
<div class="col-md-3">
|
||||
<div class="card danger-card h-100">
|
||||
<div class="card warning-card h-100">
|
||||
<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 class="card-body d-flex flex-column">
|
||||
<p class="text-muted flex-grow-1">
|
||||
Deletes <strong>all devices</strong> and their associated log entries from the database.
|
||||
Devices will re-register automatically when they next check in.
|
||||
Deletes <strong>all log entries</strong> from the database.
|
||||
Registered devices are <strong>not affected</strong> and will continue logging automatically.
|
||||
</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>
|
||||
Currently <strong id="badge-devices">{{ stats.get('devices', '?') }}</strong> devices
|
||||
and <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
|
||||
Currently <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
|
||||
</div>
|
||||
<button class="btn btn-danger w-100"
|
||||
onclick="runAction('clear-devices', 'Delete ALL devices and their logs? This cannot be undone.')">
|
||||
<i class="fas fa-trash me-2"></i>Clear All Devices
|
||||
<button class="btn btn-warning w-100"
|
||||
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 Device Logs
|
||||
</button>
|
||||
</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 -->
|
||||
<div class="col-md-3">
|
||||
<div class="card danger-card h-100">
|
||||
@@ -181,7 +228,7 @@
|
||||
<script>
|
||||
const ENDPOINTS = {
|
||||
'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-wmt': '{{ url_for("main.admin_clear_wmt") }}'
|
||||
};
|
||||
@@ -190,6 +237,7 @@ function runAction(action, confirmMsg) {
|
||||
if (!confirm(confirmMsg)) return;
|
||||
const btn = event.currentTarget;
|
||||
btn.disabled = true;
|
||||
const origLabel = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Working…';
|
||||
|
||||
fetch(ENDPOINTS[action], {
|
||||
@@ -206,19 +254,44 @@ function runAction(action, confirmMsg) {
|
||||
}
|
||||
})
|
||||
.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 = btn.innerHTML.replace(/<span.*?><\/span>/, '<i class="fas fa-trash me-2"></i>');
|
||||
// Re-render button label properly
|
||||
btn.innerHTML = '<i class="fas fa-trash me-2"></i>' + btn.textContent.trim();
|
||||
btn.innerHTML = origLabel;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('danger', 'Network error: ' + err);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origLabel;
|
||||
});
|
||||
}
|
||||
|
||||
function buildMessage(action, data) {
|
||||
if (action === 'clear-logs')
|
||||
if (action === 'clear-logs' || action === 'clear-device-logs')
|
||||
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')
|
||||
return `Inventory reset. ${data.groups_deleted} group(s) removed.`;
|
||||
if (action === 'clear-wmt')
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</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="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
@@ -87,6 +87,17 @@
|
||||
</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>
|
||||
<select class="form-select" id="customPlaybook">
|
||||
|
||||
@@ -255,6 +255,12 @@
|
||||
{% if pending_wmt_count > 0 %}<span class="badge bg-danger ms-1">{{ pending_wmt_count }}</span>{% endif %}
|
||||
</a>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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 & 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>
|
||||
<code>.gitignore</code>
|
||||
<code>.lgd-*</code>
|
||||
any hidden file/folder ·
|
||||
<code>__pycache__/</code>
|
||||
<code>*.pyc</code>
|
||||
<code>*.log</code>
|
||||
<code>*.bak</code> ·
|
||||
<code>data/</code>
|
||||
<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 & 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 > 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 & 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.<version></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 %}
|
||||
Reference in New Issue
Block a user