diff --git a/teacher/Dockerfile b/teacher/Dockerfile
index fe63b9e..7bd0021 100644
--- a/teacher/Dockerfile
+++ b/teacher/Dockerfile
@@ -34,9 +34,9 @@ RUN mkdir -p /var/lib/teacher
ENV PYTHONUNBUFFERED=1 \
LISTEN_PORT=8080 \
- SCAN_BASE=192.168.1 \
+ SCAN_BASE=192.168.10 \
SCAN_START=100 \
- SCAN_END=200 \
+ SCAN_END=150 \
SSH_USER=pi \
SSH_PASS=raspberry \
PROBE_INTERVAL=30 \
diff --git a/teacher/static/app.js b/teacher/static/app.js
index 4d1fce1..387bb85 100644
--- a/teacher/static/app.js
+++ b/teacher/static/app.js
@@ -10,11 +10,16 @@
// ─── State ───────────────────────────────────────────────────────────────────
const cards = {}; // ip → .pi-card element
-let locked = false;
+let locked = false;
let dragging = null;
let dragOX = 0;
let dragOY = 0;
+// Live scan-range values — populated from the first /api/status response
+let liveBase = '192.168.10';
+let liveStart = 100;
+let liveEnd = 150;
+
// ─── Utilities ───────────────────────────────────────────────────────────────
function fmtUptime(s) {
if (!s || s < 0) return '--';
@@ -55,9 +60,9 @@ function createCard(ip) {
CPU
@@ -99,6 +104,11 @@ function createCard(ip) {
updateHostCount();
});
+ // Deploy button — stop drag, open modal
+ const deployBtn = card.querySelector('.deploy-btn');
+ deployBtn.addEventListener('mousedown', e => e.stopPropagation());
+ deployBtn.addEventListener('click', () => openDeployModal(ip));
+
return card;
}
@@ -118,9 +128,6 @@ function updateCard(ip, host, layout) {
}
// Hostname
- const hostname = host.hostname && host.hostname !== ip ? host.hostname : '';
- card.querySelector('.card-host').textContent = hostname;
-
const h = host.health || {};
if (status === 'online' && h.cpu !== undefined) {
@@ -260,9 +267,12 @@ async function poll() {
const data = await apiFetch('/api/status');
if (!data) return;
- // Update scan range display
+ // Update scan range display and store live values for the edit popover
const c = data.config || {};
- const rangeStr = `${c.base || ''}.${c.start || ''}–${c.base || ''}.${c.end || ''}`;
+ liveBase = c.base || liveBase;
+ liveStart = c.start ?? liveStart;
+ liveEnd = c.end ?? liveEnd;
+ const rangeStr = `${liveBase}.${liveStart}–${liveBase}.${liveEnd}`;
document.getElementById('scan-range').textContent = rangeStr;
document.getElementById('hint-range').textContent = rangeStr;
document.getElementById('last-updated').textContent =
@@ -474,41 +484,37 @@ async function connectForti() {
document.getElementById('forti-status-badge').innerHTML =
'
CONNECTED';
await refreshFortiStats();
- fortiRefreshTimer = setInterval(refreshFortiStats, 5_000);
+ fortiRefreshTimer = setInterval(refreshFortiStats, 2_000);
} else {
errEl.textContent = res?.err || 'Connection failed.';
}
}
// ── Stats fetch + render ───────────────────────────────────────────────────
-
-let _fortiCache = null; // last successful interface list
-let _fortiCacheTime = 0; // timestamp of that fetch
-const FORTI_CACHE_MS = 4900; // reuse cache if called within ~5 s
+// The server polls FortiGate in a background thread and caches the result.
+// This endpoint returns instantly — no waiting on the firewall.
async function refreshFortiStats() {
- const now = Date.now();
- if (_fortiCache && (now - _fortiCacheTime) < FORTI_CACHE_MS) {
- renderFortiInterfaces(_fortiCache);
- return;
- }
const res = await apiFetch('/api/fortigate/interfaces');
if (!res) return;
if (res.auth_required) {
fortiConnected = false;
clearInterval(fortiRefreshTimer);
- _fortiCache = null;
renderFortiDisconnected();
return;
}
if (res.ok) {
- _fortiCache = res.interfaces || [];
- _fortiCacheTime = Date.now();
- renderFortiInterfaces(_fortiCache);
+ if (res.loading) {
+ // First background poll hasn't completed yet — show spinner
+ document.getElementById('forti-body').innerHTML =
+ '
⌛ Fetching port stats…
';
+ return;
+ }
+ renderFortiInterfaces(res.interfaces || [], res.age);
}
}
-function renderFortiInterfaces(ifaces) {
+function renderFortiInterfaces(ifaces, age) {
// Skip loopback, tunnel/ssl, and aggregate meta-interfaces
const SKIP = new Set(['ssl.root', 'VDOM_LINK', 'npu0_vlink0', 'npu0_vlink1']);
const shown = ifaces.filter(i =>
@@ -547,9 +553,13 @@ function renderFortiInterfaces(ifaces) {
`;
}).join('');
+ const stale = age != null && age > 20;
+ const ageStr = age != null ? `${age}s ago` : '';
+ const ageBadge = `