Initial release — Salus by Stranto v1.6.1.0

FastAPI/Jinja2 web app for viewing and rebooting TP-Link Omada APs
across all sites. Authentik OIDC auth, SQLite audit log, Docker deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 14:36:02 +02:00
commit 284924e86d
17 changed files with 1646 additions and 0 deletions

321
app/templates/index.html Normal file
View File

@@ -0,0 +1,321 @@
{% extends "base.html" %}
{% block title %}Access Points Salus by Stranto{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Header row -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Access Points</h1>
<p class="text-sm text-gray-500 mt-0.5">Live from Omada Controller · <span id="visible-count">{{ aps|length }}</span> of {{ aps|length }} device{{ 's' if aps|length != 1 }} across all sites</p>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
<input id="filter-name" type="text" placeholder="Filter by name or site…"
class="pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent w-52" />
</div>
<button id="btn-refresh"
class="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-white hover:bg-gray-50 border border-gray-300 text-gray-600 transition-colors">
<svg id="refresh-icon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Refresh
</button>
<button id="btn-reboot-selected" disabled
class="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-500 disabled:opacity-40 disabled:cursor-not-allowed border border-red-500 text-white transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Reboot selected (<span id="sel-count">0</span>)
</button>
</div>
</div>
{% if error %}
<div class="flex items-start gap-3 p-4 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
<svg class="w-5 h-5 flex-shrink-0 mt-0.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div>
<p class="font-semibold">Failed to connect to Omada Controller</p>
<p class="mt-0.5 text-red-600">{{ error }}</p>
</div>
</div>
{% endif %}
{% if aps %}
<!-- Table -->
<div class="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left w-10">
<input type="checkbox" id="chk-all"
class="rounded border-gray-300 bg-white text-blue-500 focus:ring-blue-500 cursor-pointer" />
</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Site</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">IP Address</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">MAC Address</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Model</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Status</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Uptime</th>
<th class="px-4 py-3 text-right font-semibold text-gray-500 uppercase tracking-wide text-xs">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white" id="ap-table-body">
{% for ap in aps %}
{% set online = ap.get('statusCategory', 0) == 1 %}
{% set mac = ap.get('mac', '') %}
{% set name = ap.get('name', 'Unknown') %}
{% set ip = ap.get('ip', '') %}
{% set site_name = ap.get('_site_name', '') %}
{% set site_key = ap.get('_site_key', '') %}
{% set uptime_secs = ap.get('uptimeLong', 0) | int %}
{% set reboot_allowed = online and uptime_secs >= 300 %}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-3">
<input type="checkbox" class="ap-checkbox rounded border-gray-300 bg-white text-blue-500 focus:ring-blue-500 cursor-pointer
{% if not reboot_allowed %}opacity-40 cursor-not-allowed{% endif %}"
data-mac="{{ mac }}" data-name="{{ name }}" data-ip="{{ ip }}" data-site-key="{{ site_key }}"
{% if not reboot_allowed %}disabled
title="{% if not online %}AP is offline{% else %}Uptime too low — minimum 5 minutes required{% endif %}"
{% endif %} />
</td>
<td class="px-4 py-3 font-medium text-gray-900">{{ name }}</td>
<td class="px-4 py-3 text-gray-500 text-xs">{{ site_name }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ ip or '—' }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ mac or '—' }}</td>
<td class="px-4 py-3 text-gray-500">{{ ap.get('model', '—') }}</td>
<td class="px-4 py-3">
{% if online %}
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
Online
</span>
{% else %}
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 border border-gray-200">
<span class="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
Offline
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-gray-500 text-xs">
{% set up = ap.get('uptimeLong', 0) | int %}
{% if up %}
{{ up // 86400 }}d {{ (up % 86400) // 3600 }}h {{ (up % 3600) // 60 }}m
{% else %}—{% endif %}
</td>
<td class="px-4 py-3 text-right">
<button
class="btn-reboot px-3 py-1.5 text-xs rounded-lg font-medium transition-colors
{% if reboot_allowed %}bg-red-600 hover:bg-red-500 border border-red-500 text-white cursor-pointer
{% else %}bg-gray-100 border border-gray-200 text-gray-400 cursor-not-allowed opacity-50{% endif %}"
data-mac="{{ mac }}" data-name="{{ name }}" data-ip="{{ ip }}" data-site-key="{{ site_key }}"
{% if not reboot_allowed %}disabled
title="{% if not online %}AP is offline{% else %}Uptime too low ({{ uptime_secs // 60 }}m {{ uptime_secs % 60 }}s) — minimum 5 minutes required{% endif %}"
{% endif %}>
Reboot
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif not error %}
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
<svg class="w-12 h-12 mb-4 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"/>
</svg>
<p class="text-lg font-medium">No access points found</p>
<p class="text-sm mt-1">Check your Omada site name configuration.</p>
</div>
{% endif %}
</div>
<!-- Confirmation Modal -->
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" id="modal-backdrop"></div>
<div class="relative bg-white border border-gray-200 rounded-2xl shadow-xl w-full max-w-md p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900">Confirm Reboot</h2>
</div>
<p class="text-gray-600 text-sm mb-6" id="modal-message">Are you sure?</p>
<div class="flex gap-3 justify-end">
<button id="modal-cancel"
class="px-4 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 transition-colors">
Cancel
</button>
<button id="modal-confirm"
class="px-4 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-500 border border-red-500 text-white font-semibold transition-colors">
Reboot
</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const CSRF_TOKEN = {{ csrf_token | tojson }};
// ── Name / site filter ────────────────────────────────────────────────────
const filterInput = document.getElementById('filter-name');
const visibleCount = document.getElementById('visible-count');
const allRows = [...document.querySelectorAll('#ap-table-body tr')];
filterInput.addEventListener('input', () => {
const q = filterInput.value.toLowerCase().trim();
let shown = 0;
allRows.forEach(row => {
const name = (row.querySelector('td:nth-child(2)')?.textContent || '').toLowerCase();
const site = (row.querySelector('td:nth-child(3)')?.textContent || '').toLowerCase();
const match = !q || name.includes(q) || site.includes(q);
row.style.display = match ? '' : 'none';
if (match) shown++;
});
visibleCount.textContent = shown;
updateBulkButton();
});
// ── Refresh ────────────────────────────────────────────────────────────────
document.getElementById('btn-refresh').addEventListener('click', () => {
const icon = document.getElementById('refresh-icon');
icon.classList.add('animate-spin');
window.location.reload();
});
// ── Checkbox logic ─────────────────────────────────────────────────────────
const chkAll = document.getElementById('chk-all');
const btnRebootSel = document.getElementById('btn-reboot-selected');
const selCount = document.getElementById('sel-count');
function updateBulkButton() {
const checked = [...document.querySelectorAll('.ap-checkbox:checked')]
.filter(cb => cb.closest('tr').style.display !== 'none');
selCount.textContent = checked.length;
btnRebootSel.disabled = checked.length === 0;
}
chkAll?.addEventListener('change', () => {
document.querySelectorAll('.ap-checkbox:not(:disabled)').forEach(cb => {
cb.checked = chkAll.checked;
});
updateBulkButton();
});
document.querySelectorAll('.ap-checkbox').forEach(cb => {
cb.addEventListener('change', updateBulkButton);
});
// ── Modal ──────────────────────────────────────────────────────────────────
const modal = document.getElementById('modal');
const modalMsg = document.getElementById('modal-message');
const modalConfirm = document.getElementById('modal-confirm');
let pendingAction = null;
function showModal(message, onConfirm) {
modalMsg.textContent = message;
pendingAction = onConfirm;
modal.classList.remove('hidden');
}
function hideModal() {
modal.classList.add('hidden');
pendingAction = null;
}
document.getElementById('modal-backdrop').addEventListener('click', hideModal);
document.getElementById('modal-cancel').addEventListener('click', hideModal);
modalConfirm.addEventListener('click', () => {
if (pendingAction) pendingAction();
hideModal();
});
// ── Single reboot ──────────────────────────────────────────────────────────
document.querySelectorAll('.btn-reboot').forEach(btn => {
btn.addEventListener('click', () => {
const mac = btn.dataset.mac;
const name = btn.dataset.name;
const ip = btn.dataset.ip;
const site_key = btn.dataset.siteKey;
showModal(`Reboot AP "${name}"?\nThis will disconnect all clients connected to this AP.`, async () => {
btn.disabled = true;
btn.textContent = 'Rebooting…';
try {
const res = await fetch('/api/reboot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mac, name, ip, site_key, csrf_token: CSRF_TOKEN }),
});
if (res.ok) {
showToast(`Reboot command sent to "${name}"`, 'success');
} else {
const err = await res.json();
showToast(`Error: ${err.detail || 'Unknown error'}`, 'error');
btn.disabled = false;
btn.textContent = 'Reboot';
}
} catch (e) {
showToast('Network error', 'error');
btn.disabled = false;
btn.textContent = 'Reboot';
}
});
});
});
// ── Bulk reboot ────────────────────────────────────────────────────────────
btnRebootSel?.addEventListener('click', () => {
const checked = [...document.querySelectorAll('.ap-checkbox:checked')];
const aps = checked.map(cb => ({ mac: cb.dataset.mac, name: cb.dataset.name, ip: cb.dataset.ip, site_key: cb.dataset.siteKey }));
const names = aps.map(a => a.name).join(', ');
showModal(`Reboot ${aps.length} AP${aps.length > 1 ? 's' : ''}?\n${names}`, async () => {
btnRebootSel.disabled = true;
btnRebootSel.querySelector('span').textContent = '0';
try {
const res = await fetch('/api/reboot-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aps, csrf_token: CSRF_TOKEN }),
});
const data = await res.json();
const errors = (data.results || []).filter(r => r.result === 'error');
if (errors.length === 0) {
showToast(`Reboot sent to ${aps.length} AP${aps.length > 1 ? 's' : ''}`, 'success');
} else {
showToast(`${errors.length} AP(s) failed to reboot`, 'error');
}
document.querySelectorAll('.ap-checkbox').forEach(cb => { cb.checked = false; });
if (chkAll) chkAll.checked = false;
updateBulkButton();
} catch (e) {
showToast('Network error', 'error');
updateBulkButton();
}
});
});
</script>
{% endblock %}