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
+95 -22
View File
@@ -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">
@@ -180,16 +227,17 @@
<script>
const ENDPOINTS = {
'clear-logs': '{{ url_for("main.admin_clear_logs") }}',
'clear-devices': '{{ url_for("main.admin_clear_devices") }}',
'clear-inventory': '{{ url_for("main.admin_clear_inventory") }}',
'clear-wmt': '{{ url_for("main.admin_clear_wmt") }}'
'clear-logs': '{{ url_for("main.admin_clear_logs") }}',
'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") }}'
};
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 = origLabel;
}
})
.catch(err => {
showToast('danger', 'Network error: ' + err);
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;
});
}
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')
+12 -1
View File
@@ -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">
+6
View File
@@ -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>
+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 %}