Files
Salus/app/templates/index.html
Christoph Gasser 1041777b9a Lock reboot button permanently after confirm
After confirming a reboot (single or bulk), the row button and checkbox
are immediately disabled and can't be re-triggered in the current session.
Button text changes to "Rebooting…" while the request is in flight, then
"Rebooted" on success or "Failed" on error — but stays disabled either way.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:43:41 +02:00

491 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 cursor-pointer select-none group"
data-action="view-clients" data-mac="{{ mac }}" data-name="{{ name }}" data-site-key="{{ site_key }}">
<span class="flex items-center gap-1.5 group-hover:text-blue-600 transition-colors">
{{ name }}
<svg class="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-400 transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</span>
</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>
<!-- Clients Modal -->
<div id="clients-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="clients-backdrop"></div>
<div class="relative bg-white border border-gray-200 rounded-2xl shadow-xl w-full max-w-4xl max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 flex-shrink-0">
<div>
<h2 class="text-lg font-semibold text-gray-900" id="clients-modal-title">Connected Clients</h2>
<p class="text-sm text-gray-500 mt-0.5" id="clients-modal-subtitle"></p>
</div>
<button id="clients-close" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="flex-1 overflow-auto" id="clients-body"></div>
</div>
</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 ──────────────────────────────────────────────────────────
function lockApRow(mac) {
const rowBtn = document.querySelector(`.btn-reboot[data-mac="${CSS.escape(mac)}"]`);
if (rowBtn) {
rowBtn.disabled = true;
rowBtn.className = 'btn-reboot px-3 py-1.5 text-xs rounded-lg font-medium bg-gray-100 border border-gray-200 text-gray-400 cursor-not-allowed opacity-50';
}
const cb = document.querySelector(`.ap-checkbox[data-mac="${CSS.escape(mac)}"]`);
if (cb) { cb.disabled = true; cb.checked = false; }
updateBulkButton();
}
document.querySelectorAll('.btn-reboot').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.disabled) return;
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 () => {
lockApRow(mac);
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) {
btn.textContent = 'Rebooted';
showToast(`Reboot command sent to "${name}"`, 'success');
} else {
const err = await res.json();
btn.textContent = 'Failed';
showToast(`Error: ${err.detail || 'Unknown error'}`, 'error');
}
} catch (e) {
btn.textContent = 'Failed';
showToast('Network error', 'error');
}
});
});
});
// ── 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 () => {
// Lock all selected AP rows immediately on confirm
aps.forEach(ap => lockApRow(ap.mac));
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');
// Update button text per AP based on result
(data.results || []).forEach(r => {
const b = document.querySelector(`.btn-reboot[data-mac="${CSS.escape(r.mac)}"]`);
if (b) b.textContent = r.result === 'success' ? 'Rebooted' : 'Failed';
});
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');
}
} catch (e) {
aps.forEach(ap => {
const b = document.querySelector(`.btn-reboot[data-mac="${CSS.escape(ap.mac)}"]`);
if (b) b.textContent = 'Failed';
});
showToast('Network error', 'error');
}
});
});
// ── Clients modal ──────────────────────────────────────────────────────────
const clientsModal = document.getElementById('clients-modal');
const clientsTitle = document.getElementById('clients-modal-title');
const clientsSubtitle = document.getElementById('clients-modal-subtitle');
const clientsBody = document.getElementById('clients-body');
function closeClientsModal() { clientsModal.classList.add('hidden'); }
document.getElementById('clients-backdrop').addEventListener('click', closeClientsModal);
document.getElementById('clients-close').addEventListener('click', closeClientsModal);
// Event delegation on the table body — catch clicks on name cells
document.getElementById('ap-table-body')?.addEventListener('click', e => {
const cell = e.target.closest('[data-action="view-clients"]');
if (!cell) return;
openClientsModal(cell.dataset.mac, cell.dataset.name, cell.dataset.siteKey);
});
function esc(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function signalInfo(dbm) {
if (dbm == null) return { label: '—', color: 'text-gray-400', display: '—' };
if (dbm >= -50) return { label: 'Excellent', color: 'text-green-600', display: dbm + ' dBm' };
if (dbm >= -65) return { label: 'Good', color: 'text-blue-500', display: dbm + ' dBm' };
if (dbm >= -75) return { label: 'Fair', color: 'text-yellow-500',display: dbm + ' dBm' };
return { label: 'Poor', color: 'text-red-500', display: dbm + ' dBm' };
}
function fmtRate(kbps) {
if (!kbps) return '—';
return kbps >= 1000 ? Math.round(kbps / 1000) + ' Mbps' : kbps + ' Kbps';
}
function fmtUptime(s) {
if (!s) return '—';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${sec}s` : `${sec}s`;
}
const bandMap = { 0: '2.4 GHz', 1: '5 GHz', 2: '6 GHz' };
async function openClientsModal(mac, name, siteKey) {
clientsTitle.textContent = name;
clientsSubtitle.textContent = 'Loading…';
clientsBody.innerHTML = '<div class="flex justify-center py-16"><div class="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div></div>';
clientsModal.classList.remove('hidden');
try {
const res = await fetch(`/api/ap-clients?mac=${encodeURIComponent(mac)}&site_key=${encodeURIComponent(siteKey)}`);
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Request failed'); }
const { clients } = await res.json();
renderClients(clients);
} catch (err) {
clientsSubtitle.textContent = '';
clientsBody.innerHTML = `<div class="flex flex-col items-center py-16 text-red-500">
<p class="font-medium">Failed to load clients</p>
<p class="text-sm mt-1 text-red-400">${esc(err.message)}</p></div>`;
}
}
function renderClients(clients) {
if (!clients || clients.length === 0) {
clientsSubtitle.textContent = 'No clients connected';
clientsBody.innerHTML = `<div class="flex flex-col items-center py-16 text-gray-400">
<svg class="w-12 h-12 mb-3 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
<p class="text-lg font-medium">No clients connected</p>
<p class="text-sm mt-1">No wireless devices are currently associated with this AP.</p></div>`;
return;
}
clientsSubtitle.textContent = `${clients.length} device${clients.length !== 1 ? 's' : ''} connected`;
const rows = clients.map(c => {
const displayName = esc(c.name || c.hostName || c.hostname || '');
const mac = esc(c.mac || '—');
const ip = esc(c.ip || '—');
const ssid = esc(c.ssid || '—');
const band = esc(bandMap[c.radioId ?? c.radioType] ?? '—');
const ch = esc(c.channel || '—');
const signal = c.signal ?? c.rssi ?? null;
const sq = signalInfo(signal);
const tx = fmtRate(c.txRate);
const rx = fmtRate(c.rxRate);
const uptime = fmtUptime(c.uptime ?? c.connectTime);
return `<tr class="border-t border-gray-100 hover:bg-gray-50 transition-colors">
<td class="px-4 py-3">
${displayName ? `<div class="font-medium text-gray-900 text-sm">${displayName}</div>` : ''}
<div class="text-xs text-gray-400 font-mono">${mac}</div>
</td>
<td class="px-4 py-3 text-sm text-gray-600 font-mono">${ip}</td>
<td class="px-4 py-3 text-sm text-gray-600">${ssid}</td>
<td class="px-4 py-3 text-sm text-gray-600">${band}</td>
<td class="px-4 py-3 text-sm text-gray-600">${ch}</td>
<td class="px-4 py-3 text-sm">
<span class="font-mono ${sq.color}">${sq.display}</span>
<span class="text-xs ml-1 ${sq.color}">${sq.label}</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">↑ ${tx}<br>↓ ${rx}</td>
<td class="px-4 py-3 text-xs text-gray-500">${uptime}</td>
</tr>`;
}).join('');
clientsBody.innerHTML = `<table class="min-w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200 sticky top-0">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Client</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">IP</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">SSID</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Band</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Ch</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Signal</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Link Rate</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Connected</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
}
</script>
{% endblock %}