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
+20 -10
View File
@@ -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();
+47 -3
View File
@@ -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();
}
+1
View File
@@ -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,
@@ -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
? `<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}">
<i class="fa-solid ${child.icon} ${child.iconColor} nav-dd-item-icon"></i>
<span>${child.label}</span>
+27 -8
View File
@@ -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 });
-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 ──────────────────────────────────────────────────
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");
@@ -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;
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", "");