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>
322 lines
16 KiB
HTML
322 lines
16 KiB
HTML
{% 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 %}
|