Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e
This commit is contained in:
1 parent
c61fbfd87f
commit
1a54836efc
101 files changed
+4973
-1298
No files matched your search
@@ -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"/);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
});
|
||||
@@ -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
@@ -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)));
|
||||
});
|
||||
};
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
})
|
||||
)),
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user