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) {
+
-
${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 = `${stale ? '⚠ stale · ' : ''}updated ${ageStr}`; + document.getElementById('forti-body').innerHTML = `
${rows || '
No interfaces returned
'}
- + `; // Port → student card line: click to show, click again to clear @@ -566,7 +576,6 @@ function renderFortiInterfaces(ifaces) { await apiFetch('/api/fortigate/disconnect', 'POST'); fortiConnected = false; clearInterval(fortiRefreshTimer); - _fortiCache = null; renderFortiDisconnected(); }); } @@ -632,3 +641,279 @@ document.getElementById('secret-dot').addEventListener('click', async () => { const res = await apiFetch('/api/demo', 'POST'); if (res?.ok) poll(); }); + +// ── Deploy modal ────────────────────────────────────────────────────────── + +const DEPLOY_CMDS = { + update: { + label: 'Update RPi', + cmd: 'sudo apt update && sudo DEBIAN_FRONTEND=noninteractive apt upgrade -y', + }, + cockpit: { + label: 'Install Cockpit', + cmd: 'sudo apt install -y cockpit && sudo systemctl enable --now cockpit.socket', + }, + docker: { + label: 'Install Docker', + cmd: 'curl -sSL https://get.docker.com | sh && sudo usermod -aG docker $USER', + }, +}; + +let deployIp = null; +let deployJobId = null; +let deployPollTimer = null; +let deployLinesSeen = 0; +let deployRunning = false; + +function openDeployModal(ip) { + deployIp = ip; + const host = Object.values(window._lastRoster || {}).find ? null : null; + + // populate header + const card = cards[ip]; + const label = card?.querySelector('.label-input')?.value || ip; + document.getElementById('deploy-target-label').textContent = label; + document.getElementById('deploy-target-ip').textContent = ip; + + // clear output + deployClearOutput(); + setDeployBusy(false); + + document.getElementById('deploy-modal').classList.add('open'); + document.getElementById('deploy-cmd-input').focus(); + + // kick off service check + checkDeployServices(ip); +} + +function closeDeployModal() { + document.getElementById('deploy-modal').classList.remove('open'); + if (deployPollTimer) { clearInterval(deployPollTimer); deployPollTimer = null; } +} + +function deployClearOutput() { + document.getElementById('deploy-output').innerHTML = ''; + deployJobId = null; + deployLinesSeen = 0; +} + +function setDeployBusy(busy) { + deployRunning = busy; + document.querySelectorAll('.deploy-cmd-btn, #deploy-run-btn').forEach(b => { + b.disabled = busy; + }); + if (busy) { + appendDeployLine('', 'separator'); + } +} + +function appendDeployLine(text, cls = '') { + const out = document.getElementById('deploy-output'); + const div = document.createElement('div'); + div.className = 't-line' + (cls ? ' ' + cls : ''); + if (cls === 'separator') { + div.innerHTML = '─────────────────────────────────────'; + } else { + div.textContent = text; + } + out.appendChild(div); + out.scrollTop = out.scrollHeight; +} + +async function runDeployCmd(cmd, label) { + if (deployRunning) return; + if (deployPollTimer) { clearInterval(deployPollTimer); deployPollTimer = null; } + deployLinesSeen = 0; + setDeployBusy(true); + appendDeployLine(`$ ${cmd}`, 't-cmd'); + + const res = await apiFetch('/api/deploy/exec', 'POST', { ip: deployIp, cmd }); + if (!res?.ok) { + appendDeployLine(`[error] ${res?.err || 'request failed'}`, 't-error'); + setDeployBusy(false); + return; + } + deployJobId = res.job_id; + deployPollTimer = setInterval(pollDeployJob, 500); +} + +async function pollDeployJob() { + if (!deployJobId) return; + const res = await apiFetch(`/api/deploy/status/${deployJobId}`); + if (!res?.ok) return; + + // append any new lines + const newLines = (res.lines || []).slice(deployLinesSeen); + deployLinesSeen = res.lines.length; + newLines.forEach(l => appendDeployLine(l)); + + if (!res.running) { + clearInterval(deployPollTimer); + deployPollTimer = null; + const ok = res.exit === 0; + appendDeployLine( + ok ? `✓ Done (exit 0)` : `✗ Failed (exit ${res.exit})`, + ok ? 't-success' : 't-error' + ); + setDeployBusy(false); + // re-check services so links appear after installs complete + checkDeployServices(deployIp); + } +} + +async function checkDeployServices(ip) { + const svcEl = document.getElementById('deploy-services'); + svcEl.innerHTML = 'Checking…'; + const res = await apiFetch('/api/deploy/check', 'POST', { ip }); + if (!res?.ok) { + svcEl.innerHTML = `${res?.err || 'SSH unavailable'}`; + return; + } + renderDeployServices(ip, res.services || {}); +} + +function renderDeployServices(ip, svc) { + const cockpit = !!svc.cockpit; + const cockpitActive = !!svc.cockpit_active; + const docker = !!svc.docker; + const cockpitUrl = `http://${ip}:9090`; + + document.getElementById('deploy-services').innerHTML = ` +
+ + Cockpit + ${cockpit + ? ` + ↗ ${cockpitUrl} + ${cockpitActive ? '' : ' (inactive)'}` + : 'Not installed'} +
+
+ + Docker + ${docker ? 'Installed' : 'Not installed'} +
+ `; +} + +// Wire up deploy modal buttons +document.querySelectorAll('.deploy-cmd-btn').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.cmd; + const def = DEPLOY_CMDS[key]; + if (def) runDeployCmd(def.cmd, def.label); + }); +}); + +document.getElementById('deploy-run-btn').addEventListener('click', () => { + const cmd = document.getElementById('deploy-cmd-input').value.trim(); + if (cmd) { runDeployCmd(cmd); document.getElementById('deploy-cmd-input').value = ''; } +}); + +document.getElementById('deploy-cmd-input').addEventListener('keydown', e => { + if (e.key === 'Enter') document.getElementById('deploy-run-btn').click(); + if (e.key === 'Escape') closeDeployModal(); +}); + +document.getElementById('deploy-close-btn').addEventListener('click', closeDeployModal); +document.getElementById('deploy-clear-btn').addEventListener('click', deployClearOutput); + +document.getElementById('deploy-modal').addEventListener('click', e => { + if (e.target === document.getElementById('deploy-modal')) closeDeployModal(); +}); + +// ── Scan-range inline editor ────────────────────────────────────────────────── + +let _srpPopover = null; + +function _srpOutsideClose(e) { + if (_srpPopover && !_srpPopover.contains(e.target) && + e.target !== document.getElementById('scan-range')) { + closeScanRangeEdit(); + } +} + +function openScanRangeEdit() { + if (_srpPopover) { closeScanRangeEdit(); return; } + + const anchor = document.getElementById('scan-range'); + const rect = anchor.getBoundingClientRect(); + + const pop = document.createElement('div'); + pop.id = 'srp-popover'; + pop.innerHTML = ` +
EDIT SCAN RANGE
+
+ + . + + + +
+
base  ·  first octet — last octet
+
+
+ + +
`; + + pop.style.top = (rect.bottom + 6) + 'px'; + pop.style.left = rect.left + 'px'; + document.body.appendChild(pop); + _srpPopover = pop; + + document.getElementById('srp-base').focus(); + document.getElementById('srp-base').select(); + + pop.querySelector('.srp-cancel').addEventListener('click', closeScanRangeEdit); + pop.querySelector('.srp-apply').addEventListener('click', applyScanRange); + ['srp-base', 'srp-start', 'srp-end'].forEach(id => { + document.getElementById(id).addEventListener('keydown', e => { + if (e.key === 'Enter') applyScanRange(); + if (e.key === 'Escape') closeScanRangeEdit(); + }); + }); + + setTimeout(() => document.addEventListener('click', _srpOutsideClose), 0); +} + +function closeScanRangeEdit() { + _srpPopover?.remove(); + _srpPopover = null; + document.removeEventListener('click', _srpOutsideClose); +} + +async function applyScanRange() { + const base = document.getElementById('srp-base').value.trim(); + const start = parseInt(document.getElementById('srp-start').value, 10); + const end = parseInt(document.getElementById('srp-end').value, 10); + const errEl = document.getElementById('srp-err'); + + if (!base) { errEl.textContent = 'Base IP required'; return; } + if (isNaN(start) || isNaN(end)) { errEl.textContent = 'Start and end must be numbers'; return; } + if (start > end) { errEl.textContent = 'Start must be ≤ end'; return; } + + const btn = _srpPopover.querySelector('.srp-apply'); + btn.disabled = true; + btn.textContent = '…'; + + const res = await apiFetch('/api/scan-range', 'POST', { base, start, end }); + if (!res?.ok) { + errEl.textContent = res?.err || 'Failed'; + btn.disabled = false; + btn.textContent = 'Apply'; + return; + } + + liveBase = res.base; + liveStart = res.start; + liveEnd = res.end; + const rangeStr = `${liveBase}.${liveStart}–${liveBase}.${liveEnd}`; + document.getElementById('scan-range').textContent = rangeStr; + document.getElementById('hint-range').textContent = rangeStr; + + closeScanRangeEdit(); +} + +// Wire up scan-range click +document.getElementById('scan-range').addEventListener('click', openScanRangeEdit); diff --git a/teacher/static/style.css b/teacher/static/style.css index a1cfbc2..fb454c8 100644 --- a/teacher/static/style.css +++ b/teacher/static/style.css @@ -8,8 +8,8 @@ --surface2: #180900; /* slightly lifted surface */ --border: #3a1600; /* resting border */ --text: #ffd0a0; /* warm parchment */ - --muted: #c06028; /* ember-brown muted — bright enough to read */ - --ip-color: #8a4820; /* dim secondary text — still legible */ + --muted: #d47838; /* ember-brown muted — readable on laptop screens */ + --ip-color: #c06830; /* secondary text — WCAG AA compliant on dark bg */ /* ── Neon accents ───────────────────── */ --accent: #ff5500; /* primary neon orange */ @@ -25,7 +25,7 @@ --cpu-bar: #ff5500; --mem-bar: #cc1100; - --card-w: 178px; + --card-w: 200px; --hdr-h: 52px; } @@ -34,7 +34,7 @@ html, body { background: var(--bg); color: var(--text); font-family: 'Courier New', 'Consolas', monospace; - font-size: 13px; + font-size: 14px; overflow: hidden; -webkit-font-smoothing: antialiased; } @@ -79,8 +79,88 @@ html, body { background: rgba(255, 85, 0, 0.3); } -.muted { color: var(--muted); font-size: 11px; letter-spacing: 0.04em; } -.small { font-size: 10px; } +/* scan-range is clickable */ +#scan-range { + cursor: pointer; + border-bottom: 1px dashed rgba(192, 104, 48, 0.4); + transition: color 0.15s, border-color 0.15s; +} +#scan-range:hover { + color: var(--text); + border-bottom-color: var(--accent); +} + +/* ─── Scan-range edit popover ───────────────────────────────────────── */ +#srp-popover { + position: fixed; + z-index: 300; + background: #080200; + border: 1px solid var(--accent); + border-radius: 3px; + padding: 14px 16px 12px; + box-shadow: var(--glow-md), 0 8px 32px rgba(0,0,0,0.8); + min-width: 320px; +} +.srp-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.22em; + color: var(--accent); + text-transform: uppercase; + margin-bottom: 10px; + text-shadow: var(--glow-sm); +} +.srp-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} +.srp-input { + background: #040100; + border: 1px solid var(--border); + border-radius: 2px; + color: var(--text); + font-family: inherit; + font-size: 13px; + padding: 5px 7px; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} +.srp-input:focus { + border-color: var(--accent); + box-shadow: 0 0 6px rgba(255,85,0,0.2); +} +.srp-base { width: 130px; } +.srp-oct { width: 56px; text-align: center; } +/* hide number spinners */ +.srp-oct::-webkit-inner-spin-button, +.srp-oct::-webkit-outer-spin-button { -webkit-appearance: none; } +.srp-oct { -moz-appearance: textfield; } + +.srp-sep { color: var(--muted); font-size: 14px; } +.srp-dash { color: var(--muted); font-size: 14px; padding: 0 2px; } +.srp-hint { + font-size: 10px; + color: var(--ip-color); + letter-spacing: 0.06em; + margin-bottom: 8px; +} +.srp-err { + font-size: 11px; + color: #ff3300; + min-height: 14px; + margin-bottom: 8px; + letter-spacing: 0.04em; +} +.srp-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.muted { color: var(--muted); font-size: 12px; letter-spacing: 0.04em; } +.small { font-size: 11px; } .host-count { font-weight: 700; @@ -93,7 +173,7 @@ html, body { /* ── Status badge ── */ .badge { - font-size: 10px; + font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 2px; @@ -125,7 +205,7 @@ button { cursor: pointer; border-radius: 2px; padding: 6px 13px; - font-size: 11px; + font-size: 12px; font-weight: 700; font-family: inherit; letter-spacing: 0.1em; @@ -301,7 +381,7 @@ button:active { transform: scale(0.97); } border: none; border-bottom: 1px solid transparent; color: var(--text); - font-size: 12px; + font-size: 13px; font-weight: 700; font-family: inherit; letter-spacing: 0.05em; @@ -315,7 +395,7 @@ button:active { transform: scale(0.97); } border-bottom-color: var(--accent); text-shadow: 0 0 8px rgba(255, 85, 0, 0.4); } -.label-input::placeholder { color: #4a2008; font-weight: 400; } +.label-input::placeholder { color: #7a4520; font-weight: 400; } .remove-btn { flex-shrink: 0; @@ -337,7 +417,7 @@ button:active { transform: scale(0.97); } /* ─── Card body ─────────────────────────────────────────────────────── */ .card-host { - font-size: 11px; + font-size: 12px; color: var(--muted); margin-bottom: 1px; white-space: nowrap; @@ -346,7 +426,7 @@ button:active { transform: scale(0.97); } letter-spacing: 0.04em; } .card-ip { - font-size: 10px; + font-size: 12px; color: var(--ip-color); margin-bottom: 8px; letter-spacing: 0.06em; @@ -360,7 +440,7 @@ button:active { transform: scale(0.97); } margin-bottom: 4px; } .metric-lbl { - font-size: 9px; + font-size: 11px; color: var(--muted); width: 26px; flex-shrink: 0; @@ -389,7 +469,7 @@ button:active { transform: scale(0.97); } box-shadow: 0 0 4px rgba(200, 17, 0, 0.6); } .metric-val { - font-size: 11px; + font-size: 12px; font-weight: 700; width: 30px; text-align: right; @@ -405,7 +485,7 @@ button:active { transform: scale(0.97); } flex-wrap: wrap; } .pill { - font-size: 9px; + font-size: 10px; padding: 2px 5px; border-radius: 2px; font-weight: 700; @@ -441,7 +521,7 @@ button:active { transform: scale(0.97); } /* ── Card footer ── */ .card-footer { - font-size: 9px; + font-size: 10px; color: var(--ip-color); margin-top: 5px; text-align: right; @@ -455,7 +535,7 @@ button:active { transform: scale(0.97); } .offline-label { display: none; - font-size: 10px; + font-size: 11px; color: #cc2200; text-align: center; font-weight: 700; @@ -485,7 +565,7 @@ button:active { transform: scale(0.97); } left: 16px; background: var(--bg); color: var(--accent); - font-size: 9px; + font-size: 11px; font-weight: 700; letter-spacing: 0.25em; text-transform: uppercase; @@ -539,7 +619,7 @@ button:active { transform: scale(0.97); } margin-bottom: 10px; } .forti-title { - font-size: 11px; + font-size: 13px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; @@ -548,13 +628,13 @@ button:active { transform: scale(0.97); } margin-bottom: 3px; } .forti-ip { - font-size: 10px; + font-size: 12px; color: var(--muted); letter-spacing: 0.06em; } .forti-connected-badge { - font-size: 9px; + font-size: 11px; font-weight: 700; letter-spacing: 0.15em; padding: 2px 7px; @@ -569,15 +649,15 @@ button:active { transform: scale(0.97); } .forti-auth-prompt { text-align: center; padding: 14px 0 8px; - font-size: 11px; + font-size: 12px; color: var(--muted); letter-spacing: 0.1em; } .forti-auth-prompt span { display: block; - font-size: 9px; + font-size: 11px; margin-top: 4px; - color: var(--ip-color); + color: var(--muted); letter-spacing: 0.06em; } @@ -592,7 +672,7 @@ button:active { transform: scale(0.97); } gap: 6px; padding: 5px 4px; border-bottom: 1px solid rgba(255, 60, 0, 0.08); - font-size: 10px; + font-size: 11px; border-radius: 2px; transition: background 0.15s; } @@ -637,10 +717,10 @@ button:active { transform: scale(0.97); } .iface-alias { font-weight: 400; color: var(--muted); - font-size: 9px; + font-size: 10px; } .iface-link { - font-size: 9px; + font-size: 11px; font-weight: 700; letter-spacing: 0.08em; white-space: nowrap; @@ -649,12 +729,12 @@ button:active { transform: scale(0.97); } .link-down { color: var(--offline); } .iface-speed { - font-size: 9px; + font-size: 11px; color: var(--muted); text-align: center; } .iface-ip { - font-size: 9px; + font-size: 11px; color: var(--muted); letter-spacing: 0.04em; white-space: nowrap; @@ -666,22 +746,184 @@ button:active { transform: scale(0.97); } display: flex; gap: 10px; padding: 2px 0 4px; - font-size: 9px; - color: var(--ip-color); + font-size: 11px; + color: var(--muted); letter-spacing: 0.04em; } .iface-traffic .rx { color: #66bb44; } .iface-traffic .tx { color: #cc7733; } .iface-traffic .err { color: #aa2200; } +.forti-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + gap: 6px; +} + +.forti-age { + font-size: 11px; + color: var(--muted); + white-space: nowrap; + opacity: 0.8; +} + +.forti-age.forti-stale { + color: #ee6600; + opacity: 1; + font-weight: bold; +} + +.forti-loading { + font-style: italic; + opacity: 0.7; +} + .forti-disc-btn { - margin-top: 10px; - width: 100%; - font-size: 10px; - padding: 5px; + flex-shrink: 0; + font-size: 11px; + padding: 4px 8px; border-radius: 2px; } +/* ─── Card deploy button ────────────────────────────────────────────── */ +.deploy-btn { + flex-shrink: 0; + background: transparent; + color: var(--accent); + font-size: 12px; + padding: 0 3px; + line-height: 1; + border: none; + border-radius: 2px; + opacity: 0.5; + transition: opacity 0.15s, text-shadow 0.15s; + cursor: pointer; +} +.deploy-btn:hover { + opacity: 1; + text-shadow: 0 0 6px rgba(255, 85, 0, 0.8); +} + +/* ─── Deploy modal ───────────────────────────────────────────────────── */ +#deploy-modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.88); + z-index: 200; + align-items: center; + justify-content: center; + backdrop-filter: blur(2px); +} +#deploy-modal.open { display: flex; } + +.deploy-box { + width: 520px; + max-height: 90vh; + overflow-y: auto; +} + +.deploy-actions { + display: flex; + gap: 6px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.deploy-cmd-btn { + flex: 1; + min-width: 100px; + font-size: 12px; + padding: 6px 8px; + border-radius: 2px; + background: transparent; + border: 1px solid var(--border); + color: var(--text); + cursor: pointer; + transition: border-color 0.15s, color 0.15s, box-shadow 0.15s; +} +.deploy-cmd-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); + box-shadow: 0 0 6px rgba(255, 85, 0, 0.3); +} +.deploy-cmd-btn:disabled { opacity: 0.35; cursor: not-allowed; } + +.deploy-services { + margin-bottom: 12px; +} + +.svc-row { + display: flex; + align-items: center; + gap: 7px; + padding: 4px 0; + font-size: 12px; + border-bottom: 1px solid rgba(255,85,0,0.06); +} +.svc-dot { font-size: 9px; } +.svc-dot.svc-up { color: #44cc44; } +.svc-dot.svc-dn { color: #555; } +.svc-name { color: var(--text); min-width: 60px; } +.svc-link { + color: var(--accent); + text-decoration: none; + font-size: 11px; + border-bottom: 1px solid rgba(255,85,0,0.3); + transition: color 0.15s, border-color 0.15s; +} +.svc-link:hover { color: #ff9944; border-color: #ff9944; } +.svc-ok { color: #44cc44; font-size: 11px; } +.svc-na { color: #888; font-size: 11px; } +.svc-warn { color: #ee6600; font-size: 11px; } + +.deploy-custom-row { + display: flex; + gap: 6px; + margin-bottom: 10px; +} +.deploy-custom-row .modal-input { flex: 1; } + +.deploy-output-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} +.deploy-clear-btn { + font-size: 11px; + padding: 2px 6px; + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + border-radius: 2px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.deploy-clear-btn:hover { color: var(--text); border-color: var(--accent); } + +.deploy-terminal { + background: #020100; + border: 1px solid rgba(255, 85, 0, 0.2); + border-radius: 2px; + padding: 8px 10px; + height: 220px; + overflow-y: auto; + font-family: 'Courier New', 'Consolas', monospace; + font-size: 11px; + line-height: 1.5; + color: #cc7733; + margin-bottom: 14px; + scrollbar-width: thin; + scrollbar-color: rgba(255,85,0,0.2) transparent; +} +.deploy-terminal .t-line { color: #cc7733; } +.deploy-terminal .t-cmd { color: #ff8800; font-weight: bold; } +.deploy-terminal .t-success { color: #44dd44; font-weight: bold; } +.deploy-terminal .t-error { color: #ee3322; font-weight: bold; } + /* ─── Credential modal ──────────────────────────────────────────────── */ #forti-modal { display: none; @@ -725,7 +967,7 @@ button:active { transform: scale(0.97); } } .modal-title { - font-size: 12px; + font-size: 13px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; @@ -734,13 +976,13 @@ button:active { transform: scale(0.97); } margin-bottom: 3px; } .modal-subtitle { - font-size: 10px; + font-size: 12px; color: var(--muted); letter-spacing: 0.08em; margin-bottom: 18px; } .modal-section-label { - font-size: 9px; + font-size: 11px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; @@ -752,7 +994,7 @@ button:active { transform: scale(0.97); } .modal-field { margin-bottom: 12px; } .modal-label { display: block; - font-size: 9px; + font-size: 11px; color: var(--muted); letter-spacing: 0.12em; text-transform: uppercase; @@ -765,7 +1007,7 @@ button:active { transform: scale(0.97); } border-radius: 2px; color: var(--text); font-family: inherit; - font-size: 12px; + font-size: 13px; padding: 7px 10px; outline: none; letter-spacing: 0.04em; @@ -776,8 +1018,8 @@ button:active { transform: scale(0.97); } box-shadow: 0 0 8px rgba(255, 85, 0, 0.2); } .modal-input::placeholder { - color: var(--ip-color); - font-size: 10px; + color: var(--muted); + font-size: 12px; } .modal-divider { @@ -786,7 +1028,7 @@ button:active { transform: scale(0.97); } gap: 10px; margin: 6px 0 14px; color: var(--muted); - font-size: 9px; + font-size: 11px; letter-spacing: 0.15em; } .modal-divider::before, @@ -799,7 +1041,7 @@ button:active { transform: scale(0.97); } .modal-err { min-height: 16px; - font-size: 10px; + font-size: 12px; color: #ff2200; letter-spacing: 0.04em; margin-bottom: 14px; diff --git a/teacher/teacher.py b/teacher/teacher.py index 3f4da89..9e35125 100644 --- a/teacher/teacher.py +++ b/teacher/teacher.py @@ -5,9 +5,9 @@ where the teacher arranges Pi cards to match the physical room layout. Env vars (all have defaults, all overridable at runtime): - SCAN_BASE IP prefix for the classroom subnet (default "192.168.1") + SCAN_BASE IP prefix for the classroom subnet (default "192.168.10") SCAN_START First host octet to scan (default 100) - SCAN_END Last host octet to scan (default 200) + SCAN_END Last host octet to scan (default 150) SSH_USER SSH username shared by all Pis (default "pi") SSH_PASS SSH password shared by all Pis (default "raspberry") PROBE_INTERVAL Seconds between discovery sweeps (default 30) @@ -35,9 +35,11 @@ # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- -SCAN_BASE = os.environ.get('SCAN_BASE', '192.168.1') -SCAN_START = int(os.environ.get('SCAN_START', '100')) -SCAN_END = int(os.environ.get('SCAN_END', '200')) +# Scan range — mutable so the teacher can update them at runtime via the UI. +# Env vars are the initial defaults; state.json overrides them on load. +_scan_base = os.environ.get('SCAN_BASE', '192.168.10') +_scan_start = int(os.environ.get('SCAN_START', '100')) +_scan_end = int(os.environ.get('SCAN_END', '150')) SSH_USER = os.environ.get('SSH_USER', 'pi') SSH_PASS = os.environ.get('SSH_PASS', 'raspberry') PROBE_INTERVAL = float(os.environ.get('PROBE_INTERVAL', '30')) @@ -97,13 +99,17 @@ # Persistence — atomic write via temp file rename. # --------------------------------------------------------------------------- def _load_state(): - global _roster, _layout, _locked + global _roster, _layout, _locked, _scan_base, _scan_start, _scan_end try: data = json.loads(STATE_FILE.read_text()) with _lock: _roster = data.get('roster', {}) _layout = data.get('layout', {}) _locked = data.get('locked', False) + scan = data.get('scan', {}) + if scan.get('base'): _scan_base = scan['base'] + if scan.get('start'): _scan_start = int(scan['start']) + if scan.get('end'): _scan_end = int(scan['end']) print(f'[state] loaded {len(_roster)} hosts (locked={_locked})', flush=True) except FileNotFoundError: pass @@ -114,7 +120,12 @@ def _load_state(): def _save_state(): DATA_DIR.mkdir(parents=True, exist_ok=True) with _lock: - data = {'roster': dict(_roster), 'layout': dict(_layout), 'locked': _locked} + data = { + 'roster': dict(_roster), + 'layout': dict(_layout), + 'locked': _locked, + 'scan': {'base': _scan_base, 'start': _scan_start, 'end': _scan_end}, + } tmp = STATE_FILE.with_suffix('.tmp') try: tmp.write_text(json.dumps(data, indent=2)) @@ -213,9 +224,9 @@ def _background(): known = set(_roster) candidates = [ - f'{SCAN_BASE}.{i}' - for i in range(SCAN_START, SCAN_END + 1) - if f'{SCAN_BASE}.{i}' not in known + f'{_scan_base}.{i}' + for i in range(_scan_start, _scan_end + 1) + if f'{_scan_base}.{i}' not in known ] # Parallel ping — fast, ~1 s for a /24 slice @@ -313,13 +324,39 @@ def api_status(): 'layout': dict(_layout), 'updated': datetime.now().isoformat(timespec='seconds'), 'config': { - 'base': SCAN_BASE, - 'start': SCAN_START, - 'end': SCAN_END, + 'base': _scan_base, + 'start': _scan_start, + 'end': _scan_end, }, }) +@app.route('/api/scan-range', methods=['POST']) +def api_scan_range(): + """Update the IP scan range at runtime.""" + global _scan_base, _scan_start, _scan_end + d = request.get_json(silent=True) or {} + base = d.get('base', '').strip() + start = d.get('start') + end = d.get('end') + if not base or start is None or end is None: + return jsonify({'ok': False, 'err': 'Provide base, start, and end'}), 400 + try: + start, end = int(start), int(end) + except (TypeError, ValueError): + return jsonify({'ok': False, 'err': 'start and end must be integers'}), 400 + if not (0 <= start <= 254 and 0 <= end <= 254 and start <= end): + return jsonify({'ok': False, 'err': 'start/end must be 0–254 and start ≤ end'}), 400 + parts = base.split('.') + if len(parts) != 3 or not all(p.isdigit() and 0 <= int(p) <= 255 for p in parts): + return jsonify({'ok': False, 'err': 'base must be x.x.x (e.g. 192.168.10)'}), 400 + _scan_base, _scan_start, _scan_end = base, start, end + _save_state() + _scan_now.set() # trigger an immediate scan with the new range + print(f'[scan] range updated → {base}.{start}–{base}.{end}', flush=True) + return jsonify({'ok': True, 'base': base, 'start': start, 'end': end}) + + @app.route('/api/lock', methods=['POST']) def api_lock(): global _locked @@ -462,13 +499,112 @@ def api_clear_offline(): # Only GET /api/v2/monitor/system/interface is needed so CSRF tokens are # not required (CSRF applies to mutating methods only). # --------------------------------------------------------------------------- -FORTI_IP = os.environ.get('FORTI_IP', '192.168.0.10') -FORTI_TIMEOUT = int(os.environ.get('FORTI_TIMEOUT', '8')) +FORTI_IP = os.environ.get('FORTI_IP', '192.168.0.10') +FORTI_TIMEOUT = int(os.environ.get('FORTI_TIMEOUT', '8')) +FORTI_POLL_INTERVAL = int(os.environ.get('FORTI_POLL_INTERVAL', '10')) + +_forti_lock = threading.Lock() +_forti_cookie = '' # "APSCOOKIE_...=...; ccsrftoken=..." — session auth +_forti_token = '' # Bearer token — token auth +_forti_authed = False +_forti_bg_cache = None # last successful interface list (server-side) +_forti_bg_time = 0.0 # epoch seconds of last successful fetch +_forti_bg_stop = threading.Event() +_forti_bg_thread = None # background poller thread + + +def _forti_bg_poller(): + """Background thread: polls FortiGate every FORTI_POLL_INTERVAL seconds. + + Uses a persistent HTTPS keep-alive connection so each poll avoids the + TCP + TLS handshake overhead. Polls immediately on first iteration + (no leading sleep) so the cache is populated right after connect. + """ + global _forti_bg_cache, _forti_bg_time, _forti_authed + print(f'[forti] background poller started (interval={FORTI_POLL_INTERVAL}s)', flush=True) + conn = None + PATH = '/api/v2/monitor/system/interface?vdom=root' -_forti_lock = threading.Lock() -_forti_cookie = '' # "APSCOOKIE_...=...; ccsrftoken=..." — session auth -_forti_token = '' # Bearer token — token auth -_forti_authed = False + while True: + # ── fetch now ──────────────────────────────────────────────────── + with _forti_lock: + authed = _forti_authed + cookie = _forti_cookie + token = _forti_token + if not authed: + break + + headers = {} + if token: + headers['Authorization'] = f'Bearer {token}' + elif cookie: + headers['Cookie'] = cookie + else: + break + + try: + if conn is None: + conn = http.client.HTTPSConnection( + FORTI_IP, context=_forti_ctx(), timeout=FORTI_TIMEOUT) + conn.request('GET', PATH, headers=headers) + resp = conn.getresponse() + raw = resp.read().decode(errors='ignore') + status = resp.status + try: + data = json.loads(raw) + except Exception: + data = {'_raw': raw[:500]} + except Exception as e: + print(f'[forti] poll error — {type(e).__name__}: {e}', flush=True) + try: + conn.close() + except Exception: + pass + conn = None + # don't exit — reconnect on next cycle + if _forti_bg_stop.wait(FORTI_POLL_INTERVAL): + break + continue + + if status == 200 and data: + results = data.get('results', []) + # FortiOS returns results as a dict keyed by interface name on + # some firmware versions — normalise to a flat list either way. + if isinstance(results, dict): + results = list(results.values()) + with _forti_lock: + _forti_bg_cache = results + _forti_bg_time = time.time() + print(f'[forti] cache refreshed ({len(_forti_bg_cache)} interfaces)', flush=True) + elif status in (401, 403): + with _forti_lock: + _forti_authed = False + print('[forti] session expired — poller exiting', flush=True) + break + else: + print(f'[forti] poll failed HTTP {status}', flush=True) + + # ── sleep, then loop back to poll ───────────────────────────────── + if _forti_bg_stop.wait(FORTI_POLL_INTERVAL): + break + + if conn: + try: + conn.close() + except Exception: + pass + print('[forti] background poller stopped', flush=True) + + +def _start_forti_poller(): + """Start the background poller thread if not already running.""" + global _forti_bg_thread, _forti_bg_stop + if _forti_bg_thread and _forti_bg_thread.is_alive(): + return + _forti_bg_stop.clear() + _forti_bg_thread = threading.Thread(target=_forti_bg_poller, + daemon=True, name='forti-bg') + _forti_bg_thread.start() def _forti_ctx(): @@ -527,8 +663,14 @@ def api_forti_connect(): _forti_cookie = '' status, data = _forti_get('/api/v2/monitor/system/interface?vdom=root') if status == 200: + seed = (data or {}).get('results', []) + if isinstance(seed, dict): + seed = list(seed.values()) with _forti_lock: - _forti_authed = True + _forti_authed = True + _forti_bg_cache = seed + _forti_bg_time = time.time() + _start_forti_poller() return jsonify({'ok': True, 'method': 'token'}) with _forti_lock: _forti_token = '' @@ -577,6 +719,9 @@ def api_forti_connect(): _forti_authed = True print(f'[forti] session auth ok for user={user}', flush=True) + # Poller's first iteration runs immediately and seeds the cache — + # no second blocking round-trip needed here. + _start_forti_poller() return jsonify({'ok': True, 'method': 'session'}) except Exception as e: @@ -585,35 +730,27 @@ def api_forti_connect(): @app.route('/api/fortigate/interfaces') def api_forti_interfaces(): - """Fetch interface statistics from the FortiGate.""" - global _forti_authed + """Return the server-side cached interface list immediately. + The background poller keeps this fresh every FORTI_POLL_INTERVAL seconds.""" with _forti_lock: authed = _forti_authed + cache = _forti_bg_cache + t = _forti_bg_time if not authed: return jsonify({'ok': False, 'err': 'Not authenticated', 'auth_required': True}), 401 - - status, data = _forti_get('/api/v2/monitor/system/interface?vdom=root') - - if status == 401 or status == 403: - with _forti_lock: - _forti_authed = False - return jsonify({'ok': False, 'err': 'Session expired', - 'auth_required': True}), 401 - - if status != 200 or data is None: - return jsonify({'ok': False, - 'err': f'FortiGate returned HTTP {status}', - 'detail': (data or {}).get('_raw', '')}), 502 - - interfaces = data.get('results', []) - return jsonify({'ok': True, 'interfaces': interfaces}) + if cache is None: + # Still waiting for the first background poll — tell the browser + return jsonify({'ok': True, 'interfaces': [], 'loading': True, 'age': None}) + age = round(time.time() - t, 1) + return jsonify({'ok': True, 'interfaces': cache, 'loading': False, 'age': age}) @app.route('/api/fortigate/disconnect', methods=['POST']) def api_forti_disconnect(): - """Log out of the FortiGate and clear stored credentials.""" - global _forti_cookie, _forti_token, _forti_authed + """Log out of the FortiGate, stop the background poller, and clear credentials.""" + global _forti_cookie, _forti_token, _forti_authed, _forti_bg_cache, _forti_bg_time + _forti_bg_stop.set() # signal background poller to exit (closes its conn) with _forti_lock: cookie = _forti_cookie if cookie: @@ -626,13 +763,99 @@ def api_forti_disconnect(): except Exception: pass with _forti_lock: - _forti_cookie = '' - _forti_token = '' - _forti_authed = False + _forti_cookie = '' + _forti_token = '' + _forti_authed = False + _forti_bg_cache = None + _forti_bg_time = 0.0 print('[forti] disconnected', flush=True) return jsonify({'ok': True}) +# --------------------------------------------------------------------------- +# Deploy / SSH exec +# --------------------------------------------------------------------------- +_deploy_jobs = {} # job_id -> {running, lines, exit} +_deploy_jlock = threading.Lock() + + +@app.route('/api/deploy/check', methods=['POST']) +def api_deploy_check(): + """Check which optional services are installed on a Pi.""" + ip = (request.get_json(silent=True) or {}).get('ip', '') + with _lock: + known = ip in _roster + if not known: + return jsonify({'ok': False, 'err': 'Unknown host'}), 404 + probe = ( + 'command -v cockpit-bridge >/dev/null 2>&1 && echo "cockpit:1" || echo "cockpit:0"; ' + 'systemctl is-active cockpit.socket >/dev/null 2>&1 ' + ' && echo "cockpit_active:1" || echo "cockpit_active:0"; ' + 'command -v docker >/dev/null 2>&1 && echo "docker:1" || echo "docker:0"' + ) + try: + cli = paramiko.SSHClient() + cli.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + cli.connect(ip, username=SSH_USER, password=SSH_PASS, timeout=8) + _, stdout, _ = cli.exec_command(probe) + out = stdout.read().decode(errors='ignore') + cli.close() + svc = {} + for line in out.splitlines(): + if ':' in line: + k, v = line.split(':', 1) + svc[k.strip()] = (v.strip() == '1') + return jsonify({'ok': True, 'services': svc}) + except Exception as e: + return jsonify({'ok': False, 'err': f'{type(e).__name__}: {e}'}), 500 + + +@app.route('/api/deploy/exec', methods=['POST']) +def api_deploy_exec(): + """Start an SSH command on a Pi; returns a job_id for polling.""" + d = request.get_json(silent=True) or {} + ip = d.get('ip', '').strip() + cmd = d.get('cmd', '').strip() + if not ip or not cmd: + return jsonify({'ok': False, 'err': 'Missing ip or cmd'}), 400 + + job_id = str(time.time_ns()) + with _deploy_jlock: + _deploy_jobs[job_id] = {'running': True, 'lines': [], 'exit': None} + + def _run(): + try: + cli = paramiko.SSHClient() + cli.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + cli.connect(ip, username=SSH_USER, password=SSH_PASS, timeout=10) + _, stdout, _ = cli.exec_command(cmd, get_pty=True) + for line in iter(stdout.readline, ''): + with _deploy_jlock: + _deploy_jobs[job_id]['lines'].append(line.rstrip('\r\n')) + ec = stdout.channel.recv_exit_status() + cli.close() + with _deploy_jlock: + _deploy_jobs[job_id].update({'running': False, 'exit': ec}) + print(f'[deploy] {ip} exit={ec} cmd={cmd[:60]}', flush=True) + except Exception as e: + with _deploy_jlock: + _deploy_jobs[job_id]['lines'].append(f'[error] {type(e).__name__}: {e}') + _deploy_jobs[job_id].update({'running': False, 'exit': -1}) + + threading.Thread(target=_run, daemon=True, name=f'deploy-{job_id}').start() + return jsonify({'ok': True, 'job_id': job_id}) + + +@app.route('/api/deploy/status/') +def api_deploy_status(job_id): + """Return current output lines and status for a deploy job.""" + with _deploy_jlock: + job = _deploy_jobs.get(job_id) + if job is None: + return jsonify({'ok': False, 'err': 'Unknown job'}), 404 + return jsonify({'ok': True, **job}) + + # --------------------------------------------------------------------------- # Entry # --------------------------------------------------------------------------- @@ -641,7 +864,7 @@ def api_forti_disconnect(): threading.Thread(target=_background, daemon=True, name='bg').start() print( f'[teacher] http://0.0.0.0:{LISTEN_PORT}/ ' - f'scan={SCAN_BASE}.{SCAN_START}-{SCAN_END} ' + f'scan={_scan_base}.{_scan_start}-{_scan_end} ' f'user={SSH_USER} ' f'probe={PROBE_INTERVAL}s health={HEALTH_INTERVAL}s', flush=True, diff --git a/teacher/templates/index.html b/teacher/templates/index.html index 367a195..97cbe97 100644 --- a/teacher/templates/index.html +++ b/teacher/templates/index.html @@ -74,6 +74,43 @@ + + +