Publish curated projectbrain source from 2713a23f6240bdabc1c6c45805a6264b9aaedad4
This commit is contained in:
1 parent
98f7618af2
commit
86cfcd1d7a
17 files changed
+1388
-22
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
runtimeRunnerCapacityPresentation,
|
||||
} from "@/shared/lib/activityRuntimePresentation";
|
||||
import { projectEntryPath } from "@/shared/lib/routeRegistry";
|
||||
import { RunnerStatisticsPanel } from "./RunnerStatisticsPanel";
|
||||
import { RuntimeJobCard, RuntimeJobLane } from "./RuntimeJobLane";
|
||||
|
||||
type ExtendedAutonomyProject = {
|
||||
@@ -165,6 +166,8 @@ export const PortfolioRuntimeDashboard = () => {
|
||||
<div className="rounded-lg border p-3"><strong className="text-lg capitalize">{runtime.worker?.status ?? "niet actief"}</strong><p className="text-xs text-muted-foreground">analyseworker</p></div>
|
||||
</div>
|
||||
|
||||
{runtime.runnerStatistics ? <RunnerStatisticsPanel statistics={runtime.runnerStatistics} /> : null}
|
||||
|
||||
<div className={`rounded-lg border p-3 text-sm ${runtime.counts.runnable ? "border-amber-300 bg-amber-50 text-amber-950" : "bg-muted/30 text-muted-foreground"}`}>
|
||||
{runtimeQueueExplanation(runtime.counts.runnable, runtime.counts.dependencyBlocked, capacity.idle, runnerInventoryComplete, capacity.online)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PortfolioRunnerStatistics } from "@/shared/api/portfolioTypes";
|
||||
import { RunnerStatisticsPanel } from "@/features/portfolio/components/RunnerStatisticsPanel";
|
||||
|
||||
const statistics = (configured: boolean): PortfolioRunnerStatistics => ({
|
||||
appOpsSource: configured ? "appops" : "not_configured",
|
||||
appOpsWarning: configured ? undefined : "AppOps runner statistics are not configured.",
|
||||
appOps: {
|
||||
schemaVersion: 1,
|
||||
status: configured ? "healthy" : "not_configured",
|
||||
generatedAt: "2026-10-01T09:00:00.000Z",
|
||||
scope: "ci-fleet",
|
||||
budget: { cpuLimit: configured ? 8 : null, memoryLimitBytes: configured ? 17_179_869_184 : null, activeJobLimit: configured ? 2 : null, heavyJobLimit: configured ? 1 : null, reservedCpu: null, reservedMemoryBytes: null, activeJobs: configured ? 1 : null, heavyJobs: null },
|
||||
host: { cpuBusyPercent: configured ? 24.5 : null, loadRatio: null, availableMemoryBytes: null, cacheFreeBytes: null },
|
||||
profiles: configured ? [{
|
||||
id: "backend", displayName: "ITWorx CI · Backend", labels: ["itworx-ci-backend"], runnerName: "backend-small", status: "ready",
|
||||
queueDepth: 3, activeJobs: 1, oldestQueuedSeconds: 90, reason: "Wacht op een capaciteitsslot", cpuLimit: 2, memoryLimitBytes: 4_294_967_296,
|
||||
cpuUsed: null, memoryUsedBytes: null, completedJobs: 12, p50WaitSeconds: 35, p95WaitSeconds: 120,
|
||||
p50DurationSeconds: 180, p95DurationSeconds: 510, lastDurationSeconds: 205,
|
||||
}] : [],
|
||||
window: { startAt: null, endAt: null, completedJobs: configured ? 12 : 0, samples: configured ? 60 : 0, coverage: configured ? "complete" : "unavailable" },
|
||||
history: configured ? [{ at: "2026-10-01T08:59:00.000Z", activeJobs: 1, queuedJobs: 3, cpuUsed: 2, memoryUsedBytes: 2_147_483_648 }] : [],
|
||||
},
|
||||
giteaFallback: {
|
||||
source: "gitea", availability: "live", generatedAt: "2026-10-01T09:00:00.000Z",
|
||||
runners: { observed: 4, busyObserved: 2, disabledObserved: 0, inventoryComplete: true },
|
||||
jobs: { runningObserved: 2, queuedObserved: 3, dependencyBlockedObserved: 1, queuedTotal: 3, queuedCoverage: "complete", unattributedObserved: { running: 0, queued: 2, dependencyBlocked: 0, completed: 0, total: 2 }, unattributedCoverage: "bounded" },
|
||||
profiles: [{
|
||||
id: "backend", displayName: "ITWorx CI · Backend", labels: ["itworx-ci-backend"], queueDepth: 3, queueObserved: 3,
|
||||
activeJobsObserved: 1, oldestQueuedSeconds: 90, completedJobs: 2, samples: 2,
|
||||
p50WaitSeconds: 60, p95WaitSeconds: 120, p50DurationSeconds: 180, p95DurationSeconds: 300, lastDurationSeconds: 180,
|
||||
window: { startAt: "2026-10-01T08:00:00.000Z", endAt: "2026-10-01T08:50:00.000Z", coverage: "bounded" },
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
describe("runner statistics panel", () => {
|
||||
it("states when AppOps is unconfigured and keeps Gitea observations separate", () => {
|
||||
const html = renderToStaticMarkup(createElement(RunnerStatisticsPanel, { statistics: statistics(false) }));
|
||||
expect(html).toContain("AppOps niet gekoppeld");
|
||||
expect(html).toContain("Er zijn geen betrouwbare AppOps-budget- of profielmetingen");
|
||||
expect(html).toContain("Gitea-waarneming");
|
||||
expect(html).toContain("geen hostbudget of volledige jobhistoriek");
|
||||
expect(html).toContain("Niet aan profiel toegewezen: 2 gezien");
|
||||
expect(html).not.toContain("0 vCPU");
|
||||
});
|
||||
|
||||
it("renders named profiles, reasons, percentiles, last duration, and observed host history", () => {
|
||||
const html = renderToStaticMarkup(createElement(RunnerStatisticsPanel, { statistics: statistics(true) }));
|
||||
expect(html).toContain("ITWorx CI · Backend");
|
||||
expect(html).toContain("Wacht op een capaciteitsslot");
|
||||
expect(html).toContain("Laatste duur <strong>3 min 25 s</strong>");
|
||||
expect(html).toContain("Oudste wachtrijjob sinds aanmaak");
|
||||
expect(html).toContain("Jobaanmaak → start p50 / p95");
|
||||
expect(html).toContain("oudste waargenomen wachtrijjob sinds aanmaak");
|
||||
expect(html).not.toContain("Wacht p50 / p95");
|
||||
expect(html).not.toContain("langste waargenomen wacht");
|
||||
expect(html).toContain("CPU gebruikt historiek");
|
||||
expect(html).toContain("CPU <strong>? / 2 vCPU</strong>");
|
||||
expect(html).toContain("Geheugen <strong>? / 4.0 GiB</strong>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Activity, Clock3, Cpu, HardDrive, Server } from "lucide-react";
|
||||
import { Badge } from "@/components/ui";
|
||||
import type { PortfolioRunnerStatistics } from "@/shared/api/portfolioTypes";
|
||||
import { formatRunnerBytes, formatRunnerDuration, runnerStatisticsCoverageLabel, runnerStatisticsFreshnessLabel, runnerStatisticsSparklinePath } from "@/shared/lib/runnerStatisticsPresentation";
|
||||
|
||||
const timestamp = (value: string | null | undefined) => value && Number.isFinite(Date.parse(value))
|
||||
? new Date(value).toLocaleString("nl-BE")
|
||||
: "?";
|
||||
|
||||
const statusVariant = (status: PortfolioRunnerStatistics["appOps"]["status"]) => status === "healthy" ? "secondary" : status === "degraded" || status === "not_configured" ? "outline" : "destructive";
|
||||
|
||||
const Sparkline = ({ label, points, suffix }: { label: string; points: string | null; suffix?: string }) => <div className="rounded-lg border bg-background p-3">
|
||||
<p className="text-xs font-medium">{label}</p>
|
||||
{points ? <svg className="mt-2 h-12 w-full overflow-visible text-primary" viewBox="0 0 320 48" role="img" aria-label={`${label} historiek${suffix ? ` (${suffix})` : ""}`}>
|
||||
<path d={points} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg> : <p className="mt-2 text-xs text-muted-foreground">Geen sample beschikbaar</p>}
|
||||
</div>;
|
||||
|
||||
export const RunnerStatisticsPanel = ({ statistics }: { statistics: PortfolioRunnerStatistics }) => {
|
||||
const { appOps, appOpsSource, appOpsWarning, giteaFallback } = statistics;
|
||||
const authoritative = appOps.status === "healthy" || appOps.status === "degraded";
|
||||
const metrics = [
|
||||
{ id: "activeJobs" as const, label: "Actieve jobs" },
|
||||
{ id: "queuedJobs" as const, label: "Jobs in wachtrij" },
|
||||
{ id: "cpuUsed" as const, label: "CPU gebruikt" },
|
||||
{ id: "memoryUsedBytes" as const, label: "Geheugen gebruikt" },
|
||||
];
|
||||
|
||||
return <section className="space-y-4 rounded-xl border bg-muted/10 p-4" aria-labelledby="runner-statistics-heading">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><h3 id="runner-statistics-heading" className="flex items-center gap-2 font-semibold"><Activity size={17} />ITWorx CI-profielen</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Profielstatistieken komen uit de alleen-lezen AppOps-bron. Gitea blijft een aparte, begrensde waarneming.</p></div>
|
||||
<Badge variant={statusVariant(appOps.status)}>{appOps.status === "healthy" ? "AppOps live" : appOps.status === "degraded" ? "AppOps gedeeltelijk" : appOps.status === "not_configured" ? "AppOps niet gekoppeld" : "AppOps onbeschikbaar"}</Badge>
|
||||
</div>
|
||||
|
||||
{appOpsWarning ? <p role="status" className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-950">{appOpsWarning}</p> : null}
|
||||
|
||||
{authoritative ? <>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-lg border bg-background p-3"><p className="flex items-center gap-1 text-xs text-muted-foreground"><Cpu size={13} />CPU-budget</p><strong>{appOps.budget.cpuLimit === null ? "?" : `${appOps.budget.cpuLimit} vCPU`}</strong><p className="text-[11px] text-muted-foreground">gereserveerd: {appOps.budget.reservedCpu === null ? "?" : `${appOps.budget.reservedCpu} vCPU`}</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="flex items-center gap-1 text-xs text-muted-foreground"><HardDrive size={13} />Geheugenbudget</p><strong>{formatRunnerBytes(appOps.budget.memoryLimitBytes)}</strong><p className="text-[11px] text-muted-foreground">gereserveerd: {formatRunnerBytes(appOps.budget.reservedMemoryBytes)}</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="flex items-center gap-1 text-xs text-muted-foreground"><Server size={13} />Parallelle limiet</p><strong>{appOps.budget.activeJobs === null || appOps.budget.activeJobLimit === null ? "?" : `${appOps.budget.activeJobs} / ${appOps.budget.activeJobLimit}`}</strong><p className="text-[11px] text-muted-foreground">zware jobs: {appOps.budget.heavyJobs === null || appOps.budget.heavyJobLimit === null ? "?" : `${appOps.budget.heavyJobs} / ${appOps.budget.heavyJobLimit}`}</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="flex items-center gap-1 text-xs text-muted-foreground"><HardDrive size={13} />Hostruimte</p><strong>{appOps.host.cpuBusyPercent === null ? "?" : `${appOps.host.cpuBusyPercent.toFixed(1)}% CPU`}</strong><p className="text-[11px] text-muted-foreground">vrij geheugen: {formatRunnerBytes(appOps.host.availableMemoryBytes)} · cache: {formatRunnerBytes(appOps.host.cacheFreeBytes)}</p></div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4" aria-label="Runnerresourcehistoriek">
|
||||
{metrics.map((metric) => <Sparkline key={metric.id} label={metric.label} points={runnerStatisticsSparklinePath(appOps.history, metric.id)} />)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><Clock3 size={12} />AppOps bijgewerkt {runnerStatisticsFreshnessLabel(appOps.status, appOps.generatedAt)}</span>
|
||||
<span>{runnerStatisticsCoverageLabel(appOps.window.coverage)} · {appOps.window.completedJobs} afgeronde jobs · {appOps.window.samples} samples</span>
|
||||
<span>Venster {timestamp(appOps.window.startAt)} – {timestamp(appOps.window.endAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3" aria-label="CI-runnerprofielen">
|
||||
{appOps.profiles.map((profile) => <article key={profile.id} className="min-w-0 rounded-lg border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-2"><div className="min-w-0"><h4 className="break-words text-sm font-semibold">{profile.displayName}</h4><p className="mt-1 break-words text-xs text-muted-foreground">{profile.runnerName ?? "Runner onbekend"}</p></div><Badge variant={profile.status === "ready" || profile.status === "online" ? "secondary" : "outline"}>{profile.status}</Badge></div>
|
||||
{profile.reason ? <p className="mt-2 break-words text-xs text-amber-800">{profile.reason}</p> : null}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<p>Wachtrij <strong>{profile.queueDepth ?? "?"}</strong></p><p>Actief <strong>{profile.activeJobs ?? "?"}</strong></p>
|
||||
<p>Oudste wachtrijjob sinds aanmaak <strong>{formatRunnerDuration(profile.oldestQueuedSeconds)}</strong></p><p>Laatste duur <strong>{formatRunnerDuration(profile.lastDurationSeconds)}</strong></p>
|
||||
<p>Jobaanmaak → start p50 / p95 <strong>{formatRunnerDuration(profile.p50WaitSeconds)} / {formatRunnerDuration(profile.p95WaitSeconds)}</strong></p>
|
||||
<p>Duur p50 / p95 <strong>{formatRunnerDuration(profile.p50DurationSeconds)} / {formatRunnerDuration(profile.p95DurationSeconds)}</strong></p>
|
||||
<p>CPU <strong>{profile.cpuUsed === null ? "?" : `${profile.cpuUsed} vCPU`} / {profile.cpuLimit === null ? "?" : `${profile.cpuLimit} vCPU`}</strong></p>
|
||||
<p>Geheugen <strong>{formatRunnerBytes(profile.memoryUsedBytes)} / {formatRunnerBytes(profile.memoryLimitBytes)}</strong></p>
|
||||
</div>
|
||||
</article>)}
|
||||
{!appOps.profiles.length ? <p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">Geen profielmetingen beschikbaar.</p> : null}
|
||||
</div>
|
||||
</> : <p role="status" className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">Er zijn geen betrouwbare AppOps-budget- of profielmetingen. De aparte Gitea-waarneming hieronder toont alleen wat in de huidige API-steekproef is gezien.</p>}
|
||||
|
||||
<details className="rounded-lg border bg-background">
|
||||
<summary className="cursor-pointer list-none p-3 text-sm font-medium">Gitea-waarneming <span className="ml-2 text-xs font-normal text-muted-foreground">{giteaFallback.availability} · {giteaFallback.jobs.queuedCoverage === "complete" ? "wachtrij volledig gescand" : giteaFallback.jobs.queuedCoverage === "bounded" ? "wachtrijsteekproef begrensd" : "wachtrij onbekend"}</span></summary>
|
||||
<div className="space-y-3 border-t p-3">
|
||||
<p className="text-xs text-muted-foreground">{giteaFallback.runners.observed} runners gezien ({giteaFallback.runners.busyObserved} bezig, {giteaFallback.runners.disabledObserved} uitgeschakeld{giteaFallback.runners.inventoryComplete ? "; inventaris volledig" : "; inventaris onvolledig"}). Jobs gezien: {giteaFallback.jobs.runningObserved} actief, {giteaFallback.jobs.queuedObserved} wachtend, {giteaFallback.jobs.dependencyBlockedObserved} afhankelijkheidsblokkades. Niet aan profiel toegewezen: {giteaFallback.jobs.unattributedObserved.total} gezien ({giteaFallback.jobs.unattributedObserved.queued} wachtend, {giteaFallback.jobs.unattributedObserved.running} actief; {giteaFallback.jobs.unattributedCoverage === "unavailable" ? "onbeschikbare" : "begrensde"} waarneming). Dit zijn waarnemingen uit Gitea, geen hostbudget of volledige jobhistoriek.</p>
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{giteaFallback.profiles.map((profile) => <div key={profile.id} className="rounded-lg border p-3">
|
||||
<h4 className="text-sm font-semibold">{profile.displayName}</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Wachtrij {profile.queueDepth === null ? `? (${profile.queueObserved} gezien)` : profile.queueDepth} · actief gezien {profile.activeJobsObserved} · oudste waargenomen wachtrijjob sinds aanmaak {formatRunnerDuration(profile.oldestQueuedSeconds)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Laatste duur {formatRunnerDuration(profile.lastDurationSeconds)} · p50/p95 duur {formatRunnerDuration(profile.p50DurationSeconds)} / {formatRunnerDuration(profile.p95DurationSeconds)} · {profile.samples} terminale sample(s)</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">Meetperiode {timestamp(profile.window.startAt)} – {timestamp(profile.window.endAt)} · {runnerStatisticsCoverageLabel(profile.window.coverage)}</p>
|
||||
</div>)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">Gitea-snapshot {timestamp(giteaFallback.generatedAt)}; terminale jobstatistieken gebruiken alleen geldige waargenomen timestamps en de nieuwste bekende poging per job.</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>;
|
||||
};
|
||||
@@ -1,5 +1,70 @@
|
||||
import type { CanonicalLifecycle } from "@projectbrain/contracts";
|
||||
|
||||
export type RunnerProfileId = "checks" | "backend" | "frontend" | "security" | "browser" | "build";
|
||||
export type RunnerStatisticsProfile = {
|
||||
id: RunnerProfileId; displayName: string; labels: string[]; runnerName: string | null; status: string;
|
||||
queueDepth: number | null; activeJobs: number | null; oldestQueuedSeconds: number | null; reason: string | null;
|
||||
cpuLimit: number | null; memoryLimitBytes: number | null; cpuUsed: number | null; memoryUsedBytes: number | null;
|
||||
completedJobs: number; p50WaitSeconds: number | null; p95WaitSeconds: number | null;
|
||||
p50DurationSeconds: number | null; p95DurationSeconds: number | null; lastDurationSeconds?: number | null;
|
||||
};
|
||||
export type RunnerStatisticsSnapshot = {
|
||||
schemaVersion: 1;
|
||||
status: "healthy" | "degraded" | "unavailable" | "not_configured";
|
||||
generatedAt: string;
|
||||
scope: "ci-fleet";
|
||||
budget: {
|
||||
cpuLimit: 8 | null; memoryLimitBytes: 17179869184 | null; activeJobLimit: 2 | null; heavyJobLimit: 1 | null;
|
||||
reservedCpu: number | null; reservedMemoryBytes: number | null; activeJobs: number | null; heavyJobs: number | null;
|
||||
};
|
||||
host: { cpuBusyPercent: number | null; loadRatio: number | null; availableMemoryBytes: number | null; cacheFreeBytes: number | null };
|
||||
profiles: RunnerStatisticsProfile[];
|
||||
window: { startAt: string | null; endAt: string | null; completedJobs: number; samples: number; coverage: "bounded" | "complete" | "unavailable" };
|
||||
history: Array<{ at: string; activeJobs: number | null; queuedJobs: number | null; cpuUsed: number | null; memoryUsedBytes: number | null }>;
|
||||
};
|
||||
|
||||
export type GiteaRunnerStatisticsProfile = {
|
||||
id: RunnerStatisticsSnapshot["profiles"][number]["id"];
|
||||
displayName: string;
|
||||
labels: string[];
|
||||
queueDepth: number | null;
|
||||
queueObserved: number;
|
||||
activeJobsObserved: number;
|
||||
oldestQueuedSeconds: number | null;
|
||||
completedJobs: number;
|
||||
samples: number;
|
||||
p50WaitSeconds: number | null;
|
||||
p95WaitSeconds: number | null;
|
||||
p50DurationSeconds: number | null;
|
||||
p95DurationSeconds: number | null;
|
||||
lastDurationSeconds: number | null;
|
||||
window: { startAt: string | null; endAt: string | null; coverage: "bounded" | "unavailable" };
|
||||
};
|
||||
|
||||
export type GiteaRunnerStatisticsFallback = {
|
||||
source: "gitea";
|
||||
availability: "live" | "stale" | "unavailable";
|
||||
generatedAt: string;
|
||||
runners: { observed: number; busyObserved: number; disabledObserved: number; inventoryComplete: boolean };
|
||||
jobs: {
|
||||
runningObserved: number;
|
||||
queuedObserved: number;
|
||||
dependencyBlockedObserved: number;
|
||||
queuedTotal: number | null;
|
||||
queuedCoverage: "complete" | "bounded" | "unavailable";
|
||||
unattributedObserved: { running: number; queued: number; dependencyBlocked: number; completed: number; total: number };
|
||||
unattributedCoverage: "bounded" | "unavailable";
|
||||
};
|
||||
profiles: GiteaRunnerStatisticsProfile[];
|
||||
};
|
||||
|
||||
export type PortfolioRunnerStatistics = {
|
||||
appOps: RunnerStatisticsSnapshot;
|
||||
appOpsSource: "appops" | "not_configured";
|
||||
appOpsWarning?: string;
|
||||
giteaFallback: GiteaRunnerStatisticsFallback;
|
||||
};
|
||||
|
||||
export type PortfolioRepositoryRecord = {
|
||||
id: string;
|
||||
owner: string;
|
||||
@@ -265,6 +330,7 @@ export type PortfolioRuntime = {
|
||||
counts: { running: number; runnable: number; dependencyBlocked: number };
|
||||
generatedAt: string;
|
||||
stateVersion: string;
|
||||
runnerStatistics?: PortfolioRunnerStatistics;
|
||||
warning?: string;
|
||||
claimHealth?: RunnerClaimHealth;
|
||||
pollAfterMs: number;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatRunnerBytes, formatRunnerDuration, runnerStatisticsCoverageLabel, runnerStatisticsFreshnessLabel, runnerStatisticsSparklinePath } from "@/shared/lib/runnerStatisticsPresentation";
|
||||
|
||||
describe("runner statistics presentation", () => {
|
||||
it("keeps unknown measurements distinct from zero and formats observed units", () => {
|
||||
expect(formatRunnerDuration(null)).toBe("?");
|
||||
expect(formatRunnerDuration(0)).toBe("0 s");
|
||||
expect(formatRunnerDuration(3_661)).toBe("1 u 1 min");
|
||||
expect(formatRunnerBytes(null)).toBe("?");
|
||||
expect(formatRunnerBytes(0)).toBe("0 B");
|
||||
expect(formatRunnerBytes(1_073_741_824)).toBe("1.0 GiB");
|
||||
});
|
||||
|
||||
it("explains sample coverage and freshness instead of implying current complete data", () => {
|
||||
const now = Date.parse("2026-10-01T09:00:00.000Z");
|
||||
expect(runnerStatisticsFreshnessLabel("healthy", "2026-10-01T08:58:00.000Z", now)).toBe("120 s geleden");
|
||||
expect(runnerStatisticsFreshnessLabel("healthy", "2026-10-01T08:57:59.000Z", now)).toBe("Verouderd");
|
||||
expect(runnerStatisticsFreshnessLabel("not_configured", "2026-10-01T09:00:00.000Z", now)).toBe("Niet geconfigureerd");
|
||||
expect(runnerStatisticsFreshnessLabel("healthy", "2026-10-01T09:01:00.000Z", now)).toBe("Tijdstip onbekend");
|
||||
expect(runnerStatisticsCoverageLabel("bounded")).toBe("Begrensde steekproef");
|
||||
expect(runnerStatisticsCoverageLabel("unavailable")).toBe("Geen bruikbare steekproef");
|
||||
});
|
||||
|
||||
it("plots only finite observed samples and leaves an empty chart unknown", () => {
|
||||
const history = [
|
||||
{ at: "2026-10-01T08:00:00.000Z", activeJobs: null, queuedJobs: 0, cpuUsed: 0, memoryUsedBytes: null },
|
||||
{ at: "2026-10-01T08:01:00.000Z", activeJobs: 2, queuedJobs: null, cpuUsed: Number.NaN, memoryUsedBytes: 1_024 },
|
||||
];
|
||||
expect(runnerStatisticsSparklinePath(history, "activeJobs")).toBe("M320.0,2.0");
|
||||
expect(runnerStatisticsSparklinePath(history, "queuedJobs")).toBe("M0.0,46.0");
|
||||
expect(runnerStatisticsSparklinePath(history.map((point) => ({ ...point, cpuUsed: null })), "cpuUsed")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses elapsed time on the x-axis and breaks a long stale gap", () => {
|
||||
const history = [
|
||||
{ at: "2026-10-01T08:00:00.000Z", activeJobs: 0, queuedJobs: 0, cpuUsed: null, memoryUsedBytes: null },
|
||||
{ at: "2026-10-01T08:10:00.000Z", activeJobs: 2, queuedJobs: 1, cpuUsed: null, memoryUsedBytes: null },
|
||||
];
|
||||
expect(runnerStatisticsSparklinePath(history, "activeJobs")).toBe("M0.0,46.0 M320.0,2.0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { RunnerStatisticsSnapshot } from "@/shared/api/portfolioTypes";
|
||||
|
||||
type HistoryPoint = RunnerStatisticsSnapshot["history"][number];
|
||||
type HistoryMetric = "activeJobs" | "queuedJobs" | "cpuUsed" | "memoryUsedBytes";
|
||||
const MAX_CONTIGUOUS_HISTORY_GAP_MS = 5 * 60 * 1_000;
|
||||
|
||||
export const formatRunnerDuration = (seconds: number | null | undefined): string => {
|
||||
if (seconds === null || seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return "?";
|
||||
if (seconds < 60) return `${Math.round(seconds)} s`;
|
||||
if (seconds < 3_600) return `${Math.floor(seconds / 60)} min ${Math.round(seconds % 60)} s`;
|
||||
const hours = Math.floor(seconds / 3_600);
|
||||
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||
return minutes ? `${hours} u ${minutes} min` : `${hours} u`;
|
||||
};
|
||||
|
||||
export const formatRunnerBytes = (bytes: number | null | undefined): string => {
|
||||
if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return "?";
|
||||
if (bytes < 1_024) return `${Math.round(bytes)} B`;
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1_024 && unit < units.length) { value /= 1_024; unit += 1; }
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit - 1]}`;
|
||||
};
|
||||
|
||||
export const runnerStatisticsFreshnessLabel = (
|
||||
status: RunnerStatisticsSnapshot["status"],
|
||||
generatedAt: string,
|
||||
now = Date.now(),
|
||||
): string => {
|
||||
if (status === "not_configured") return "Niet geconfigureerd";
|
||||
if (status === "unavailable") return "Onbeschikbaar";
|
||||
const generatedMs = Date.parse(generatedAt);
|
||||
if (!Number.isFinite(generatedMs) || generatedMs > now + 30_000) return "Tijdstip onbekend";
|
||||
const ageSeconds = Math.max(0, Math.floor((now - generatedMs) / 1_000));
|
||||
if (ageSeconds > 120) return "Verouderd";
|
||||
if (status === "degraded") return `Gedeeltelijk · ${ageSeconds} s geleden`;
|
||||
return `${ageSeconds} s geleden`;
|
||||
};
|
||||
|
||||
export const runnerStatisticsCoverageLabel = (coverage: RunnerStatisticsSnapshot["window"]["coverage"]): string => {
|
||||
if (coverage === "complete") return "Volledig venster";
|
||||
if (coverage === "bounded") return "Begrensde steekproef";
|
||||
return "Geen bruikbare steekproef";
|
||||
};
|
||||
|
||||
export const runnerStatisticsSparklinePath = (
|
||||
history: HistoryPoint[],
|
||||
metric: HistoryMetric,
|
||||
width = 320,
|
||||
height = 48,
|
||||
): string | null => {
|
||||
const values = history.map((point) => {
|
||||
const value = point[metric];
|
||||
const timestamp = Date.parse(point.at);
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 && Number.isFinite(timestamp) ? { timestamp, value } : null;
|
||||
});
|
||||
const observed = values.filter((point): point is { timestamp: number; value: number } => point !== null);
|
||||
if (!observed.length) return null;
|
||||
const max = Math.max(...observed.map((point) => point.value), 1);
|
||||
const validTimestamps = history.map((point) => Date.parse(point.at)).filter(Number.isFinite);
|
||||
const minTimestamp = Math.min(...validTimestamps);
|
||||
const span = Math.max(Math.max(...validTimestamps) - minTimestamp, 1);
|
||||
let activeSegment = false;
|
||||
let previousTimestamp: number | null = null;
|
||||
return values.map((point) => {
|
||||
if (!point) {
|
||||
activeSegment = false;
|
||||
previousTimestamp = null;
|
||||
return "";
|
||||
}
|
||||
if (previousTimestamp !== null && point.timestamp - previousTimestamp > MAX_CONTIGUOUS_HISTORY_GAP_MS) activeSegment = false;
|
||||
const x = ((point.timestamp - minTimestamp) / span) * width;
|
||||
const y = height - (point.value / max) * (height - 4) - 2;
|
||||
const command = activeSegment ? "L" : "M";
|
||||
activeSegment = true;
|
||||
previousTimestamp = point.timestamp;
|
||||
return `${command}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).filter(Boolean).join(" ");
|
||||
};
|
||||
Reference in new issue
Block a user