From b08d8ba028326fd93dc0b456232a4cf5bfd17618 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Mon, 13 Jul 2026 20:00:04 -0500 Subject: [PATCH] finish up turbo and add stimulus. --- Overlord-Server/bun.lock | 3 + Overlord-Server/package.json | 1 + Overlord-Server/public/assets/client-graph.js | 30 ++- Overlord-Server/public/assets/metrics.js | 50 ++++- Overlord-Server/public/assets/nav.js | 1 + Overlord-Server/public/assets/nav/template.js | 5 +- .../public/assets/notifications.js | 35 +++- Overlord-Server/public/assets/settings.js | 176 ------------------ .../public/assets/stimulus/application.js | 10 + .../controllers/sessions-controller.js | 45 +++++ .../public/assets/turbo-navigation.js | 6 +- Overlord-Server/public/graph.html | 1 - Overlord-Server/public/login.html | 1 + Overlord-Server/public/metrics.html | 1 - Overlord-Server/public/settings.html | 63 ++----- Overlord-Server/scripts/vendor.ts | 30 ++- Overlord-Server/src/main-server.ts | 2 + .../src/server/routes/ui-routes.test.ts | 125 +++++++++++++ .../src/server/routes/ui-routes.ts | 118 ++++++++++++ Overlord-Server/src/server/ui/html.ts | 19 ++ .../src/server/ui/sessions-view.ts | 108 +++++++++++ 21 files changed, 576 insertions(+), 254 deletions(-) create mode 100644 Overlord-Server/public/assets/stimulus/application.js create mode 100644 Overlord-Server/public/assets/stimulus/controllers/sessions-controller.js create mode 100644 Overlord-Server/src/server/routes/ui-routes.test.ts create mode 100644 Overlord-Server/src/server/routes/ui-routes.ts create mode 100644 Overlord-Server/src/server/ui/html.ts create mode 100644 Overlord-Server/src/server/ui/sessions-view.ts diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock index ac1d12d..3acf910 100644 --- a/Overlord-Server/bun.lock +++ b/Overlord-Server/bun.lock @@ -5,6 +5,7 @@ "": { "name": "overlord-server", "dependencies": { + "@hotwired/stimulus": "^3.2.2", "@hotwired/turbo": "^8.0.23", "@msgpack/msgpack": "^3.0.0", "@solana/web3.js": "^1.98.4", @@ -63,6 +64,8 @@ "@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@6.5.2", "", {}, "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q=="], + "@hotwired/stimulus": ["@hotwired/stimulus@3.2.2", "", {}, "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A=="], + "@hotwired/turbo": ["@hotwired/turbo@8.0.23", "", {}, "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index 2b44dcc..16fce15 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@hotwired/stimulus": "^3.2.2", "@hotwired/turbo": "^8.0.23", "@msgpack/msgpack": "^3.0.0", "@solana/web3.js": "^1.98.4", diff --git a/Overlord-Server/public/assets/client-graph.js b/Overlord-Server/public/assets/client-graph.js index a00538a..5c7ea42 100644 --- a/Overlord-Server/public/assets/client-graph.js +++ b/Overlord-Server/public/assets/client-graph.js @@ -1,3 +1,5 @@ +import cytoscape from "/vendor/cytoscape/cytoscape.esm.min.mjs"; + const container = document.getElementById("client-graph"); const searchInput = document.getElementById("graph-search"); const statusSelect = document.getElementById("graph-status"); @@ -12,6 +14,7 @@ const detailActions = document.getElementById("graph-detail-actions"); let cy = null; let currentGraph = null; let searchTimer = null; +let graphPageActive = true; const LARGE_GRAPH_ANIMATION_CLIENTS = 30000; const typeColors = { @@ -215,16 +218,12 @@ function selectNode(node) { } function renderGraph(graph) { + if (!graphPageActive || !container?.isConnected) return; currentGraph = graph; setSummary(graph.summary); - if (!window.cytoscape) { - if (summaryEl) summaryEl.textContent = "Cytoscape failed to load"; - return; - } - if (!cy) { - cy = window.cytoscape({ + cy = cytoscape({ container, elements: graphElements(graph), minZoom: 0.18, @@ -305,7 +304,7 @@ function renderGraph(graph) { } async function loadGraph() { - if (!container) return; + if (!graphPageActive || !container) return; if (summaryEl) summaryEl.textContent = "Loading..."; const params = new URLSearchParams({ limit: "350", @@ -314,10 +313,12 @@ async function loadGraph() { }); const res = await fetch(`/api/client-graph?${params}`, { credentials: "include" }); if (!res.ok) { + if (!graphPageActive) return; if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`; return; } - renderGraph(await res.json()); + const graph = await res.json(); + if (graphPageActive) renderGraph(graph); } function debounceLoad() { @@ -332,10 +333,19 @@ searchInput?.addEventListener("input", debounceLoad); statusSelect?.addEventListener("change", loadGraph); refreshBtn?.addEventListener("click", loadGraph); fitBtn?.addEventListener("click", () => cy?.fit(undefined, 32)); -window.addEventListener("resize", () => { +const handleResize = () => { cy?.resize(); cy?.fit(undefined, 32); -}); +}; +window.addEventListener("resize", handleResize); + +window.addEventListener("pagehide", () => { + graphPageActive = false; + clearTimeout(searchTimer); + window.removeEventListener("resize", handleResize); + cy?.destroy(); + cy = null; +}, { once: true }); bindInspectorActions(); loadGraph(); diff --git a/Overlord-Server/public/assets/metrics.js b/Overlord-Server/public/assets/metrics.js index 1f9c182..2a14dce 100644 --- a/Overlord-Server/public/assets/metrics.js +++ b/Overlord-Server/public/assets/metrics.js @@ -1,3 +1,5 @@ +import Chart from "/vendor/chart.js/chart.esm.js"; + let clientsChart = null; let commandsChart = null; let bandwidthChart = null; @@ -29,6 +31,8 @@ let countryGlobeHoverEntry = null; let countryGlobeSortedFeatures = []; let countryGlobeLastSpinFrameAt = 0; let countryGlobeLastHoverFrameAt = 0; +let metricsPollTimer = null; +let metricsPageActive = true; const GEOJSON_URL = "/vendor/geo-countries/countries.geojson"; const MAX_CHART_POINTS = 240; @@ -1806,6 +1810,7 @@ function updateCharts(history, snapshot) { } async function fetchMetrics() { + if (!metricsPageActive) return; try { const response = await fetch(`/api/metrics?historyLimit=${MAX_CHART_POINTS}`, { credentials: "include", @@ -1821,11 +1826,13 @@ async function fetchMetrics() { } const data = await response.json(); + if (!metricsPageActive) return; updateMetrics(data.snapshot, data.debug); updateCharts(data.history, data.snapshot); document.getElementById("status-text").textContent = "Live"; } catch (err) { + if (!metricsPageActive) return; console.error("Error fetching metrics:", err); document.getElementById("status-text").textContent = "Error"; } @@ -1834,12 +1841,14 @@ async function fetchMetrics() { async function checkAuth() { try { const res = await fetch("/api/auth/me", { credentials: "include" }); + if (!metricsPageActive) return; if (!res.ok) { window.location.href = "/"; return; } const data = await res.json(); + if (!metricsPageActive) return; document.getElementById("username-display").textContent = data.username; const roleBadge = document.getElementById("role-badge"); @@ -1895,16 +1904,18 @@ async function checkAuth() { } } -document.addEventListener("DOMContentLoaded", async () => { +async function initMetricsPage() { await checkAuth(); + if (!metricsPageActive) return; initCharts(); await initCountryMap(); + if (!metricsPageActive) return; await fetchMetrics(); - setInterval(fetchMetrics, METRICS_POLL_INTERVAL_MS); + metricsPollTimer = setInterval(fetchMetrics, METRICS_POLL_INTERVAL_MS); const logoutBtn = document.getElementById("logout-btn"); if (logoutBtn && !logoutBtn.dataset.boundLogout) { @@ -1929,4 +1940,37 @@ document.addEventListener("DOMContentLoaded", async () => { } }); } -}); +} + +function teardownMetricsPage() { + metricsPageActive = false; + if (metricsPollTimer !== null) clearInterval(metricsPollTimer); + metricsPollTimer = null; + if (countryGlobeFrame !== null) cancelAnimationFrame(countryGlobeFrame); + countryGlobeFrame = null; + countryGlobeResizeObserver?.disconnect(); + countryGlobeResizeObserver = null; + countryGlobeCtx = null; + countryGlobeCanvas = null; + for (const chart of [ + clientsChart, + commandsChart, + bandwidthChart, + httpRequestsChart, + httpChart, + memoryChart, + eventLoopChart, + sessionsChart, + osChart, + ]) { + chart?.destroy(); + } +} + +window.addEventListener("pagehide", teardownMetricsPage, { once: true }); + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initMetricsPage, { once: true }); +} else { + void initMetricsPage(); +} diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js index a9629f9..13f4d0b 100644 --- a/Overlord-Server/public/assets/nav.js +++ b/Overlord-Server/public/assets/nav.js @@ -12,6 +12,7 @@ import { createAdaptiveNavController } from "./nav/layout.js"; import { applyUserRoleUI, applyThumbnailWallVisibility } from "./nav/role-ui.js"; import { loadPluginNavItems } from "./nav/plugins-loader.js"; import { init as initCommandPalette } from "./command-palette.js"; +import "./stimulus/application.js"; import { runWithoutPageTracking, setupTurboNavigation, diff --git a/Overlord-Server/public/assets/nav/template.js b/Overlord-Server/public/assets/nav/template.js index a574a07..6ab0a10 100644 --- a/Overlord-Server/public/assets/nav/template.js +++ b/Overlord-Server/public/assets/nav/template.js @@ -64,7 +64,7 @@ export const NAV_GROUPS = [ children: [ { href: "/metrics", label: "Metrics", icon: "fa-chart-line", iconColor: "text-emerald-400", linkId: "metrics-link" }, { href: "/graph", label: "Graph", icon: "fa-diagram-project", iconColor: "text-cyan-400", linkId: "graph-link" }, - { href: "/screenshots", label: "Screenshot Wall", icon: "fa-images", iconColor: "text-sky-400", linkId: "screenshots-link", hidden: true }, + { href: "/screenshots", label: "Screenshot Wall", icon: "fa-images", iconColor: "text-sky-400", linkId: "screenshots-link", hidden: true, turboPrefetch: false }, ], }, { @@ -83,10 +83,11 @@ export const NAV_GROUPS = [ export function dropdownItem(child) { const hiddenCls = child.hidden ? " hidden" : ""; + const prefetchAttr = child.turboPrefetch === false ? ' data-turbo-prefetch="false"' : ""; const badgeHtml = child.hasBadge ? `` : ""; - return ` ${child.label} diff --git a/Overlord-Server/public/assets/notifications.js b/Overlord-Server/public/assets/notifications.js index f42e6d4..d16ad46 100644 --- a/Overlord-Server/public/assets/notifications.js +++ b/Overlord-Server/public/assets/notifications.js @@ -85,6 +85,8 @@ let defaultWebhookTemplate = ""; let defaultTelegramTemplate = ""; let webhookTemplateEditor = null; let notificationTable = null; +let pageActive = true; +const subscriptionCleanups = []; function tryFormatJsonText(text) { const source = String(text || "").trim(); @@ -336,12 +338,17 @@ function entryMatchesSearch(entry, query) { } function applyTableView() { + if (!pageActive) return; const q = tableState.search.trim().toLowerCase(); const all = Array.from(tableState.entries.values()); const filtered = q ? all.filter((entry) => entryMatchesSearch(entry, q)) : all; filtered.sort(compareEntries); const rows = filtered.map(entryToRow); - if (notificationTable) notificationTable.replaceData(rows); + if (notificationTable && tableEl?.isConnected) { + void notificationTable.replaceData(rows).catch((err) => { + if (pageActive) console.error("Failed to update notification table:", err); + }); + } if (emptyState) { if (all.length === 0) { @@ -894,24 +901,25 @@ function connect() { startNotificationClient(); if (panel) markAllNotificationsRead(); - subscribeStatus((status) => { + subscriptionCleanups.push(subscribeStatus((status) => { if (status === "connected") setStatus("Connected", "ok"); if (status === "error") setStatus("Error", "error"); if (status === "disconnected") setStatus("Disconnected", "warn"); - }); + })); - subscribeReady((history) => { + subscriptionCleanups.push(subscribeReady((history) => { + if (!pageActive) return; clearTable(); for (const item of history) { upsertEntry(item, "notification", false); } applyTableView(); markAllNotificationsRead(); - }); + })); - subscribeNotifications((item) => addNotification(item, true)); - subscribeClientEvents((item) => addClientEvent(item)); - subscribeNotificationsCleared((clientId) => removeEntriesForClient(clientId)); + subscriptionCleanups.push(subscribeNotifications((item) => addNotification(item, true))); + subscriptionCleanups.push(subscribeClientEvents((item) => addClientEvent(item))); + subscriptionCleanups.push(subscribeNotificationsCleared((clientId) => removeEntriesForClient(clientId))); } const CLIENT_EVENT_BADGE = { @@ -1166,3 +1174,14 @@ wireTableControls(); wireClientIdToggle(); initRoleUi(); connect(); + +window.addEventListener("pagehide", () => { + pageActive = false; + for (const unsubscribe of subscriptionCleanups.splice(0)) unsubscribe(); + webhookTemplateEditor?.dispose?.(); + webhookTemplateEditor = null; + try { + notificationTable?.destroy(); + } catch {} + notificationTable = null; +}, { once: true }); diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index 176f972..4bd0d63 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -1858,178 +1858,6 @@ async function wipeOfflineClients() { } } -const sessionsTableBody = document.getElementById("sessions-table-body"); -const sessionsMessage = document.getElementById("sessions-message"); -const refreshSessionsBtn = document.getElementById("refresh-sessions-btn"); -const removeInactiveSessionsBtn = document.getElementById("remove-inactive-sessions-btn"); - -function parseUserAgent(ua) { - if (!ua) return "Unknown"; - if (ua.length > 80) return ua.substring(0, 77) + "..."; - return ua; -} - -function formatRelativeTime(epochSeconds) { - if (!epochSeconds) return "—"; - const diff = Math.floor(Date.now() / 1000) - epochSeconds; - if (diff < 60) return "Just now"; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - return `${Math.floor(diff / 86400)}d ago`; -} - -function showSessionsMessage(text, type = "ok") { - if (!sessionsMessage) return; - sessionsMessage.textContent = text; - sessionsMessage.classList.remove( - "hidden", - "text-emerald-200", "border-emerald-700", "bg-emerald-900/30", - "text-rose-200", "border-rose-700", "bg-rose-900/30", - ); - if (type === "error") { - sessionsMessage.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30"); - } else { - sessionsMessage.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30"); - } - setTimeout(() => sessionsMessage.classList.add("hidden"), 5000); -} - -async function loadSessions() { - if (!sessionsTableBody) return; - - try { - const res = await fetch("/api/sessions", { credentials: "include" }); - if (!res.ok) { - sessionsTableBody.innerHTML = ` - - Failed to load sessions - - `; - return; - } - - const data = await res.json(); - const sessions = Array.isArray(data.sessions) ? data.sessions : []; - - if (sessions.length === 0) { - sessionsTableBody.innerHTML = ` - - No sessions found - - `; - return; - } - - sessionsTableBody.innerHTML = sessions - .map((s) => { - const isExpired = s.expiresAt && s.expiresAt < Math.floor(Date.now() / 1000); - const statusLabel = s.revoked - ? 'Revoked' - : isExpired - ? 'Expired' - : 'Active'; - const currentBadge = s.current - ? ' Current' - : ""; - const canRevoke = !s.revoked && !isExpired; - - return ` - - ${escapeHtml(s.ip || "—")} - ${escapeHtml(parseUserAgent(s.userAgent))} - ${formatDate(s.createdAt * 1000)} - ${formatRelativeTime(s.lastActivity)} - ${statusLabel}${currentBadge} - - ${canRevoke ? ` - - ` : ""} - - - `; - }) - .join(""); - } catch (err) { - sessionsTableBody.innerHTML = ` - - Error loading sessions - - `; - } -} - -async function handleRevokeSessionClick(event) { - const button = event.target.closest(".revoke-session-btn"); - if (!button) return; - - const sessionId = button.dataset.sessionId; - const isCurrent = button.dataset.isCurrent === "true"; - - if (isCurrent) { - if (!confirm("This will revoke your current session and log you out. Continue?")) return; - } - - button.disabled = true; - - try { - const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { - method: "DELETE", - credentials: "include", - }); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - showSessionsMessage(data.error || "Failed to revoke session", "error"); - button.disabled = false; - return; - } - - if (isCurrent) { - window.location.href = "/"; - return; - } - - showSessionsMessage("Session revoked successfully"); - await loadSessions(); - } catch { - showSessionsMessage("Request failed", "error"); - button.disabled = false; - } -} - -async function handleRemoveInactiveSessions() { - if (!confirm("Remove all expired and revoked sessions?")) return; - - if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.disabled = true; - - try { - const res = await fetch("/api/sessions/inactive", { - method: "DELETE", - credentials: "include", - }); - - const data = await res.json().catch(() => ({})); - if (!res.ok) { - showSessionsMessage(data.error || "Failed to remove inactive sessions", "error"); - return; - } - - showSessionsMessage(`Removed ${data.removed || 0} inactive session(s)`); - await loadSessions(); - } catch { - showSessionsMessage("Request failed", "error"); - } finally { - if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.disabled = false; - } -} - // ── Registration Settings ────────────────────────────────────────────────── async function loadRegistrationSettings() { @@ -2844,10 +2672,6 @@ async function init() { } } - await loadSessions(); - if (refreshSessionsBtn) refreshSessionsBtn.addEventListener("click", loadSessions); - if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.addEventListener("click", handleRemoveInactiveSessions); - if (sessionsTableBody) sessionsTableBody.addEventListener("click", handleRevokeSessionClick); } catch (error) { console.error("settings init failed", error); showMessage("Failed to load settings.", "error"); diff --git a/Overlord-Server/public/assets/stimulus/application.js b/Overlord-Server/public/assets/stimulus/application.js new file mode 100644 index 0000000..182fbde --- /dev/null +++ b/Overlord-Server/public/assets/stimulus/application.js @@ -0,0 +1,10 @@ +import { Application } from "/vendor/hotwired/stimulus.js"; +import SessionsController from "./controllers/sessions-controller.js"; + +const application = Application.start(); +application.debug = false; +application.register("sessions", SessionsController); + +window.OverlordStimulus = application; + +export { application }; diff --git a/Overlord-Server/public/assets/stimulus/controllers/sessions-controller.js b/Overlord-Server/public/assets/stimulus/controllers/sessions-controller.js new file mode 100644 index 0000000..2eb1823 --- /dev/null +++ b/Overlord-Server/public/assets/stimulus/controllers/sessions-controller.js @@ -0,0 +1,45 @@ +import { Controller } from "/vendor/hotwired/stimulus.js"; + +export default class extends Controller { + static targets = ["time"]; + + connect() { + this.updateTimes(); + this.clock = window.setInterval(() => this.updateTimes(), 30_000); + } + + disconnect() { + window.clearInterval(this.clock); + } + + confirm(event) { + const message = event.currentTarget.dataset.confirmMessage; + if (message && !window.confirm(message)) { + event.preventDefault(); + } + } + + updateTimes() { + const now = Math.floor(Date.now() / 1000); + for (const element of this.timeTargets) { + const timestamp = Number(element.dataset.timestamp); + if (!Number.isFinite(timestamp) || timestamp <= 0) { + element.textContent = "—"; + continue; + } + + if (element.dataset.format === "relative") { + const difference = Math.max(0, now - timestamp); + element.textContent = difference < 60 + ? "Just now" + : difference < 3_600 + ? `${Math.floor(difference / 60)}m ago` + : difference < 86_400 + ? `${Math.floor(difference / 3_600)}h ago` + : `${Math.floor(difference / 86_400)}d ago`; + } else { + element.textContent = new Date(timestamp * 1_000).toLocaleString(); + } + } + } +} diff --git a/Overlord-Server/public/assets/turbo-navigation.js b/Overlord-Server/public/assets/turbo-navigation.js index 5f3d5f4..5656b98 100644 --- a/Overlord-Server/public/assets/turbo-navigation.js +++ b/Overlord-Server/public/assets/turbo-navigation.js @@ -45,7 +45,7 @@ function cleanupPageResources() { pageTrackingEnabled = false; window.dispatchEvent(new Event("pagehide")); - for (const id of tracked.intervals) original.clearInterval(id); + for (const id of tracked.intervals) original.clearInterval.call(window, id); for (const entry of tracked.events) { original.removeEventListener.call(entry.target, entry.type, entry.listener, entry.options); } @@ -225,8 +225,8 @@ export function setupTurboNavigation({ onPathChange } = {}) { installPageResourceTracker(); config.drive.progressBarDelay = 150; - // Existing forms already have explicit fetch handlers. Turbo owns navigation only. - config.forms.mode = "off"; + // Existing forms keep their explicit fetch handlers. New Turbo Frames opt in per form. + config.forms.mode = "optin"; cache.exemptPageFromCache(); document.getElementById("top-nav")?.setAttribute("data-turbo-permanent", ""); diff --git a/Overlord-Server/public/graph.html b/Overlord-Server/public/graph.html index 3b0928d..ad0f8d4 100644 --- a/Overlord-Server/public/graph.html +++ b/Overlord-Server/public/graph.html @@ -14,7 +14,6 @@ -
diff --git a/Overlord-Server/public/login.html b/Overlord-Server/public/login.html index 0f8f8ce..ce121f6 100644 --- a/Overlord-Server/public/login.html +++ b/Overlord-Server/public/login.html @@ -3,6 +3,7 @@ + Overlord Login diff --git a/Overlord-Server/public/metrics.html b/Overlord-Server/public/metrics.html index 10d81b1..c29df91 100644 --- a/Overlord-Server/public/metrics.html +++ b/Overlord-Server/public/metrics.html @@ -76,7 +76,6 @@ display: none; } - diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 77d7c6c..06fa262 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -273,56 +273,23 @@ - -
-
-
-
- -
-
-

Active Sessions

-

View and manage your active login sessions.

-
+ + +
+
+ +
+
+

Active Sessions

+

Loading sessions…

- -
- -
- - - - - - - - - - - - - - - - -
IPBrowser / ClientCreatedLast ActiveStatusAction
Loading...
-
- - -
+
diff --git a/Overlord-Server/scripts/vendor.ts b/Overlord-Server/scripts/vendor.ts index 43d77b0..9b6c7df 100644 --- a/Overlord-Server/scripts/vendor.ts +++ b/Overlord-Server/scripts/vendor.ts @@ -155,6 +155,24 @@ copyFile( path.join(NM, "chart.js", "dist", "chart.umd.js"), path.join(VENDOR, "chart.js", "chart.umd.js"), ); +const chartEntry = ` +export { default } from '${path.join(NM, "chart.js", "auto", "auto.js").replace(/\\/g, "/")}'; +`; +const chartTmp = path.join(ROOT, "scripts", "_chart-entry.ts"); +await Bun.write(chartTmp, chartEntry); +const chartBuild = await Bun.build({ + entrypoints: [chartTmp], + minify: true, + target: "browser", + format: "esm", +}); +if (chartBuild.success) { + await Bun.write(path.join(VENDOR, "chart.js", "chart.esm.js"), chartBuild.outputs[0]); +} else { + console.error("Chart.js bundle failed:", chartBuild.logs); + process.exit(1); +} +rmSync(chartTmp, { force: true }); /* ── Monaco Editor ───────────────────────────────────────────────── */ @@ -193,8 +211,8 @@ copyFile( console.log("Copying Cytoscape.js ..."); copyFile( - path.join(NM, "cytoscape", "dist", "cytoscape.min.js"), - path.join(VENDOR, "cytoscape", "cytoscape.min.js"), + path.join(NM, "cytoscape", "dist", "cytoscape.esm.min.mjs"), + path.join(VENDOR, "cytoscape", "cytoscape.esm.min.mjs"), ); /* ── Hotwire Turbo ───────────────────────────────────────────────── */ @@ -205,6 +223,14 @@ copyFile( path.join(VENDOR, "hotwired", "turbo.es2017-esm.js"), ); +/* ── Hotwire Stimulus ────────────────────────────────────────────── */ + +console.log("Copying Hotwire Stimulus ..."); +copyFile( + path.join(NM, "@hotwired", "stimulus", "dist", "stimulus.js"), + path.join(VENDOR, "hotwired", "stimulus.js"), +); + /* ── highlight.js (bundle core + languages) ──────────────────────── */ console.log("Bundling highlight.js ..."); diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 4fddb30..6d74773 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -19,6 +19,7 @@ import { metrics } from "./metrics"; import { getClientDbSyncStats } from "./client-db-sync"; import { ensureDataDir } from "./paths"; import { handleAuthRoutes } from "./server/routes/auth-routes"; +import { handleUiRoutes } from "./server/routes/ui-routes"; import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes"; import { handleSavedScriptsRoutes } from "./server/routes/saved-scripts-routes"; import { handleEnrollmentRoutes, setPostApproveHook } from "./server/routes/enrollment-routes"; @@ -715,6 +716,7 @@ async function startServer() { ...routeDeps.registration, requestIP: (srv as any).requestIP, }), + (req, url, srv) => handleUiRoutes(req, url, srv as any), (req, url, srv) => handleAuthRoutes(req, url, srv as any), (req, url, srv) => handleOidcRoutes(req, url, srv as any), (req, url, srv) => handleNotificationsConfigRoutes(req, url, srv as any, routeDeps.notificationsConfig), diff --git a/Overlord-Server/src/server/routes/ui-routes.test.ts b/Overlord-Server/src/server/routes/ui-routes.test.ts new file mode 100644 index 0000000..f5170a9 --- /dev/null +++ b/Overlord-Server/src/server/routes/ui-routes.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { generateToken } from "../../auth"; +import { + createSession, + getSessionById, + hashTokenForSession, + listUserSessions, + revokeSessionById, +} from "../../db"; +import { createUser, deleteUser, getUserById } from "../../users"; +import { handleUiRoutes } from "./ui-routes"; + +const PASSWORD = "Aa1!TurboFrameSessionsTest"; +const createdUserIds: number[] = []; +const mockServer = { requestIP: () => ({ address: "127.0.0.1" }) }; + +async function tempUser(userAgent = "Turbo Frame Test") { + const username = `ui_sessions_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`; + const created = await createUser(username, PASSWORD, "admin", "test"); + expect(created.success).toBe(true); + const user = getUserById(created.userId!); + expect(user).not.toBeNull(); + createdUserIds.push(user!.id); + const token = await generateToken(user!, { ip: "127.0.0.1", userAgent }); + return { user: user!, token }; +} + +function call(method: string, path: string, token: string | null) { + const url = new URL(`https://localhost${path}`); + const headers: Record = {}; + if (token) headers.Authorization = `Bearer ${token}`; + return handleUiRoutes(new Request(url, { method, headers }), url, mockServer); +} + +function addSession(userId: number, userAgent: string) { + const now = Math.floor(Date.now() / 1000); + const session = { + id: crypto.randomUUID(), + userId, + tokenHash: crypto.randomUUID().replaceAll("-", ""), + ip: "127.0.0.2", + userAgent, + createdAt: now, + expiresAt: now + 3600, + }; + createSession(session); + return session; +} + +afterEach(() => { + while (createdUserIds.length > 0) { + const id = createdUserIds.pop(); + if (typeof id === "number") deleteUser(id); + } +}); + +describe("settings sessions Turbo Frame", () => { + test("redirects unauthenticated frame requests to login", async () => { + const response = await call("GET", "/ui/settings/sessions", null); + expect(response!.status).toBe(303); + expect(response!.headers.get("location")).toBe("https://localhost/login.html"); + }); + + test("renders an escaped frame and marks the current session", async () => { + const account = await tempUser(''); + const response = await call("GET", "/ui/settings/sessions", account.token); + expect(response!.status).toBe(200); + expect(response!.headers.get("content-type")).toContain("text/html"); + expect(response!.headers.get("cache-control")).toBe("no-store"); + + const body = await response!.text(); + expect(body).toContain('alert("xss")'); + expect(body).toContain('data-controller="sessions"'); + expect(body).toContain('data-turbo="true"'); + }); + + test("revokes another session and returns the refreshed frame", async () => { + const account = await tempUser(); + const added = addSession(account.user.id, "Second browser"); + const secondSession = getSessionById(added.id); + expect(secondSession).toBeDefined(); + + const response = await call( + "POST", + `/ui/settings/sessions/${encodeURIComponent(secondSession!.id)}/revoke`, + account.token, + ); + expect(response!.status).toBe(200); + expect(await response!.text()).toContain("Session revoked successfully."); + expect(getSessionById(secondSession!.id)?.revoked).toBe(true); + }); + + test("revoking the current session clears the cookie and redirects", async () => { + const account = await tempUser(); + const currentHash = hashTokenForSession(account.token); + const currentSession = listUserSessions(account.user.id).find((session) => session.tokenHash === currentHash); + expect(currentSession).toBeDefined(); + + const response = await call( + "POST", + `/ui/settings/sessions/${encodeURIComponent(currentSession!.id)}/revoke`, + account.token, + ); + expect(response!.status).toBe(303); + expect(response!.headers.get("location")).toBe("/"); + expect(response!.headers.get("set-cookie")).toContain("Max-Age=0"); + expect(getSessionById(currentSession!.id)?.revoked).toBe(true); + }); + + test("removes inactive sessions and reports the count", async () => { + const account = await tempUser(); + const added = addSession(account.user.id, "Inactive browser"); + const secondSession = getSessionById(added.id); + expect(secondSession).toBeDefined(); + revokeSessionById(secondSession!.id); + + const response = await call("POST", "/ui/settings/sessions/inactive", account.token); + expect(response!.status).toBe(200); + expect(await response!.text()).toContain("Removed 1 inactive session(s)."); + expect(getSessionById(secondSession!.id)).toBeNull(); + }); +}); diff --git a/Overlord-Server/src/server/routes/ui-routes.ts b/Overlord-Server/src/server/routes/ui-routes.ts new file mode 100644 index 0000000..8d8d226 --- /dev/null +++ b/Overlord-Server/src/server/routes/ui-routes.ts @@ -0,0 +1,118 @@ +import { authenticateRequest, extractTokenFromRequest } from "../../auth"; +import { AuditAction, logAudit } from "../../auditLog"; +import { + deleteInactiveSessions, + getSessionById, + hashTokenForSession, + listUserSessions, + persistRevokedTokenHash, + revokeSessionById, +} from "../../db"; +import { htmlResponse } from "../ui/html"; +import { renderSessionsFrame } from "../ui/sessions-view"; +import { makeAuthCookieClear } from "./auth-cookie"; + +type UiRouteServer = { + requestIP: (req: Request) => { address?: string } | null | undefined; +}; + +function redirectToLogin(req: Request): Response { + return Response.redirect(new URL("/login.html", req.url), 303); +} + +function currentTokenHash(req: Request): string | null { + const token = extractTokenFromRequest(req); + return token ? hashTokenForSession(token) : null; +} + +export async function handleUiRoutes( + req: Request, + url: URL, + server: UiRouteServer, +): Promise { + if (!url.pathname.startsWith("/ui/")) return null; + + const user = await authenticateRequest(req); + if (!user) return redirectToLogin(req); + + if (req.method === "GET" && url.pathname === "/ui/settings/sessions") { + return htmlResponse(renderSessionsFrame( + listUserSessions(user.userId), + currentTokenHash(req), + )); + } + + if (req.method === "POST" && url.pathname === "/ui/settings/sessions/inactive") { + const removed = deleteInactiveSessions(user.userId); + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: server.requestIP(req)?.address || "unknown", + action: AuditAction.LOGOUT, + details: `Removed ${removed} inactive session(s)`, + success: true, + }); + return htmlResponse(renderSessionsFrame( + listUserSessions(user.userId), + currentTokenHash(req), + { text: `Removed ${removed} inactive session(s).` }, + )); + } + + const revokeMatch = req.method === "POST" + ? url.pathname.match(/^\/ui\/settings\/sessions\/([^/]+)\/revoke$/) + : null; + if (revokeMatch) { + let sessionId = ""; + try { + sessionId = decodeURIComponent(revokeMatch[1]); + } catch { + return htmlResponse(renderSessionsFrame( + listUserSessions(user.userId), + currentTokenHash(req), + { text: "Invalid session identifier.", type: "error" }, + ), { status: 400 }); + } + + const session = getSessionById(sessionId); + if (!session || session.userId !== user.userId) { + return htmlResponse(renderSessionsFrame( + listUserSessions(user.userId), + currentTokenHash(req), + { text: "Session not found.", type: "error" }, + ), { status: 404 }); + } + + const tokenHash = currentTokenHash(req); + const revokingCurrentSession = session.tokenHash === tokenHash; + const result = revokeSessionById(sessionId); + if (result.tokenHash) persistRevokedTokenHash(result.tokenHash, session.expiresAt); + + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: server.requestIP(req)?.address || "unknown", + action: AuditAction.LOGOUT, + details: `Revoked session ${sessionId}`, + success: true, + }); + + if (revokingCurrentSession) { + return new Response(null, { + status: 303, + headers: { + "Location": "/", + "Set-Cookie": makeAuthCookieClear(req), + }, + }); + } + + return htmlResponse(renderSessionsFrame( + listUserSessions(user.userId), + tokenHash, + { text: "Session revoked successfully." }, + )); + } + + return htmlResponse("

Not found

", { status: 404 }); +} diff --git a/Overlord-Server/src/server/ui/html.ts b/Overlord-Server/src/server/ui/html.ts new file mode 100644 index 0000000..ad11fb3 --- /dev/null +++ b/Overlord-Server/src/server/ui/html.ts @@ -0,0 +1,19 @@ +export function escapeHtml(value: unknown): string { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function htmlResponse( + html: string, + init: ResponseInit = {}, +): Response { + const headers = new Headers(init.headers); + headers.set("Content-Type", "text/html; charset=utf-8"); + headers.set("Cache-Control", "no-store"); + headers.append("Vary", "Turbo-Frame"); + return new Response(html, { ...init, headers }); +} diff --git a/Overlord-Server/src/server/ui/sessions-view.ts b/Overlord-Server/src/server/ui/sessions-view.ts new file mode 100644 index 0000000..4f92f37 --- /dev/null +++ b/Overlord-Server/src/server/ui/sessions-view.ts @@ -0,0 +1,108 @@ +import type { SessionRecord } from "../../db"; +import { escapeHtml } from "./html"; + +type SessionsMessage = { + text: string; + type?: "success" | "error"; +}; + +function summarizeUserAgent(userAgent: string | null): string { + if (!userAgent) return "Unknown"; + return userAgent.length > 80 ? `${userAgent.slice(0, 77)}...` : userAgent; +} + +function renderMessage(message?: SessionsMessage): string { + if (!message) return ""; + const classes = message.type === "error" + ? "text-rose-200 border-rose-700 bg-rose-900/30" + : "text-emerald-200 border-emerald-700 bg-emerald-900/30"; + return `

${escapeHtml(message.text)}

`; +} + +function renderSessionRow( + session: SessionRecord, + currentTokenHash: string | null, + now: number, +): string { + const current = session.tokenHash === currentTokenHash; + const expired = session.expiresAt <= now; + const canRevoke = !session.revoked && !expired; + const status = session.revoked + ? 'Revoked' + : expired + ? 'Expired' + : 'Active'; + const currentBadge = current + ? ' Current' + : ""; + const confirmation = current + ? "This will revoke your current session and log you out. Continue?" + : "Revoke this session?"; + + return ` + ${escapeHtml(session.ip || "—")} + ${escapeHtml(summarizeUserAgent(session.userAgent))} + + + ${status}${currentBadge} + + ${canRevoke ? `
+ +
` : ""} + + `; +} + +export function renderSessionsFrame( + sessions: SessionRecord[], + currentTokenHash: string | null, + message?: SessionsMessage, +): string { + const now = Math.floor(Date.now() / 1000); + const rows = sessions.length > 0 + ? sessions.map((session) => renderSessionRow(session, currentTokenHash, now)).join("") + : 'No sessions found'; + + return ` +
+ +
+ + + + + + + + + + + + ${rows} +
IPBrowser / ClientCreatedLast ActiveStatusAction
+
+ ${renderMessage(message)} + `; +}