fix: add sentinel run memory detail curves

This commit is contained in:
Codex
2026-07-01 02:07:23 +00:00
parent 1f5fddd8b1
commit ebcbdc766e
11 changed files with 626 additions and 12 deletions
+213 -1
View File
@@ -7,7 +7,7 @@
// Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard.
import { Buffer } from "node:buffer";
import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Database } from "bun:sqlite";
import { renderWebProbeSentinelDashboardHtml, webProbeSentinelDashboardAssetResponse } from "./hwlab-node-web-sentinel-dashboard-assets";
@@ -814,6 +814,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
sentinelId: config.sentinelId,
run: dashboardRunSummary(config, db, row),
summary: record(stored.summary),
memory: dashboardRunMemoryDetail(config, row, stored),
findings,
viewsAvailable: Object.keys(views),
artifacts: {
@@ -940,6 +941,217 @@ function dashboardReportView(config: WebProbeSentinelServiceConfig, db: Database
};
}
function dashboardRunMemoryDetail(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>, stored: Record<string, unknown>): Record<string, unknown> {
const options = dashboardDetailMemoryOptions(config);
const storedMemory = compactDashboardMemory(record(record(record(stored.summary).analysis).browserProcess), options, "recorded-analysis-summary");
if (storedMemory.pageSeries.length > 0) return storedMemory;
const fromArtifacts = readDashboardMemoryFromStateDir(config, row, options);
if (fromArtifacts.pageSeries.length > 0) return fromArtifacts;
return {
ok: false,
source: "unavailable",
unit: "MB",
metric: "page-heap-used",
pageCount: 0,
sampleCount: 0,
maxMemoryMb: null,
maxPageLabel: null,
pageSeries: [],
reason: fromArtifacts.reason ?? storedMemory.reason ?? "page-memory-samples-missing",
valuesRedacted: true,
};
}
function dashboardDetailMemoryOptions(config: WebProbeSentinelServiceConfig): Record<string, number> {
const detailMemory = recordTarget(valueAtPath(config.reportViews, "detailMemory"));
return {
maxPages: Math.max(1, Math.floor(numberAt(detailMemory, "maxPages"))),
maxSamplesPerPage: Math.max(1, Math.floor(numberAt(detailMemory, "maxSamplesPerPage"))),
};
}
function readDashboardMemoryFromStateDir(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>, options: Record<string, number>): Record<string, unknown> {
const stateDir = stringOrNull(row.state_dir) ?? stringOrNull(row.stateDir);
if (stateDir === null) return { ok: false, source: "artifact-browser-process-jsonl", reason: "state-dir-missing", pageSeries: [], valuesRedacted: true };
if (!isSafeDashboardStateDir(stateDir)) return { ok: false, source: "artifact-browser-process-jsonl", reason: "unsafe-state-dir", pageSeries: [], valuesRedacted: true };
const browserProcessPath = join(config.stateRoot, stateDir, "browser-process.jsonl");
if (!existsSync(browserProcessPath)) return { ok: false, source: "artifact-browser-process-jsonl", reason: "browser-process-jsonl-missing", pageSeries: [], valuesRedacted: true };
try {
const rows = readFileSync(browserProcessPath, "utf8")
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.flatMap((line) => {
const parsed = parseJsonObject(line);
return parsed === null ? [] : [parsed];
});
return buildDashboardMemoryDetail(rows, options, "artifact-browser-process-jsonl");
} catch (error) {
return {
ok: false,
source: "artifact-browser-process-jsonl",
reason: "browser-process-jsonl-read-failed",
error: error instanceof Error ? error.message : String(error),
pageSeries: [],
valuesRedacted: true,
};
}
}
function compactDashboardMemory(value: Record<string, unknown>, options: Record<string, number>, source: string): Record<string, unknown> {
const pageSeries = arrayRecords(value.pageSeries);
if (pageSeries.length > 0) return finalizeDashboardMemorySeries(pageSeries, options, source);
return { ok: false, source, reason: "recorded-page-series-missing", pageSeries: [], valuesRedacted: true };
}
function buildDashboardMemoryDetail(rows: readonly Record<string, unknown>[], options: Record<string, number>, source: string): Record<string, unknown> {
const seriesByKey = new Map<string, Record<string, unknown>>();
let firstTsMs: number | null = null;
for (const row of rows) {
if (stringOrNull(row.type) !== "browser-process-sample") continue;
const ts = stringOrNull(row.ts);
const tsMs = ts === null ? null : Date.parse(ts);
if (ts === null || tsMs === null || !Number.isFinite(tsMs)) continue;
for (const page of arrayRecords(row.pages)) {
const memory = dashboardPageMemoryPoint(page);
if (memory === null) continue;
if (firstTsMs === null) firstTsMs = tsMs;
const pageRole = stringOrNull(page.pageRole) ?? "page";
const pageId = stringOrNull(page.pageId) ?? `${pageRole}-${stringOrNull(page.pageEpoch) ?? seriesByKey.size}`;
const key = `${pageRole}:${pageId}`;
const existing = seriesByKey.get(key);
const label = `${pageRole} ${shortDashboardText(pageId, 10)}`;
const points = arrayRecords(existing?.points);
points.push({
ts,
elapsedSeconds: Math.max(0, Math.round((tsMs - firstTsMs) / 1000)),
elapsedMinutes: Math.round(Math.max(0, (tsMs - firstTsMs) / 60_000) * 100) / 100,
...memory,
valuesRedacted: true,
});
seriesByKey.set(key, {
key,
pageRole,
pageId,
label,
url: safeDashboardUrl(stringOrNull(page.url)),
points,
valuesRedacted: true,
});
}
}
return finalizeDashboardMemorySeries([...seriesByKey.values()], options, source);
}
function finalizeDashboardMemorySeries(rawSeries: readonly Record<string, unknown>[], options: Record<string, number>, source: string): Record<string, unknown> {
const maxPages = numberOr(options.maxPages, 1);
const maxSamplesPerPage = numberOr(options.maxSamplesPerPage, 1);
const series = rawSeries
.map((item) => {
const points = arrayRecords(item.points)
.map((point) => ({
ts: stringOrNull(point.ts),
elapsedSeconds: numberOrNull(point.elapsedSeconds),
elapsedMinutes: numberOrNull(point.elapsedMinutes),
memoryMb: numberOrNull(point.memoryMb),
heapUsedMb: numberOrNull(point.heapUsedMb),
jsHeapUsedMb: numberOrNull(point.jsHeapUsedMb),
effectiveHeapUsedMb: numberOrNull(point.effectiveHeapUsedMb),
effectiveJsHeapUsedMb: numberOrNull(point.effectiveJsHeapUsedMb),
domNodes: numberOrNull(point.domNodes),
valuesRedacted: true,
}))
.filter((point) => point.ts !== null && point.memoryMb !== null);
const thinned = thinDashboardPoints(points, maxSamplesPerPage);
const maxMemoryMb = maxNumber(thinned.map((point) => numberOrNull(point.memoryMb)));
return {
key: stringOrNull(item.key) ?? stringOrNull(item.pageId) ?? "page",
pageRole: stringOrNull(item.pageRole),
pageId: stringOrNull(item.pageId),
label: stringOrNull(item.label) ?? stringOrNull(item.pageRole) ?? stringOrNull(item.pageId) ?? "page",
url: safeDashboardUrl(stringOrNull(item.url)),
sampleCount: thinned.length,
maxMemoryMb,
latestMemoryMb: thinned.length > 0 ? numberOrNull(thinned[thinned.length - 1].memoryMb) : null,
points: thinned,
valuesRedacted: true,
};
})
.filter((item) => item.sampleCount > 0)
.sort((a, b) => numberOr(b.maxMemoryMb, 0) - numberOr(a.maxMemoryMb, 0))
.slice(0, maxPages);
const sampleCount = series.reduce((sum, item) => sum + numberOr(item.sampleCount, 0), 0);
const maxSeries = series.reduce<Record<string, unknown> | null>((best, item) => best === null || numberOr(item.maxMemoryMb, 0) > numberOr(best.maxMemoryMb, 0) ? item : best, null);
return {
ok: series.length > 0,
source,
unit: "MB",
metric: "page-heap-used",
pageCount: series.length,
sampleCount,
maxMemoryMb: maxSeries === null ? null : numberOrNull(maxSeries.maxMemoryMb),
maxPageLabel: maxSeries === null ? null : stringOrNull(maxSeries.label),
pageSeries: series,
valuesRedacted: true,
};
}
function dashboardPageMemoryPoint(page: Record<string, unknown>): Record<string, unknown> | null {
const effectiveMemory = record(page.effectiveMemory);
const heapUsage = record(page.heapUsage);
const performance = record(page.performance);
const metrics = record(performance.metrics);
const heapUsedMb = numberOrNull(effectiveMemory.heapUsedMb) ?? bytesToMbNullable(heapUsage.usedSize);
const jsHeapUsedMb = numberOrNull(effectiveMemory.jsHeapUsedMb) ?? bytesToMbNullable(metrics.JSHeapUsedSize);
const memoryMb = heapUsedMb ?? jsHeapUsedMb;
if (memoryMb === null) return null;
return {
memoryMb,
heapUsedMb,
jsHeapUsedMb,
effectiveHeapUsedMb: numberOrNull(effectiveMemory.effectiveHeapUsedMb),
effectiveJsHeapUsedMb: numberOrNull(effectiveMemory.effectiveJsHeapUsedMb),
domNodes: numberOrNull(effectiveMemory.domNodes) ?? numberOrNull(record(page.domCounters).nodes) ?? numberOrNull(metrics.Nodes),
};
}
function thinDashboardPoints(points: readonly Record<string, unknown>[], maxSamples: number): readonly Record<string, unknown>[] {
if (points.length <= maxSamples) return points;
const selected: Record<string, unknown>[] = [];
const last = points.length - 1;
const targetLast = Math.max(1, maxSamples - 1);
for (let index = 0; index < maxSamples; index += 1) {
selected.push(points[Math.round((index / targetLast) * last)]);
}
return selected;
}
function isSafeDashboardStateDir(value: string): boolean {
if (value.startsWith("/") || value.includes("\0")) return false;
const normalized = value.replace(/\\/gu, "/");
if (!normalized.startsWith(".state/")) return false;
return normalized.split("/").every((part) => part !== "..");
}
function bytesToMbNullable(value: unknown): number | null {
const bytes = numberOrNull(value);
return bytes === null ? null : Math.round((bytes / 1024 / 1024) * 100) / 100;
}
function maxNumber(values: readonly (number | null)[]): number | null {
const finite = values.filter((value): value is number => value !== null && Number.isFinite(value));
return finite.length === 0 ? null : Math.max(...finite);
}
function safeDashboardUrl(value: string | null): string | null {
if (value === null) return null;
return value.slice(0, 180);
}
function shortDashboardText(value: string, max: number): string {
return value.length <= max ? value : `${value.slice(0, max)}`;
}
function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database, row: Record<string, unknown>): Record<string, unknown> {
const id = stringOrNull(row.id);
const severityCounts = id === null ? {} : severityCountsForRun(config, db, id);