diff --git a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css index 2f7db17d..1b667f49 100644 --- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css +++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css @@ -401,7 +401,7 @@ select { position: absolute; z-index: 8; display: grid; - width: min(218px, calc(100% - 16px)); + width: min(286px, calc(100% - 16px)); max-width: calc(100% - 16px); gap: 3px; transform: translate(-50%, -100%); diff --git a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js index 37228fa1..0a8569a0 100644 --- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js +++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js @@ -154,6 +154,8 @@ createApp({ const redY = trendY(red); const warningY = trendY(warning); const rawTime = run.updatedAt || run.createdAt || ""; + const durationText = runDurationText(run); + const configTimingText = runTimingConfigText(run); return { id: run.id || String(index), runId: run.id || "", @@ -170,8 +172,10 @@ createApp({ rawTime, timeLabel: formatDate(rawTime), absoluteTime: formatAbsoluteDate(rawTime), + durationText, + configTimingText, reportSha: shortHash(run.reportJsonSha256 || run.report_json_sha256 || run.reportSha256 || ""), - title: `${shortId(run.id)} ${formatAbsoluteDate(rawTime)} 错误 ${red} 告警 ${warning} 合计 ${total}`, + title: `${shortId(run.id)} ${formatAbsoluteDate(rawTime)} 错误 ${red} 告警 ${warning} 合计 ${total} 运行 ${durationText} ${configTimingText}`, }; })); const timelineRuns = computed(() => runs.value.slice(0, 16)); @@ -463,6 +467,8 @@ createApp({ trendTotalCount, trendErrorCount, trendWarningCount, + runDurationText, + runTimingConfigText, severityClass, formatDate, formatAbsoluteDate, @@ -578,6 +584,8 @@ createApp({ {{ shortId(hoveredTrendDot.runId) }} {{ hoveredTrendDot.absoluteTime }} 状态 {{ hoveredTrendDot.status }} + 运行 {{ hoveredTrendDot.durationText }} + 配置 {{ hoveredTrendDot.configTimingText }} 错误 {{ hoveredTrendDot.red }} / 告警 {{ hoveredTrendDot.warning }} / 合计 {{ hoveredTrendDot.total }} report {{ hoveredTrendDot.reportSha }} @@ -615,6 +623,7 @@ createApp({ 错误 {{ runCheckErrorCount(run) }} 告警 {{ runCheckWarningCount(run) }} 正常 + 运行 {{ runDurationText(run) }}
暂无时间线记录
@@ -687,6 +696,7 @@ createApp({ {{ run.status || "-" }} + 运行 {{ runDurationText(run) }} {{ formatDate(run.updatedAt || run.createdAt) }} @@ -707,6 +717,8 @@ createApp({

摘要

状态{{ selectedRun.status || "-" }}
+
运行分钟{{ runDurationText(selectedRun) }}
+
配置周期{{ runTimingConfigText(selectedRun) }}
监测项类型{{ findingCount(selectedRun) }}
错误/告警样本{{ alertSampleCount(selectedRun) }}
全部样本{{ findingSampleCount(selectedRun) }}
@@ -1035,6 +1047,35 @@ function formatDuration(seconds) { return `${Math.round(value / 86400)}d`; } +function formatMinutes(value) { + const numberValue = optionalNumber(value); + if (numberValue === null) return "-"; + const rounded = Math.round(numberValue * 100) / 100; + return `${Number.isInteger(rounded) ? String(rounded) : String(rounded).replace(/0+$/u, "").replace(/\.$/u, "")} 分钟`; +} + +function runDurationText(run) { + const timing = run?.timing || {}; + return formatMinutes(optionalNumber(run?.runDurationMinutes, run?.durationMinutes, timing.runDurationMinutes, timing.durationMinutes)); +} + +function runTimingConfigText(run) { + const timing = run?.timing || {}; + const cadence = optionalNumber(run?.scenarioCadenceMinutes, timing.scenarioCadenceMinutes); + const maxRun = optionalNumber(run?.scenarioMaxRunMinutes, timing.scenarioMaxRunMinutes); + const scheduler = optionalNumber(run?.schedulerIntervalMinutes, timing.schedulerIntervalMinutes); + const parts = []; + if (cadence !== null) parts.push(`场景周期 ${formatMinutes(cadence)}`); + if (maxRun !== null) parts.push(`上限 ${formatMinutes(maxRun)}`); + if (scheduler !== null) parts.push(`调度 ${formatMinutes(scheduler)}`); + return parts.length > 0 ? parts.join(" / ") : "-"; +} + +function runTimingSourceText(run) { + const timing = run?.timing || {}; + return safeDetailValue(run?.durationSource || timing.durationSource || timing.sourceOfTruth); +} + function timeWindowLabel(value) { if (value === "1h") return "最近 1 小时"; if (value === "24h") return "最近 24 小时"; @@ -1248,6 +1289,9 @@ function detailSummaryRows(detail) { { key: "run", label: "运行", value: shortId(run.id || run.runId || detail.runId) }, { key: "scenario", label: "场景", value: run.scenarioId || run.scenario_id || "-" }, { key: "status", label: "状态", value: run.status || "-" }, + { key: "duration", label: "运行分钟", value: runDurationText(run) }, + { key: "timing", label: "周期/上限", value: runTimingConfigText(run) }, + { key: "durationSource", label: "计时来源", value: runTimingSourceText(run) }, { key: "checks", label: "监测项类型", value: String(findingCount(run)) }, { key: "alertSamples", label: "错误/告警样本", value: String(alertSampleCount(run)) }, { key: "allSamples", label: "全部样本", value: String(findingSampleCount(run)) }, @@ -1282,3 +1326,12 @@ function number(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } + +function optionalNumber(...values) { + for (const value of values) { + if (value === null || value === undefined || value === "") continue; + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} diff --git a/scripts/src/hwlab-node-web-sentinel-p5.ts b/scripts/src/hwlab-node-web-sentinel-p5.ts index d6e8a4a5..b5a48b96 100644 --- a/scripts/src/hwlab-node-web-sentinel-p5.ts +++ b/scripts/src/hwlab-node-web-sentinel-p5.ts @@ -619,6 +619,7 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId const latestRunCounts = { runId: latestRun?.id || latestRun?.runId || null, typeCount: numberValue(latestRun?.findingTypeCount ?? latestRun?.findingCount ?? latestRun?.finding_count), + durationMinutes: numberValue(latestRun?.runDurationMinutes ?? latestRun?.durationMinutes ?? latestRun?.timing?.durationMinutes), error: 0, warning: 0, total: 0, @@ -661,6 +662,15 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId latestRunCounts.warning = latestDetailSummary.warningTypeCount; latestRunCounts.total = latestDetailSummary.alertTypeCount; latestRunCounts.all = latestDetailSummary.typeCount; + const trendTooltipSummary = tooltipSummary(trendTooltip); + const detailText = text(".pane-detail"); + const runListText = text(".run-list"); + const timingVisibility = { + latestRunMinutes: latestRunCounts.durationMinutes, + detailHasDuration: /运行分钟\s+[\d.]+\s*分钟/u.test(detailText), + listHasDuration: /运行\s+[\d.]+\s*分钟/u.test(runListText), + tooltipHasDuration: trendTooltipSummary.hasDuration, + }; const workspaceRect = workspace?.getBoundingClientRect(); const checksRect = checksPanel?.getBoundingClientRect(); const heightSummary = (rect) => { @@ -851,11 +861,12 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId badCardBodyCount: badCardBodies.length, trendCurve: Boolean(trend), trendDotCount: document.querySelectorAll(".trend-dot-hit").length, - trendTooltip: tooltipSummary(trendTooltip), + trendTooltip: trendTooltipSummary, trendPanelText: text("#trend-heading"), chartCounts, latestRunCounts, latestDetailSummary, + timingVisibility, checkScope, selectedRunTags, trendPanelCompact, @@ -896,6 +907,7 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId text: body.slice(0, 240), hasValues: /错误\s+\d+/u.test(body) && /告警\s+\d+/u.test(body) && /合计\s+\d+/u.test(body), hasTime: /UTC/u.test(body) || /\d{4}-\d{2}-\d{2}/u.test(body), + hasDuration: /运行\s+[\d.]+\s*分钟/u.test(body), }; } @@ -1059,7 +1071,9 @@ const ok = !navigationError && dom.checkScope?.fullWidth === true && dom.scopeLabels?.latestPointLegend === true && dom.scopeLabels?.historicalSamples === true - && (dom.trendDotCount === 0 || (dom.trendTooltip?.visible === true && dom.trendTooltip?.hasValues === true && dom.trendTooltip?.hasTime === true)) + && (dom.trendDotCount === 0 || (dom.trendTooltip?.visible === true && dom.trendTooltip?.hasValues === true && dom.trendTooltip?.hasTime === true && dom.trendTooltip?.hasDuration === true)) + && dom.timingVisibility?.detailHasDuration === true + && dom.timingVisibility?.listHasDuration === true && dom.trendPanelCompact?.ok === true && (dom.checkScope?.expectsAlertRows !== true || (dom.checkRows > 0 && dom.checkDialog?.opened === true && dom.checkDialog?.large === true)) && dom.badCardTitleCount === 0 @@ -1515,6 +1529,7 @@ function renderDashboardResult(result: Record): string { const chartCounts = record(dom.chartCounts); const latestRunCounts = record(dom.latestRunCounts); const latestDetailSummary = record(dom.latestDetailSummary); + const timingVisibility = record(dom.timingVisibility); const checkScope = record(dom.checkScope); const selectedRunTags = record(dom.selectedRunTags); const trendPanelCompact = record(dom.trendPanelCompact); @@ -1569,6 +1584,13 @@ function renderDashboardResult(result: Record): string { overviewSamples.warning ?? "-", ]]), "", + table(["RUN_MIN", "DETAIL_MIN_VISIBLE", "LIST_MIN_VISIBLE", "TOOLTIP_MIN_VISIBLE"], [[ + latestRunCounts.durationMinutes ?? "-", + timingVisibility.detailHasDuration ?? "-", + timingVisibility.listHasDuration ?? "-", + timingVisibility.tooltipHasDuration ?? "-", + ]]), + "", table(["CHECK_SCOPE", "CHECK_RUN", "CHECK_TYPES", "CHECK_ERR_TYPES", "CHECK_ALERT_TYPES", "SAMPLE_ERR", "SAMPLE_ALERT", "CHECK_MATCH_LATEST", "CHECK_MATCH_DETAIL"], [[ checkScope.scope ?? "-", checkScope.runId ?? "-", diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts index 7fea0508..6638f7ab 100644 --- a/scripts/src/hwlab-node-web-sentinel-service.ts +++ b/scripts/src/hwlab-node-web-sentinel-service.ts @@ -947,11 +947,14 @@ function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database const findingTypeCount = numberOr(row.finding_count, 0); const findingSampleCount = Object.values(severityCounts).reduce((sum, value) => sum + numberOr(value, 0), 0); const findingAlertSampleCount = alertSeveritySampleCount(severityCounts); + const stored = id === null ? {} : record(readMetadata(db, `run.report.${id}`)); + const scenarioId = stringOrNull(row.scenario_id); + const timing = dashboardRunTiming(config, row, stored); return { id, runId: id, - scenario_id: stringOrNull(row.scenario_id), - scenarioId: stringOrNull(row.scenario_id), + scenario_id: scenarioId, + scenarioId, status: stringOrNull(row.status), node: stringOrNull(row.node) ?? config.node, lane: stringOrNull(row.lane) ?? config.lane, @@ -976,6 +979,18 @@ function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database updatedAt: stringOrNull(row.updated_at), interrupted_at: stringOrNull(row.interrupted_at), interruptedAt: stringOrNull(row.interrupted_at), + startedAt: stringOrNull(timing.startedAt), + finishedAt: stringOrNull(timing.finishedAt), + durationMs: numberOrNull(timing.durationMs), + runDurationMs: numberOrNull(timing.durationMs), + durationMinutes: numberOrNull(timing.durationMinutes), + runDurationMinutes: numberOrNull(timing.durationMinutes), + durationSource: stringOrNull(timing.durationSource), + scenarioCadence: stringOrNull(timing.scenarioCadence), + scenarioCadenceMinutes: numberOrNull(timing.scenarioCadenceMinutes), + scenarioMaxRunMinutes: numberOrNull(timing.scenarioMaxRunMinutes), + schedulerIntervalMinutes: numberOrNull(timing.schedulerIntervalMinutes), + timing, severityCounts, maxSeverity, traceability: runTraceability(config, row), @@ -983,6 +998,41 @@ function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database }; } +function dashboardRunTiming(config: WebProbeSentinelServiceConfig, row: Record, stored: Record): Record { + const scenarioId = stringOrNull(row.scenario_id); + const scenario = scenarioId === null ? null : config.scenarios.find((item) => stringOrNull(item.id) === scenarioId) ?? null; + const summary = record(stored.summary); + const summaryElapsedMs = numberOrNull(summary.elapsedMs); + const startedAt = stringOrNull(row.created_at); + const finishedAt = stringOrNull(row.interrupted_at) ?? stringOrNull(row.updated_at); + const timestampDurationMs = durationMsBetween(startedAt, finishedAt); + const durationMs = summaryElapsedMs ?? timestampDurationMs; + const durationSource = summaryElapsedMs !== null + ? "report-summary-elapsedMs" + : timestampDurationMs !== null + ? "sqlite-created-updated" + : "unknown"; + const scenarioCadence = scenario === null ? null : stringOrNull(scenario.cadence); + const scenarioCadenceSeconds = durationStringSeconds(scenarioCadence); + const scenarioMaxRunSeconds = scenario === null ? null : numberOrNull(scenario.maxRunSeconds); + return { + startedAt, + finishedAt, + durationMs, + durationMinutes: minutesFromMs(durationMs), + durationSource, + scenarioCadence, + scenarioCadenceSeconds, + scenarioCadenceMinutes: minutesFromSeconds(scenarioCadenceSeconds), + scenarioMaxRunSeconds, + scenarioMaxRunMinutes: minutesFromSeconds(scenarioMaxRunSeconds), + schedulerIntervalMs: config.schedulerIntervalMs, + schedulerIntervalMinutes: minutesFromMs(config.schedulerIntervalMs), + sourceOfTruth: "sqlite-run-timestamps+report-summary+yaml-scenario-runtime", + valuesRedacted: true, + }; +} + function alertSeveritySampleCount(counts: Record): number { return numberOr(counts.red, 0) + numberOr(counts.critical, 0) @@ -1453,6 +1503,43 @@ function ageSeconds(iso: string): number | null { return Number.isFinite(parsed) ? Math.max(0, Math.round((Date.now() - parsed) / 1000)) : null; } +function durationMsBetween(startIso: string | null, endIso: string | null): number | null { + if (startIso === null || endIso === null) return null; + const start = Date.parse(startIso); + const end = Date.parse(endIso); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null; + return end - start; +} + +function durationStringSeconds(value: string | null): number | null { + if (value === null) return null; + const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/iu.exec(value.trim()); + if (match === null) return null; + const amount = Number(match[1]); + if (!Number.isFinite(amount)) return null; + const unit = match[2].toLowerCase(); + const seconds = unit === "ms" ? amount / 1000 + : unit === "s" ? amount + : unit === "m" ? amount * 60 + : unit === "h" ? amount * 3600 + : amount * 86400; + return Math.round(seconds * 1000) / 1000; +} + +function minutesFromMs(value: unknown): number | null { + const ms = numberOrNull(value); + return ms === null ? null : roundMinutes(ms / 60_000); +} + +function minutesFromSeconds(value: unknown): number | null { + const seconds = numberOrNull(value); + return seconds === null ? null : roundMinutes(seconds / 60); +} + +function roundMinutes(value: number): number { + return Math.round(value * 100) / 100; +} + function maxSeverityFromCounts(counts: Record): string | null { let best: string | null = null; for (const [severity, count] of Object.entries(counts)) { @@ -1576,14 +1663,14 @@ function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view const findings = findingsForRun(config, db, selectedRunId, 50); const views = record(stored.views); const storedView = record(views[view]); - const renderedText = view === "findings" ? renderStoredFindings(row, findings) : typeof storedView.renderedText === "string" ? storedView.renderedText : view === "summary" ? renderStoredSummary(row, stored, findings) : null; + const renderedText = view === "findings" ? renderStoredFindings(row, findings) : typeof storedView.renderedText === "string" ? storedView.renderedText : view === "summary" ? renderStoredSummary(config, row, stored, findings) : null; if (renderedText === null) { return { ok: false, error: "report-view-not-indexed", runId: selectedRunId, view, availableViews: Object.keys(views), valuesRedacted: true }; } return { ok: true, view, - run: row, + run: dashboardRunSummary(config, db, row), summary: record(stored.summary), findings, renderedText, @@ -1622,14 +1709,16 @@ function formatStoredFindingTiming(item: Record): string | null return `timing=${pieces.join(" ")}`; } -function renderStoredSummary(row: Record, stored: Record, findings: readonly Record[]): string { +function renderStoredSummary(config: WebProbeSentinelServiceConfig, row: Record, stored: Record, findings: readonly Record[]): string { const summary = record(stored.summary); + const timing = dashboardRunTiming(config, row, stored); return [ "Web Probe Sentinel Report", "=======================================================", `run=${stringOrNull(row.id) ?? "-"} scenario=${stringOrNull(row.scenario_id) ?? "-"} status=${stringOrNull(row.status) ?? "-"}`, `observer=${stringOrNull(row.observer_id) ?? "-"} stateDir=${stringOrNull(row.state_dir) ?? "-"}`, `report=${stringOrNull(row.report_json_sha256) ?? "-"} artifacts=${String(row.artifact_count ?? 0)} findings=${String(row.finding_count ?? findings.length)}`, + `timing=${formatRunTimingSummary(timing)}`, `publicOrigin=${stringOrNull(stored.publicOrigin) ?? "-"}`, `analysisWindow=${formatStoredAnalysisWindow(summary.analysisWindow)}`, "", @@ -1658,10 +1747,25 @@ function formatStoredAnalysisWindow(value: unknown): string { return fields.length === 0 ? "-" : fields.map(([key, item]) => `${key}=${item}`).join(" "); } +function formatRunTimingSummary(value: Record): string { + const fields = [ + ["durationMinutes", numberOrNull(value.durationMinutes)], + ["durationSource", stringOrNull(value.durationSource)], + ["scenarioCadenceMinutes", numberOrNull(value.scenarioCadenceMinutes)], + ["scenarioMaxRunMinutes", numberOrNull(value.scenarioMaxRunMinutes)], + ["schedulerIntervalMinutes", numberOrNull(value.schedulerIntervalMinutes)], + ].filter((entry): entry is [string, string | number] => entry[1] !== null); + return fields.length === 0 ? "-" : fields.map(([key, item]) => `${key}=${item}`).join(" "); +} + function thisMaintenanceFlag(input: Record): number { return input.maintenance === true ? 1 : 0; } +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + function numberOr(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; }