feat: add web sentinel dashboard API contract
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard.
|
||||
// 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, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -8,6 +10,9 @@ import { rootPath } from "./config";
|
||||
import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./hwlab-node-web-sentinel-config";
|
||||
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
|
||||
|
||||
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p7-web-probe-sentinel-dashboard";
|
||||
const DASHBOARD_MAX_TEXT_BYTES = 16_000;
|
||||
|
||||
export interface WebProbeSentinelServiceConfig {
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
@@ -15,6 +20,8 @@ export interface WebProbeSentinelServiceConfig {
|
||||
readonly runtime: Record<string, unknown>;
|
||||
readonly scenarios: readonly Record<string, unknown>[];
|
||||
readonly reportViews: Record<string, unknown>;
|
||||
readonly publicExposure: Record<string, unknown>;
|
||||
readonly cicd: Record<string, unknown>;
|
||||
readonly stateRoot: string;
|
||||
readonly sqlitePath: string;
|
||||
readonly listenHost: string;
|
||||
@@ -58,6 +65,11 @@ export interface WebProbeSentinelService {
|
||||
health(): Record<string, unknown>;
|
||||
status(): Record<string, unknown>;
|
||||
runs(limit?: number): readonly Record<string, unknown>[];
|
||||
overview(): Record<string, unknown>;
|
||||
dashboardRuns(url: URL): Record<string, unknown>;
|
||||
runDetail(runId: string): Record<string, unknown>;
|
||||
findings(url: URL): Record<string, unknown>;
|
||||
runViews(runId: string, view: string | null, url: URL): Record<string, unknown>;
|
||||
maintenance(): MaintenanceState;
|
||||
setMaintenance(active: boolean, input: Record<string, unknown>): MaintenanceState;
|
||||
planScenarioRun(scenarioId: string, reason: string): Record<string, unknown>;
|
||||
@@ -75,6 +87,8 @@ export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, op
|
||||
const runtime = recordTarget(readConfigRefTarget(sentinel.configRefs.runtime));
|
||||
const scenarios = arrayTarget(readConfigRefTarget(sentinel.configRefs.scenarios));
|
||||
const reportViews = recordTarget(readConfigRefTarget(sentinel.configRefs.reportViews));
|
||||
const publicExposure = recordTarget(readConfigRefTarget(sentinel.configRefs.publicExposure));
|
||||
const cicd = recordTarget(readConfigRefTarget(sentinel.configRefs.cicd));
|
||||
const stateRoot = options.stateRootOverride ?? stringAt(runtime, "stateRoot");
|
||||
const yamlSqlitePath = stringAt(runtime, "sqlite.path");
|
||||
return {
|
||||
@@ -84,6 +98,8 @@ export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, op
|
||||
runtime,
|
||||
scenarios,
|
||||
reportViews,
|
||||
publicExposure,
|
||||
cicd,
|
||||
stateRoot,
|
||||
sqlitePath: options.stateRootOverride === undefined ? yamlSqlitePath : join(stateRoot, "index.sqlite"),
|
||||
listenHost: options.hostOverride ?? stringAt(runtime, "listenHost"),
|
||||
@@ -162,6 +178,21 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
|
||||
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>[];
|
||||
},
|
||||
overview() {
|
||||
return dashboardOverview(config, db, this.health(), this.maintenance());
|
||||
},
|
||||
dashboardRuns(url: URL) {
|
||||
return dashboardRunList(config, db, url);
|
||||
},
|
||||
runDetail(runId: string) {
|
||||
return dashboardRunDetail(config, db, runId);
|
||||
},
|
||||
findings(url: URL) {
|
||||
return dashboardFindings(config, db, url);
|
||||
},
|
||||
runViews(runId: string, view: string | null, url: URL) {
|
||||
return dashboardRunViews(config, db, runId, view, url);
|
||||
},
|
||||
maintenance() {
|
||||
return readMetadata(db, "maintenance") as MaintenanceState | null ?? emptyMaintenance();
|
||||
},
|
||||
@@ -244,7 +275,20 @@ async function sentinelFetch(service: WebProbeSentinelService, request: Request)
|
||||
const url = new URL(request.url);
|
||||
if (request.method === "GET" && url.pathname === "/api/health") return jsonResponse(service.health(), service.health().ok === true ? 200 : 503);
|
||||
if (request.method === "GET" && url.pathname === "/api/status") return jsonResponse(service.status());
|
||||
if (request.method === "GET" && url.pathname === "/api/runs") return jsonResponse({ ok: true, runs: service.runs(numberParam(url, "limit", 20)), valuesRedacted: true });
|
||||
if (request.method === "GET" && url.pathname === "/api/overview") return jsonResponse(service.overview());
|
||||
if (request.method === "GET" && url.pathname === "/api/runs") return jsonResponse(service.dashboardRuns(url));
|
||||
if (request.method === "GET" && url.pathname === "/api/findings") return jsonResponse(service.findings(url));
|
||||
const runViewsMatch = /^\/api\/runs\/([^/]+)\/views$/u.exec(url.pathname);
|
||||
if (request.method === "GET" && runViewsMatch !== null) {
|
||||
const view = url.searchParams.get("view");
|
||||
const result = service.runViews(decodeURIComponent(runViewsMatch[1]), view, url);
|
||||
return jsonResponse(result, result.ok === false ? 404 : 200);
|
||||
}
|
||||
const runDetailMatch = /^\/api\/runs\/([^/]+)$/u.exec(url.pathname);
|
||||
if (request.method === "GET" && runDetailMatch !== null) {
|
||||
const result = service.runDetail(decodeURIComponent(runDetailMatch[1]));
|
||||
return jsonResponse(result, result.ok === false ? 404 : 200);
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/maintenance") return jsonResponse({ ok: true, maintenance: service.maintenance(), valuesRedacted: true });
|
||||
if (request.method === "POST" && (url.pathname === "/api/maintenance/start" || url.pathname === "/api/maintenance/stop")) {
|
||||
const body = await readJsonBody(request);
|
||||
@@ -478,6 +522,512 @@ function runCounts(db: Database): Record<string, number> {
|
||||
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
||||
}
|
||||
|
||||
function dashboardOverview(config: WebProbeSentinelServiceConfig, db: Database, health: Record<string, unknown>, maintenance: MaintenanceState): Record<string, unknown> {
|
||||
const latestRow = db.query("SELECT * FROM runs ORDER BY updated_at DESC LIMIT 1").get() as Record<string, unknown> | null;
|
||||
const latestRun = latestRow === null ? null : dashboardRunSummary(config, db, latestRow);
|
||||
const severityCounts = globalSeverityCounts(db);
|
||||
const latestUpdatedAt = latestRow === null ? null : stringOrNull(latestRow.updated_at);
|
||||
const latestRunAgeSeconds = latestUpdatedAt === null ? null : ageSeconds(latestUpdatedAt);
|
||||
return {
|
||||
ok: health.ok === true,
|
||||
contractVersion: DASHBOARD_CONTRACT_VERSION,
|
||||
status: dashboardOverallStatus(health, latestRun, severityCounts),
|
||||
node: config.node,
|
||||
lane: config.lane,
|
||||
publicOrigin: stringOrNull(config.publicExposure.publicBaseUrl),
|
||||
configReady: config.plan.ok,
|
||||
health,
|
||||
scheduler: schedulerSummary(config, db),
|
||||
maintenance,
|
||||
latestRun,
|
||||
runCounts: runCounts(db),
|
||||
severityCounts,
|
||||
freshness: {
|
||||
latestRunUpdatedAt: latestUpdatedAt,
|
||||
latestRunAgeSeconds,
|
||||
schedulerHeartbeatAgeSeconds: numberOr(record(record(health.checks).scheduler).heartbeatAgeSeconds, -1),
|
||||
},
|
||||
targetValidation: {
|
||||
scenarioId: stringOrNull(record(config.cicd.targetValidation).scenarioId),
|
||||
maxSeconds: numberOr(record(config.cicd.targetValidation).maxSeconds, 120),
|
||||
sourceRef: "config/hwlab-web-probe-sentinel/cicd.d601-v03.yaml#sentinel.cicd.targetValidation",
|
||||
},
|
||||
traceability: {
|
||||
source: "sqlite-index+run-report-metadata",
|
||||
stateRoot: config.stateRoot,
|
||||
latestRun: latestRow === null ? null : runTraceability(config, latestRow),
|
||||
},
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, url: URL): Record<string, unknown> {
|
||||
const filters = dashboardRunFilters(url);
|
||||
const page = dashboardPage(url, config);
|
||||
const sort = dashboardRunSort(url);
|
||||
const where = runWhereClause(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);
|
||||
const items = visibleRows.map((row) => dashboardRunSummary(config, db, row));
|
||||
return {
|
||||
ok: true,
|
||||
contractVersion: DASHBOARD_CONTRACT_VERSION,
|
||||
node: config.node,
|
||||
lane: config.lane,
|
||||
filters,
|
||||
page: {
|
||||
limit: page.limit,
|
||||
cursor: String(page.offset),
|
||||
nextCursor: rows.length > page.limit ? String(page.offset + visibleRows.length) : null,
|
||||
hasMore: rows.length > page.limit,
|
||||
sort: sort.name,
|
||||
direction: sort.direction,
|
||||
},
|
||||
items,
|
||||
runs: items,
|
||||
traceability: {
|
||||
source: "sqlite-index",
|
||||
stateRoot: config.stateRoot,
|
||||
},
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, unknown> {
|
||||
const row = readRunRow(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(db, runId, dashboardPageSize(config));
|
||||
const views = record(stored.views);
|
||||
return {
|
||||
ok: true,
|
||||
contractVersion: DASHBOARD_CONTRACT_VERSION,
|
||||
run: dashboardRunSummary(config, db, row),
|
||||
summary: record(stored.summary),
|
||||
findings,
|
||||
viewsAvailable: Object.keys(views),
|
||||
artifacts: {
|
||||
artifactCount: numberOr(row.artifact_count, 0),
|
||||
reportJsonSha256: stringOrNull(row.report_json_sha256),
|
||||
screenshot: compactArtifactRef(stored.screenshot),
|
||||
publicOrigin: stringOrNull(stored.publicOrigin),
|
||||
},
|
||||
commands: {
|
||||
summary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view summary`,
|
||||
turnSummary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view turn-summary`,
|
||||
traceFrame: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view trace-frame`,
|
||||
},
|
||||
redaction: record(config.reportViews.redaction),
|
||||
traceability: runTraceability(config, row),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
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 rows = db.query(`
|
||||
SELECT f.finding_id, f.severity, r.scenario_id, SUM(f.count) AS count, COUNT(DISTINCT f.run_id) AS run_count,
|
||||
MAX(f.created_at) AS latest_at, MAX(f.summary) AS summary
|
||||
FROM findings f
|
||||
JOIN runs r ON r.id = f.run_id
|
||||
${where.sql}
|
||||
GROUP BY f.finding_id, f.severity, r.scenario_id
|
||||
ORDER BY ${severityRankSql("f.severity")} DESC, latest_at DESC
|
||||
LIMIT ?
|
||||
`).all(...where.params, limit) as Record<string, unknown>[];
|
||||
const items = rows.map((row) => {
|
||||
const latestRun = latestRunForFinding(db, row);
|
||||
return {
|
||||
code: stringOrNull(row.finding_id),
|
||||
findingId: stringOrNull(row.finding_id),
|
||||
severity: stringOrNull(row.severity),
|
||||
scenarioId: stringOrNull(row.scenario_id),
|
||||
count: numberOr(row.count, 0),
|
||||
runCount: numberOr(row.run_count, 0),
|
||||
latestAt: stringOrNull(row.latest_at),
|
||||
latestRunId: latestRun === null ? null : stringOrNull(latestRun.id),
|
||||
latestReportJsonSha256: latestRun === null ? null : stringOrNull(latestRun.report_json_sha256),
|
||||
summary: stringOrNull(row.summary),
|
||||
traceability: latestRun === null ? null : runTraceability(config, latestRun),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
contractVersion: DASHBOARD_CONTRACT_VERSION,
|
||||
node: config.node,
|
||||
lane: config.lane,
|
||||
filters,
|
||||
page: {
|
||||
limit,
|
||||
hasMore: items.length === limit,
|
||||
sort: "severity",
|
||||
direction: "desc",
|
||||
},
|
||||
groups: items,
|
||||
findings: items,
|
||||
traceability: {
|
||||
source: "sqlite-index-findings",
|
||||
stateRoot: config.stateRoot,
|
||||
},
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardRunViews(config: WebProbeSentinelServiceConfig, db: Database, runId: string, view: string | null, url: URL): Record<string, unknown> {
|
||||
const row = readRunRow(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);
|
||||
const views = requestedViews.map((item) => dashboardReportView(config, db, runId, item, maxBytes));
|
||||
return {
|
||||
ok: true,
|
||||
contractVersion: DASHBOARD_CONTRACT_VERSION,
|
||||
run: dashboardRunSummary(config, db, row),
|
||||
views,
|
||||
view: view ?? null,
|
||||
redaction: record(config.reportViews.redaction),
|
||||
traceability: runTraceability(config, row),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardReportView(config: WebProbeSentinelServiceConfig, db: Database, runId: string, view: string, maxBytes: number): Record<string, unknown> {
|
||||
const report = reportRunView(config, db, view, runId);
|
||||
const renderedText = typeof report.renderedText === "string" ? report.renderedText : "";
|
||||
const bounded = boundedText(renderedText, maxBytes);
|
||||
return {
|
||||
ok: report.ok !== false,
|
||||
view,
|
||||
error: stringOrNull(report.error),
|
||||
availableViews: Array.isArray(report.availableViews) ? report.availableViews : undefined,
|
||||
renderedText: bounded.text,
|
||||
renderedTextBytes: bounded.bytes,
|
||||
truncated: bounded.truncated,
|
||||
finalResponse: view === "trace-frame" ? finalResponseBlock(bounded.text) : null,
|
||||
summary: record(report.summary),
|
||||
findingCount: Array.isArray(report.findings) ? report.findings.length : 0,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database, row: Record<string, unknown>): Record<string, unknown> {
|
||||
const id = stringOrNull(row.id);
|
||||
const severityCounts = id === null ? {} : severityCountsForRun(db, id);
|
||||
const maxSeverity = maxSeverityFromCounts(severityCounts);
|
||||
return {
|
||||
id,
|
||||
runId: id,
|
||||
scenario_id: stringOrNull(row.scenario_id),
|
||||
scenarioId: stringOrNull(row.scenario_id),
|
||||
status: stringOrNull(row.status),
|
||||
node: stringOrNull(row.node) ?? config.node,
|
||||
lane: stringOrNull(row.lane) ?? config.lane,
|
||||
observer_id: stringOrNull(row.observer_id),
|
||||
observerId: stringOrNull(row.observer_id),
|
||||
state_dir: stringOrNull(row.state_dir),
|
||||
stateDir: stringOrNull(row.state_dir),
|
||||
report_json_sha256: stringOrNull(row.report_json_sha256),
|
||||
reportJsonSha256: stringOrNull(row.report_json_sha256),
|
||||
finding_count: numberOr(row.finding_count, 0),
|
||||
findingCount: numberOr(row.finding_count, 0),
|
||||
artifact_count: numberOr(row.artifact_count, 0),
|
||||
artifactCount: numberOr(row.artifact_count, 0),
|
||||
maintenance: numberOr(row.maintenance, 0) === 1,
|
||||
created_at: stringOrNull(row.created_at),
|
||||
createdAt: stringOrNull(row.created_at),
|
||||
updated_at: stringOrNull(row.updated_at),
|
||||
updatedAt: stringOrNull(row.updated_at),
|
||||
interrupted_at: stringOrNull(row.interrupted_at),
|
||||
interruptedAt: stringOrNull(row.interrupted_at),
|
||||
severityCounts,
|
||||
maxSeverity,
|
||||
traceability: runTraceability(config, row),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardOverallStatus(health: Record<string, unknown>, latestRun: Record<string, unknown> | null, severityCounts: Record<string, number>): string {
|
||||
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";
|
||||
}
|
||||
|
||||
function dashboardRunFilters(url: URL): Record<string, unknown> {
|
||||
return {
|
||||
status: optionalSearchParam(url, "status"),
|
||||
scenario: optionalSearchParam(url, "scenario"),
|
||||
severity: optionalSearchParam(url, "severity"),
|
||||
maintenance: maintenanceFilter(url),
|
||||
observerId: optionalSearchParam(url, "observerId"),
|
||||
search: optionalSearchParam(url, "search"),
|
||||
from: validIsoParam(url, "from"),
|
||||
to: validIsoParam(url, "to"),
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardFindingFilters(url: URL): Record<string, unknown> {
|
||||
return {
|
||||
severity: optionalSearchParam(url, "severity"),
|
||||
code: optionalSearchParam(url, "code"),
|
||||
scenario: optionalSearchParam(url, "scenario"),
|
||||
search: optionalSearchParam(url, "search"),
|
||||
window: optionalSearchParam(url, "window"),
|
||||
since: windowSinceIso(optionalSearchParam(url, "window")) ?? validIsoParam(url, "from"),
|
||||
to: validIsoParam(url, "to"),
|
||||
};
|
||||
}
|
||||
|
||||
function runWhereClause(filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
|
||||
const clauses: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
const status = stringOrNull(filters.status);
|
||||
if (status !== null) {
|
||||
clauses.push("status = ?");
|
||||
params.push(status);
|
||||
}
|
||||
const scenario = stringOrNull(filters.scenario);
|
||||
if (scenario !== null) {
|
||||
clauses.push("scenario_id = ?");
|
||||
params.push(scenario);
|
||||
}
|
||||
const observerId = stringOrNull(filters.observerId);
|
||||
if (observerId !== null) {
|
||||
clauses.push("observer_id = ?");
|
||||
params.push(observerId);
|
||||
}
|
||||
const severity = stringOrNull(filters.severity);
|
||||
if (severity !== null) {
|
||||
clauses.push("EXISTS (SELECT 1 FROM findings f WHERE f.run_id = runs.id AND f.severity = ?)");
|
||||
params.push(severity);
|
||||
}
|
||||
if (typeof filters.maintenance === "boolean") {
|
||||
clauses.push("maintenance = ?");
|
||||
params.push(filters.maintenance ? 1 : 0);
|
||||
}
|
||||
const from = stringOrNull(filters.from);
|
||||
if (from !== null) {
|
||||
clauses.push("updated_at >= ?");
|
||||
params.push(from);
|
||||
}
|
||||
const to = stringOrNull(filters.to);
|
||||
if (to !== null) {
|
||||
clauses.push("updated_at <= ?");
|
||||
params.push(to);
|
||||
}
|
||||
const search = stringOrNull(filters.search);
|
||||
if (search !== null) {
|
||||
const pattern = `%${escapeSqlLike(search)}%`;
|
||||
clauses.push("(id LIKE ? ESCAPE '\\' OR scenario_id LIKE ? ESCAPE '\\' OR observer_id LIKE ? ESCAPE '\\' OR state_dir LIKE ? ESCAPE '\\' OR report_json_sha256 LIKE ? ESCAPE '\\')");
|
||||
params.push(pattern, pattern, pattern, pattern, pattern);
|
||||
}
|
||||
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)[] = [];
|
||||
const severity = stringOrNull(filters.severity);
|
||||
if (severity !== null) {
|
||||
clauses.push("f.severity = ?");
|
||||
params.push(severity);
|
||||
}
|
||||
const code = stringOrNull(filters.code);
|
||||
if (code !== null) {
|
||||
clauses.push("f.finding_id = ?");
|
||||
params.push(code);
|
||||
}
|
||||
const scenario = stringOrNull(filters.scenario);
|
||||
if (scenario !== null) {
|
||||
clauses.push("r.scenario_id = ?");
|
||||
params.push(scenario);
|
||||
}
|
||||
const since = stringOrNull(filters.since);
|
||||
if (since !== null) {
|
||||
clauses.push("f.created_at >= ?");
|
||||
params.push(since);
|
||||
}
|
||||
const to = stringOrNull(filters.to);
|
||||
if (to !== null) {
|
||||
clauses.push("f.created_at <= ?");
|
||||
params.push(to);
|
||||
}
|
||||
const search = stringOrNull(filters.search);
|
||||
if (search !== null) {
|
||||
const pattern = `%${escapeSqlLike(search)}%`;
|
||||
clauses.push("(f.finding_id LIKE ? ESCAPE '\\' OR f.summary LIKE ? ESCAPE '\\')");
|
||||
params.push(pattern, pattern);
|
||||
}
|
||||
return { sql: clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`, params };
|
||||
}
|
||||
|
||||
function dashboardRunSort(url: URL): { readonly name: string; readonly direction: "asc" | "desc"; readonly sql: string } {
|
||||
const requested = optionalSearchParam(url, "sort") ?? "updated";
|
||||
const direction = optionalSearchParam(url, "direction") === "asc" ? "asc" : "desc";
|
||||
const column = requested === "created" ? "created_at"
|
||||
: requested === "findings" ? "finding_count"
|
||||
: requested === "severity" ? severityRankSql("(SELECT severity FROM findings f WHERE f.run_id = runs.id ORDER BY " + severityRankSql("f.severity") + " DESC LIMIT 1)")
|
||||
: "updated_at";
|
||||
return { name: requested, direction, sql: `${column} ${direction.toUpperCase()}, id ${direction.toUpperCase()}` };
|
||||
}
|
||||
|
||||
function dashboardPage(url: URL, config: WebProbeSentinelServiceConfig): { readonly limit: number; readonly offset: number } {
|
||||
const limit = Math.min(numberParam(url, "limit", dashboardPageSize(config)), dashboardMaxPageSize(config));
|
||||
const rawCursor = url.searchParams.get("cursor");
|
||||
const offset = rawCursor === null ? 0 : Math.max(0, Math.min(100_000, Number.isInteger(Number(rawCursor)) ? Number(rawCursor) : 0));
|
||||
return { limit, offset };
|
||||
}
|
||||
|
||||
function dashboardPageSize(config: WebProbeSentinelServiceConfig): number {
|
||||
return Math.max(1, Math.min(dashboardMaxPageSize(config), numberOr(config.reportViews.pageSize, 20)));
|
||||
}
|
||||
|
||||
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 findingsForRun(db: Database, runId: string, limit: number): readonly Record<string, unknown>[] {
|
||||
return 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>[];
|
||||
}
|
||||
|
||||
function globalSeverityCounts(db: Database): Record<string, number> {
|
||||
const rows = db.query("SELECT severity, SUM(count) AS count FROM findings GROUP BY severity").all() as { severity: string; count: number }[];
|
||||
return Object.fromEntries(rows.map((row) => [row.severity, Number(row.count)]));
|
||||
}
|
||||
|
||||
function severityCountsForRun(db: Database, runId: string): Record<string, number> {
|
||||
const rows = db.query("SELECT severity, SUM(count) AS count FROM findings WHERE run_id = ? GROUP BY severity").all(runId) as { severity: string; count: number }[];
|
||||
return Object.fromEntries(rows.map((row) => [row.severity, Number(row.count)]));
|
||||
}
|
||||
|
||||
function latestRunForFinding(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 f.severity = ? AND r.scenario_id = ?
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT 1
|
||||
`).get(stringOrNull(row.finding_id), stringOrNull(row.severity), stringOrNull(row.scenario_id)) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function runTraceability(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
source: "sqlite-index+run-report-metadata",
|
||||
node: stringOrNull(row.node) ?? config.node,
|
||||
lane: stringOrNull(row.lane) ?? config.lane,
|
||||
runId: stringOrNull(row.id),
|
||||
observerId: stringOrNull(row.observer_id),
|
||||
stateDir: stringOrNull(row.state_dir),
|
||||
reportJsonSha256: stringOrNull(row.report_json_sha256),
|
||||
stateRoot: config.stateRoot,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function compactArtifactRef(value: unknown): Record<string, unknown> | null {
|
||||
const source = record(value);
|
||||
if (Object.keys(source).length === 0) return null;
|
||||
return {
|
||||
path: stringOrNull(source.path) ?? stringOrNull(source.file) ?? stringOrNull(source.screenshotPath),
|
||||
sha256: stringOrNull(source.sha256) ?? stringOrNull(source.hash),
|
||||
byteCount: typeof source.byteCount === "number" ? source.byteCount : null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function boundedText(value: string, maxBytes: number): { readonly text: string; readonly bytes: number; readonly truncated: boolean } {
|
||||
const bytes = Buffer.byteLength(value, "utf8");
|
||||
if (bytes <= maxBytes) return { text: value, bytes, truncated: false };
|
||||
return { text: Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8"), bytes, truncated: true };
|
||||
}
|
||||
|
||||
function finalResponseBlock(text: string): Record<string, unknown> {
|
||||
const index = text.toLowerCase().lastIndexOf("final response");
|
||||
const body = index === -1 ? "" : text.slice(index + "final response".length).replace(/^[\s=\-:]+/u, "").trim();
|
||||
const normalized = body.length === 0 ? "(空内容)" : body;
|
||||
return {
|
||||
label: "Final Response",
|
||||
empty: body.length === 0 || body === "(空内容)",
|
||||
text: normalized,
|
||||
byteCount: Buffer.byteLength(normalized, "utf8"),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function optionalSearchParam(url: URL, key: string): string | null {
|
||||
const raw = url.searchParams.get(key);
|
||||
if (raw === null) return null;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.length === 0 ? null : trimmed.slice(0, 200);
|
||||
}
|
||||
|
||||
function validIsoParam(url: URL, key: string): string | null {
|
||||
const value = optionalSearchParam(url, key);
|
||||
if (value === null) return null;
|
||||
return Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
|
||||
}
|
||||
|
||||
function maintenanceFilter(url: URL): boolean | null {
|
||||
const raw = optionalSearchParam(url, "maintenance");
|
||||
if (raw === null) return null;
|
||||
if (/^(1|true|yes)$/iu.test(raw)) return true;
|
||||
if (/^(0|false|no)$/iu.test(raw)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function windowSinceIso(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
const match = /^(\d+)(m|h|d)$/iu.exec(value);
|
||||
if (match === null) return null;
|
||||
const amount = Number(match[1]);
|
||||
const unit = match[2].toLowerCase();
|
||||
const ms = unit === "m" ? amount * 60_000 : unit === "h" ? amount * 3_600_000 : amount * 86_400_000;
|
||||
return new Date(Date.now() - ms).toISOString();
|
||||
}
|
||||
|
||||
function escapeSqlLike(value: string): string {
|
||||
return value.replace(/[\\%_]/gu, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
function ageSeconds(iso: string): number | null {
|
||||
const parsed = Date.parse(iso);
|
||||
return Number.isFinite(parsed) ? Math.max(0, Math.round((Date.now() - parsed) / 1000)) : null;
|
||||
}
|
||||
|
||||
function maxSeverityFromCounts(counts: Record<string, number>): string | null {
|
||||
let best: string | null = null;
|
||||
for (const [severity, count] of Object.entries(counts)) {
|
||||
if (count <= 0) continue;
|
||||
if (best === null || severityRank(severity) > severityRank(best)) best = severity;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function severityRank(value: string | null): number {
|
||||
const normalized = (value ?? "").toLowerCase();
|
||||
if (/critical|red|fatal/iu.test(normalized)) return 4;
|
||||
if (/error|failed|blocked/iu.test(normalized)) return 3;
|
||||
if (/warn|amber|yellow/iu.test(normalized)) return 2;
|
||||
if (/info|notice/iu.test(normalized)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
return Number(row?.count ?? 0);
|
||||
|
||||
Reference in New Issue
Block a user