fix: add sentinel run memory detail curves
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user