Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e

This commit is contained in:
Public Source Publisher committed 2026-10-01 19:39:31 +00:00
1 parent c61fbfd87f
commit 1a54836efc
101 files changed
+4973 -1298

No files matched your search

+5 -4
View File
@@ -16,12 +16,13 @@ describe("accessible operator shell", () => {
it("announces read-only state without claiming the status data is locked", () => {
assert.match(banner, /role="status" aria-live="polite"/);
assert.match(banner, /Read-only modus/);
assert.match(banner, /Operatorsessie ontgrendelen/);
assert.match(banner, /Alleen lezen/);
assert.match(banner, /Je beheerder kan extra rechten toekennen/);
assert.doesNotMatch(banner, /ontgrendel|operator-token/i);
});
it("keeps notification mutations and write-only channel fields locked without an operator session", () => {
assert.match(notifications, /const unlocked = useOperatorSession\(\)/);
it("keeps notification mutations and write-only channel fields locked without notification permissions", () => {
assert.match(notifications, /const unlocked = usePermission\("notifications.manage"\)/);
assert.match(notifications, /disabled=\{!unlocked \|\| working !== null\}/);
assert.match(notifications, /disabled=\{!unlocked\} className="border-slate-800 bg-slate-950 text-slate-100"/);
});
+40
View File
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildExposureView, readExposureFilter, toggleExposureFilter, withExposureFilter } from "./exposureView.ts";
import type { NetworkExposure } from "../types/appops.ts";
const rows: NetworkExposure[] = [
{ source: "reverse_proxy", host: "safe.example.test", protocol: "https", port: 443, protected: true, risk: "low" },
{ source: "reverse_proxy", host: "open.example.test", protocol: "https", port: 443, protected: false, risk: "high" },
{ source: "reverse_proxy", host: "expired.example.test", protocol: "https", port: 443, protected: true, risk: "critical" },
];
describe("exposure filters", () => {
it("filters canonical rows without changing full snapshot counts or risk", () => {
const view = buildExposureView(rows, "protected");
assert.deepEqual(view.rows.map(row => row.host), ["expired.example.test", "safe.example.test"]);
assert.deepEqual(view.counts, { total: 3, protected: 2, highRisk: 2 });
assert.deepEqual(buildExposureView(rows, "high-risk").rows.map(row => row.host), ["expired.example.test", "open.example.test"]);
assert.equal(rows[1].protected, false);
assert.equal(rows[1].risk, "high");
});
it("toggles a selected card off and allows switching cards", () => {
assert.equal(toggleExposureFilter("protected", "protected"), "all");
assert.equal(toggleExposureFilter("protected", "high-risk"), "high-risk");
assert.equal(buildExposureView(rows, "all").rows.length, 3);
assert.deepEqual(buildExposureView([], "high-risk").counts, { total: 0, protected: 0, highRisk: 0 });
});
it("restores filters from URL and preserves unrelated parameters on reset", () => {
const original = new URLSearchParams("filter=protected&query=host");
assert.equal(readExposureFilter(original), "protected");
assert.equal(readExposureFilter(new URLSearchParams("filter=unknown")), "all");
assert.equal(readExposureFilter(new URLSearchParams("filter=high-risk")), "high-risk");
const updated = withExposureFilter(original, "high-risk");
assert.equal(updated.get("query"), "host");
assert.equal(updated.get("filter"), "high-risk");
assert.equal(original.get("filter"), "protected");
assert.equal(withExposureFilter(updated, "all").toString(), "query=host");
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { NetworkExposure } from "@/types/appops";
export type ExposureFilter = "all" | "protected" | "high-risk";
export const readExposureFilter = (params: URLSearchParams): ExposureFilter => {
const value = params.get("filter");
return value === "protected" || value === "high-risk" ? value : "all";
};
export const withExposureFilter = (params: URLSearchParams, filter: ExposureFilter) => {
const next = new URLSearchParams(params);
if (filter === "all") next.delete("filter");
else next.set("filter", filter);
return next;
};
export const toggleExposureFilter = (current: ExposureFilter, selected: ExposureFilter): ExposureFilter => (
current === selected ? "all" : selected
);
const highRisk = (row: NetworkExposure) => row.risk === "high" || row.risk === "critical";
const riskRank = { critical: 4, high: 3, medium: 2, low: 1 };
export const buildExposureView = (rows: NetworkExposure[], filter: ExposureFilter) => ({
counts: {
total: rows.length,
protected: rows.filter(row => row.protected).length,
highRisk: rows.filter(highRisk).length,
},
rows: rows.filter(row => filter === "all" || (filter === "protected" ? row.protected : highRisk(row)))
.sort((left, right) => riskRank[right.risk] - riskRank[left.risk] || left.host.localeCompare(right.host)),
});
+33 -2
View File
@@ -18,8 +18,8 @@ describe("jobRunView", () => {
it("filters jobs by id, action, target and audit reference", () => {
const jobs = [
{ id: "job-1", actionId: "container.restart", target: "sonarr", auditEventId: "audit-1" },
{ id: "job-2", actionId: "stack.redeploy", target: "auth-stack", auditEventId: "audit-2" },
{ id: "job-1", status: "running" as const, actionId: "container.restart", target: "sonarr", auditEventId: "audit-1" },
{ id: "job-2", status: "succeeded" as const, actionId: "stack.redeploy", target: "auth-stack", auditEventId: "audit-2" },
];
assert.deepEqual(filterJobRuns(jobs, "sonarr").map(job => job.id), ["job-1"]);
@@ -27,6 +27,37 @@ describe("jobRunView", () => {
assert.deepEqual(filterJobRuns(jobs, "").map(job => job.id), ["job-1", "job-2"]);
});
const jobsByStatus = [
{ id: "job-queued", status: "queued" as const, actionId: "container.restart", target: "sonarr", auditEventId: "audit-queued" },
{ id: "job-running", status: "running" as const, actionId: "stack.redeploy", target: "media" },
{ id: "job-failed", status: "failed" as const, actionId: "container.restart", target: "SONARR", auditEventId: "audit-failed" },
{ id: "job-succeeded", status: "succeeded" as const, actionId: "container.restart", target: "sonarr", auditEventId: "audit-succeeded" },
{ id: "job-cancelled", status: "cancelled" as const, actionId: "stack.redeploy", target: "media" },
];
it("shows only failed jobs when the Failed filter is selected", () => {
assert.deepEqual(filterJobRuns(jobsByStatus, "", "failed").map(job => job.id), ["job-failed"]);
});
it("keeps succeeded and cancelled jobs in Closed without including failed jobs", () => {
assert.deepEqual(filterJobRuns(jobsByStatus, "", "closed").map(job => job.id), ["job-succeeded", "job-cancelled"]);
});
it("groups queued and running jobs under Open", () => {
assert.deepEqual(filterJobRuns(jobsByStatus, "", "open").map(job => job.id), ["job-queued", "job-running"]);
});
it("combines text matching with the selected status filter", () => {
assert.deepEqual(filterJobRuns(jobsByStatus, " SoNaRr ", "closed").map(job => job.id), ["job-succeeded"]);
assert.deepEqual(filterJobRuns(jobsByStatus, "audit-failed", "closed"), []);
assert.deepEqual(filterJobRuns(jobsByStatus, "audit-failed", "failed").map(job => job.id), ["job-failed"]);
});
it("restores all search matches after clearing a status filter", () => {
assert.deepEqual(filterJobRuns(jobsByStatus, "sonarr", "all").map(job => job.id), ["job-queued", "job-failed", "job-succeeded"]);
assert.deepEqual(filterJobRuns([], "", "failed"), []);
});
it("builds operator follow-up state for running jobs", () => {
const view = buildJobRunView({
id: "job-1",
+19 -11
View File
@@ -19,14 +19,15 @@ export const formatJobDuration = (durationMs?: number): string => {
export interface JobRunFilterTarget {
id: string;
status: JobStatus;
actionId: string;
target: string;
auditEventId?: string;
}
export interface JobRunViewTarget extends JobRunFilterTarget {
status: JobStatus;
}
export type JobRunViewTarget = JobRunFilterTarget;
export type JobRunStatusFilter = "all" | "open" | "failed" | "closed";
export interface JobRunView {
statusLabel: string;
@@ -65,14 +66,21 @@ export const buildJobRunView = (job: JobRunViewTarget): JobRunView => {
};
};
export const filterJobRuns = <T extends JobRunFilterTarget>(jobs: T[], query: string): T[] => {
export const filterJobRuns = <T extends JobRunFilterTarget>(jobs: T[], query: string, statusFilter: JobRunStatusFilter = "all"): T[] => {
const normalized = query.trim().toLowerCase();
if (!normalized) return jobs;
if (!normalized && statusFilter === "all") return jobs;
return jobs.filter(job => [
job.id,
job.actionId,
job.target,
job.auditEventId || "",
].some(value => value.toLowerCase().includes(normalized)));
return jobs.filter(job => {
const matchesStatus = statusFilter === "all"
|| (statusFilter === "open" && ["queued", "running"].includes(job.status))
|| (statusFilter === "failed" && job.status === "failed")
|| (statusFilter === "closed" && ["succeeded", "cancelled"].includes(job.status));
return matchesStatus && (!normalized || [
job.id,
job.actionId,
job.target,
job.auditEventId || "",
].some(value => value.toLowerCase().includes(normalized)));
});
};
+100
View File
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { MediaPipelineEdge, MediaPipelineNode } from "../types/appops.ts";
import { buildMediaPipelineFlowLayout } from "./mediaPipelineFlowLayout.ts";
const node = (id: string, type = "unknown"): MediaPipelineNode => ({
id, label: id, type, status: "healthy", metrics: {}, actions: [],
});
const edge = (from: string, to: string): MediaPipelineEdge => ({
from, to, status: "healthy", label: "relatie",
});
describe("media pipeline flow layout", () => {
it("keeps the download and Widefrog chains independent without adding connections", () => {
const layout = buildMediaPipelineFlowLayout([
node("sonarr", "wanted"), node("prowlarr", "indexers"), node("deluge", "downloads"),
node("widefrog", "processing"), node("plex", "library"), node("tautulli", "analytics"),
], [edge("sonarr", "prowlarr"), edge("prowlarr", "deluge"), edge("widefrog", "plex"), edge("plex", "tautulli")]);
assert.equal(layout.lanes.length, 2);
assert.deepEqual(layout.lanes.map(lane => lane.nodeIds), [
["sonarr", "prowlarr", "deluge"], ["widefrog", "plex", "tautulli"],
]);
assert.deepEqual(layout.edges.map(item => [item.edge.from, item.edge.to]), [
["sonarr", "prowlarr"], ["prowlarr", "deluge"], ["widefrog", "plex"], ["plex", "tautulli"],
]);
assert.ok(layout.nodes.find(item => item.node.id === "widefrog")!.y > layout.nodes.find(item => item.node.id === "deluge")!.y);
});
it("lays out dynamic Radarr branching and merging in the direction of the supplied edges", () => {
const layout = buildMediaPipelineFlowLayout([
node("sonarr", "wanted"), node("radarr", "wanted"), node("prowlarr", "indexers"),
node("deluge", "downloads"), node("new-downloader", "downloads"),
], [edge("sonarr", "prowlarr"), edge("radarr", "prowlarr"), edge("prowlarr", "deluge"), edge("prowlarr", "new-downloader")]);
const byId = new Map(layout.nodes.map(item => [item.node.id, item]));
assert.equal(layout.lanes.length, 1);
assert.ok(byId.get("sonarr")!.x < byId.get("prowlarr")!.x);
assert.ok(byId.get("radarr")!.x < byId.get("prowlarr")!.x);
assert.ok(byId.get("prowlarr")!.x < byId.get("new-downloader")!.x);
assert.notEqual(byId.get("sonarr")!.y, byId.get("radarr")!.y);
assert.notEqual(byId.get("deluge")!.y, byId.get("new-downloader")!.y);
for (const first of layout.nodes) {
for (const second of layout.nodes) {
if (first === second) continue;
assert.ok(first.x + layout.nodeWidth <= second.x || second.x + layout.nodeWidth <= first.x || first.y + layout.nodeHeight <= second.y || second.y + layout.nodeHeight <= first.y);
}
}
});
it("places unknown and isolated connector IDs without assuming a fixed six-node pipeline", () => {
const layout = buildMediaPipelineFlowLayout([
node("custom-source"), node("custom-target"), node("standalone"),
], [edge("custom-source", "custom-target")]);
assert.equal(layout.nodes.length, 3);
assert.equal(layout.lanes.length, 2);
assert.ok(layout.nodes.every(item => Number.isFinite(item.x) && Number.isFinite(item.y)));
assert.ok(layout.nodes.every(item => item.x >= 0 && item.y >= 0 && item.x + layout.nodeWidth <= layout.width && item.y + layout.nodeHeight <= layout.height));
});
it("retains dangling edges for a text explanation without drawing invented nodes", () => {
const missingEdge = edge("present", "missing");
const layout = buildMediaPipelineFlowLayout([node("present")], [missingEdge, edge("absent", "present")]);
assert.equal(layout.nodes.length, 1);
assert.equal(layout.edges.length, 0);
assert.deepEqual(layout.unresolvedEdges, [missingEdge, edge("absent", "present")]);
});
it("handles cycles and self-references without dropping connectors or producing invalid geometry", () => {
const layout = buildMediaPipelineFlowLayout([node("a"), node("b"), node("c")], [
edge("a", "b"), edge("b", "a"), edge("b", "c"), edge("c", "c"),
]);
const byId = new Map(layout.nodes.map(item => [item.node.id, item]));
assert.equal(layout.nodes.length, 3);
assert.equal(layout.edges.length, 4);
assert.notEqual(byId.get("a")!.y, byId.get("b")!.y);
assert.ok(byId.get("c")!.x > byId.get("b")!.x);
assert.ok(layout.edges.every(item => !/NaN|Infinity|undefined/.test(item.path)));
assert.ok(layout.edges.every(item => Number.isFinite(item.labelX) && Number.isFinite(item.labelY)));
});
it("routes a connection that skips a stage around the intermediate node", () => {
const layout = buildMediaPipelineFlowLayout([node("a"), node("b"), node("c")], [edge("a", "b"), edge("b", "c"), edge("a", "c")]);
const skip = layout.edges.find(item => item.edge.from === "a" && item.edge.to === "c")!;
const middle = layout.nodes.find(item => item.node.id === "b")!;
assert.ok(skip.labelY < middle.y);
assert.equal(layout.edges.length, 3);
});
it("keeps return-edge labels within the scrollable canvas", () => {
const layout = buildMediaPipelineFlowLayout([node("a"), node("b")], [edge("a", "b"), edge("b", "b")]);
assert.ok(layout.edges.every(item => item.labelX - 65 >= 0 && item.labelX + 65 <= layout.width));
});
});
+191
View File
@@ -0,0 +1,191 @@
import type { MediaPipelineEdge, MediaPipelineNode } from "../types/appops";
export interface FlowPlacedNode {
node: MediaPipelineNode;
x: number;
y: number;
}
export interface FlowPlacedEdge {
edge: MediaPipelineEdge;
index: number;
path: string;
labelX: number;
labelY: number;
}
export interface FlowLane {
nodeIds: string[];
y: number;
height: number;
}
export interface MediaPipelineFlowLayout {
nodes: FlowPlacedNode[];
edges: FlowPlacedEdge[];
unresolvedEdges: MediaPipelineEdge[];
lanes: FlowLane[];
width: number;
height: number;
nodeWidth: number;
nodeHeight: number;
}
/** Lay out only the supplied topology. Disconnected components receive separate lanes. */
export function buildMediaPipelineFlowLayout(nodes: MediaPipelineNode[], edges: MediaPipelineEdge[]): MediaPipelineFlowLayout {
const nodeWidth = 204;
const nodeHeight = 172;
const columnGap = 144;
const rowGap = 24;
const padding = 24;
const uniqueNodes = nodes.filter((node, index) => nodes.findIndex(other => other.id === node.id) === index);
const byId = new Map(uniqueNodes.map(node => [node.id, node]));
const outgoing = new Map(uniqueNodes.map(node => [node.id, new Set<string>()]));
const neighbours = new Map(uniqueNodes.map(node => [node.id, new Set<string>()]));
const validEdges = edges.flatMap((edge, index) => {
if (!byId.has(edge.from) || !byId.has(edge.to)) return [];
outgoing.get(edge.from)!.add(edge.to);
neighbours.get(edge.from)!.add(edge.to);
neighbours.get(edge.to)!.add(edge.from);
return [{ edge, index }];
});
const unresolvedEdges = edges.filter(edge => !byId.has(edge.from) || !byId.has(edge.to));
// Collapse cycles before ranking so malformed or future topology always terminates.
const order = new Map<string, number>();
const low = new Map<string, number>();
const stack: string[] = [];
const onStack = new Set<string>();
const clusters: string[][] = [];
const visit = (id: string) => {
order.set(id, order.size);
low.set(id, order.get(id)!);
stack.push(id);
onStack.add(id);
for (const target of outgoing.get(id)!) {
if (!order.has(target)) {
visit(target);
low.set(id, Math.min(low.get(id)!, low.get(target)!));
} else if (onStack.has(target)) {
low.set(id, Math.min(low.get(id)!, order.get(target)!));
}
}
if (low.get(id) === order.get(id)) {
const cluster: string[] = [];
let member: string;
do {
member = stack.pop()!;
onStack.delete(member);
cluster.push(member);
} while (member !== id);
clusters.push(cluster);
}
};
for (const node of uniqueNodes) if (!order.has(node.id)) visit(node.id);
const clusterById = new Map(clusters.flatMap((cluster, index) => cluster.map(id => [id, index] as const)));
const clusterEdges = clusters.map(() => new Set<number>());
const incoming = clusters.map(() => 0);
for (const { edge } of validEdges) {
const from = clusterById.get(edge.from)!;
const to = clusterById.get(edge.to)!;
if (from !== to && !clusterEdges[from].has(to)) {
clusterEdges[from].add(to);
incoming[to]++;
}
}
const ranks = clusters.map(() => 0);
const ready = incoming.flatMap((count, index) => count === 0 ? [index] : []);
for (let index = 0; index < ready.length; index++) {
const from = ready[index];
for (const to of clusterEdges[from]) {
ranks[to] = Math.max(ranks[to], ranks[from] + 1);
if (--incoming[to] === 0) ready.push(to);
}
}
const components: string[][] = [];
const seen = new Set<string>();
for (const node of uniqueNodes) {
if (seen.has(node.id)) continue;
const connected = [node.id];
seen.add(node.id);
for (let index = 0; index < connected.length; index++) {
for (const neighbour of neighbours.get(connected[index])!) {
if (!seen.has(neighbour)) {
seen.add(neighbour);
connected.push(neighbour);
}
}
}
components.push(uniqueNodes.filter(item => connected.includes(item.id)).map(item => item.id));
}
const placedNodes: FlowPlacedNode[] = [];
const lanes: FlowLane[] = [];
let nextY = padding;
let maxRank = 0;
for (const nodeIds of components) {
const columns = new Map<number, string[]>();
for (const id of nodeIds) {
const rank = ranks[clusterById.get(id)!];
maxRank = Math.max(maxRank, rank);
columns.set(rank, [...(columns.get(rank) || []), id]);
}
const rows = Math.max(...Array.from(columns.values(), ids => ids.length));
const height = 68 + rows * nodeHeight + (rows - 1) * rowGap + padding;
lanes.push({ nodeIds, y: nextY, height });
for (const [rank, ids] of [...columns.entries()].sort(([first], [second]) => first - second)) {
ids.forEach((id, row) => placedNodes.push({
node: byId.get(id)!,
x: padding + rank * (nodeWidth + columnGap),
y: nextY + 68 + (rows - ids.length) * (nodeHeight + rowGap) / 2 + row * (nodeHeight + rowGap),
}));
}
nextY += height + 18;
}
const placedById = new Map(placedNodes.map(node => [node.node.id, node]));
const laneById = new Map(lanes.flatMap(lane => lane.nodeIds.map(id => [id, lane] as const)));
let hasReturnEdge = false;
const placedEdges = validEdges.map(({ edge, index }): FlowPlacedEdge => {
const from = placedById.get(edge.from)!;
const to = placedById.get(edge.to)!;
const startX = from.x + nodeWidth;
const startY = from.y + nodeHeight / 2;
const endX = to.x;
const endY = to.y + nodeHeight / 2;
if (to.x <= from.x) {
hasReturnEdge = true;
const turnX = Math.max(startX, to.x + nodeWidth) + 48;
const targetX = to.x + nodeWidth;
const targetY = edge.from === edge.to ? endY + 44 : endY;
return {
edge, index,
path: `M ${startX} ${startY} C ${turnX} ${startY - 36}, ${turnX} ${targetY + 36}, ${targetX} ${targetY}`,
labelX: turnX + 12,
labelY: (startY + targetY) / 2,
};
}
if (to.x - from.x > nodeWidth + columnGap) {
const railY = laneById.get(edge.from)!.y + 46;
return {
edge, index,
path: `M ${startX} ${startY} C ${startX + 28} ${startY}, ${startX + 28} ${railY}, ${startX + 48} ${railY} L ${endX - 48} ${railY} C ${endX - 28} ${railY}, ${endX - 28} ${endY}, ${endX} ${endY}`,
labelX: (startX + endX) / 2,
labelY: railY,
};
}
const midpoint = (startX + endX) / 2;
return {
edge, index,
path: `M ${startX} ${startY} C ${midpoint} ${startY}, ${midpoint} ${endY}, ${endX} ${endY}`,
labelX: midpoint,
labelY: (startY + endY) / 2 - 22,
};
});
return {
nodes: placedNodes, edges: placedEdges, unresolvedEdges, lanes,
width: padding * 2 + (maxRank + 1) * nodeWidth + maxRank * columnGap + (hasReturnEdge ? 128 : 0),
height: Math.max(nextY - 18 + padding, padding * 2), nodeWidth, nodeHeight,
};
}
+33
View File
@@ -9,6 +9,39 @@ const exposureRows = [
];
describe("loadNetworkSnapshot", () => {
const client = () => ({
getNpmProxyHosts: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getNpmCertificates: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getNpmAccessLists: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getNpmExposureSummary: async () => { throw new Error("summary unavailable"); },
getNpmSecurityWarnings: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getAuthentikAuthCoverage: async () => { throw new Error("coverage unavailable"); },
getAuthentikOutpostHealth: async () => ({ success: true, status: "warning", available: false, total: 0, embeddedExpected: true, items: [] }),
getAuthentikSecurityWarnings: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getNetworkExposure: async () => exposureRows,
});
it("keeps checks enabled with explicit partial evidence when preference permission is denied", async () => {
const snapshot = await loadNetworkSnapshot({ ...client(), getExposurePreferences: async () => { throw new Error("permission denied"); } });
assert.equal(snapshot.preferences.nginxAccessListChecksEnabled, true);
assert(snapshot.errors.some(error => error.source === "exposurePreferences"));
assert.deepEqual(snapshot.exposureRows, exposureRows);
});
it("does not fetch access lists when their diagnostics are disabled", async () => {
let accessListCalls = 0;
const snapshot = await loadNetworkSnapshot({
...client(),
getExposurePreferences: async () => ({ nginxAccessListChecksEnabled: false }),
getNpmAccessLists: async () => { accessListCalls++; throw new Error("unused ACL endpoint"); },
});
assert.equal(accessListCalls, 0);
assert.equal(snapshot.preferences.nginxAccessListChecksEnabled, false);
assert(!snapshot.errors.some(error => error.source === "accessLists"));
assert.equal(snapshot.exposureRows[0].risk, "high");
assert.equal(snapshot.exposureRows[0].protected, false);
});
it("keeps canonical exposure rows when optional network detail calls fail", async () => {
const snapshot = await loadNetworkSnapshot({
getNpmProxyHosts: async () => {
+21 -24
View File
@@ -2,6 +2,7 @@ import type {
AuthentikAuthCoverage,
AuthentikOutpostHealth,
AuthentikSecurityWarnings,
ExposurePreferences,
NetworkExposure,
NpmAccessLists,
NpmCertificates,
@@ -11,6 +12,7 @@ import type {
} from "@/types/appops";
export type NetworkSnapshotClient = {
getExposurePreferences?: () => Promise<ExposurePreferences>;
getNpmProxyHosts: () => Promise<NpmProxyHosts>;
getNpmCertificates: () => Promise<NpmCertificates>;
getNpmAccessLists: () => Promise<NpmAccessLists>;
@@ -28,6 +30,7 @@ export type NetworkSnapshotError = {
};
export type NetworkSnapshot = {
preferences: ExposurePreferences;
hosts: NpmProxyHosts | null;
certificates: NpmCertificates | null;
accessLists: NpmAccessLists | null;
@@ -65,6 +68,7 @@ type SettledNetworkPart<T> = {
};
type NetworkSnapshotParts = {
preferences: SettledNetworkPart<ExposurePreferences>;
hosts: SettledNetworkPart<NpmProxyHosts>;
certificates: SettledNetworkPart<NpmCertificates>;
accessLists: SettledNetworkPart<NpmAccessLists>;
@@ -83,6 +87,7 @@ const buildSnapshot = (parts: NetworkSnapshotParts): NetworkSnapshot => {
.map((item) => ({ source: item.source, message: item.error || "unknown error" }));
return {
preferences: parts.preferences.value || { nginxAccessListChecksEnabled: true },
hosts: parts.hosts.value,
certificates: parts.certificates.value,
accessLists: parts.accessLists.value,
@@ -97,41 +102,32 @@ const buildSnapshot = (parts: NetworkSnapshotParts): NetworkSnapshot => {
};
};
const startOptionalDetails = (client: NetworkSnapshotClient) => Promise.all([
const startOptionalDetails = (client: NetworkSnapshotClient) => {
const preferences = settle("exposurePreferences", client.getExposurePreferences
? client.getExposurePreferences()
: Promise.resolve({ nginxAccessListChecksEnabled: true }));
return Promise.all([
settle("npmHosts", client.getNpmProxyHosts()),
settle("certificates", client.getNpmCertificates()),
settle("accessLists", client.getNpmAccessLists()),
settle("accessLists", preferences.then(part => part.value?.nginxAccessListChecksEnabled === false
? { success: true, status: "disabled", items: [], total: 0 }
: client.getNpmAccessLists())),
settle("summary", client.getNpmExposureSummary()),
settle("npmWarnings", client.getNpmSecurityWarnings()),
settle("authCoverage", client.getAuthentikAuthCoverage()),
settle("outpostHealth", client.getAuthentikOutpostHealth()),
settle("authWarnings", client.getAuthentikSecurityWarnings()),
preferences,
] as const);
};
export const loadNetworkSnapshot = async (client: NetworkSnapshotClient): Promise<NetworkSnapshot> => {
const [
hosts,
certificates,
accessLists,
summary,
warnings,
authCoverage,
outpostHealth,
authWarnings,
exposureRows,
] = await Promise.all([
settle("npmHosts", client.getNpmProxyHosts()),
settle("certificates", client.getNpmCertificates()),
settle("accessLists", client.getNpmAccessLists()),
settle("summary", client.getNpmExposureSummary()),
settle("npmWarnings", client.getNpmSecurityWarnings()),
settle("authCoverage", client.getAuthentikAuthCoverage()),
settle("outpostHealth", client.getAuthentikOutpostHealth()),
settle("authWarnings", client.getAuthentikSecurityWarnings()),
const [details, exposureRows] = await Promise.all([
startOptionalDetails(client),
settle("exposure", client.getNetworkExposure()),
]);
return buildSnapshot({ hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings, exposureRows });
const [hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings, preferences] = details;
return buildSnapshot({ hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings, preferences, exposureRows });
};
export const startNetworkSnapshotLoad = async (client: NetworkSnapshotClient): Promise<StartedNetworkSnapshotLoad> => {
@@ -141,7 +137,7 @@ export const startNetworkSnapshotLoad = async (client: NetworkSnapshotClient): P
return {
exposureRows,
details: optionalDetails.then(([hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings]) => (
details: optionalDetails.then(([hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings, preferences]) => (
buildSnapshot({
hosts,
certificates,
@@ -151,6 +147,7 @@ export const startNetworkSnapshotLoad = async (client: NetworkSnapshotClient): P
authCoverage,
outpostHealth,
authWarnings,
preferences,
exposureRows: { source: "exposure", value: exposureRows, error: null },
})
)),
+55 -12
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildSystemLoadChartData, formatLoadPercent } from "./systemLoadChartView.ts";
import { buildSystemLoadChartData, formatLoadPercent, buildLoadSegments } from "./systemLoadChartView.ts";
import type { StatisticsSummary } from "../types/appops.ts";
const statistics = (cpu: Array<[string, number | null]>, ram: Array<[string, number | null]>): StatisticsSummary => ({
@@ -14,19 +14,17 @@ const statistics = (cpu: Array<[string, number | null]>, ram: Array<[string, num
});
describe("buildSystemLoadChartData", () => {
it("turns one live sample into a renderable flat trend without inventing values", () => {
it("shows a single measured point without inventing five minutes of history", () => {
const view = buildSystemLoadChartData(statistics(
[["2026-07-10T00:00:00Z", 0]],
[["2026-07-10T00:00:00Z", 38]],
));
assert.equal(view.renderable, true);
assert.equal(view.points.length, 2);
assert.equal(view.points.length, 1);
assert.equal(view.points[0].cpu, 0);
assert.equal(view.points[1].cpu, 0);
assert.equal(view.points[0].ram, 38);
assert.equal(view.points[1].ram, 38);
assert.match(view.caption, /huidige live sample/i);
assert.match(view.caption, /eerste meting/i);
});
it("keeps multi-sample history unchanged except for labels", () => {
@@ -38,7 +36,7 @@ describe("buildSystemLoadChartData", () => {
assert.equal(view.renderable, true);
assert.deepEqual(view.points.map((point) => point.cpu), [12, 18]);
assert.deepEqual(view.points.map((point) => point.ram), [40, 44]);
assert.equal(view.caption, "Live trend uit statistics history.");
assert.match(view.caption, /gemeten/i);
});
it("returns an explicit non-renderable state when no real samples exist", () => {
@@ -46,7 +44,7 @@ describe("buildSystemLoadChartData", () => {
assert.equal(view.renderable, false);
assert.deepEqual(view.points, []);
assert.match(view.caption, /geen live statistics/i);
assert.match(view.caption, /geen.*metingen/i);
});
it("keeps unavailable live metrics explicit instead of formatting them as zero", () => {
@@ -56,9 +54,54 @@ describe("buildSystemLoadChartData", () => {
));
assert.equal(view.renderable, true);
assert.deepEqual(view.points.map((point) => point.cpu), [null, null]);
assert.deepEqual(view.points.map((point) => point.ram), [92, 92]);
assert.equal(formatLoadPercent(view.currentCpu), "n/a");
assert.equal(formatLoadPercent(view.currentRam), "92%");
assert.deepEqual(view.points.map((point) => point.cpu), [null]);
assert.deepEqual(view.points.map((point) => point.ram), [92]);
assert.equal(formatLoadPercent(view.currentCpu), "—");
assert.equal(formatLoadPercent(view.currentRam), "92,0%");
});
it("aligns CPU, RAM and GPU by real timestamp without borrowing another sample", () => {
const input = statistics(
[["2026-07-10T00:00:00Z", 12.35], ["2026-07-10T00:00:30Z", 18.25]],
[["2026-07-10T00:00:00Z", 40.55]],
);
input.gpuHistory = [{ time: "2026-07-10T00:00:10Z", value: 65.45 }];
const view = buildSystemLoadChartData(input);
assert.deepEqual(view.points.map(p => [p.cpu, p.ram, p.gpu]), [[12.35, 40.55, null], [null, null, 65.45], [18.25, null, null]]);
assert.equal(view.points[2].timestamp - view.points[1].timestamp, 20_000);
assert.equal(view.currentRam, null);
assert.equal(view.currentGpu, null);
assert.equal(formatLoadPercent(view.currentCpu), "18,3%");
});
it("sorts timestamps and rejects impossible values and invalid timestamps", () => {
const view = buildSystemLoadChartData(statistics(
[["2026-07-10T00:00:20Z", Infinity], ["invalid", 12], ["2026-07-10T00:00:00Z", 25.5]],
[["2026-07-10T00:00:20Z", 101]],
));
assert.equal(view.points.length, 2);
assert.equal(view.points[0].cpu, 25.5);
assert.equal(view.points[1].cpu, null);
assert.equal(view.points[1].ram, null);
assert.equal(formatLoadPercent(NaN), "—");
});
it("limits history to the selected real time window", () => {
const view = buildSystemLoadChartData(statistics(
[["2026-07-10T00:00:00Z", 25], ["2026-07-10T00:10:00Z", 35]], [],
), 5);
assert.equal(view.points.length, 1);
assert.equal(view.points[0].cpu, 35);
});
it("draws time-proportional segments and breaks at missing measurements and long pauses", () => {
const view = buildSystemLoadChartData(statistics([
["2026-07-10T00:00:00Z", 25], ["2026-07-10T00:00:10Z", 35],
["2026-07-10T00:00:30Z", null], ["2026-07-10T00:01:40Z", 15],
], []));
const segments = buildLoadSegments(view.points, "cpu");
assert.equal(segments.length, 2);
assert.deepEqual(segments[0], [{ x: 0, y: 75 }, { x: 10, y: 65 }]);
assert.deepEqual(segments[1], [{ x: 100, y: 85 }]);
});
});
+59 -58
View File
@@ -1,9 +1,13 @@
import type { StatisticsSummary } from "../types/appops.ts";
export const metricRefreshInterval = (visibility: string): number | false => visibility === "visible" ? 5_000 : false;
export interface SystemLoadChartPoint {
time: string;
timestamp: number;
cpu: number | null;
ram: number | null;
gpu: number | null;
}
export interface SystemLoadChartView {
@@ -12,73 +16,70 @@ export interface SystemLoadChartView {
caption: string;
currentCpu: number | null;
currentRam: number | null;
currentGpu: number | null;
}
const toTimeLabel = (time: string): string => {
const date = new Date(time);
if (Number.isNaN(date.getTime())) return time;
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
export const validLoadPercent = (value: unknown): number | null => (
typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100 ? value : null
);
const previousTimeLabel = (time: string): string => {
const date = new Date(time);
if (Number.isNaN(date.getTime())) return "vorige";
date.setMinutes(date.getMinutes() - 5);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
export const formatSampleTime = (timestamp: number): string => new Date(timestamp).toLocaleTimeString("nl-BE", {
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
export const buildSystemLoadChartData = (statistics: StatisticsSummary | null): SystemLoadChartView => {
const cpuHistory = statistics?.cpuHistory ?? [];
const ramHistory = statistics?.ramHistory ?? [];
const currentCpu = cpuHistory.at(-1)?.value ?? null;
const currentRam = ramHistory.at(-1)?.value ?? null;
const hasRealSample = [...cpuHistory, ...ramHistory].some((point) => point.value !== null);
if ((cpuHistory.length === 0 && ramHistory.length === 0) || !hasRealSample) {
return {
points: [],
renderable: false,
caption: "Geen live statistics samples beschikbaar.",
currentCpu,
currentRam,
};
export const buildSystemLoadChartData = (statistics: StatisticsSummary | null, windowMinutes = 15): SystemLoadChartView => {
const samples = new Map<number, SystemLoadChartPoint>();
const histories = { cpu: statistics?.cpuHistory, ram: statistics?.ramHistory, gpu: statistics?.gpuHistory };
for (const key of ["cpu", "ram", "gpu"] as const) {
for (const sample of histories[key] ?? []) {
const timestamp = Date.parse(sample.time);
if (!Number.isFinite(timestamp)) continue;
const point = samples.get(timestamp) ?? { time: sample.time, timestamp, cpu: null, ram: null, gpu: null };
point[key] = validLoadPercent(sample.value);
samples.set(timestamp, point);
}
}
const pointCount = Math.max(cpuHistory.length, ramHistory.length);
const points = Array.from({ length: pointCount }, (_, index) => {
const cpuPoint = cpuHistory[index] ?? cpuHistory.at(-1);
const ramPoint = ramHistory[index] ?? ramHistory.at(-1);
const time = cpuPoint?.time ?? ramPoint?.time ?? statistics?.generatedAt ?? "";
return {
time: toTimeLabel(time),
cpu: cpuPoint?.value ?? null,
ram: ramPoint?.value ?? null,
};
});
if (points.length === 1) {
const sampleTime = cpuHistory[0]?.time ?? ramHistory[0]?.time ?? statistics?.generatedAt ?? "";
return {
points: [
{ ...points[0], time: previousTimeLabel(sampleTime) },
{ ...points[0], time: toTimeLabel(sampleTime) },
],
renderable: true,
caption: "Vlakke trend uit huidige live sample; extra punten verschijnen bij volgende reads.",
currentCpu,
currentRam,
};
}
const ordered = [...samples.values()].sort((a, b) => a.timestamp - b.timestamp);
const lastTimestamp = Math.max(ordered.at(-1)?.timestamp ?? 0, Date.parse(statistics?.generatedAt ?? "") || 0);
const minutes = Number.isFinite(windowMinutes) && windowMinutes > 0 ? windowMinutes : 15;
const points = ordered.filter(point => point.timestamp >= lastTimestamp - minutes * 60_000);
const current = points.at(-1);
const renderable = points.some(point => point.cpu !== null || point.ram !== null || point.gpu !== null);
return {
points,
renderable: true,
caption: "Live trend uit statistics history.",
currentCpu,
currentRam,
renderable,
caption: !renderable ? "Geen recente metingen beschikbaar." : points.length === 1
? "Eerste meting. De historie bouwt op bij volgende metingen."
: `${points.length} gemeten punten · ontbrekende waarden blijven onderbroken.`,
currentCpu: current?.cpu ?? null,
currentRam: current?.ram ?? null,
currentGpu: current?.gpu ?? null,
};
};
export const formatLoadPercent = (value: number | null): string => (
typeof value === "number" ? `${value}%` : "n/a"
validLoadPercent(value) !== null ? `${value!.toLocaleString("nl-BE", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%` : "—"
);
export const chartTimePosition = (points: SystemLoadChartPoint[], timestamp: number): number => {
const start = points[0]?.timestamp ?? timestamp;
const end = points.at(-1)?.timestamp ?? timestamp;
return start === end ? 50 : ((timestamp - start) / (end - start)) * 100;
};
export const buildLoadSegments = (points: SystemLoadChartPoint[], key: "cpu" | "ram" | "gpu"): Array<Array<{ x: number; y: number }>> => {
const segments: Array<Array<{ x: number; y: number }>> = [];
let segment: Array<{ x: number; y: number }> | null = null;
let previousTimestamp = 0;
for (const point of points) {
const value = point[key];
if (value === null) { segment = null; continue; }
if (!segment || point.timestamp - previousTimestamp > 30_000) {
segment = [];
segments.push(segment);
}
segment.push({ x: chartTimePosition(points, point.timestamp), y: 100 - value });
previousTimestamp = point.timestamp;
}
return segments;
};