fix: isolate D518 web sentinel runners
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p10-monitor-web-aggregation.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p12-cadence-scheduler-monitor-web.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-28-p13-1206-multi-runner-boundaries.
|
||||
// Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel.
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
@@ -2067,6 +2068,8 @@ function probeSentinelDashboardBrowser(state: SentinelCicdState, options: Extrac
|
||||
`export UNIDESK_SENTINEL_DASHBOARD_HEIGHT=${shellQuote(heightRaw ?? "900")}`,
|
||||
`export UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`,
|
||||
`export UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE=${shellQuote(options.fullPage ? "1" : "0")}`,
|
||||
`export UNIDESK_SENTINEL_DASHBOARD_EXPECTED_SENTINEL_ID=${shellQuote(state.sentinelId)}`,
|
||||
`export UNIDESK_SENTINEL_DASHBOARD_EXPECTED_ROUTE_PREFIX=${shellQuote(stringAtNullable(state.publicExposure, "routePrefix") ?? "/")}`,
|
||||
`export UNIDESK_SENTINEL_DASHBOARD_PLAYWRIGHT_MODULE=${shellQuote(`${state.spec.workspace}/node_modules/playwright/index.mjs`)}`,
|
||||
"export PLAYWRIGHT_BROWSERS_PATH=0",
|
||||
"if command -v chromium >/dev/null 2>&1; then",
|
||||
@@ -2149,6 +2152,8 @@ const height = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_HEIGHT || 900);
|
||||
const timeout = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS || 30000);
|
||||
const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0";
|
||||
const executablePath = process.env.UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH || "";
|
||||
const expectedSentinelId = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_SENTINEL_ID || "";
|
||||
const expectedRoutePrefix = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_ROUTE_PREFIX || "/";
|
||||
|
||||
if (!url) throw new Error("missing dashboard URL");
|
||||
|
||||
@@ -2353,6 +2358,23 @@ const dom = await page.evaluate(async () => {
|
||||
&& chartCounts.error === latestRunCounts.error
|
||||
&& chartCounts.warning === latestRunCounts.warning
|
||||
&& chartCounts.total === latestRunCounts.total;
|
||||
const datasetSentinelId = root?.getAttribute("data-sentinel-id") || "";
|
||||
const finalPath = new URL(window.location.href).pathname.replace(/\/+$/u, "") || "/";
|
||||
const expectedPath = expectedRoutePrefix.replace(/\/+$/u, "") || "/";
|
||||
const routePrefixMatches = expectedPath === "/" ? finalPath === "/" : finalPath === expectedPath || finalPath.startsWith(expectedPath + "/");
|
||||
const sentinelBoundary = {
|
||||
expectedSentinelId,
|
||||
expectedRoutePrefix,
|
||||
datasetSentinelId,
|
||||
overviewSentinelId: overviewPayload?.sentinelId || null,
|
||||
runsSentinelId: runsPayload?.sentinelId || null,
|
||||
finalPath,
|
||||
routePrefixMatches,
|
||||
datasetMatches: expectedSentinelId ? datasetSentinelId === expectedSentinelId : true,
|
||||
overviewMatches: expectedSentinelId ? overviewPayload?.sentinelId === expectedSentinelId : true,
|
||||
runsPayloadMatches: expectedSentinelId ? runsPayload?.sentinelId === expectedSentinelId : true,
|
||||
runRowsMatch: expectedSentinelId ? runs.every((run) => (run?.sentinelId || expectedSentinelId) === expectedSentinelId) : true,
|
||||
};
|
||||
const statusText = text(".status-strip");
|
||||
const doc = document.documentElement;
|
||||
const body = document.body;
|
||||
@@ -2389,10 +2411,11 @@ const dom = await page.evaluate(async () => {
|
||||
dataset: root ? {
|
||||
node: root.getAttribute("data-node"),
|
||||
lane: root.getAttribute("data-lane"),
|
||||
sentinelId: root.getAttribute("data-sentinel-id"),
|
||||
sentinelId: datasetSentinelId,
|
||||
basePath: root.getAttribute("data-base-path"),
|
||||
contractVersion: root.getAttribute("data-contract-version"),
|
||||
} : {},
|
||||
sentinelBoundary,
|
||||
title: document.title,
|
||||
finalUrl: window.location.href,
|
||||
statusText: text(".topbar .pill"),
|
||||
@@ -2486,6 +2509,11 @@ const ok = !navigationError
|
||||
&& httpStatus < 300
|
||||
&& dom.shell === true
|
||||
&& dom.ready === true
|
||||
&& dom.sentinelBoundary?.datasetMatches === true
|
||||
&& dom.sentinelBoundary?.overviewMatches === true
|
||||
&& dom.sentinelBoundary?.runsPayloadMatches === true
|
||||
&& dom.sentinelBoundary?.runRowsMatch === true
|
||||
&& dom.sentinelBoundary?.routePrefixMatches === true
|
||||
&& dom.errorVisible !== true
|
||||
&& dom.trendCurve === true
|
||||
&& dom.chartCounts?.ok === true
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-28-p13-1206-multi-runner-boundaries.
|
||||
// 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";
|
||||
@@ -122,8 +123,8 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
|
||||
const config = loadWebProbeSentinelServiceConfig(options.spec, options);
|
||||
mkdirSync(config.stateRoot, { recursive: true });
|
||||
const db = new Database(config.sqlitePath);
|
||||
initializeIndex(db);
|
||||
const restored = markInterruptedRuns(db, nowIso());
|
||||
initializeIndex(db, config);
|
||||
const restored = markInterruptedRuns(config, db, nowIso());
|
||||
const schedulerEnabled = options.schedulerEnabled ?? true;
|
||||
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let schedulerHeartbeatAt = nowIso();
|
||||
@@ -178,14 +179,14 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
|
||||
configReady: config.plan.ok,
|
||||
scheduler: schedulerSummary(config, db),
|
||||
maintenance: this.maintenance(),
|
||||
runs: runCounts(db),
|
||||
runs: runCounts(config, db),
|
||||
latestRuns: this.runs(8),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
},
|
||||
runs(limit = 20) {
|
||||
return db.query("SELECT id, scenario_id, status, node, lane, observer_id, state_dir, report_json_sha256, finding_count, artifact_count, maintenance, created_at, updated_at, interrupted_at FROM runs ORDER BY created_at DESC LIMIT ?")
|
||||
.all(limit) as Record<string, unknown>[];
|
||||
return db.query("SELECT id, sentinel_id, scenario_id, status, node, lane, observer_id, state_dir, report_json_sha256, finding_count, artifact_count, maintenance, created_at, updated_at, interrupted_at FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? ORDER BY created_at DESC LIMIT ?")
|
||||
.all(config.sentinelId, config.node, config.lane, limit) as Record<string, unknown>[];
|
||||
},
|
||||
overview() {
|
||||
return dashboardOverview(config, db, this.health(), this.maintenance());
|
||||
@@ -244,8 +245,8 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
|
||||
const runId = `sentinel-run-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const commandPlan = buildObserveCommandPlan(config, scenario);
|
||||
const createdAt = nowIso();
|
||||
db.query("INSERT INTO runs (id, scenario_id, node, lane, status, maintenance, created_at, updated_at, command_plan_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(runId, scenarioId, config.node, config.lane, "planned", this.maintenance().active ? 1 : 0, createdAt, createdAt, JSON.stringify({ reason, commandPlan, valuesRedacted: true }));
|
||||
db.query("INSERT INTO runs (id, sentinel_id, scenario_id, node, lane, status, maintenance, created_at, updated_at, command_plan_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(runId, config.sentinelId, scenarioId, config.node, config.lane, "planned", this.maintenance().active ? 1 : 0, createdAt, createdAt, JSON.stringify({ reason, commandPlan, valuesRedacted: true }));
|
||||
return { ok: true, runId, scenarioId, status: "planned", commandPlanSha256: sha256Json(commandPlan), valuesRedacted: true };
|
||||
},
|
||||
recordRun(input: Record<string, unknown>) {
|
||||
@@ -291,6 +292,8 @@ export function startWebProbeSentinelHttpService(service: WebProbeSentinelServic
|
||||
|
||||
async function sentinelFetch(service: WebProbeSentinelService, request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const routeMismatch = sentinelRouteMismatch(service.config, url.pathname);
|
||||
if (routeMismatch !== null) return jsonResponse(routeMismatch, 409);
|
||||
const pathname = normalizedSentinelRequestPath(service, url.pathname);
|
||||
if (request.method === "GET" && pathname === "/api/health") {
|
||||
const health = service.health();
|
||||
@@ -359,6 +362,28 @@ function normalizedSentinelRequestPath(service: WebProbeSentinelService, pathnam
|
||||
return pathname;
|
||||
}
|
||||
|
||||
function sentinelRouteMismatch(config: WebProbeSentinelServiceConfig, pathname: string): Record<string, unknown> | null {
|
||||
const match = /^\/sentinels\/([^/]+)/u.exec(pathname);
|
||||
if (match === null) return null;
|
||||
const routeSegment = decodeURIComponent(match[1]);
|
||||
const nodePrefix = `${config.node.toLowerCase()}-`;
|
||||
const routeSentinelId = routeSegment.startsWith(nodePrefix) ? routeSegment.slice(nodePrefix.length) : routeSegment;
|
||||
if (routeSentinelId === config.sentinelId) return null;
|
||||
return {
|
||||
ok: false,
|
||||
error: "sentinel-route-mismatch",
|
||||
code: "sentinel-route-mismatch",
|
||||
blocker: true,
|
||||
routeSegment,
|
||||
routeSentinelId,
|
||||
serviceSentinelId: config.sentinelId,
|
||||
node: config.node,
|
||||
lane: config.lane,
|
||||
message: "route sentinelId does not match the selected sentinel runner; refusing cross-sentinel fallback",
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function publicExposurePath(publicExposure: Record<string, unknown>): string {
|
||||
const publicBaseUrl = stringOrNull(publicExposure.publicBaseUrl);
|
||||
if (publicBaseUrl === null) return "";
|
||||
@@ -378,7 +403,7 @@ function normalizedDashboardAssetPath(pathname: string): string {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
function initializeIndex(db: Database): void {
|
||||
function initializeIndex(db: Database, config: WebProbeSentinelServiceConfig): void {
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE IF NOT EXISTS metadata (
|
||||
@@ -388,6 +413,7 @@ function initializeIndex(db: Database): void {
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
sentinel_id TEXT NOT NULL DEFAULT '',
|
||||
scenario_id TEXT NOT NULL,
|
||||
node TEXT NOT NULL,
|
||||
lane TEXT NOT NULL,
|
||||
@@ -405,6 +431,7 @@ function initializeIndex(db: Database): void {
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS findings (
|
||||
run_id TEXT NOT NULL,
|
||||
sentinel_id TEXT NOT NULL DEFAULT '',
|
||||
finding_id TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -412,11 +439,25 @@ function initializeIndex(db: Database): void {
|
||||
report_json_sha256 TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_sentinel_updated ON runs(sentinel_id, node, lane, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_sentinel_scenario ON runs(sentinel_id, scenario_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_findings_sentinel_run ON findings(sentinel_id, run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_findings_sentinel_code ON findings(sentinel_id, finding_id, created_at DESC);
|
||||
`);
|
||||
ensureColumn(db, "runs", "sentinel_id", "TEXT");
|
||||
ensureColumn(db, "findings", "sentinel_id", "TEXT");
|
||||
db.query("UPDATE runs SET sentinel_id = ? WHERE sentinel_id IS NULL OR sentinel_id = ''").run(config.sentinelId);
|
||||
db.query("UPDATE findings SET sentinel_id = ? WHERE sentinel_id IS NULL OR sentinel_id = ''").run(config.sentinelId);
|
||||
}
|
||||
|
||||
function markInterruptedRuns(db: Database, at: string): number {
|
||||
const result = db.query("UPDATE runs SET status = 'interrupted', interrupted_at = ?, updated_at = ? WHERE status IN ('queued', 'running', 'analyzing')").run(at, at);
|
||||
function ensureColumn(db: Database, table: "runs" | "findings", column: string, definition: string): void {
|
||||
const rows = db.query(`PRAGMA table_info(${table})`).all() as Record<string, unknown>[];
|
||||
if (rows.some((row) => stringOrNull(row.name) === column)) return;
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
function markInterruptedRuns(config: WebProbeSentinelServiceConfig, db: Database, at: string): number {
|
||||
const result = db.query("UPDATE runs SET status = 'interrupted', interrupted_at = ?, updated_at = ? WHERE sentinel_id = ? AND node = ? AND lane = ? AND status IN ('queued', 'running', 'analyzing')").run(at, at, config.sentinelId, config.node, config.lane);
|
||||
return Number(result.changes ?? 0);
|
||||
}
|
||||
|
||||
@@ -563,7 +604,7 @@ function schedulerSummary(config: WebProbeSentinelServiceConfig, db: Database):
|
||||
enabledScenarios: config.scenarios.filter((item) => boolAt(item, "enabled")).map((item) => stringAt(item, "id")),
|
||||
intervalMs: config.schedulerIntervalMs,
|
||||
maxConcurrentRuns: config.maxConcurrentRuns,
|
||||
activeRuns: countWhere(db, "status IN ('queued', 'running', 'analyzing')"),
|
||||
activeRuns: countWhere(config, db, "status IN ('queued', 'running', 'analyzing')"),
|
||||
plannedRuns: planned.count,
|
||||
oldestPlannedRunId: planned.oldestRunId,
|
||||
oldestPlannedRunScenarioId: planned.oldestScenarioId,
|
||||
@@ -578,7 +619,7 @@ function schedulerSummary(config: WebProbeSentinelServiceConfig, db: Database):
|
||||
}
|
||||
|
||||
function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, health: Record<string, unknown>, maintenance: MaintenanceState): string {
|
||||
const counts = runCounts(db);
|
||||
const counts = runCounts(config, db);
|
||||
const heartbeat = record(readMetadata(db, "scheduler.heartbeat"));
|
||||
const heartbeatAt = stringOrNull(heartbeat.at);
|
||||
const heartbeatAge = heartbeatAt === null ? -1 : Math.max(0, Math.round((Date.now() - Date.parse(heartbeatAt)) / 1000));
|
||||
@@ -595,10 +636,10 @@ function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, heal
|
||||
...Object.entries(counts).map(([status, count]) => `web_probe_sentinel_runs_total{${labels},status="${metricLabel(status)}"} ${count}`),
|
||||
"# HELP web_probe_sentinel_active_runs Active observe runs known to the sentinel index.",
|
||||
"# TYPE web_probe_sentinel_active_runs gauge",
|
||||
`web_probe_sentinel_active_runs{${labels}} ${countWhere(db, "status IN ('queued', 'running', 'analyzing')")}`,
|
||||
`web_probe_sentinel_active_runs{${labels}} ${countWhere(config, db, "status IN ('queued', 'running', 'analyzing')")}`,
|
||||
"# HELP web_probe_sentinel_recent_findings Findings indexed from recent reports.",
|
||||
"# TYPE web_probe_sentinel_recent_findings gauge",
|
||||
`web_probe_sentinel_recent_findings{${labels}} ${sumColumn(db, "runs", "finding_count")}`,
|
||||
`web_probe_sentinel_recent_findings{${labels}} ${sumColumn(config, db, "runs", "finding_count")}`,
|
||||
"# HELP web_probe_sentinel_maintenance_active Maintenance window active flag.",
|
||||
"# TYPE web_probe_sentinel_maintenance_active gauge",
|
||||
`web_probe_sentinel_maintenance_active{${labels}} ${maintenance.active ? 1 : 0}`,
|
||||
@@ -607,7 +648,7 @@ function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, heal
|
||||
`web_probe_sentinel_scheduler_heartbeat_age_seconds{${labels}} ${heartbeatAge}`,
|
||||
"# HELP web_probe_sentinel_planned_runs Planned runs waiting for host cadence execution.",
|
||||
"# TYPE web_probe_sentinel_planned_runs gauge",
|
||||
`web_probe_sentinel_planned_runs{${labels}} ${countWhere(db, "status = 'planned'")}`,
|
||||
`web_probe_sentinel_planned_runs{${labels}} ${countWhere(config, db, "status = 'planned'")}`,
|
||||
"# HELP web_probe_sentinel_oldest_planned_run_age_seconds Oldest planned run age, or -1 when no planned run exists.",
|
||||
"# TYPE web_probe_sentinel_oldest_planned_run_age_seconds gauge",
|
||||
`web_probe_sentinel_oldest_planned_run_age_seconds{${labels}} ${plannedRunBacklog(config, db).oldestAgeSeconds ?? -1}`,
|
||||
@@ -624,10 +665,21 @@ function plannedRunBacklog(config: WebProbeSentinelServiceConfig, db: Database):
|
||||
readonly staleAfterSeconds: number;
|
||||
readonly stale: boolean;
|
||||
} {
|
||||
const activeWhere = "status = 'planned' AND NOT EXISTS (SELECT 1 FROM runs newer WHERE newer.scenario_id = runs.scenario_id AND newer.status <> 'planned' AND newer.created_at > runs.created_at)";
|
||||
const count = countWhere(db, activeWhere);
|
||||
const oldest = db.query(`SELECT id, scenario_id, created_at FROM runs WHERE ${activeWhere} ORDER BY created_at ASC LIMIT 1`)
|
||||
.get() as Record<string, unknown> | null;
|
||||
const activeWhere = [
|
||||
"status = 'planned'",
|
||||
"AND NOT EXISTS (",
|
||||
"SELECT 1 FROM runs newer",
|
||||
"WHERE newer.sentinel_id = runs.sentinel_id",
|
||||
"AND newer.node = runs.node",
|
||||
"AND newer.lane = runs.lane",
|
||||
"AND newer.scenario_id = runs.scenario_id",
|
||||
"AND newer.status <> 'planned'",
|
||||
"AND newer.created_at > runs.created_at",
|
||||
")",
|
||||
].join(" ");
|
||||
const count = countWhere(config, db, activeWhere);
|
||||
const oldest = db.query(`SELECT id, scenario_id, created_at FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? AND ${activeWhere} ORDER BY created_at ASC LIMIT 1`)
|
||||
.get(config.sentinelId, config.node, config.lane) as Record<string, unknown> | null;
|
||||
const oldestCreatedAt = stringOrNull(oldest?.created_at);
|
||||
const oldestAgeSeconds = oldestCreatedAt === null ? null : ageSeconds(oldestCreatedAt);
|
||||
const staleAfterSeconds = Math.max(60, Math.round(config.schedulerIntervalMs / 1000));
|
||||
@@ -665,8 +717,9 @@ function readMetadata(db: Database, key: string): Record<string, unknown> | null
|
||||
}
|
||||
}
|
||||
|
||||
function runCounts(db: Database): Record<string, number> {
|
||||
const rows = db.query("SELECT status, COUNT(*) AS count FROM runs GROUP BY status").all() as { status: string; count: number }[];
|
||||
function runCounts(config: WebProbeSentinelServiceConfig, db: Database): Record<string, number> {
|
||||
const rows = db.query("SELECT status, COUNT(*) AS count FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? GROUP BY status")
|
||||
.all(config.sentinelId, config.node, config.lane) as { status: string; count: number }[];
|
||||
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
||||
}
|
||||
|
||||
@@ -689,7 +742,7 @@ function dashboardOverview(config: WebProbeSentinelServiceConfig, db: Database,
|
||||
scheduler: schedulerSummary(config, db),
|
||||
maintenance,
|
||||
latestRun,
|
||||
runCounts: runCounts(db),
|
||||
runCounts: runCounts(config, db),
|
||||
severityCounts,
|
||||
freshness: {
|
||||
latestRunUpdatedAt: latestUpdatedAt,
|
||||
@@ -719,7 +772,7 @@ function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, u
|
||||
const filters = dashboardRunFilters(url);
|
||||
const page = dashboardPage(url, config);
|
||||
const sort = dashboardRunSort(url);
|
||||
const where = runWhereClause(filters);
|
||||
const where = runWhereClause(config, filters);
|
||||
const sql = `SELECT * FROM runs ${where.sql} ORDER BY ${sort.sql} LIMIT ? OFFSET ?`;
|
||||
const rows = db.query(sql).all(...where.params, page.limit + 1, page.offset) as Record<string, unknown>[];
|
||||
const visibleRows = rows.slice(0, page.limit);
|
||||
@@ -750,7 +803,7 @@ function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, u
|
||||
}
|
||||
|
||||
function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, unknown> {
|
||||
const row = readRunRow(db, runId);
|
||||
const row = readRunRow(config, db, runId);
|
||||
if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true };
|
||||
const stored = readMetadata(db, `run.report.${runId}`) ?? {};
|
||||
const findings = findingsForRun(config, db, runId, dashboardPageSize(config));
|
||||
@@ -783,7 +836,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
|
||||
function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database, url: URL): Record<string, unknown> {
|
||||
const limit = dashboardPage(url, config).limit;
|
||||
const filters = dashboardFindingFilters(url);
|
||||
const where = findingWhereClause(filters);
|
||||
const where = findingWhereClause(config, filters);
|
||||
const queryLimit = Math.min(dashboardMaxPageSize(config), Math.max(limit + 1, limit * 4));
|
||||
const rows = db.query(`
|
||||
SELECT f.finding_id,
|
||||
@@ -799,7 +852,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database,
|
||||
`).all(...where.params, queryLimit) as Record<string, unknown>[];
|
||||
const severityFilter = stringOrNull(filters.severity);
|
||||
const items = rows.map((row) => {
|
||||
const latestRun = latestRunForFinding(db, row);
|
||||
const latestRun = latestRunForFinding(config, db, row);
|
||||
const latestDetail = latestRun === null ? null : storedFindingDetailForRow(db, row, stringOrNull(latestRun.id));
|
||||
return enrichFindingWithCheck(config, {
|
||||
code: stringOrNull(row.finding_id),
|
||||
@@ -847,7 +900,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database,
|
||||
}
|
||||
|
||||
function dashboardRunViews(config: WebProbeSentinelServiceConfig, db: Database, runId: string, view: string | null, url: URL): Record<string, unknown> {
|
||||
const row = readRunRow(db, runId);
|
||||
const row = readRunRow(config, db, runId);
|
||||
if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true };
|
||||
const requestedViews = view === null ? stringArrayAt(config.reportViews, "views") : [view];
|
||||
const maxBytes = Math.min(numberParam(url, "maxBytes", DASHBOARD_MAX_TEXT_BYTES), 64_000);
|
||||
@@ -940,7 +993,6 @@ function dashboardOverallStatus(health: Record<string, unknown>, latestRun: Reco
|
||||
if (health.ok !== true) return "degraded";
|
||||
const latestStatus = latestRun === null ? null : stringOrNull(latestRun.status);
|
||||
if (latestStatus !== null && /blocked|failed|error|timeout/iu.test(latestStatus)) return "blocked";
|
||||
if (severityRank(maxSeverityFromCounts(severityCounts)) >= 3) return "blocked";
|
||||
if (Object.values(severityCounts).some((count) => count > 0)) return "warning";
|
||||
return latestRun === null ? "idle" : "healthy";
|
||||
}
|
||||
@@ -970,9 +1022,9 @@ function dashboardFindingFilters(url: URL): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function runWhereClause(filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
|
||||
const clauses: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
function runWhereClause(config: WebProbeSentinelServiceConfig, filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
|
||||
const clauses: string[] = ["sentinel_id = ?", "node = ?", "lane = ?"];
|
||||
const params: (string | number)[] = [config.sentinelId, config.node, config.lane];
|
||||
const status = stringOrNull(filters.status);
|
||||
if (status !== null) {
|
||||
clauses.push("status = ?");
|
||||
@@ -1016,9 +1068,9 @@ function runWhereClause(filters: Record<string, unknown>): { readonly sql: strin
|
||||
return { sql: clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`, params };
|
||||
}
|
||||
|
||||
function findingWhereClause(filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
|
||||
const clauses: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
function findingWhereClause(config: WebProbeSentinelServiceConfig, filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
|
||||
const clauses: string[] = ["r.sentinel_id = ?", "r.node = ?", "r.lane = ?", "f.sentinel_id = ?"];
|
||||
const params: (string | number)[] = [config.sentinelId, config.node, config.lane, config.sentinelId];
|
||||
const code = stringOrNull(filters.code);
|
||||
if (code !== null) {
|
||||
clauses.push("f.finding_id = ?");
|
||||
@@ -1073,13 +1125,14 @@ function dashboardMaxPageSize(config: WebProbeSentinelServiceConfig): number {
|
||||
return Math.max(1, Math.min(200, numberOr(config.reportViews.maxPageSize, 100)));
|
||||
}
|
||||
|
||||
function readRunRow(db: Database, runId: string): Record<string, unknown> | null {
|
||||
return db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record<string, unknown> | null;
|
||||
function readRunRow(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, unknown> | null {
|
||||
return db.query("SELECT * FROM runs WHERE id = ? AND sentinel_id = ? AND node = ? AND lane = ?")
|
||||
.get(runId, config.sentinelId, config.node, config.lane) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function findingsForRun(config: WebProbeSentinelServiceConfig, db: Database, runId: string, limit: number): readonly Record<string, unknown>[] {
|
||||
const rows = db.query("SELECT finding_id, severity, count, summary, report_json_sha256, created_at FROM findings WHERE run_id = ? ORDER BY created_at DESC LIMIT ?")
|
||||
.all(runId, limit) as Record<string, unknown>[];
|
||||
const rows = db.query("SELECT finding_id, severity, count, summary, report_json_sha256, created_at FROM findings WHERE run_id = ? AND sentinel_id = ? ORDER BY created_at DESC LIMIT ?")
|
||||
.all(runId, config.sentinelId, limit) as Record<string, unknown>[];
|
||||
return rows.map((row) => enrichFindingRowWithStoredDetail(config, db, runId, row));
|
||||
}
|
||||
|
||||
@@ -1259,24 +1312,24 @@ function normalizeCheckLevel(value: string | null): string | null {
|
||||
}
|
||||
|
||||
function globalSeverityCounts(config: WebProbeSentinelServiceConfig, db: Database): Record<string, number> {
|
||||
const rows = db.query("SELECT finding_id, severity, count FROM findings").all() as Record<string, unknown>[];
|
||||
const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE sentinel_id = ?").all(config.sentinelId) as Record<string, unknown>[];
|
||||
return checkLevelCounts(config, rows);
|
||||
}
|
||||
|
||||
function severityCountsForRun(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, number> {
|
||||
const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE run_id = ?").all(runId) as Record<string, unknown>[];
|
||||
const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE run_id = ? AND sentinel_id = ?").all(runId, config.sentinelId) as Record<string, unknown>[];
|
||||
return checkLevelCounts(config, rows);
|
||||
}
|
||||
|
||||
function latestRunForFinding(db: Database, row: Record<string, unknown>): Record<string, unknown> | null {
|
||||
function latestRunForFinding(config: WebProbeSentinelServiceConfig, db: Database, row: Record<string, unknown>): Record<string, unknown> | null {
|
||||
return db.query(`
|
||||
SELECT r.*
|
||||
FROM findings f
|
||||
JOIN runs r ON r.id = f.run_id
|
||||
WHERE f.finding_id = ? AND r.scenario_id = ?
|
||||
WHERE f.finding_id = ? AND r.scenario_id = ? AND r.sentinel_id = ? AND r.node = ? AND r.lane = ? AND f.sentinel_id = ?
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT 1
|
||||
`).get(stringOrNull(row.finding_id), stringOrNull(row.scenario_id)) as Record<string, unknown> | null;
|
||||
`).get(stringOrNull(row.finding_id), stringOrNull(row.scenario_id), config.sentinelId, config.node, config.lane, config.sentinelId) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function runTraceability(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>): Record<string, unknown> {
|
||||
@@ -1284,6 +1337,7 @@ function runTraceability(config: WebProbeSentinelServiceConfig, row: Record<stri
|
||||
source: "sqlite-index+run-report-metadata",
|
||||
node: stringOrNull(row.node) ?? config.node,
|
||||
lane: stringOrNull(row.lane) ?? config.lane,
|
||||
sentinelId: stringOrNull(row.sentinel_id) ?? config.sentinelId,
|
||||
runId: stringOrNull(row.id),
|
||||
observerId: stringOrNull(row.observer_id),
|
||||
stateDir: stringOrNull(row.state_dir),
|
||||
@@ -1385,13 +1439,15 @@ function severityRankSql(expression: string): string {
|
||||
return `CASE LOWER(COALESCE(${expression}, '')) WHEN 'critical' THEN 4 WHEN 'red' THEN 4 WHEN 'fatal' THEN 4 WHEN 'error' THEN 3 WHEN 'failed' THEN 3 WHEN 'blocked' THEN 3 WHEN 'warning' THEN 2 WHEN 'warn' THEN 2 WHEN 'amber' THEN 2 WHEN 'yellow' THEN 2 WHEN 'info' THEN 1 WHEN 'notice' THEN 1 ELSE 0 END`;
|
||||
}
|
||||
|
||||
function countWhere(db: Database, where: string): number {
|
||||
const row = db.query(`SELECT COUNT(*) AS count FROM runs WHERE ${where}`).get() as { count?: number } | null;
|
||||
function countWhere(config: WebProbeSentinelServiceConfig, db: Database, where: string): number {
|
||||
const row = db.query(`SELECT COUNT(*) AS count FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? AND ${where}`)
|
||||
.get(config.sentinelId, config.node, config.lane) as { count?: number } | null;
|
||||
return Number(row?.count ?? 0);
|
||||
}
|
||||
|
||||
function sumColumn(db: Database, table: string, column: string): number {
|
||||
const row = db.query(`SELECT COALESCE(SUM(${column}), 0) AS total FROM ${table}`).get() as { total?: number } | null;
|
||||
function sumColumn(config: WebProbeSentinelServiceConfig, db: Database, table: "runs", column: "finding_count"): number {
|
||||
const row = db.query(`SELECT COALESCE(SUM(${column}), 0) AS total FROM ${table} WHERE sentinel_id = ? AND node = ? AND lane = ?`)
|
||||
.get(config.sentinelId, config.node, config.lane) as { total?: number } | null;
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
@@ -1428,9 +1484,10 @@ function recordRunResult(config: WebProbeSentinelServiceConfig, db: Database, in
|
||||
const artifactCount = numberOr(input.artifactCount, 0);
|
||||
const createdAt = stringOrNull(input.createdAt) ?? now;
|
||||
db.query(`
|
||||
INSERT INTO runs (id, scenario_id, node, lane, status, observer_id, state_dir, report_json_sha256, finding_count, artifact_count, maintenance, created_at, updated_at, command_plan_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO runs (id, sentinel_id, scenario_id, node, lane, status, observer_id, state_dir, report_json_sha256, finding_count, artifact_count, maintenance, created_at, updated_at, command_plan_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
sentinel_id = excluded.sentinel_id,
|
||||
status = excluded.status,
|
||||
observer_id = excluded.observer_id,
|
||||
state_dir = excluded.state_dir,
|
||||
@@ -1438,20 +1495,21 @@ function recordRunResult(config: WebProbeSentinelServiceConfig, db: Database, in
|
||||
finding_count = excluded.finding_count,
|
||||
artifact_count = excluded.artifact_count,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(runId, scenarioId, config.node, config.lane, status, observerId, stateDir, reportJsonSha256, findingCount, artifactCount, thisMaintenanceFlag(input), createdAt, now, JSON.stringify({ source: "recorded-analyze-summary", valuesRedacted: true }));
|
||||
const supersededPlannedRuns = db.query("UPDATE runs SET status = 'superseded', updated_at = ? WHERE status = 'planned' AND scenario_id = ? AND created_at < ?")
|
||||
.run(now, scenarioId, createdAt);
|
||||
db.query("DELETE FROM findings WHERE run_id = ?").run(runId);
|
||||
`).run(runId, config.sentinelId, scenarioId, config.node, config.lane, status, observerId, stateDir, reportJsonSha256, findingCount, artifactCount, thisMaintenanceFlag(input), createdAt, now, JSON.stringify({ source: "recorded-analyze-summary", valuesRedacted: true }));
|
||||
const supersededPlannedRuns = db.query("UPDATE runs SET status = 'superseded', updated_at = ? WHERE sentinel_id = ? AND node = ? AND lane = ? AND status = 'planned' AND scenario_id = ? AND created_at < ?")
|
||||
.run(now, config.sentinelId, config.node, config.lane, scenarioId, createdAt);
|
||||
db.query("DELETE FROM findings WHERE run_id = ? AND sentinel_id = ?").run(runId, config.sentinelId);
|
||||
for (const item of findings) {
|
||||
const findingId = stringOrNull(item.id) ?? stringOrNull(item.kind) ?? stringOrNull(item.code) ?? "finding";
|
||||
const check = checkForFinding(config, { ...item, id: findingId });
|
||||
const severity = stringOrNull(check.level) ?? normalizeCheckLevel(stringOrNull(item.severity) ?? stringOrNull(item.level)) ?? "unknown";
|
||||
const summary = stringOrNull(item.summary) ?? stringOrNull(item.message) ?? findingId;
|
||||
db.query("INSERT INTO findings (run_id, finding_id, severity, count, summary, report_json_sha256, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(runId, findingId.slice(0, 160), severity.slice(0, 40), numberOr(item.count, 1), summary.slice(0, 500), reportJsonSha256, now);
|
||||
db.query("INSERT INTO findings (run_id, sentinel_id, finding_id, severity, count, summary, report_json_sha256, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(runId, config.sentinelId, findingId.slice(0, 160), severity.slice(0, 40), numberOr(item.count, 1), summary.slice(0, 500), reportJsonSha256, now);
|
||||
}
|
||||
writeMetadata(db, `run.report.${runId}`, {
|
||||
runId,
|
||||
sentinelId: config.sentinelId,
|
||||
scenarioId,
|
||||
observerId,
|
||||
stateDir,
|
||||
@@ -1473,8 +1531,8 @@ function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view
|
||||
return { ok: false, error: "unsupported-report-view", view, valuesRedacted: true };
|
||||
}
|
||||
const row = runId === null
|
||||
? db.query("SELECT * FROM runs WHERE report_json_sha256 IS NOT NULL ORDER BY updated_at DESC LIMIT 1").get() as Record<string, unknown> | null
|
||||
: db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record<string, unknown> | null;
|
||||
? db.query("SELECT * FROM runs WHERE report_json_sha256 IS NOT NULL AND sentinel_id = ? AND node = ? AND lane = ? ORDER BY updated_at DESC LIMIT 1").get(config.sentinelId, config.node, config.lane) as Record<string, unknown> | null
|
||||
: readRunRow(config, db, runId);
|
||||
if (row === null) return { ok: false, error: "report-run-missing", runId, view, valuesRedacted: true };
|
||||
const selectedRunId = stringOrNull(row.id);
|
||||
if (selectedRunId === null) return { ok: false, error: "report-run-id-missing", view, valuesRedacted: true };
|
||||
|
||||
Reference in New Issue
Block a user