finish up turbo and add stimulus.

This commit is contained in:
KingKDot
2026-07-13 20:00:04 -05:00
parent e390dbc966
commit b08d8ba028
21 changed files with 576 additions and 254 deletions
+3
View File
@@ -5,6 +5,7 @@
"": { "": {
"name": "overlord-server", "name": "overlord-server",
"dependencies": { "dependencies": {
"@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo": "^8.0.23", "@hotwired/turbo": "^8.0.23",
"@msgpack/msgpack": "^3.0.0", "@msgpack/msgpack": "^3.0.0",
"@solana/web3.js": "^1.98.4", "@solana/web3.js": "^1.98.4",
@@ -63,6 +64,8 @@
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@6.5.2", "", {}, "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q=="], "@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=="], "@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=="], "@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=="],
+1
View File
@@ -19,6 +19,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo": "^8.0.23", "@hotwired/turbo": "^8.0.23",
"@msgpack/msgpack": "^3.0.0", "@msgpack/msgpack": "^3.0.0",
"@solana/web3.js": "^1.98.4", "@solana/web3.js": "^1.98.4",
+20 -10
View File
@@ -1,3 +1,5 @@
import cytoscape from "/vendor/cytoscape/cytoscape.esm.min.mjs";
const container = document.getElementById("client-graph"); const container = document.getElementById("client-graph");
const searchInput = document.getElementById("graph-search"); const searchInput = document.getElementById("graph-search");
const statusSelect = document.getElementById("graph-status"); const statusSelect = document.getElementById("graph-status");
@@ -12,6 +14,7 @@ const detailActions = document.getElementById("graph-detail-actions");
let cy = null; let cy = null;
let currentGraph = null; let currentGraph = null;
let searchTimer = null; let searchTimer = null;
let graphPageActive = true;
const LARGE_GRAPH_ANIMATION_CLIENTS = 30000; const LARGE_GRAPH_ANIMATION_CLIENTS = 30000;
const typeColors = { const typeColors = {
@@ -215,16 +218,12 @@ function selectNode(node) {
} }
function renderGraph(graph) { function renderGraph(graph) {
if (!graphPageActive || !container?.isConnected) return;
currentGraph = graph; currentGraph = graph;
setSummary(graph.summary); setSummary(graph.summary);
if (!window.cytoscape) {
if (summaryEl) summaryEl.textContent = "Cytoscape failed to load";
return;
}
if (!cy) { if (!cy) {
cy = window.cytoscape({ cy = cytoscape({
container, container,
elements: graphElements(graph), elements: graphElements(graph),
minZoom: 0.18, minZoom: 0.18,
@@ -305,7 +304,7 @@ function renderGraph(graph) {
} }
async function loadGraph() { async function loadGraph() {
if (!container) return; if (!graphPageActive || !container) return;
if (summaryEl) summaryEl.textContent = "Loading..."; if (summaryEl) summaryEl.textContent = "Loading...";
const params = new URLSearchParams({ const params = new URLSearchParams({
limit: "350", limit: "350",
@@ -314,10 +313,12 @@ async function loadGraph() {
}); });
const res = await fetch(`/api/client-graph?${params}`, { credentials: "include" }); const res = await fetch(`/api/client-graph?${params}`, { credentials: "include" });
if (!res.ok) { if (!res.ok) {
if (!graphPageActive) return;
if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`; if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`;
return; return;
} }
renderGraph(await res.json()); const graph = await res.json();
if (graphPageActive) renderGraph(graph);
} }
function debounceLoad() { function debounceLoad() {
@@ -332,10 +333,19 @@ searchInput?.addEventListener("input", debounceLoad);
statusSelect?.addEventListener("change", loadGraph); statusSelect?.addEventListener("change", loadGraph);
refreshBtn?.addEventListener("click", loadGraph); refreshBtn?.addEventListener("click", loadGraph);
fitBtn?.addEventListener("click", () => cy?.fit(undefined, 32)); fitBtn?.addEventListener("click", () => cy?.fit(undefined, 32));
window.addEventListener("resize", () => { const handleResize = () => {
cy?.resize(); cy?.resize();
cy?.fit(undefined, 32); 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(); bindInspectorActions();
loadGraph(); loadGraph();
+47 -3
View File
@@ -1,3 +1,5 @@
import Chart from "/vendor/chart.js/chart.esm.js";
let clientsChart = null; let clientsChart = null;
let commandsChart = null; let commandsChart = null;
let bandwidthChart = null; let bandwidthChart = null;
@@ -29,6 +31,8 @@ let countryGlobeHoverEntry = null;
let countryGlobeSortedFeatures = []; let countryGlobeSortedFeatures = [];
let countryGlobeLastSpinFrameAt = 0; let countryGlobeLastSpinFrameAt = 0;
let countryGlobeLastHoverFrameAt = 0; let countryGlobeLastHoverFrameAt = 0;
let metricsPollTimer = null;
let metricsPageActive = true;
const GEOJSON_URL = "/vendor/geo-countries/countries.geojson"; const GEOJSON_URL = "/vendor/geo-countries/countries.geojson";
const MAX_CHART_POINTS = 240; const MAX_CHART_POINTS = 240;
@@ -1806,6 +1810,7 @@ function updateCharts(history, snapshot) {
} }
async function fetchMetrics() { async function fetchMetrics() {
if (!metricsPageActive) return;
try { try {
const response = await fetch(`/api/metrics?historyLimit=${MAX_CHART_POINTS}`, { const response = await fetch(`/api/metrics?historyLimit=${MAX_CHART_POINTS}`, {
credentials: "include", credentials: "include",
@@ -1821,11 +1826,13 @@ async function fetchMetrics() {
} }
const data = await response.json(); const data = await response.json();
if (!metricsPageActive) return;
updateMetrics(data.snapshot, data.debug); updateMetrics(data.snapshot, data.debug);
updateCharts(data.history, data.snapshot); updateCharts(data.history, data.snapshot);
document.getElementById("status-text").textContent = "Live"; document.getElementById("status-text").textContent = "Live";
} catch (err) { } catch (err) {
if (!metricsPageActive) return;
console.error("Error fetching metrics:", err); console.error("Error fetching metrics:", err);
document.getElementById("status-text").textContent = "Error"; document.getElementById("status-text").textContent = "Error";
} }
@@ -1834,12 +1841,14 @@ async function fetchMetrics() {
async function checkAuth() { async function checkAuth() {
try { try {
const res = await fetch("/api/auth/me", { credentials: "include" }); const res = await fetch("/api/auth/me", { credentials: "include" });
if (!metricsPageActive) return;
if (!res.ok) { if (!res.ok) {
window.location.href = "/"; window.location.href = "/";
return; return;
} }
const data = await res.json(); const data = await res.json();
if (!metricsPageActive) return;
document.getElementById("username-display").textContent = data.username; document.getElementById("username-display").textContent = data.username;
const roleBadge = document.getElementById("role-badge"); const roleBadge = document.getElementById("role-badge");
@@ -1895,16 +1904,18 @@ async function checkAuth() {
} }
} }
document.addEventListener("DOMContentLoaded", async () => { async function initMetricsPage() {
await checkAuth(); await checkAuth();
if (!metricsPageActive) return;
initCharts(); initCharts();
await initCountryMap(); await initCountryMap();
if (!metricsPageActive) return;
await fetchMetrics(); await fetchMetrics();
setInterval(fetchMetrics, METRICS_POLL_INTERVAL_MS); metricsPollTimer = setInterval(fetchMetrics, METRICS_POLL_INTERVAL_MS);
const logoutBtn = document.getElementById("logout-btn"); const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn && !logoutBtn.dataset.boundLogout) { 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();
}
+1
View File
@@ -12,6 +12,7 @@ import { createAdaptiveNavController } from "./nav/layout.js";
import { applyUserRoleUI, applyThumbnailWallVisibility } from "./nav/role-ui.js"; import { applyUserRoleUI, applyThumbnailWallVisibility } from "./nav/role-ui.js";
import { loadPluginNavItems } from "./nav/plugins-loader.js"; import { loadPluginNavItems } from "./nav/plugins-loader.js";
import { init as initCommandPalette } from "./command-palette.js"; import { init as initCommandPalette } from "./command-palette.js";
import "./stimulus/application.js";
import { import {
runWithoutPageTracking, runWithoutPageTracking,
setupTurboNavigation, setupTurboNavigation,
@@ -64,7 +64,7 @@ export const NAV_GROUPS = [
children: [ children: [
{ href: "/metrics", label: "Metrics", icon: "fa-chart-line", iconColor: "text-emerald-400", linkId: "metrics-link" }, { 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: "/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) { export function dropdownItem(child) {
const hiddenCls = child.hidden ? " hidden" : ""; const hiddenCls = child.hidden ? " hidden" : "";
const prefetchAttr = child.turboPrefetch === false ? ' data-turbo-prefetch="false"' : "";
const badgeHtml = child.hasBadge const badgeHtml = child.hasBadge
? `<span id="enrollment-badge" class="nav-dd-badge hidden"></span>` ? `<span id="enrollment-badge" class="nav-dd-badge hidden"></span>`
: ""; : "";
return `<a href="${child.href}" id="${child.linkId}" return `<a href="${child.href}" id="${child.linkId}"${prefetchAttr}
class="nav-dd-item${hiddenCls}" data-link-id="${child.linkId}"> class="nav-dd-item${hiddenCls}" data-link-id="${child.linkId}">
<i class="fa-solid ${child.icon} ${child.iconColor} nav-dd-item-icon"></i> <i class="fa-solid ${child.icon} ${child.iconColor} nav-dd-item-icon"></i>
<span>${child.label}</span> <span>${child.label}</span>
+27 -8
View File
@@ -85,6 +85,8 @@ let defaultWebhookTemplate = "";
let defaultTelegramTemplate = ""; let defaultTelegramTemplate = "";
let webhookTemplateEditor = null; let webhookTemplateEditor = null;
let notificationTable = null; let notificationTable = null;
let pageActive = true;
const subscriptionCleanups = [];
function tryFormatJsonText(text) { function tryFormatJsonText(text) {
const source = String(text || "").trim(); const source = String(text || "").trim();
@@ -336,12 +338,17 @@ function entryMatchesSearch(entry, query) {
} }
function applyTableView() { function applyTableView() {
if (!pageActive) return;
const q = tableState.search.trim().toLowerCase(); const q = tableState.search.trim().toLowerCase();
const all = Array.from(tableState.entries.values()); const all = Array.from(tableState.entries.values());
const filtered = q ? all.filter((entry) => entryMatchesSearch(entry, q)) : all; const filtered = q ? all.filter((entry) => entryMatchesSearch(entry, q)) : all;
filtered.sort(compareEntries); filtered.sort(compareEntries);
const rows = filtered.map(entryToRow); 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 (emptyState) {
if (all.length === 0) { if (all.length === 0) {
@@ -894,24 +901,25 @@ function connect() {
startNotificationClient(); startNotificationClient();
if (panel) markAllNotificationsRead(); if (panel) markAllNotificationsRead();
subscribeStatus((status) => { subscriptionCleanups.push(subscribeStatus((status) => {
if (status === "connected") setStatus("Connected", "ok"); if (status === "connected") setStatus("Connected", "ok");
if (status === "error") setStatus("Error", "error"); if (status === "error") setStatus("Error", "error");
if (status === "disconnected") setStatus("Disconnected", "warn"); if (status === "disconnected") setStatus("Disconnected", "warn");
}); }));
subscribeReady((history) => { subscriptionCleanups.push(subscribeReady((history) => {
if (!pageActive) return;
clearTable(); clearTable();
for (const item of history) { for (const item of history) {
upsertEntry(item, "notification", false); upsertEntry(item, "notification", false);
} }
applyTableView(); applyTableView();
markAllNotificationsRead(); markAllNotificationsRead();
}); }));
subscribeNotifications((item) => addNotification(item, true)); subscriptionCleanups.push(subscribeNotifications((item) => addNotification(item, true)));
subscribeClientEvents((item) => addClientEvent(item)); subscriptionCleanups.push(subscribeClientEvents((item) => addClientEvent(item)));
subscribeNotificationsCleared((clientId) => removeEntriesForClient(clientId)); subscriptionCleanups.push(subscribeNotificationsCleared((clientId) => removeEntriesForClient(clientId)));
} }
const CLIENT_EVENT_BADGE = { const CLIENT_EVENT_BADGE = {
@@ -1166,3 +1174,14 @@ wireTableControls();
wireClientIdToggle(); wireClientIdToggle();
initRoleUi(); initRoleUi();
connect(); 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 });
-176
View File
@@ -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 = `
<tr>
<td colspan="6" class="px-3 py-6 text-center text-rose-300">Failed to load sessions</td>
</tr>
`;
return;
}
const data = await res.json();
const sessions = Array.isArray(data.sessions) ? data.sessions : [];
if (sessions.length === 0) {
sessionsTableBody.innerHTML = `
<tr>
<td colspan="6" class="px-3 py-6 text-center text-slate-400">No sessions found</td>
</tr>
`;
return;
}
sessionsTableBody.innerHTML = sessions
.map((s) => {
const isExpired = s.expiresAt && s.expiresAt < Math.floor(Date.now() / 1000);
const statusLabel = s.revoked
? '<span class="text-rose-400">Revoked</span>'
: isExpired
? '<span class="text-slate-500">Expired</span>'
: '<span class="text-emerald-400">Active</span>';
const currentBadge = s.current
? ' <span class="ml-1 px-1.5 py-0.5 text-[10px] rounded bg-sky-600/30 text-sky-300 border border-sky-500/30">Current</span>'
: "";
const canRevoke = !s.revoked && !isExpired;
return `
<tr>
<td class="px-3 py-2 font-mono text-xs text-slate-100">${escapeHtml(s.ip || "—")}</td>
<td class="px-3 py-2 text-slate-300 text-xs max-w-[200px] truncate" title="${escapeHtml(s.userAgent || "")}">${escapeHtml(parseUserAgent(s.userAgent))}</td>
<td class="px-3 py-2 text-slate-400 text-xs">${formatDate(s.createdAt * 1000)}</td>
<td class="px-3 py-2 text-slate-400 text-xs">${formatRelativeTime(s.lastActivity)}</td>
<td class="px-3 py-2 text-xs">${statusLabel}${currentBadge}</td>
<td class="px-3 py-2 text-right">
${canRevoke ? `
<button
type="button"
class="revoke-session-btn px-2.5 py-1.5 rounded bg-red-700/80 hover:bg-red-600 text-white text-xs"
data-session-id="${escapeHtml(s.id)}"
${s.current ? 'data-is-current="true"' : ""}
>
<i class="fa-solid fa-right-from-bracket mr-1"></i>Revoke
</button>
` : ""}
</td>
</tr>
`;
})
.join("");
} catch (err) {
sessionsTableBody.innerHTML = `
<tr>
<td colspan="6" class="px-3 py-6 text-center text-rose-300">Error loading sessions</td>
</tr>
`;
}
}
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 ────────────────────────────────────────────────── // ── Registration Settings ──────────────────────────────────────────────────
async function loadRegistrationSettings() { 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) { } catch (error) {
console.error("settings init failed", error); console.error("settings init failed", error);
showMessage("Failed to load settings.", "error"); showMessage("Failed to load settings.", "error");
@@ -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 };
@@ -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();
}
}
}
}
@@ -45,7 +45,7 @@ function cleanupPageResources() {
pageTrackingEnabled = false; pageTrackingEnabled = false;
window.dispatchEvent(new Event("pagehide")); 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) { for (const entry of tracked.events) {
original.removeEventListener.call(entry.target, entry.type, entry.listener, entry.options); original.removeEventListener.call(entry.target, entry.type, entry.listener, entry.options);
} }
@@ -225,8 +225,8 @@ export function setupTurboNavigation({ onPathChange } = {}) {
installPageResourceTracker(); installPageResourceTracker();
config.drive.progressBarDelay = 150; config.drive.progressBarDelay = 150;
// Existing forms already have explicit fetch handlers. Turbo owns navigation only. // Existing forms keep their explicit fetch handlers. New Turbo Frames opt in per form.
config.forms.mode = "off"; config.forms.mode = "optin";
cache.exemptPageFromCache(); cache.exemptPageFromCache();
document.getElementById("top-nav")?.setAttribute("data-turbo-permanent", ""); document.getElementById("top-nav")?.setAttribute("data-turbo-permanent", "");
-1
View File
@@ -14,7 +14,6 @@
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" /> <link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
<link rel="stylesheet" href="/assets/main.css" /> <link rel="stylesheet" href="/assets/main.css" />
<link rel="stylesheet" href="/assets/custom.css" /> <link rel="stylesheet" href="/assets/custom.css" />
<script defer src="/vendor/cytoscape/cytoscape.min.js"></script>
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-100"> <body class="min-h-screen bg-slate-950 text-slate-100">
<header id="top-nav"></header> <header id="top-nav"></header>
+1
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="turbo-visit-control" content="reload" />
<title>Overlord Login</title> <title>Overlord Login</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="stylesheet" href="/vendor/inter/400.css" /> <link rel="stylesheet" href="/vendor/inter/400.css" />
-1
View File
@@ -76,7 +76,6 @@
display: none; display: none;
} }
</style> </style>
<script defer src="/vendor/chart.js/chart.umd.js"></script>
<script defer src="/assets/toast.js"></script> <script defer src="/assets/toast.js"></script>
<script defer src="/assets/ripple.js"></script> <script defer src="/assets/ripple.js"></script>
</head> </head>
+15 -48
View File
@@ -273,56 +273,23 @@
<p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p> <p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section> </section>
<!-- Active Sessions --> <!-- Active Sessions: server-rendered and managed independently by Turbo. -->
<section id="section-sessions" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <turbo-frame
<div class="flex items-center justify-between gap-3"> id="section-sessions"
<div class="flex items-center gap-3"> src="/ui/settings/sessions"
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30"> loading="lazy"
<i class="fa-solid fa-desktop text-emerald-400"></i> class="block bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"
</div> >
<div> <div class="flex items-center gap-3">
<h2 class="text-lg font-semibold">Active Sessions</h2> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
<p class="text-sm text-slate-400">View and manage your active login sessions.</p> <i class="fa-solid fa-desktop text-emerald-400"></i>
</div> </div>
<div>
<h2 class="text-lg font-semibold">Active Sessions</h2>
<p class="text-sm text-slate-400">Loading sessions…</p>
</div> </div>
<button
id="refresh-sessions-btn"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm transition-colors"
type="button"
>
<i class="fa-solid fa-rotate"></i>Refresh
</button>
<button
id="remove-inactive-sessions-btn"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 hover:bg-red-800/60 text-red-100 border border-red-700/60 text-sm transition-colors"
type="button"
>
<i class="fa-solid fa-broom"></i>Remove Inactive
</button>
</div> </div>
</turbo-frame>
<div class="overflow-x-auto border border-slate-800 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-slate-800/60 text-slate-300">
<tr>
<th class="text-left px-3 py-2">IP</th>
<th class="text-left px-3 py-2">Browser / Client</th>
<th class="text-left px-3 py-2">Created</th>
<th class="text-left px-3 py-2">Last Active</th>
<th class="text-left px-3 py-2">Status</th>
<th class="text-right px-3 py-2">Action</th>
</tr>
</thead>
<tbody id="sessions-table-body" class="divide-y divide-slate-800">
<tr>
<td colspan="6" class="px-3 py-6 text-center text-slate-400">Loading...</td>
</tr>
</tbody>
</table>
</div>
<p id="sessions-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section>
<!-- Dashboard Preferences --> <!-- Dashboard Preferences -->
<section id="section-prefs" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="section-prefs" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
+28 -2
View File
@@ -155,6 +155,24 @@ copyFile(
path.join(NM, "chart.js", "dist", "chart.umd.js"), path.join(NM, "chart.js", "dist", "chart.umd.js"),
path.join(VENDOR, "chart.js", "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 ───────────────────────────────────────────────── */ /* ── Monaco Editor ───────────────────────────────────────────────── */
@@ -193,8 +211,8 @@ copyFile(
console.log("Copying Cytoscape.js ..."); console.log("Copying Cytoscape.js ...");
copyFile( copyFile(
path.join(NM, "cytoscape", "dist", "cytoscape.min.js"), path.join(NM, "cytoscape", "dist", "cytoscape.esm.min.mjs"),
path.join(VENDOR, "cytoscape", "cytoscape.min.js"), path.join(VENDOR, "cytoscape", "cytoscape.esm.min.mjs"),
); );
/* ── Hotwire Turbo ───────────────────────────────────────────────── */ /* ── Hotwire Turbo ───────────────────────────────────────────────── */
@@ -205,6 +223,14 @@ copyFile(
path.join(VENDOR, "hotwired", "turbo.es2017-esm.js"), 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) ──────────────────────── */ /* ── highlight.js (bundle core + languages) ──────────────────────── */
console.log("Bundling highlight.js ..."); console.log("Bundling highlight.js ...");
+2
View File
@@ -19,6 +19,7 @@ import { metrics } from "./metrics";
import { getClientDbSyncStats } from "./client-db-sync"; import { getClientDbSyncStats } from "./client-db-sync";
import { ensureDataDir } from "./paths"; import { ensureDataDir } from "./paths";
import { handleAuthRoutes } from "./server/routes/auth-routes"; import { handleAuthRoutes } from "./server/routes/auth-routes";
import { handleUiRoutes } from "./server/routes/ui-routes";
import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes"; import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
import { handleSavedScriptsRoutes } from "./server/routes/saved-scripts-routes"; import { handleSavedScriptsRoutes } from "./server/routes/saved-scripts-routes";
import { handleEnrollmentRoutes, setPostApproveHook } from "./server/routes/enrollment-routes"; import { handleEnrollmentRoutes, setPostApproveHook } from "./server/routes/enrollment-routes";
@@ -715,6 +716,7 @@ async function startServer() {
...routeDeps.registration, ...routeDeps.registration,
requestIP: (srv as any).requestIP, 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) => handleAuthRoutes(req, url, srv as any),
(req, url, srv) => handleOidcRoutes(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), (req, url, srv) => handleNotificationsConfigRoutes(req, url, srv as any, routeDeps.notificationsConfig),
@@ -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<string, string> = {};
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('<script>alert("xss")</script>');
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('<turbo-frame id="section-sessions"');
expect(body).toContain("Current");
expect(body).toContain("&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;");
expect(body).not.toContain('<script>alert("xss")</script>');
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();
});
});
@@ -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<Response | null> {
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("<h1>Not found</h1>", { status: 404 });
}
+19
View File
@@ -0,0 +1,19 @@
export function escapeHtml(value: unknown): string {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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 });
}
@@ -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 `<p class="text-sm rounded-lg px-3 py-2 border ${classes}" role="status">${escapeHtml(message.text)}</p>`;
}
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
? '<span class="text-rose-400">Revoked</span>'
: expired
? '<span class="text-slate-500">Expired</span>'
: '<span class="text-emerald-400">Active</span>';
const currentBadge = current
? ' <span class="ml-1 px-1.5 py-0.5 text-[10px] rounded bg-sky-600/30 text-sky-300 border border-sky-500/30">Current</span>'
: "";
const confirmation = current
? "This will revoke your current session and log you out. Continue?"
: "Revoke this session?";
return `<tr id="session-${escapeHtml(session.id)}">
<td class="px-3 py-2 font-mono text-xs text-slate-100">${escapeHtml(session.ip || "—")}</td>
<td class="px-3 py-2 text-slate-300 text-xs max-w-[200px] truncate" title="${escapeHtml(session.userAgent || "")}">${escapeHtml(summarizeUserAgent(session.userAgent))}</td>
<td class="px-3 py-2 text-slate-400 text-xs"><time data-sessions-target="time" data-timestamp="${session.createdAt}" datetime="${new Date(session.createdAt * 1000).toISOString()}">—</time></td>
<td class="px-3 py-2 text-slate-400 text-xs"><time data-sessions-target="time" data-format="relative" data-timestamp="${session.lastActivity}" datetime="${new Date(session.lastActivity * 1000).toISOString()}">—</time></td>
<td class="px-3 py-2 text-xs">${status}${currentBadge}</td>
<td class="px-3 py-2 text-right">
${canRevoke ? `<form method="post" action="/ui/settings/sessions/${encodeURIComponent(session.id)}/revoke" data-turbo="true" data-action="submit->sessions#confirm" data-confirm-message="${escapeHtml(confirmation)}" class="inline">
<button type="submit" class="px-2.5 py-1.5 rounded bg-red-700/80 hover:bg-red-600 text-white text-xs disabled:opacity-50">
<i class="fa-solid fa-right-from-bracket mr-1"></i>Revoke
</button>
</form>` : ""}
</td>
</tr>`;
}
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("")
: '<tr><td colspan="6" class="px-3 py-6 text-center text-slate-400">No sessions found</td></tr>';
return `<turbo-frame id="section-sessions" class="block bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section" data-controller="sessions">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
<i class="fa-solid fa-desktop text-emerald-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Active Sessions</h2>
<p class="text-sm text-slate-400">View and manage your active login sessions.</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<a href="/ui/settings/sessions" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm transition-colors">
<i class="fa-solid fa-rotate"></i>Refresh
</a>
<form method="post" action="/ui/settings/sessions/inactive" data-turbo="true" data-action="submit->sessions#confirm" data-confirm-message="Remove all expired and revoked sessions?">
<button type="submit" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 hover:bg-red-800/60 text-red-100 border border-red-700/60 text-sm transition-colors disabled:opacity-50">
<i class="fa-solid fa-broom"></i>Remove Inactive
</button>
</form>
</div>
</div>
<div class="overflow-x-auto border border-slate-800 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-slate-800/60 text-slate-300">
<tr>
<th class="text-left px-3 py-2">IP</th>
<th class="text-left px-3 py-2">Browser / Client</th>
<th class="text-left px-3 py-2">Created</th>
<th class="text-left px-3 py-2">Last Active</th>
<th class="text-left px-3 py-2">Status</th>
<th class="text-right px-3 py-2">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">${rows}</tbody>
</table>
</div>
${renderMessage(message)}
</turbo-frame>`;
}