Merge branch 'main' into dev

This commit is contained in:
KingKDot
2026-06-04 21:23:42 -05:00
41 changed files with 3961 additions and 387 deletions
+1 -1
View File
@@ -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"
@@ -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 {
+40 -19
View File
@@ -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)
}
@@ -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)
}
}
}
+9
View File
@@ -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=="],
+4 -1
View File
@@ -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",
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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]) => `
<div class="graph-meta-row">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</div>
`).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
? ""
: `<div class="graph-action-note"><i class="fa-solid fa-plug-circle-xmark"></i><span>Client is offline</span></div>`;
detailActions.innerHTML = `
${disabledNote}
<button class="graph-action graph-action--remote" data-graph-action="/remotedesktop?clientId=${id}"${disabled}>
<i class="fa-solid fa-desktop"></i><span>Remote Desktop</span>
</button>
<button class="graph-action graph-action--console" data-graph-action="/${id}/console"${disabled}>
<i class="fa-solid fa-terminal"></i><span>Console</span>
</button>
<button class="graph-action graph-action--files" data-graph-action="/${id}/files"${disabled}>
<i class="fa-solid fa-folder-tree"></i><span>File Browser</span>
</button>
<button class="graph-action graph-action--copy" data-graph-copy="${escapeHtml(data.clientId)}">
<i class="fa-solid fa-copy"></i><span>Copy ID</span>
</button>
`;
} 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();
@@ -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();
}
+264 -10
View File
@@ -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);
});
+24
View File
@@ -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
+427 -36
View File
@@ -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);
}
+480 -8
View File
@@ -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 = '<div class="text-slate-500">No clients</div>';
}
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 `
<div class="bg-slate-800/50 rounded p-3 min-w-0">
<div class="text-xs text-slate-400 mb-2 truncate" title="${escapeHtml(route.route)}">
${escapeHtml(route.route)}
</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
<span class="text-slate-500">p95</span>
<span class="font-semibold text-right">${formatMs(route.latencyP95 || 0)}</span>
<span class="text-slate-500">avg</span>
<span class="font-semibold text-right">${formatMs(route.latencyAvg || 0)}</span>
<span class="text-slate-500">req/min</span>
<span class="font-semibold text-right">${Number(route.countLastMinute || 0).toLocaleString()}</span>
<span class="text-slate-500">errors</span>
<span class="font-semibold text-right ${errorClass}">${Number(route.errorsLastMinute || 0).toLocaleString()}</span>
</div>
</div>
`;
})
.join("");
} else {
httpRoutesList.innerHTML =
'<div class="text-slate-500 col-span-full text-center py-4">No HTTP route samples yet</div>';
}
}
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) {
+74 -22
View File
@@ -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 = '<i class="fa-solid fa-comments"></i><span class="sb-text">Chat</span>';
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 = '<i class="fa-solid fa-comments"></i><span class="sb-text">Chat</span>';
restoreBtn.addEventListener("click", () => {
chatWidget.show();
restoreBtn.remove();
});
refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild);
}
});
});
installPageResourceTracker();
setupSoftNavigation(host, { onPathChange: applyActivePath });
startPageTracking();
}
@@ -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 },
],
},
+79 -3
View File
@@ -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");
+182 -27
View File
@@ -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 `
<div class="flex items-start justify-between gap-3 rounded bg-slate-950/70 border border-slate-800 px-3 py-2">
<div class="min-w-0">
<div class="font-mono text-xs ${has ? "text-slate-100" : "text-slate-500"}">${escapeHtml(perm)}</div>
<div class="text-[11px] text-slate-500 leading-snug">${escapeHtml(byId.get(perm) || "")}</div>
</div>
<span class="shrink-0 text-xs ${has ? "text-emerald-300" : "text-slate-600"}">
<i class="fa-solid ${has ? "fa-circle-check" : "fa-circle-minus"}"></i>
</span>
</div>
`;
})
.join("");
return `
<div class="rounded-lg border border-slate-800 bg-slate-900/70 p-3">
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
<i class="fa-solid ${group.icon} text-violet-400"></i>${escapeHtml(group.title)}
</h3>
<div class="space-y-2">${rows}</div>
</div>
`;
})
.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 = '<i class="fa-solid fa-spinner fa-spin mr-2"></i>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();
}
+357
View File
@@ -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;
}
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/assets/nav-prelude.js"></script>
<link rel="modulepreload" href="/assets/nav.js" />
<title>Overlord Graph</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="stylesheet" href="/vendor/inter/400.css" />
<link rel="stylesheet" href="/vendor/inter/600.css" />
<link rel="stylesheet" href="/vendor/inter/700.css" />
<link rel="stylesheet" href="/assets/tailwind.css" />
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
<link rel="stylesheet" href="/assets/main.css" />
<link rel="stylesheet" href="/assets/custom.css" />
<script defer src="/vendor/cytoscape/cytoscape.min.js"></script>
<script defer type="module" src="/assets/client-graph.js"></script>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<header id="top-nav"></header>
<main class="graph-shell">
<section class="graph-toolbar" aria-label="Client graph controls">
<div class="graph-title">
<i class="fa-solid fa-diagram-project"></i>
<div>
<h1>Client Graph</h1>
<p id="graph-summary">Loading...</p>
</div>
</div>
<div class="graph-controls">
<label class="graph-field">
<i class="fa-solid fa-magnifying-glass"></i>
<input id="graph-search" type="search" autocomplete="off" placeholder="Search clients" />
</label>
<select id="graph-status" class="graph-select" aria-label="Status filter">
<option value="all">All status</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
</select>
<select id="graph-layout" class="graph-select" aria-label="Graph layout">
<option value="cose">Force</option>
<option value="breadthfirst">Tree</option>
<option value="circle">Circle</option>
<option value="grid">Grid</option>
</select>
<button id="graph-refresh" class="button ghost" type="button" title="Refresh graph" aria-label="Refresh graph">
<i class="fa-solid fa-rotate-right"></i>
</button>
<button id="graph-fit" class="button ghost" type="button" title="Fit graph" aria-label="Fit graph">
<i class="fa-solid fa-expand"></i>
</button>
</div>
</section>
<section class="graph-workspace">
<div id="client-graph" class="graph-canvas" role="application" aria-label="Client relationship graph"></div>
<aside class="graph-inspector" aria-label="Selected graph item">
<div class="graph-inspector-kicker">Selection</div>
<h2 id="graph-detail-title">Nothing selected</h2>
<div id="graph-detail-meta" class="graph-detail-meta"></div>
<div id="graph-detail-actions" class="graph-detail-actions"></div>
</aside>
</section>
</main>
<script type="module" src="/assets/nav.js"></script>
</body>
</html>
+88 -64
View File
@@ -12,11 +12,13 @@
<link rel="stylesheet" href="/assets/tailwind.css" />
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
<link rel="stylesheet" href="/vendor/flag-icons/css/flag-icons.min.css" />
<link rel="stylesheet" href="/vendor/gridstack/gridstack.min.css" />
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="stylesheet" href="/assets/main.css" />
<link rel="stylesheet" href="/assets/custom.css" />
<script defer src="/vendor/animejs/anime.min.js"></script>
<script defer src="/vendor/chart.js/chart.umd.js"></script>
<script defer src="/vendor/gridstack/gridstack-all.js"></script>
<link rel="preload" href="/api/clients?page=1&amp;pageSize=12&amp;q=&amp;sort=stable&amp;status=all&amp;os=all&amp;country=all&amp;group=all" as="fetch" crossorigin="use-credentials" />
<link rel="preload" href="/api/auth/me" as="fetch" crossorigin="use-credentials" />
<link rel="preload" href="/api/groups" as="fetch" crossorigin="use-credentials" />
@@ -34,6 +36,18 @@
<span id="server-version-text" class="dashboard-version-text">Server version: loading...</span>
</div>
<div class="dashboard-stats-controls" aria-label="Status overview controls">
<details id="dashboard-card-menu" class="dashboard-card-menu">
<summary class="dashboard-stats-toggle" title="Dashboard cards" aria-label="Dashboard cards">
<i class="fa-solid fa-table-cells-large"></i>
</summary>
<div id="dashboard-card-menu-panel" class="dashboard-card-menu-panel">
<label><input type="checkbox" data-dashboard-card-toggle="online" checked /> Online</label>
<label><input type="checkbox" data-dashboard-card-toggle="sessions" checked /> Sessions</label>
<label><input type="checkbox" data-dashboard-card-toggle="trend" checked /> Clients Online</label>
<label><input type="checkbox" data-dashboard-card-toggle="fleet" checked /> Operating Systems</label>
<label><input type="checkbox" data-dashboard-card-toggle="pulse" checked /> Server Pulse</label>
</div>
</details>
<button id="dashboard-stats-reset" class="dashboard-stats-toggle" type="button" title="Reset status overview">
<i class="fa-solid fa-arrow-rotate-left"></i>
</button>
@@ -42,79 +56,89 @@
</button>
</div>
</div>
<div id="dashboard-stats-content" class="dashboard-stats-content">
<div class="dashboard-stat-card dashboard-stat-card--online" data-dashboard-card="online" draggable="true">
<div class="dashboard-stat-topline">
<span class="dashboard-live-dot is-live"></span>
<span>Online</span>
</div>
<div id="dash-online-count" class="dashboard-stat-value">0</div>
<div class="dashboard-stat-subline">
<span id="dash-total-count">0 total</span>
<span id="dash-online-ratio">0%</span>
</div>
</div>
<div class="dashboard-stat-card dashboard-stat-card--sessions" data-dashboard-card="sessions" draggable="true">
<div class="dashboard-stat-topline">
<i class="fa-solid fa-wave-square"></i>
<span>Sessions</span>
</div>
<div id="dash-session-count" class="dashboard-stat-value">0</div>
<div class="dashboard-stat-subline">
<span id="dash-command-minute">0 cmd/min</span>
<span id="dash-http-errors">0 errors</span>
<div id="dashboard-stats-content" class="dashboard-stats-content grid-stack">
<div class="grid-stack-item" data-dashboard-card="online" gs-id="online" gs-x="0" gs-y="0" gs-w="2" gs-h="2" gs-min-w="2" gs-min-h="1">
<div class="grid-stack-item-content dashboard-stat-card dashboard-stat-card--online">
<div class="dashboard-stat-topline">
<span class="dashboard-live-dot is-live"></span>
<span>Online</span>
</div>
<div id="dash-online-count" class="dashboard-stat-value">0</div>
<div class="dashboard-stat-subline">
<span id="dash-total-count">0 total</span>
<span id="dash-online-ratio">0%</span>
</div>
</div>
</div>
</div>
<div class="dashboard-chart-card" data-dashboard-card="trend" draggable="true">
<div class="dashboard-chart-head">
<div>
<span class="dashboard-chart-kicker">Past Week</span>
<strong>Clients Online</strong>
<div class="grid-stack-item" data-dashboard-card="sessions" gs-id="sessions" gs-x="2" gs-y="0" gs-w="2" gs-h="2" gs-min-w="2" gs-min-h="1">
<div class="grid-stack-item-content dashboard-stat-card dashboard-stat-card--sessions">
<div class="dashboard-stat-topline">
<i class="fa-solid fa-wave-square"></i>
<span>Sessions</span>
</div>
<div id="dash-session-count" class="dashboard-stat-value">0</div>
<div class="dashboard-stat-subline">
<span id="dash-command-minute">0 cmd/min</span>
<span id="dash-http-errors">0 errors</span>
</div>
</div>
<span id="dash-trend-status" class="dashboard-chart-pill">Live</span>
</div>
<div class="dashboard-chart-wrap">
<canvas id="dash-online-chart"></canvas>
</div>
</div>
<div class="dashboard-chart-card" data-dashboard-card="fleet" draggable="true">
<div class="dashboard-chart-head">
<div>
<span class="dashboard-chart-kicker">Fleet Mix</span>
<strong>Operating Systems</strong>
<div class="grid-stack-item" data-dashboard-card="trend" gs-id="trend" gs-x="4" gs-y="0" gs-w="3" gs-h="2" gs-min-w="2" gs-min-h="1">
<div class="grid-stack-item-content dashboard-chart-card">
<div class="dashboard-chart-head">
<div>
<span class="dashboard-chart-kicker">Past Week</span>
<strong>Clients Online</strong>
</div>
<span id="dash-trend-status" class="dashboard-chart-pill">Live</span>
</div>
<div class="dashboard-chart-wrap">
<canvas id="dash-online-chart"></canvas>
</div>
</div>
<span id="dash-os-leader" class="dashboard-chart-pill">None</span>
</div>
<div class="dashboard-chart-wrap dashboard-chart-wrap--donut">
<canvas id="dash-os-chart"></canvas>
</div>
</div>
<div class="dashboard-health-card" data-dashboard-card="pulse" draggable="true">
<div class="dashboard-chart-head">
<div>
<span class="dashboard-chart-kicker">Status</span>
<strong>Server Pulse</strong>
<div class="grid-stack-item" data-dashboard-card="fleet" gs-id="fleet" gs-x="7" gs-y="0" gs-w="3" gs-h="2" gs-min-w="2" gs-min-h="1">
<div class="grid-stack-item-content dashboard-chart-card">
<div class="dashboard-chart-head">
<div>
<span class="dashboard-chart-kicker">Fleet Mix</span>
<strong>Operating Systems</strong>
</div>
<span id="dash-os-leader" class="dashboard-chart-pill">None</span>
</div>
<div class="dashboard-chart-wrap dashboard-chart-wrap--donut">
<canvas id="dash-os-chart"></canvas>
</div>
</div>
<span id="dash-last-refresh" class="dashboard-chart-pill">Waiting</span>
</div>
<div class="dashboard-health-list">
<div class="dashboard-health-row" data-pulse-row="api" draggable="true">
<span class="dashboard-live-dot is-live"></span>
<span>API</span>
<strong id="dash-api-status">Live</strong>
</div>
<div class="dashboard-health-row" data-pulse-row="memory" draggable="true">
<span id="dash-memory-dot" class="dashboard-live-dot is-live"></span>
<span>Memory</span>
<strong id="dash-memory-status">0%</strong>
</div>
<div class="dashboard-health-row" data-pulse-row="ping" draggable="true">
<span id="dash-ping-dot" class="dashboard-live-dot is-muted"></span>
<span>Ping Avg</span>
<strong id="dash-ping-status">-</strong>
<div class="grid-stack-item" data-dashboard-card="pulse" gs-id="pulse" gs-x="10" gs-y="0" gs-w="2" gs-h="2" gs-min-w="2" gs-min-h="1">
<div class="grid-stack-item-content dashboard-health-card">
<div class="dashboard-chart-head">
<div>
<span class="dashboard-chart-kicker">Status</span>
<strong>Server Pulse</strong>
</div>
<span id="dash-last-refresh" class="dashboard-chart-pill">Waiting</span>
</div>
<div class="dashboard-health-list">
<div class="dashboard-health-row" data-pulse-row="api" draggable="true">
<span class="dashboard-live-dot is-live"></span>
<span>API</span>
<strong id="dash-api-status">Live</strong>
</div>
<div class="dashboard-health-row" data-pulse-row="memory" draggable="true">
<span id="dash-memory-dot" class="dashboard-live-dot is-live"></span>
<span>Memory</span>
<strong id="dash-memory-status">0%</strong>
</div>
<div class="dashboard-health-row" data-pulse-row="ping" draggable="true">
<span id="dash-ping-dot" class="dashboard-live-dot is-muted"></span>
<span>Ping Avg</span>
<strong id="dash-ping-status">-</strong>
</div>
</div>
</div>
</div>
</div>
</div>
<div
id="dashboard-stats-resizer"
+120 -34
View File
@@ -145,6 +145,101 @@
</div>
</div>
<!-- Operational Pressure Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<i class="fa-solid fa-network-wired text-orange-400"></i>
Network Throughput
</h3>
<div class="h-64">
<canvas id="bandwidth-chart"></canvas>
</div>
</div>
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<i class="fa-solid fa-arrow-right-arrow-left text-emerald-400"></i>
HTTP Requests
</h3>
<div class="h-64">
<canvas id="http-requests-chart"></canvas>
</div>
</div>
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<i class="fa-solid fa-gauge-high text-rose-400"></i>
HTTP Latency
</h3>
<div class="h-64">
<canvas id="http-chart"></canvas>
</div>
</div>
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<i class="fa-solid fa-memory text-cyan-400"></i>
Runtime Memory
</h3>
<div class="h-64">
<canvas id="memory-chart"></canvas>
</div>
</div>
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<i class="fa-solid fa-stopwatch text-amber-400"></i>
Event Loop Lag
</h3>
<div class="h-64">
<canvas id="event-loop-chart"></canvas>
</div>
</div>
</div>
<!-- Slow HTTP Routes -->
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<div class="flex items-center justify-between gap-3 flex-wrap mb-3">
<h3 class="text-lg font-semibold flex items-center gap-2">
<i class="fa-solid fa-route text-rose-400"></i>
Slowest HTTP Routes
</h3>
<div class="text-xs text-slate-500">Sorted by p95 latency, last minute</div>
</div>
<div
id="http-routes"
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3"
>
<div class="text-slate-500 col-span-full text-center py-4">
No HTTP route samples yet
</div>
</div>
</div>
<!-- Mix Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<i class="fa-solid fa-plug text-emerald-400"></i>
Session Mix
</h3>
<div class="h-64">
<canvas id="sessions-chart"></canvas>
</div>
</div>
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<i class="fa-brands fa-windows text-cyan-400"></i>
Operating Systems
</h3>
<div class="h-64">
<canvas id="os-chart"></canvas>
</div>
</div>
</div>
<!-- Additional Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Server Stats -->
@@ -181,36 +276,22 @@
</div>
</div>
<!-- Ping Stats -->
<!-- Clients by OS -->
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
<i class="fa-solid fa-signal text-blue-400"></i>
Ping Statistics
<i class="fa-brands fa-windows text-cyan-400"></i>
Clients by OS
</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-slate-400">Average:</span>
<span id="ping-avg">-</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">Min / Max:</span>
<span
><span id="ping-min">-</span> /
<span id="ping-max">-</span></span
>
</div>
<div class="flex justify-between">
<span class="text-slate-400">Samples:</span>
<span id="ping-count">0</span>
</div>
<div id="clients-by-os" class="space-y-2 text-sm">
<div class="text-slate-500">No data</div>
</div>
</div>
<!-- HTTP & Event Loop -->
<!-- HTTP, Ping & Event Loop -->
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
<i class="fa-solid fa-wave-square text-amber-400"></i>
HTTP & Event Loop
HTTP, Ping & Event Loop
</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
@@ -222,10 +303,11 @@
<span id="http-errors-minute">0</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">Latency avg/p95:</span>
<span class="text-slate-400">Latency avg/p95/p99:</span>
<span>
<span id="http-latency-avg">0</span> /
<span id="http-latency-p95">0</span>
<span id="http-latency-p95">0</span> /
<span id="http-latency-p99">0</span>
</span>
</div>
<div class="flex justify-between">
@@ -239,17 +321,21 @@
<span class="text-slate-400">Loop lag max:</span>
<span id="event-loop-max">0</span>
</div>
</div>
</div>
<!-- Client Distribution -->
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
<i class="fa-brands fa-windows text-cyan-400"></i>
Clients by OS
</h3>
<div id="clients-by-os" class="space-y-2 text-sm">
<div class="text-slate-500">No data</div>
<div class="flex justify-between">
<span class="text-slate-400">Ping avg:</span>
<span id="ping-avg">-</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">Ping min/max:</span>
<span>
<span id="ping-min">-</span> /
<span id="ping-max">-</span>
</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">Ping samples:</span>
<span id="ping-count">0</span>
</div>
</div>
</div>
</div>
+46 -23
View File
@@ -90,15 +90,16 @@
<a data-target="wipe-offline-section" class="settings-nav-link" data-permission="clients:control" style="--accent: 248 113 113"><i class="fa-solid fa-circle-minus w-4 text-red-400"></i><span>Wipe Offline Clients</span></a>
<span class="settings-nav-group-label">Admin</span>
<a data-target="section-security" class="settings-nav-link" data-permission="system:configure" style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
<a data-target="thumbnails-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
<a data-target="section-tls" class="settings-nav-link" data-permission="system:configure" style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
<a data-target="chat-settings-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
<a data-target="registration-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
<a data-target="build-rate-limit-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
<a data-target="section-appearance" class="settings-nav-link" data-permission="system:configure" style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
<a data-target="export-import-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
<a data-target="section-health" class="settings-nav-link" data-permission="system:configure" style="--accent: 34 211 238"><i class="fa-solid fa-heart-pulse w-4 text-cyan-400"></i><span>Server Health</span></a>
<a data-target="section-permissions-overview" class="settings-nav-link" data-permission="users:manage" style="--accent: 167 139 250"><i class="fa-solid fa-user-shield w-4 text-violet-400"></i><span>Access Overview</span></a>
<a data-target="section-security" class="settings-nav-link" data-permission="system:security" style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
<a data-target="section-tls" class="settings-nav-link" data-permission="system:tls" style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
<a data-target="chat-settings-section" class="settings-nav-link" data-permission="system:chat" style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
<a data-target="registration-section" class="settings-nav-link" data-permission="system:registration" style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
<a data-target="thumbnails-section" class="settings-nav-link" data-permission="system:thumbnails" style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
<a data-target="build-rate-limit-section" class="settings-nav-link" data-permission="system:build-limits" style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
<a data-target="section-appearance" class="settings-nav-link" data-permission="system:appearance" style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
<a data-target="export-import-section" class="settings-nav-link" data-permission="system:export-import" style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
<a data-target="section-health" class="settings-nav-link" data-permission="system:health" style="--accent: 34 211 238"><i class="fa-solid fa-heart-pulse w-4 text-cyan-400"></i><span>Server Health</span></a>
</nav>
</aside>
@@ -552,8 +553,30 @@
<!-- ========== ADMIN ========== -->
<!-- Security Policy -->
<section id="section-security" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<!-- Access Overview -->
<section id="section-permissions-overview" data-permission="users:manage" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-violet-500/10 border border-violet-500/30">
<i class="fa-solid fa-user-shield text-violet-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Access Overview</h2>
<p class="text-sm text-slate-400">Review your effective permissions and jump to user access management.</p>
</div>
</div>
<div id="settings-permissions-summary" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"></div>
<div class="flex flex-wrap items-center gap-3">
<a href="/users" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-violet-700 hover:bg-violet-600 text-white text-sm font-medium">
<i class="fa-solid fa-users-gear"></i> Manage Users
</a>
<span class="inline-flex items-center gap-2 max-w-full px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-300 text-sm">
<i class="fa-solid fa-desktop text-sky-400"></i>
<span class="min-w-0 leading-snug">Client access rules are edited from a selected user in Manage Users.</span>
</span>
</div>
</section>
<section id="section-security" data-permission="system:security" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-500/10 border border-indigo-500/30">
<i class="fa-solid fa-shield-halved text-indigo-400"></i>
@@ -566,7 +589,7 @@
<form id="security-form" class="space-y-4">
<div id="security-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
Only admins can update security policy settings.
Your permissions do not allow security policy changes.
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
@@ -675,7 +698,7 @@
</section>
<!-- TLS Certificates -->
<section id="section-tls" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<section id="section-tls" data-permission="system:tls" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
<i class="fa-solid fa-certificate text-cyan-400"></i>
@@ -688,7 +711,7 @@
<form id="tls-form" class="space-y-4">
<div id="tls-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
Only admins can update TLS settings.
Your permissions do not allow TLS setting changes.
</div>
<label class="flex items-center gap-2 text-sm">
@@ -777,7 +800,7 @@
</section>
<!-- Team Chat -->
<section id="chat-settings-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<section id="chat-settings-section" data-permission="system:chat" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
<i class="fa-solid fa-comments text-sky-400"></i>
@@ -813,7 +836,7 @@
</section>
<!-- Registration Settings (admin only) -->
<section id="registration-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<section id="registration-section" data-permission="system:registration" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
<i class="fa-solid fa-user-plus text-emerald-400"></i>
@@ -920,8 +943,8 @@
</div>
</section>
<!-- Screenshot Thumbnails (admin only) -->
<section id="thumbnails-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<!-- Screenshot Thumbnails -->
<section id="thumbnails-section" data-permission="system:thumbnails" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
<i class="fa-solid fa-images text-sky-400"></i>
@@ -961,7 +984,7 @@
</section>
<!-- Build Rate Limit (admin only) -->
<section id="build-rate-limit-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<section id="build-rate-limit-section" data-permission="system:build-limits" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
<i class="fa-solid fa-gauge-high text-orange-400"></i>
@@ -995,7 +1018,7 @@
</section>
<!-- Custom CSS -->
<section id="section-appearance" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<section id="section-appearance" data-permission="system:appearance" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-fuchsia-500/10 border border-fuchsia-500/30">
<i class="fa-solid fa-paintbrush text-fuchsia-400"></i>
@@ -1008,7 +1031,7 @@
<form id="appearance-form" class="space-y-4">
<div id="appearance-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
Only admins can update custom CSS.
Your permissions do not allow custom CSS changes.
</div>
<label class="block">
@@ -1033,7 +1056,7 @@
</section>
<!-- Export & Import -->
<section id="export-import-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<section id="export-import-section" data-permission="system:export-import" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30">
<i class="fa-solid fa-file-export text-amber-400"></i>
@@ -1081,7 +1104,7 @@
</section>
<!-- Server Health -->
<section id="section-health" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section hidden" data-permission="system:configure">
<section id="section-health" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section hidden" data-permission="system:health">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
+21
View File
@@ -186,6 +186,27 @@ copyFile(
path.join(VENDOR, "tabulator", "tabulator_midnight.min.css"),
);
/* ── GridStack ───────────────────────────────────────────────────── */
console.log("Copying GridStack ...");
const gridstackRoot = path.join(NM, "gridstack", "dist");
copyFile(
path.join(gridstackRoot, "gridstack-all.js"),
path.join(VENDOR, "gridstack", "gridstack-all.js"),
);
copyFile(
path.join(gridstackRoot, "gridstack.min.css"),
path.join(VENDOR, "gridstack", "gridstack.min.css"),
);
/* ── Cytoscape.js ────────────────────────────────────────────────── */
console.log("Copying Cytoscape.js ...");
copyFile(
path.join(NM, "cytoscape", "dist", "cytoscape.min.js"),
path.join(VENDOR, "cytoscape", "cytoscape.min.js"),
);
/* ── highlight.js (bundle core + languages) ──────────────────────── */
console.log("Bundling highlight.js ...");
+104 -2
View File
@@ -1,5 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test";
import { deleteClientRow, listClients, setClientBookmark, upsertClientRow } from "./db";
import { db } from "./db/connection";
import { deleteClientRow, listClients, setClientBookmark, setClientTag, upsertClientRow } from "./db";
const createdClientIds: string[] = [];
@@ -110,4 +111,105 @@ describe("client list ordering", () => {
cleanupCreatedClients();
}
});
});
test("search tolerates typos across client metadata", () => {
try {
const prefix = `fuse-${Date.now().toString(36)}`;
const id = `${prefix}-finance-terminal`;
createTempClient(id, {
online: true,
lastSeen: Date.now(),
host: "finance-terminal",
});
setClientTag(id, "Payroll Workstation", "Quarterly reporting machine");
const result = listClients({
page: 1,
pageSize: 12,
search: "payrol workstaton",
sort: "last_seen_desc",
statusFilter: "all",
osFilter: "all",
countryFilter: "all",
enrollmentFilter: "all",
});
expect(result.items.some((item) => item.id === id)).toBe(true);
} finally {
cleanupCreatedClients();
}
});
});
describe("client search index", () => {
function getSearchIndexRow(id: string) {
return db
.query<{ id: string; host: string | null; customTag: string | null; customTagNote: string | null }>(
`SELECT id, host, custom_tag as customTag, custom_tag_note as customTagNote
FROM client_search_fts
WHERE id = ?`,
)
.get(id);
}
test("keeps the FTS search index in sync when client metadata changes", () => {
try {
const id = `fts-sync-${Date.now().toString(36)}`;
createTempClient(id, {
online: true,
lastSeen: Date.now(),
host: "dispatch-terminal-old",
});
expect(getSearchIndexRow(id)?.host).toBe("dispatch-terminal-old");
createTempClient(id, {
online: true,
lastSeen: Date.now(),
host: "dispatch-terminal-new",
});
setClientTag(id, "Priority Ops", "Shift handoff workstation");
const updatedRow = getSearchIndexRow(id);
expect(updatedRow?.host).toBe("dispatch-terminal-new");
expect(updatedRow?.customTag).toBe("Priority Ops");
expect(updatedRow?.customTagNote).toBe("Shift handoff workstation");
deleteClientRow(id);
expect(getSearchIndexRow(id)).toBeNull();
} finally {
cleanupCreatedClients();
}
});
test("uses indexed candidates before fuzzy matching so exact token searches stay fast", () => {
try {
const prefix = `fts-candidate-${Date.now().toString(36)}`;
const id = `${prefix}-ops-node`;
createTempClient(id, {
online: true,
lastSeen: Date.now(),
host: "northbridge-ops-node",
});
setClientTag(id, "Incident Desk", "CPU spike triage host");
const result = listClients({
page: 1,
pageSize: 12,
search: "northbridge",
sort: "last_seen_desc",
statusFilter: "all",
osFilter: "all",
countryFilter: "all",
enrollmentFilter: "all",
});
expect(result.items.some((item) => item.id === id)).toBe(true);
} finally {
cleanupCreatedClients();
}
});
});
+262 -85
View File
@@ -1,4 +1,5 @@
import type { ClientInfo, ListFilters, ListResult, ClientRole } from "../types";
import Fuse from "fuse.js";
import { getThumbnailSummaries } from "../thumbnails";
import { db, dbPath } from "./connection";
import "./schema";
@@ -770,6 +771,119 @@ export function computeClientSuspiciousFlags(
return flags;
}
const CLIENT_SEARCH_KEYS = [
{ name: "nickname", weight: 0.32 },
{ name: "host", weight: 0.28 },
{ name: "user", weight: 0.18 },
{ name: "id", weight: 0.18 },
{ name: "customTag", weight: 0.2 },
{ name: "customTagNote", weight: 0.1 },
{ name: "groupName", weight: 0.14 },
{ name: "os", weight: 0.12 },
{ name: "ip", weight: 0.1 },
{ name: "hwid", weight: 0.08 },
{ name: "country", weight: 0.06 },
{ name: "version", weight: 0.06 },
{ name: "buildTag", weight: 0.06 },
{ name: "cpu", weight: 0.05 },
{ name: "gpu", weight: 0.05 },
{ name: "ram", weight: 0.04 },
] as const;
const CLIENT_SEARCH_SQL_COLUMNS = [
"COALESCE(c.nickname,'')",
"COALESCE(c.host,'')",
"COALESCE(c.user,'')",
"c.id",
"COALESCE(c.custom_tag,'')",
"COALESCE(c.custom_tag_note,'')",
"COALESCE(g.name,'')",
"COALESCE(c.os,'')",
"COALESCE(c.ip,'')",
"COALESCE(c.hwid,'')",
"COALESCE(c.country,'')",
"COALESCE(c.version,'')",
"COALESCE(c.build_tag,'')",
"COALESCE(c.cpu,'')",
"COALESCE(c.gpu,'')",
"COALESCE(c.ram,'')",
] as const;
function escapeLikeTerm(term: string): string {
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
}
function buildFuzzyCandidateWhere(search: string): { sql: string; params: string[] } {
const query = search.trim().toLowerCase();
if (!query) return { sql: "", params: [] };
const terms = Array.from(new Set([
query,
...query.split(/[\s,;:|/\\]+/),
].map((term) => term.trim()).filter((term) => term.length >= 2))).slice(0, 8);
if (!terms.length) return { sql: "", params: [] };
const fieldClause = CLIENT_SEARCH_SQL_COLUMNS
.map((column) => `LOWER(${column}) LIKE ? ESCAPE '\\'`)
.join(" OR ");
const sql = terms.map(() => `(${fieldClause})`).join(" OR ");
const params = terms.flatMap((term) => {
const like = `%${escapeLikeTerm(term)}%`;
return CLIENT_SEARCH_SQL_COLUMNS.map(() => like);
});
return { sql: `(${sql})`, params };
}
function buildClientSearchFtsQuery(search: string): string {
const terms = Array.from(new Set(
search
.trim()
.split(/[\s,;:|/\\]+/)
.map((term) => term.replace(/["*]/g, "").trim())
.filter((term) => term.length >= 2),
)).slice(0, 8);
return terms.map((term) => `"${term}"*`).join(" OR ");
}
function getFtsClientSearchCandidateIds(search: string, limit: number): string[] | null {
const ftsQuery = buildClientSearchFtsQuery(search);
if (!ftsQuery) return [];
try {
return db
.query<{ id: string }>(
`SELECT id
FROM client_search_fts
WHERE client_search_fts MATCH ?
ORDER BY rank
LIMIT ?`,
)
.all(ftsQuery, limit)
.map((row) => row.id)
.filter(Boolean);
} catch {
return null;
}
}
function fuzzySearchClientRows(rows: any[], search: string): any[] {
const query = search.trim();
if (!query) return rows;
const fuse = new Fuse(rows, {
keys: CLIENT_SEARCH_KEYS as any,
threshold: 0.36,
distance: 120,
ignoreLocation: true,
shouldSort: true,
});
return fuse.search(query).map((result) => result.item);
}
export function listClients(filters: ListFilters): ListResult {
const {
page,
@@ -789,14 +903,6 @@ export function listClients(filters: ListFilters): ListResult {
const where: string[] = [];
const params: any[] = [];
if (search) {
where.push(
"(LOWER(COALESCE(c.host,'')) LIKE ? OR LOWER(COALESCE(c.user,'')) LIKE ? OR LOWER(COALESCE(c.nickname,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag_note,'')) LIKE ? OR LOWER(c.id) LIKE ? OR LOWER(COALESCE(g.name,'')) LIKE ?)",
);
const needle = `%${search}%`;
params.push(needle, needle, needle, needle, needle, needle, needle);
}
if (statusFilter === "online") {
where.push("c.online=1");
} else if (statusFilter === "offline") {
@@ -859,11 +965,9 @@ export function listClients(filters: ListFilters): ListResult {
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const needsGroupJoinForFilter =
!!search ||
sort === "group_asc" ||
sort === "group_desc" ||
(groupFilter !== undefined && groupFilter !== "all");
const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : "";
const orderBy = (() => {
const online = "c.online DESC";
@@ -894,49 +998,99 @@ export function listClients(filters: ListFilters): ListResult {
}
})();
const totalRow = db
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`)
.get(...params) ?? { c: 0 };
const onlineRow = db
.query<{ c: number }>(
`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
)
.get(...params) ?? { c: 0 };
const offset = (page - 1) * pageSize;
const clientFields =
"c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.battery_percent as batteryPercent, c.battery_charging as batteryCharging, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason";
const rows = needsGroupJoinForFilter
? db
.query<any>(
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
FROM clients c
LEFT JOIN client_groups g ON g.id = c.group_id
${whereSql}
${orderBy}
LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset)
: db
.query<any>(
`WITH page_clients AS (
SELECT ${clientFields}
let rows: any[] | undefined;
let totalCount = 0;
let onlineCount = 0;
if (search) {
const searchWhere = [...where];
const searchParams = [...params];
const candidateLimit = Math.min(5000, Math.max(1000, offset + pageSize * 8));
const ftsCandidateIds = getFtsClientSearchCandidateIds(search, candidateLimit);
if (ftsCandidateIds) {
if (!ftsCandidateIds.length) {
rows = [];
totalCount = 0;
onlineCount = 0;
} else {
searchWhere.push(`c.id IN (${ftsCandidateIds.map(() => "?").join(",")})`);
searchParams.push(...ftsCandidateIds);
}
} else {
const searchCandidate = buildFuzzyCandidateWhere(search);
if (searchCandidate.sql) {
searchWhere.push(searchCandidate.sql);
searchParams.push(...searchCandidate.params);
}
}
if (rows === undefined) {
const searchWhereSql = searchWhere.length ? `WHERE ${searchWhere.join(" AND ")}` : "";
const candidates = db
.query<any>(
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
FROM clients c
LEFT JOIN client_groups g ON g.id = c.group_id
${searchWhereSql}
${orderBy}`,
)
.all(...searchParams);
const matches = fuzzySearchClientRows(candidates, search);
totalCount = matches.length;
onlineCount = matches.filter((row: any) => row.online === 1).length;
rows = matches.slice(offset, offset + pageSize);
}
} else {
const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : "";
const totalRow = db
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`)
.get(...params) ?? { c: 0 };
const onlineRow = db
.query<{ c: number }>(
`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
)
.get(...params) ?? { c: 0 };
totalCount = totalRow.c;
onlineCount = onlineRow.c;
rows = needsGroupJoinForFilter
? db
.query<any>(
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
FROM clients c
LEFT JOIN client_groups g ON g.id = c.group_id
${whereSql}
${orderBy}
LIMIT ? OFFSET ?
)
SELECT pc.*, g.name as groupName, g.color as groupColor
FROM page_clients pc
LEFT JOIN client_groups g ON g.id = pc.groupId`,
)
.all(...params, pageSize, offset);
LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset)
: db
.query<any>(
`WITH page_clients AS (
SELECT ${clientFields}
FROM clients c
${whereSql}
${orderBy}
LIMIT ? OFFSET ?
)
SELECT pc.*, g.name as groupName, g.color as groupColor
FROM page_clients pc
LEFT JOIN client_groups g ON g.id = pc.groupId`,
)
.all(...params, pageSize, offset);
}
const shouldScanSuspicious = SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalRow.c <= SUSPICIOUS_SCAN_MAX_CLIENTS;
const shouldScanSuspicious = !search && (SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalCount <= SUSPICIOUS_SCAN_MAX_CLIENTS);
const suspiciousSets = shouldScanSuspicious
? getFloodSetsWithCache()
: { floodedHwids: new Set<string>(), floodedHardware: new Set<string>(), floodedIps: new Set<string>() };
rows = rows || [];
const thumbnailSummaries = getThumbnailSummaries(rows.map((row: any) => row.id));
const items = rows.map((c: any) => {
@@ -986,7 +1140,7 @@ export function listClients(filters: ListFilters): ListResult {
};
});
return { page, pageSize, total: totalRow.c, online: onlineRow.c, items };
return { page, pageSize, total: totalCount, online: onlineCount, items };
}
export type ClientMetricsSummary = {
@@ -998,7 +1152,56 @@ export type ClientMetricsSummary = {
byCountryOnline: Record<string, number>;
};
const CLIENT_METRICS_SUMMARY_TTL_MS = 4_000;
let clientMetricsSummaryCache: { expiresAt: number; summary: ClientMetricsSummary } | null = null;
const userClientMetricsSummaryCache = new Map<number, { expiresAt: number; summary: ClientMetricsSummary }>();
function cloneClientMetricsSummary(summary: ClientMetricsSummary): ClientMetricsSummary {
return {
total: summary.total,
online: summary.online,
byOS: { ...summary.byOS },
byCountry: { ...summary.byCountry },
byOSOnline: { ...summary.byOSOnline },
byCountryOnline: { ...summary.byCountryOnline },
};
}
function makeClientMetricsSummary(
counts: { total: number | null; online: number | null },
osRows: { key: string; total: number; online: number | null }[],
countryRows: { key: string; total: number; online: number | null }[],
): ClientMetricsSummary {
const byOS: Record<string, number> = {};
const byOSOnline: Record<string, number> = {};
for (const row of osRows) {
byOS[row.key] = Number(row.total) || 0;
byOSOnline[row.key] = Number(row.online) || 0;
}
const byCountry: Record<string, number> = {};
const byCountryOnline: Record<string, number> = {};
for (const row of countryRows) {
byCountry[row.key] = Number(row.total) || 0;
byCountryOnline[row.key] = Number(row.online) || 0;
}
return {
total: Number(counts.total) || 0,
online: Number(counts.online) || 0,
byOS,
byCountry,
byOSOnline,
byCountryOnline,
};
}
export function getClientMetricsSummary(): ClientMetricsSummary {
const now = Date.now();
if (clientMetricsSummaryCache && clientMetricsSummaryCache.expiresAt > now) {
return cloneClientMetricsSummary(clientMetricsSummaryCache.summary);
}
const counts = db
.query<{ total: number; online: number }>(
`SELECT COUNT(*) as total, SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients`,
@@ -1027,31 +1230,21 @@ export function getClientMetricsSummary(): ClientMetricsSummary {
)
.all();
const byOS: Record<string, number> = {};
const byOSOnline: Record<string, number> = {};
for (const row of osRows) {
byOS[row.key] = Number(row.total) || 0;
byOSOnline[row.key] = Number(row.online) || 0;
}
const byCountry: Record<string, number> = {};
const byCountryOnline: Record<string, number> = {};
for (const row of countryRows) {
byCountry[row.key] = Number(row.total) || 0;
byCountryOnline[row.key] = Number(row.online) || 0;
}
return {
total: Number(counts.total) || 0,
online: Number(counts.online) || 0,
byOS,
byCountry,
byOSOnline,
byCountryOnline,
const summary = makeClientMetricsSummary(counts, osRows, countryRows);
clientMetricsSummaryCache = {
expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS,
summary,
};
return cloneClientMetricsSummary(summary);
}
export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSummary {
const now = Date.now();
const cached = userClientMetricsSummaryCache.get(userId);
if (cached && cached.expiresAt > now) {
return cloneClientMetricsSummary(cached.summary);
}
const filter = `WHERE built_by_user_id = ?`;
const counts = db
@@ -1082,28 +1275,12 @@ export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSum
)
.all(userId);
const byOS: Record<string, number> = {};
const byOSOnline: Record<string, number> = {};
for (const row of osRows) {
byOS[row.key] = Number(row.total) || 0;
byOSOnline[row.key] = Number(row.online) || 0;
}
const byCountry: Record<string, number> = {};
const byCountryOnline: Record<string, number> = {};
for (const row of countryRows) {
byCountry[row.key] = Number(row.total) || 0;
byCountryOnline[row.key] = Number(row.online) || 0;
}
return {
total: Number(counts.total) || 0,
online: Number(counts.online) || 0,
byOS,
byCountry,
byOSOnline,
byCountryOnline,
};
const summary = makeClientMetricsSummary(counts, osRows, countryRows);
userClientMetricsSummaryCache.set(userId, {
expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS,
summary,
});
return cloneClientMetricsSummary(summary);
}
export function getOnlineClientCountForUser(userId: number): number {
+76
View File
@@ -148,6 +148,82 @@ try {
} catch {}
db.run(`CREATE INDEX IF NOT EXISTS idx_clients_group_id ON clients(group_id);`);
try {
db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS client_search_fts USING fts5(
id UNINDEXED,
host,
user,
nickname,
custom_tag,
custom_tag_note,
os,
ip,
hwid,
country,
version,
build_tag,
cpu,
gpu,
ram,
tokenize = 'unicode61'
);
`);
const counts = db
.query<{ clients: number; indexed: number }>(
`SELECT
(SELECT COUNT(*) FROM clients) as clients,
(SELECT COUNT(*) FROM client_search_fts) as indexed`,
)
.get() ?? { clients: 0, indexed: 0 };
if (counts.clients !== counts.indexed) {
db.run(`DELETE FROM client_search_fts`);
db.run(`
INSERT INTO client_search_fts(
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
)
SELECT
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
FROM clients
`);
}
db.run(`
CREATE TRIGGER IF NOT EXISTS clients_search_ai AFTER INSERT ON clients BEGIN
INSERT INTO client_search_fts(
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
)
VALUES (
new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note,
new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram
);
END;
`);
db.run(`
CREATE TRIGGER IF NOT EXISTS clients_search_ad AFTER DELETE ON clients BEGIN
DELETE FROM client_search_fts WHERE rowid = old.rowid;
END;
`);
db.run(`
CREATE TRIGGER IF NOT EXISTS clients_search_au AFTER UPDATE ON clients BEGIN
DELETE FROM client_search_fts WHERE rowid = old.rowid;
INSERT INTO client_search_fts(
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
)
VALUES (
new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note,
new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram
);
END;
`);
} catch {}
db.run(`
CREATE TABLE IF NOT EXISTS banned_ips (
ip TEXT PRIMARY KEY,
+45
View File
@@ -125,6 +125,31 @@ describe("MetricsCollector", () => {
const snap = metrics.getSnapshot();
expect(snap.http.latencyAvg).toBeCloseTo(50.5, 0);
expect(snap.http.latencyP95).toBeGreaterThanOrEqual(90);
expect(snap.http.latencyP99).toBeGreaterThanOrEqual(99);
});
test("tracks slow HTTP routes", () => {
metrics.recordHttpRequest(15, 200, "GET /api/metrics");
metrics.recordHttpRequest(120, 200, "GET /api/clients/:id/rd/ws");
metrics.recordHttpRequest(200, 500, "GET /api/clients/:id/rd/ws");
const snap = metrics.getSnapshot();
expect(snap.http.routes[0].route).toBe("GET /api/clients/:id/rd/ws");
expect(snap.http.routes[0].countLastMinute).toBe(2);
expect(snap.http.routes[0].errorsLastMinute).toBe(1);
expect(snap.http.routes[0].latencyP95).toBeGreaterThanOrEqual(120);
});
test("does not count the metrics dashboard poll as HTTP pressure", () => {
metrics.recordHttpRequest(100, 200, "GET /api/metrics");
metrics.recordHttpRequest(20, 200, "GET /api/clients");
const snap = metrics.getSnapshot();
expect(snap.http.total).toBe(1);
expect(snap.http.lastMinute).toBe(1);
expect(snap.http.routes.map((route) => route.route)).toEqual([
"GET /api/clients",
]);
});
});
@@ -177,6 +202,26 @@ describe("MetricsCollector", () => {
expect(history[0].timestamp).toBe(snap.timestamp);
});
test("history captures pressure metrics for charting", () => {
metrics.recordHttpRequest(25, 200);
metrics.recordHttpRequest(80, 500);
metrics.recordBytesSent(1024);
metrics.recordBytesReceived(2048);
const snap = metrics.getSnapshot();
metrics.recordHistoryEntry(snap);
const [entry] = metrics.getHistory();
expect(entry.httpRequestsPerMinute).toBeGreaterThanOrEqual(2);
expect(entry.httpErrorsPerMinute).toBeGreaterThanOrEqual(1);
expect(entry.httpLatencyAvg).toBeGreaterThan(0);
expect(entry.httpLatencyP95).toBeGreaterThan(0);
expect(entry.heapUsed).toBeGreaterThan(0);
expect(entry.rss).toBeGreaterThan(0);
expect(entry.systemMemoryUsedPercent).toBeGreaterThanOrEqual(0);
expect(entry.activeSessions).toBe(0);
});
test("history is capped at maxHistoryPoints", () => {
const weekOfFiveSecondSamples = 7 * 24 * 60 * 12;
for (let i = 0; i < weekOfFiveSecondSamples + 180; i++) {
+148 -20
View File
@@ -59,6 +59,8 @@ export interface MetricsSnapshot {
lastMinuteErrors: number;
latencyAvg: number;
latencyP95: number;
latencyP99: number;
routes: HttpRouteStats[];
};
eventLoop: {
avg: number;
@@ -67,12 +69,41 @@ export interface MetricsSnapshot {
};
}
export interface HttpRouteStats {
route: string;
countLastMinute: number;
errorsLastMinute: number;
latencyAvg: number;
latencyP95: number;
latencyP99: number;
latencyMax: number;
lastDuration: number;
lastStatus: number;
}
export interface MetricsHistory {
timestamp: number;
clientsOnline: number;
commandsPerMinute: number;
bandwidthSent: number;
bandwidthReceived: number;
httpRequestsPerMinute?: number;
httpErrorsPerMinute?: number;
httpLatencyAvg?: number;
httpLatencyP95?: number;
httpLatencyP99?: number;
eventLoopAvg?: number;
eventLoopP95?: number;
heapUsed?: number;
rss?: number;
systemMemoryUsedPercent?: number;
activeSessions?: number;
}
interface TimedHttpSample {
ts: number;
duration: number;
statusCode: number;
}
class MetricsCollector {
@@ -103,8 +134,11 @@ class MetricsCollector {
private httpTotal: number = 0;
private httpTimestamps: number[] = [];
private httpErrorTimestamps: number[] = [];
private httpLatencies: number[] = [];
private maxHttpLatencyHistory: number = 1000;
private httpSamples: TimedHttpSample[] = [];
private httpRouteSamples: Map<string, TimedHttpSample[]> = new Map();
private maxHttpLatencyHistory: number = 2000;
private maxHttpRouteSamples: number = 300;
private ignoredHttpMetricRoutes: Set<string> = new Set(["GET /api/metrics"]);
private eventLoopDelays: number[] = [];
private maxEventLoopHistory: number = 300;
@@ -236,7 +270,74 @@ class MetricsCollector {
}, intervalMs);
}
recordHttpRequest(durationMs: number, statusCode: number) {
private percentile(samples: number[], percentile: number): number {
if (samples.length === 0) return 0;
const index = Math.max(0, Math.ceil(samples.length * percentile) - 1);
return samples[index] ?? 0;
}
private average(samples: number[]): number {
return samples.length
? samples.reduce((sum, value) => sum + value, 0) / samples.length
: 0;
}
private pruneHttpSamples(list: TimedHttpSample[], minTs: number): void {
let removeCount = 0;
while (removeCount < list.length && list[removeCount].ts <= minTs) {
removeCount += 1;
}
if (removeCount > 0) {
list.splice(0, removeCount);
}
}
private countHttpErrors(samples: TimedHttpSample[]): number {
let count = 0;
for (const sample of samples) {
if (sample.statusCode >= 400) count += 1;
}
return count;
}
private getTopHttpRoutes(minTs: number): HttpRouteStats[] {
const routes: HttpRouteStats[] = [];
for (const [route, samples] of this.httpRouteSamples.entries()) {
this.pruneHttpSamples(samples, minTs);
if (samples.length === 0) {
this.httpRouteSamples.delete(route);
continue;
}
const durations = samples
.map((sample) => sample.duration)
.sort((a, b) => a - b);
const last = samples[samples.length - 1];
routes.push({
route,
countLastMinute: samples.length,
errorsLastMinute: this.countHttpErrors(samples),
latencyAvg: this.average(durations),
latencyP95: this.percentile(durations, 0.95),
latencyP99: this.percentile(durations, 0.99),
latencyMax: durations[durations.length - 1] ?? 0,
lastDuration: last?.duration ?? 0,
lastStatus: last?.statusCode ?? 0,
});
}
return routes
.sort((a, b) => {
if (b.latencyP95 !== a.latencyP95) return b.latencyP95 - a.latencyP95;
if (b.latencyAvg !== a.latencyAvg) return b.latencyAvg - a.latencyAvg;
return b.countLastMinute - a.countLastMinute;
})
.slice(0, 8);
}
recordHttpRequest(durationMs: number, statusCode: number, route = "unknown") {
if (this.ignoredHttpMetricRoutes.has(route)) return;
this.httpTotal++;
const now = Date.now();
this.httpTimestamps.push(now);
@@ -246,14 +347,25 @@ class MetricsCollector {
this.pruneTimestampWindow(this.httpErrorTimestamps, now - 60000);
}
if (Number.isFinite(durationMs)) {
this.httpLatencies.push(durationMs);
if (this.httpLatencies.length > this.maxHttpLatencyHistory) {
this.httpLatencies.shift();
const sample = { ts: now, duration: Math.max(0, durationMs), statusCode };
this.httpSamples.push(sample);
if (this.httpSamples.length > this.maxHttpLatencyHistory) {
this.httpSamples.splice(0, this.httpSamples.length - this.maxHttpLatencyHistory);
}
const routeSamples = this.httpRouteSamples.get(route) || [];
routeSamples.push(sample);
if (routeSamples.length > this.maxHttpRouteSamples) {
routeSamples.splice(0, routeSamples.length - this.maxHttpRouteSamples);
}
this.httpRouteSamples.set(route, routeSamples);
}
}
async withHttpMetrics<T extends Response>(handler: () => Promise<T>): Promise<T> {
async withHttpMetrics<T extends Response>(
handler: () => Promise<T>,
route = "unknown",
): Promise<T> {
const start = typeof performance !== "undefined" && performance.now
? performance.now()
: Date.now();
@@ -262,13 +374,13 @@ class MetricsCollector {
const end = typeof performance !== "undefined" && performance.now
? performance.now()
: Date.now();
this.recordHttpRequest(end - start, response?.status ?? 0);
this.recordHttpRequest(end - start, response?.status ?? 0, route);
return response;
} catch (err) {
const end = typeof performance !== "undefined" && performance.now
? performance.now()
: Date.now();
this.recordHttpRequest(end - start, 500);
this.recordHttpRequest(end - start, 500, route);
throw err;
}
}
@@ -280,6 +392,21 @@ class MetricsCollector {
commandsPerMinute: snapshot.commands.lastMinute,
bandwidthSent: this.sentPerSecond,
bandwidthReceived: this.receivedPerSecond,
httpRequestsPerMinute: snapshot.http.lastMinute,
httpErrorsPerMinute: snapshot.http.lastMinuteErrors,
httpLatencyAvg: snapshot.http.latencyAvg,
httpLatencyP95: snapshot.http.latencyP95,
httpLatencyP99: snapshot.http.latencyP99,
eventLoopAvg: snapshot.eventLoop.avg,
eventLoopP95: snapshot.eventLoop.p95,
heapUsed: snapshot.server.memoryUsage.heapUsed,
rss: snapshot.server.memoryUsage.rss,
systemMemoryUsedPercent: snapshot.server.systemMemory.usedPercent,
activeSessions:
snapshot.sessions.console +
snapshot.sessions.remoteDesktop +
snapshot.sessions.fileBrowser +
snapshot.sessions.process,
};
this.history.push(historyEntry);
@@ -297,6 +424,7 @@ class MetricsCollector {
this.pruneTimestampWindow(this.commandTimestamps, oneHourAgo);
this.pruneTimestampWindow(this.httpTimestamps, oneMinuteAgo);
this.pruneTimestampWindow(this.httpErrorTimestamps, oneMinuteAgo);
this.pruneHttpSamples(this.httpSamples, oneMinuteAgo);
const commandsLastMinute = this.countRecent(this.commandTimestamps, oneMinuteAgo);
const commandsLastHour = this.commandTimestamps.length;
@@ -309,16 +437,13 @@ class MetricsCollector {
const httpLastMinute = this.httpTimestamps.length;
const httpErrorsLastMinute = this.httpErrorTimestamps.length;
const httpLatencySamples = [...this.httpLatencies].sort((a, b) => a - b);
const httpLatencyAvg = httpLatencySamples.length
? httpLatencySamples.reduce((a, b) => a + b, 0) / httpLatencySamples.length
: 0;
const httpP95Index = httpLatencySamples.length
? Math.max(0, Math.floor(httpLatencySamples.length * 0.95) - 1)
: 0;
const httpLatencyP95 = httpLatencySamples.length
? httpLatencySamples[httpP95Index] ?? 0
: 0;
const httpLatencySamples = this.httpSamples
.map((sample) => sample.duration)
.sort((a, b) => a - b);
const httpLatencyAvg = this.average(httpLatencySamples);
const httpLatencyP95 = this.percentile(httpLatencySamples, 0.95);
const httpLatencyP99 = this.percentile(httpLatencySamples, 0.99);
const httpRoutes = this.getTopHttpRoutes(oneMinuteAgo);
const eventLoopSamples = [...this.eventLoopDelays].sort((a, b) => a - b);
const eventLoopAvg = eventLoopSamples.length
@@ -400,6 +525,8 @@ class MetricsCollector {
lastMinuteErrors: httpErrorsLastMinute,
latencyAvg: httpLatencyAvg,
latencyP95: httpLatencyP95,
latencyP99: httpLatencyP99,
routes: httpRoutes,
},
eventLoop: {
avg: eventLoopAvg,
@@ -428,7 +555,8 @@ class MetricsCollector {
this.httpTotal = 0;
this.httpTimestamps = [];
this.httpErrorTimestamps = [];
this.httpLatencies = [];
this.httpSamples = [];
this.httpRouteSamples.clear();
this.eventLoopDelays = [];
}
}
+60 -3
View File
@@ -84,7 +84,55 @@ const PERMISSIONS = {
roles: ["admin"],
},
"system:configure": {
description: "Change server-level settings (TLS, security, registration, notifications, appearance, etc.)",
description: "Legacy full server settings access (grants all system:* settings permissions)",
roles: ["admin"],
},
"system:security": {
description: "Change security policy settings",
roles: ["admin"],
},
"system:tls": {
description: "Change TLS and certificate settings",
roles: ["admin"],
},
"system:registration": {
description: "Change user registration policy settings",
roles: ["admin"],
},
"system:notifications": {
description: "Change global notification delivery settings",
roles: ["admin"],
},
"system:chat": {
description: "Change team chat settings",
roles: ["admin"],
},
"system:appearance": {
description: "Change custom CSS and appearance settings",
roles: ["admin"],
},
"system:thumbnails": {
description: "Change screenshot thumbnail settings",
roles: ["admin"],
},
"system:build-limits": {
description: "Change build rate limit settings",
roles: ["admin"],
},
"system:export-import": {
description: "Export and import server settings",
roles: ["admin"],
},
"system:health": {
description: "View server health diagnostics",
roles: ["admin"],
},
"system:health:manage": {
description: "Run server health maintenance actions",
roles: ["admin"],
},
"system:profiler": {
description: "Run server CPU and memory profiler captures",
roles: ["admin"],
},
"clients:elevate": {
@@ -128,8 +176,17 @@ export function hasPermission(
if (grantedByRole) return true;
// Permission groups + per-user extras are *additive only*: they can grant a
// permission the role doesn't have, but they cannot revoke one the role does.
if (userId !== undefined && getUserGrantedPermissions(userId).has(permission)) {
return true;
if (userId !== undefined) {
const granted = getUserGrantedPermissions(userId);
if (granted.has(permission)) return true;
if (
typeof permission === "string" &&
permission.startsWith("system:") &&
permission !== "system:configure" &&
granted.has("system:configure")
) {
return true;
}
}
return false;
}
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import { buildClientGraph, clientIpSubnet } from "./client-graph";
describe("client graph", () => {
test("builds client relationship nodes and edges", () => {
const graph = buildClientGraph([
{
id: "client-a",
host: "alpha",
user: "alice",
os: "windows",
ip: "10.20.30.40",
country: "US",
online: true,
lastSeen: Date.now(),
groupId: 7,
groupName: "Ops",
groupColor: "#38bdf8",
buildTag: "prod-build",
} as any,
{
id: "client-b",
host: "beta",
user: "alice",
os: "windows",
ip: "10.20.30.55",
country: "US",
online: false,
lastSeen: Date.now(),
groupId: 7,
groupName: "Ops",
groupColor: "#38bdf8",
buildTag: "prod-build",
} as any,
]);
const nodeIds = new Set(graph.nodes.map((node) => node.data.id));
expect(nodeIds.has("client:client-a")).toBe(true);
expect(nodeIds.has("client:client-b")).toBe(true);
expect(nodeIds.has("group:7")).toBe(true);
expect(nodeIds.has("build:prod-build")).toBe(true);
expect(nodeIds.has("os:windows")).toBe(true);
expect(nodeIds.has("subnet:10.20.30.0/24")).toBe(true);
expect(nodeIds.has("user:alice")).toBe(true);
expect(graph.summary.clients).toBe(2);
expect(graph.summary.online).toBe(1);
expect(graph.edges.some((edge) => edge.data.source === "client:client-a" && edge.data.target === "group:7")).toBe(true);
});
test("normalizes IPv4 and IPv6 subnets", () => {
expect(clientIpSubnet("192.168.4.33")).toBe("192.168.4.0/24");
expect(clientIpSubnet("2001:db8:abcd:12::45")).toBe("2001:db8:abcd:12::/64");
expect(clientIpSubnet("not-an-ip")).toBeNull();
});
});
+213
View File
@@ -0,0 +1,213 @@
import type { ListItem } from "../types";
export type GraphNodeKind =
| "client"
| "group"
| "build"
| "os"
| "country"
| "subnet"
| "user"
| "status";
export type ClientGraphNode = {
data: {
id: string;
label: string;
type: GraphNodeKind;
weight: number;
count?: number;
onlineCount?: number;
clientId?: string;
online?: boolean;
href?: string;
color?: string | null;
meta?: Record<string, unknown>;
};
};
export type ClientGraphEdge = {
data: {
id: string;
source: string;
target: string;
label: string;
type: string;
};
};
export type ClientGraph = {
nodes: ClientGraphNode[];
edges: ClientGraphEdge[];
summary: {
clients: number;
totalClients?: number;
online: number;
categories: number;
relationships: number;
generatedAt: number;
};
};
function compactLabel(value: unknown, fallback = "Unknown"): string {
const text = String(value ?? "").trim();
if (!text) return fallback;
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
}
function safeKey(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "-").slice(0, 96) || "unknown";
}
function ipv4Subnet(ip: string): string | null {
const parts = ip.trim().split(".");
if (parts.length !== 4) return null;
const octets = parts.map((part) => Number(part));
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;
return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
}
function ipv6Subnet(ip: string): string | null {
if (!ip.includes(":")) return null;
const parts = ip.split(":").filter(Boolean);
if (parts.length < 2) return null;
return `${parts.slice(0, 4).join(":")}::/64`;
}
export function clientIpSubnet(ip: unknown): string | null {
const value = String(ip ?? "").trim();
if (!value) return null;
return ipv4Subnet(value) || ipv6Subnet(value);
}
export function buildClientGraph(clients: ListItem[]): ClientGraph {
const nodes = new Map<string, ClientGraphNode>();
const edges = new Map<string, ClientGraphEdge>();
const addNode = (
id: string,
label: string,
type: GraphNodeKind,
data: Partial<ClientGraphNode["data"]> = {},
) => {
const existing = nodes.get(id);
if (existing) {
existing.data.count = (existing.data.count || 0) + 1;
if (data.online) existing.data.onlineCount = (existing.data.onlineCount || 0) + 1;
existing.data.weight = Math.max(existing.data.weight, data.weight || 1);
return existing;
}
const node: ClientGraphNode = {
data: {
id,
label,
type,
weight: data.weight || 1,
count: data.count,
onlineCount: data.online ? 1 : data.onlineCount,
...data,
},
};
nodes.set(id, node);
return node;
};
const addEdge = (source: string, target: string, label: string, type: string) => {
const id = `${source}->${target}:${type}`;
if (edges.has(id)) return;
edges.set(id, { data: { id, source, target, label, type } });
};
for (const client of clients) {
const clientNodeId = `client:${client.id}`;
const clientLabel = compactLabel(client.nickname || client.host || client.id);
addNode(clientNodeId, clientLabel, "client", {
weight: client.online ? 5 : 3,
clientId: client.id,
online: client.online,
href: `/?q=${encodeURIComponent(client.id)}`,
meta: {
id: client.id,
host: client.host || "",
user: client.user || "",
os: client.os || "",
ip: client.ip || "",
country: client.country || "",
groupName: client.groupName || "",
buildTag: client.buildTag || "",
lastSeen: client.lastSeen,
},
});
const statusNode = client.online
? { id: "status:online", label: "Online", color: "#22c55e" }
: { id: "status:offline", label: "Offline", color: "#64748b" };
addNode(statusNode.id, statusNode.label, "status", {
weight: 4,
online: client.online,
color: statusNode.color,
});
addEdge(clientNodeId, statusNode.id, client.online ? "online" : "offline", "status");
if (client.groupName || client.groupId) {
const groupId = `group:${client.groupId || safeKey(client.groupName || "ungrouped")}`;
addNode(groupId, compactLabel(client.groupName || "Group"), "group", {
weight: 4,
online: client.online,
color: client.groupColor || null,
});
addEdge(clientNodeId, groupId, "group", "group");
}
if (client.buildTag) {
const id = `build:${safeKey(client.buildTag)}`;
addNode(id, compactLabel(client.buildTag), "build", { weight: 3, online: client.online });
addEdge(clientNodeId, id, "build", "build");
}
if (client.os) {
const id = `os:${safeKey(client.os)}`;
addNode(id, compactLabel(client.os), "os", { weight: 3, online: client.online });
addEdge(clientNodeId, id, "os", "os");
}
if (client.country) {
const label = String(client.country).toUpperCase();
const id = `country:${safeKey(label)}`;
addNode(id, label, "country", { weight: 3, online: client.online });
addEdge(clientNodeId, id, "country", "country");
}
const subnet = clientIpSubnet(client.ip);
if (subnet) {
const id = `subnet:${safeKey(subnet)}`;
addNode(id, subnet, "subnet", { weight: 3, online: client.online });
addEdge(clientNodeId, id, "subnet", "subnet");
}
if (client.user) {
const id = `user:${safeKey(client.user)}`;
addNode(id, compactLabel(client.user), "user", { weight: 2, online: client.online });
addEdge(clientNodeId, id, "user", "user");
}
}
const sortedNodes = Array.from(nodes.values()).map((node) => {
if (node.data.type !== "client") {
node.data.weight = Math.max(node.data.weight, Math.min(12, 2 + (node.data.count || 1)));
}
return node;
});
return {
nodes: sortedNodes,
edges: Array.from(edges.values()),
summary: {
clients: clients.length,
online: clients.filter((client) => client.online).length,
categories: sortedNodes.filter((node) => node.data.type !== "client").length,
relationships: edges.size,
generatedAt: Date.now(),
},
};
}
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { clearRequestRateLimitsForTests } from "../rateLimit";
import { createHttpFetchHandler } from "./http-dispatch";
import { createHttpFetchHandler, normalizeHttpRoute } from "./http-dispatch";
const metrics = {
withHttpMetrics: (fn: () => Promise<Response>) => fn(),
@@ -35,3 +35,22 @@ describe("createHttpFetchHandler rate limiting", () => {
expect(limited.headers.get("Retry-After")).toBeTruthy();
});
});
describe("normalizeHttpRoute", () => {
test("collapses dynamic route segments", () => {
const req = new Request(
"https://localhost/api/clients/0a44a8d4d61c7840540b43fd187fe3c43e2c46c9600b2aa1a61a6b1ab03c5b92/rd/ws?cache=1",
{ method: "POST" },
);
expect(normalizeHttpRoute(req, new URL(req.url))).toBe(
"POST /api/clients/:id/rd/ws",
);
});
test("groups static asset files", () => {
const req = new Request("https://localhost/assets/metrics.js");
expect(normalizeHttpRoute(req, new URL(req.url))).toBe("GET /assets/:file");
});
});
+24 -3
View File
@@ -3,6 +3,9 @@ import { consumeUnauthorizedRateLimit } from "../rateLimit";
export type RouteHandler = (req: Request, url: URL, server: unknown) => Promise<Response | null>;
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const LONG_ID_SEGMENT = /^[0-9a-f]{16,}$/i;
function tooManyRequestsResponse(retryAfter = 60): Response {
return new Response("Too many requests", {
status: 429,
@@ -13,17 +16,35 @@ function tooManyRequestsResponse(retryAfter = 60): Response {
});
}
function normalizeRouteSegment(segment: string): string {
if (/^\d+$/.test(segment)) return ":id";
if (UUID_SEGMENT.test(segment) || LONG_ID_SEGMENT.test(segment)) return ":id";
if (segment.includes(".") && !segment.startsWith(".")) return ":file";
if (segment.length >= 24 && /^[a-z0-9_-]+$/i.test(segment)) return ":id";
return segment;
}
export function normalizeHttpRoute(req: Request, url: URL): string {
const path = url.pathname
.split("/")
.filter(Boolean)
.map(normalizeRouteSegment)
.join("/");
return `${req.method.toUpperCase()} /${path}`;
}
export function createHttpFetchHandler(deps: {
metrics: { withHttpMetrics: (fn: () => Promise<Response>) => Promise<Response> };
metrics: { withHttpMetrics: (fn: () => Promise<Response>, route?: string) => Promise<Response> };
CORS_HEADERS: Record<string, string>;
routes: RouteHandler[];
}) {
return async function fetchHandler(req: Request, server: unknown): Promise<Response> {
const url = new URL(req.url);
const routeLabel = normalizeHttpRoute(req, url);
return deps.metrics.withHttpMetrics(async () => {
if (req.method === "OPTIONS") {
return new Response("", { headers: deps.CORS_HEADERS });
}
const url = new URL(req.url);
const wrapped = wrapServerWithClientIp(server as RequestServerLike);
const ip = wrapped.requestIP(req)?.address || "unknown";
@@ -38,6 +59,6 @@ export function createHttpFetchHandler(deps: {
}
}
return new Response("Not found", { status: 404 });
});
}, routeLabel);
};
}
@@ -21,6 +21,7 @@ describe("SECURITY_HEADERS", () => {
const csp = SECURITY_HEADERS["Content-Security-Policy"];
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("script-src 'self'");
expect(csp).toContain("font-src 'self' data:");
expect(csp).toContain("frame-ancestors 'none'");
});
+1 -1
View File
@@ -5,7 +5,7 @@ export const CORS_HEADERS = {
};
export const SECURITY_HEADERS = {
"Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self'; connect-src 'self'; object-src blob:; frame-ancestors 'none'",
"Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' data:; connect-src 'self'; object-src blob:; frame-ancestors 'none'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
@@ -103,6 +103,19 @@ describe("/api/auth/me permissions array", () => {
expect(new Set(body.permissions).has("clients:metadata")).toBe(true);
});
test("legacy system:configure extra grants the split settings permissions", async () => {
const viewer = await tempUser("viewer");
setUserExtraPermissions(viewer.user.id, ["system:configure"]);
const body = await fetchMe(viewer.token);
const set = new Set(body.permissions);
expect(set.has("system:configure")).toBe(true);
expect(set.has("system:security")).toBe(true);
expect(set.has("system:tls")).toBe(true);
expect(set.has("system:registration")).toBe(true);
expect(set.has("system:health")).toBe(true);
expect(set.has("system:profiler")).toBe(true);
});
test("viewer in a group gains the group's permissions in /api/auth/me", async () => {
const viewer = await tempUser("viewer");
const group = tempGroup("me_group", ["clients:disconnect", "audit:view"]);
@@ -2,12 +2,13 @@ import { authenticateRequest } from "../../auth";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig } from "../../config";
import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes } from "../../db";
import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes, listClients } from "../../db";
import { getThumbnailStats } from "../../thumbnails";
import { getClientCount, getOnlineClients } from "../../clientManager";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient, getUserById } from "../../users";
import { buildClientGraph } from "../client-graph";
import { runCertbotSetup } from "../certbot-setup";
import {
getActiveProxies,
@@ -329,13 +330,59 @@ export async function handleMiscRoutes(
snapshot.sessions.fileBrowser = deps.getFileBrowserSessionCount();
snapshot.sessions.process = deps.getProcessSessionCount();
const history = metrics.getHistory();
const requestedHistoryLimit = Number(url.searchParams.get("historyLimit") || 240);
const historyLimit = Number.isFinite(requestedHistoryLimit)
? Math.max(1, Math.min(2000, requestedHistoryLimit))
: 240;
const history = metrics.getHistory().slice(-historyLimit);
return new Response(JSON.stringify({ snapshot, history }), {
headers: { "Content-Type": "application/json" },
});
}
if (req.method === "GET" && url.pathname === "/api/client-graph") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
const limit = Math.max(25, Math.min(500, Number(url.searchParams.get("limit") || 300)));
const search = (url.searchParams.get("q") || "").trim().toLowerCase();
const statusFilter = url.searchParams.get("status") || "all";
const groupFilter = url.searchParams.get("group") || "all";
let allowedClientIds: string[] | undefined;
let deniedClientIds: string[] | undefined;
if (user.role !== "admin") {
const scope = getUserClientAccessScope(user.userId);
if (scope === "none") {
return Response.json(buildClientGraph([]), { headers: deps.CORS_HEADERS });
}
if (scope === "allowlist") {
allowedClientIds = listUserClientRuleIdsByAccess(user.userId, "allow");
} else if (scope === "denylist") {
deniedClientIds = listUserClientRuleIdsByAccess(user.userId, "deny");
}
}
const clients = listClients({
page: 1,
pageSize: limit,
search,
sort: "last_seen_desc",
statusFilter,
osFilter: "all",
countryFilter: "all",
enrollmentFilter: "approved",
groupFilter,
allowedClientIds,
deniedClientIds,
});
const graph = buildClientGraph(clients.items);
graph.summary.totalClients = clients.total;
return Response.json(graph, { headers: deps.CORS_HEADERS });
}
if (url.pathname === "/health") {
return new Response("ok", { headers: deps.CORS_HEADERS });
}
@@ -385,7 +432,7 @@ export async function handleMiscRoutes(
}
try {
requirePermission(user, "system:configure");
requirePermission(user, "system:security");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -447,7 +494,7 @@ export async function handleMiscRoutes(
}
try {
requirePermission(user, "system:configure");
requirePermission(user, "system:tls");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -502,7 +549,7 @@ export async function handleMiscRoutes(
}
try {
requirePermission(user, "system:configure");
requirePermission(user, "system:tls");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -743,7 +790,7 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 });
}
try {
requirePermission(user, "system:configure");
requirePermission(user, "system:export-import");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -776,7 +823,7 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 });
}
try {
requirePermission(user, "system:configure");
requirePermission(user, "system:export-import");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -828,7 +875,7 @@ export async function handleMiscRoutes(
}
try {
requirePermission(user, "system:configure");
requirePermission(user, "system:chat");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -869,7 +916,7 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 });
}
try {
requirePermission(user, "system:configure");
requirePermission(user, "system:appearance");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -934,7 +981,7 @@ export async function handleMiscRoutes(
if (url.pathname === "/api/settings/registration") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
try { requirePermission(user, "system:registration"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
if (req.method === "GET") {
return Response.json({ registration: getConfig().registration }, { headers: deps.CORS_HEADERS });
@@ -970,7 +1017,7 @@ export async function handleMiscRoutes(
if (url.pathname === "/api/settings/build-rate-limit") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
try { requirePermission(user, "system:build-limits"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
if (req.method === "GET") {
return Response.json({ buildRateLimit: getConfig().buildRateLimit }, { headers: deps.CORS_HEADERS });
@@ -1011,7 +1058,7 @@ export async function handleMiscRoutes(
}
if (req.method === "PUT") {
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
try { requirePermission(user, "system:thumbnails"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
let body: any = {};
try { body = await req.json(); } catch {
@@ -1040,7 +1087,7 @@ export async function handleMiscRoutes(
if (req.method === "GET" && url.pathname === "/api/settings/health") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
try { requirePermission(user, "system:health"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
const mem = process.memoryUsage();
return Response.json({
@@ -1069,7 +1116,7 @@ export async function handleMiscRoutes(
if (req.method === "POST" && url.pathname === "/api/settings/gc") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
try { requirePermission(user, "system:health:manage"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
const before = process.memoryUsage().heapUsed;
if (typeof Bun !== "undefined" && typeof (Bun as any).gc === "function") {
@@ -1083,7 +1130,7 @@ export async function handleMiscRoutes(
if (req.method === "POST" && url.pathname === "/api/settings/profile") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
try { requirePermission(user, "system:profiler"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
if (activeServerProfile) {
return Response.json({ error: "A server profile capture is already running." }, { status: 409 });
@@ -78,7 +78,7 @@ export async function handleNotificationsConfigRoutes(
});
}
try {
requirePermission(user, "system:configure");
requirePermission(user, "system:notifications");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -95,7 +95,7 @@ export async function handleNotificationsConfigRoutes(
});
}
try {
requirePermission(user, "system:configure");
requirePermission(user, "system:notifications");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -82,6 +82,7 @@ type QueryClientPageDef = {
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
const STATIC_PAGES: StaticPageDef[] = [
{ path: "/metrics", file: "metrics.html", access: "any", checkPasswordChange: true },
{ path: "/graph", file: "graph.html", access: "any", checkPasswordChange: true },
{ path: "/screenshots", file: "screenshots.html", access: "no-viewer", checkPasswordChange: true },
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, test } from "bun:test";
import * as clientManager from "../clientManager";
import { decodeMessage } from "../protocol";
import * as sessionManager from "../sessions/sessionManager";
import type { SocketData } from "../sessions/types";
import type { ClientInfo } from "../types";
import {
handleRemoteDesktopViewerMessage,
handleRemoteDesktopViewerOpen,
rdStreamingState,
} from "./ws-console-rd-hvnc";
type MockWs = {
data: SocketData;
sent: unknown[];
closedCode?: number;
closedReason?: string;
send: (msg: unknown) => void;
close: (code: number, reason: string) => void;
getBufferedAmount: () => number;
};
const clientIdsToCleanup = new Set<string>();
function createMockWs(data: Partial<SocketData>): MockWs {
return {
data: {
role: "rd_viewer",
clientId: "rd-test-client",
...data,
} as SocketData,
sent: [],
send(msg: unknown) {
this.sent.push(msg);
},
close(code: number, reason: string) {
this.closedCode = code;
this.closedReason = reason;
},
getBufferedAmount() {
return 0;
},
};
}
function createClient(id: string) {
const agentWs = createMockWs({ role: "client", clientId: id });
const info: ClientInfo = {
id,
role: "client",
ws: agentWs,
lastSeen: Date.now(),
online: true,
host: "rd-test-host",
os: "windows",
user: "tester",
monitors: 1,
};
clientManager.addClient(id, info);
clientIdsToCleanup.add(id);
return { info, agentWs };
}
function agentCommands(ws: MockWs) {
return ws.sent.map((msg) => decodeMessage(msg as Uint8Array) as any);
}
afterEach(() => {
for (const clientId of clientIdsToCleanup) {
for (const session of sessionManager.getRdSessionsForClient(clientId)) {
sessionManager.deleteRdSession(session.id);
}
rdStreamingState.delete(clientId);
clientManager.deleteClient(clientId);
}
clientIdsToCleanup.clear();
});
describe("remote desktop viewer control", () => {
test("starts once, ignores duplicate starts, and only stops after the last viewer leaves", () => {
const clientId = `rd-control-${Date.now().toString(36)}`;
const { agentWs } = createClient(clientId);
const firstViewer = createMockWs({ clientId });
const secondViewer = createMockWs({ clientId });
handleRemoteDesktopViewerOpen(firstViewer as any);
handleRemoteDesktopViewerOpen(secondViewer as any);
handleRemoteDesktopViewerMessage(firstViewer as any, JSON.stringify({ type: "desktop_start" }));
handleRemoteDesktopViewerMessage(secondViewer as any, JSON.stringify({ type: "desktop_start" }));
let commands = agentCommands(agentWs);
expect(commands.filter((msg) => msg.commandType === "desktop_start")).toHaveLength(1);
expect(rdStreamingState.get(clientId)?.isStreaming).toBe(true);
handleRemoteDesktopViewerMessage(firstViewer as any, JSON.stringify({ type: "desktop_stop" }));
commands = agentCommands(agentWs);
expect(commands.filter((msg) => msg.commandType === "desktop_stop")).toHaveLength(0);
expect(rdStreamingState.get(clientId)?.isStreaming).toBe(true);
sessionManager.deleteRdSession(firstViewer.data.sessionId!);
handleRemoteDesktopViewerMessage(secondViewer as any, JSON.stringify({ type: "desktop_stop" }));
commands = agentCommands(agentWs);
expect(commands.filter((msg) => msg.commandType === "desktop_stop")).toHaveLength(1);
expect(commands.filter((msg) => msg.commandType === "webrtc_stop")).toHaveLength(1);
expect(rdStreamingState.get(clientId)?.isStreaming).toBe(false);
});
test("does not forward desktop_start when a macOS client is missing required permissions", () => {
const clientId = `rd-mac-perms-${Date.now().toString(36)}`;
const { info, agentWs } = createClient(clientId);
info.os = "darwin";
info.permissions = { screenRecording: false, accessibility: true };
const viewer = createMockWs({ clientId });
handleRemoteDesktopViewerOpen(viewer as any);
handleRemoteDesktopViewerMessage(viewer as any, JSON.stringify({ type: "desktop_start" }));
expect(agentCommands(agentWs).filter((msg) => msg.commandType === "desktop_start")).toHaveLength(0);
expect(rdStreamingState.get(clientId)?.isStreaming).not.toBe(true);
expect(viewer.sent.length).toBeGreaterThan(0);
});
});
@@ -160,6 +160,7 @@ function broadcastFrameToViewers(
}
const rdSendStats = { lastLog: 0, frames: 0, sendMs: 0, bytes: 0 };
const rdDebugFrameLogAt = new Map<string, number>();
export const rdStreamingState = new Map<string, {
isStreaming: boolean;
display: number;
@@ -223,9 +224,12 @@ function sendConsoleCommand(target: ClientInfo | undefined, commandType: string,
}
function sendDesktopCommandWithId(target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>, commandId: string) {
if (!target) return false;
if (!target) {
logger.warn(`[rd-debug] send command skipped, target missing command=${commandType} id=${commandId}`);
return false;
}
try {
logger.debug(`[rd] send command ${commandType} -> ${target.id}`);
logger.debug(`[rd-debug] send command command=${commandType} client=${target.id} id=${commandId} payload=${JSON.stringify(payload || {})}`);
target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, payload, id: commandId }));
metrics.recordCommand(commandType);
return true;
@@ -326,6 +330,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
switch (payload.type) {
case "desktop_start":
logger.debug(`[rd-debug] desktop_start requested client=${clientId} session=${ws.data.sessionId || ""} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length} webrtc=${(payload as any).webrtc === true}`);
if (!state.isStreaming) {
const targetOs = String(target.os || "").toLowerCase();
if ((targetOs.includes("darwin") || targetOs.includes("mac")) && target.permissions) {
@@ -364,14 +369,15 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
sendDesktopCommand(target, "desktop_start", {});
state.isStreaming = true;
rdStreamingState.set(clientId, state);
logger.debug(`[rd] started streaming for client ${clientId}${(payload as any).webrtc === true ? " (webrtc)" : ""}`);
logger.debug(`[rd-debug] desktop_start forwarded client=${clientId} nextState=${JSON.stringify(state)} webrtc=${(payload as any).webrtc === true}`);
} else {
logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`);
logger.warn(`[rd-debug] desktop_start ignored duplicate client=${clientId} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length}`);
}
break;
case "desktop_stop": {
const otherViewers = sessionManager.getRdSessionsForClient(clientId)
.filter(s => s.id !== ws.data.sessionId);
logger.debug(`[rd-debug] desktop_stop requested client=${clientId} session=${ws.data.sessionId || ""} otherViewers=${otherViewers.length} state=${JSON.stringify(state)}`);
if (otherViewers.length === 0) {
stopRemoteDesktopRecording(clientId, "desktop stopped");
sendDesktopCommand(target, "desktop_stop", {});
@@ -649,6 +655,12 @@ function handleRemoteDesktopFrame(payload: any) {
const frameFps = Number(header?.fps) || 0;
if (frameFps > 0) state.lastFps = frameFps;
rdStreamingState.set(clientId, state);
const now = Date.now();
const lastDebugFrameLog = rdDebugFrameLogAt.get(clientId) || 0;
if (!lastDebugFrameLog || now - lastDebugFrameLog >= 2000) {
rdDebugFrameLogAt.set(clientId, now);
logger.debug(`[rd-debug] frame received client=${clientId} bytes=${bytes?.byteLength || 0} fps=${frameFps || 0} viewers=${sessionManager.getRdSessionsForClient(clientId).length} header=${JSON.stringify(header || {})}`);
}
broadcastRemoteDesktopFrame(clientId, bytes, header);
}
@@ -661,7 +673,9 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header
}
recordRemoteDesktopFrame(clientId, header, bytes);
const buf = buildViewerFrameBuffer(bytes, header);
return broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header);
const sessions = sessionManager.getRdSessionsForClient(clientId);
const sent = broadcastFrameToViewers(sessions, buf, header);
return sent;
}
(globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
+1 -1
View File
@@ -1 +1 @@
export const SERVER_VERSION = "2.3.4";
export const SERVER_VERSION = "2.3.5";