fix: expose sentinel run timing in monitor
This commit is contained in:
@@ -401,7 +401,7 @@ select {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
display: grid;
|
display: grid;
|
||||||
width: min(218px, calc(100% - 16px));
|
width: min(286px, calc(100% - 16px));
|
||||||
max-width: calc(100% - 16px);
|
max-width: calc(100% - 16px);
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
transform: translate(-50%, -100%);
|
transform: translate(-50%, -100%);
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ createApp({
|
|||||||
const redY = trendY(red);
|
const redY = trendY(red);
|
||||||
const warningY = trendY(warning);
|
const warningY = trendY(warning);
|
||||||
const rawTime = run.updatedAt || run.createdAt || "";
|
const rawTime = run.updatedAt || run.createdAt || "";
|
||||||
|
const durationText = runDurationText(run);
|
||||||
|
const configTimingText = runTimingConfigText(run);
|
||||||
return {
|
return {
|
||||||
id: run.id || String(index),
|
id: run.id || String(index),
|
||||||
runId: run.id || "",
|
runId: run.id || "",
|
||||||
@@ -170,8 +172,10 @@ createApp({
|
|||||||
rawTime,
|
rawTime,
|
||||||
timeLabel: formatDate(rawTime),
|
timeLabel: formatDate(rawTime),
|
||||||
absoluteTime: formatAbsoluteDate(rawTime),
|
absoluteTime: formatAbsoluteDate(rawTime),
|
||||||
|
durationText,
|
||||||
|
configTimingText,
|
||||||
reportSha: shortHash(run.reportJsonSha256 || run.report_json_sha256 || run.reportSha256 || ""),
|
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));
|
const timelineRuns = computed(() => runs.value.slice(0, 16));
|
||||||
@@ -463,6 +467,8 @@ createApp({
|
|||||||
trendTotalCount,
|
trendTotalCount,
|
||||||
trendErrorCount,
|
trendErrorCount,
|
||||||
trendWarningCount,
|
trendWarningCount,
|
||||||
|
runDurationText,
|
||||||
|
runTimingConfigText,
|
||||||
severityClass,
|
severityClass,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatAbsoluteDate,
|
formatAbsoluteDate,
|
||||||
@@ -578,6 +584,8 @@ createApp({
|
|||||||
<strong>{{ shortId(hoveredTrendDot.runId) }}</strong>
|
<strong>{{ shortId(hoveredTrendDot.runId) }}</strong>
|
||||||
<span>{{ hoveredTrendDot.absoluteTime }}</span>
|
<span>{{ hoveredTrendDot.absoluteTime }}</span>
|
||||||
<span>状态 {{ hoveredTrendDot.status }}</span>
|
<span>状态 {{ hoveredTrendDot.status }}</span>
|
||||||
|
<span>运行 {{ hoveredTrendDot.durationText }}</span>
|
||||||
|
<span v-if="hoveredTrendDot.configTimingText !== '-'">配置 {{ hoveredTrendDot.configTimingText }}</span>
|
||||||
<span>错误 {{ hoveredTrendDot.red }} / 告警 {{ hoveredTrendDot.warning }} / 合计 {{ hoveredTrendDot.total }}</span>
|
<span>错误 {{ hoveredTrendDot.red }} / 告警 {{ hoveredTrendDot.warning }} / 合计 {{ hoveredTrendDot.total }}</span>
|
||||||
<span v-if="hoveredTrendDot.reportSha">report {{ hoveredTrendDot.reportSha }}</span>
|
<span v-if="hoveredTrendDot.reportSha">report {{ hoveredTrendDot.reportSha }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -615,6 +623,7 @@ createApp({
|
|||||||
<span v-if="runCheckErrorCount(run) > 0" class="tag red">错误 {{ runCheckErrorCount(run) }}</span>
|
<span v-if="runCheckErrorCount(run) > 0" class="tag red">错误 {{ runCheckErrorCount(run) }}</span>
|
||||||
<span v-if="runCheckWarningCount(run) > 0" class="tag warning">告警 {{ runCheckWarningCount(run) }}</span>
|
<span v-if="runCheckWarningCount(run) > 0" class="tag warning">告警 {{ runCheckWarningCount(run) }}</span>
|
||||||
<span v-if="runCheckAlertCount(run) === 0" class="tag healthy">正常</span>
|
<span v-if="runCheckAlertCount(run) === 0" class="tag healthy">正常</span>
|
||||||
|
<span class="tag">运行 {{ runDurationText(run) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="timelineRuns.length === 0" class="empty">暂无时间线记录</div>
|
<div v-if="timelineRuns.length === 0" class="empty">暂无时间线记录</div>
|
||||||
@@ -687,6 +696,7 @@ createApp({
|
|||||||
</span>
|
</span>
|
||||||
<span class="row-line muted">
|
<span class="row-line muted">
|
||||||
<span>{{ run.status || "-" }}</span>
|
<span>{{ run.status || "-" }}</span>
|
||||||
|
<span>运行 {{ runDurationText(run) }}</span>
|
||||||
<span>{{ formatDate(run.updatedAt || run.createdAt) }}</span>
|
<span>{{ formatDate(run.updatedAt || run.createdAt) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -707,6 +717,8 @@ createApp({
|
|||||||
<h3>摘要</h3>
|
<h3>摘要</h3>
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<div class="metric"><span>状态</span><strong>{{ selectedRun.status || "-" }}</strong></div>
|
<div class="metric"><span>状态</span><strong>{{ selectedRun.status || "-" }}</strong></div>
|
||||||
|
<div class="metric"><span>运行分钟</span><strong>{{ runDurationText(selectedRun) }}</strong></div>
|
||||||
|
<div class="metric"><span>配置周期</span><strong>{{ runTimingConfigText(selectedRun) }}</strong></div>
|
||||||
<div class="metric"><span>监测项类型</span><strong>{{ findingCount(selectedRun) }}</strong></div>
|
<div class="metric"><span>监测项类型</span><strong>{{ findingCount(selectedRun) }}</strong></div>
|
||||||
<div class="metric"><span>错误/告警样本</span><strong>{{ alertSampleCount(selectedRun) }}</strong></div>
|
<div class="metric"><span>错误/告警样本</span><strong>{{ alertSampleCount(selectedRun) }}</strong></div>
|
||||||
<div class="metric"><span>全部样本</span><strong>{{ findingSampleCount(selectedRun) }}</strong></div>
|
<div class="metric"><span>全部样本</span><strong>{{ findingSampleCount(selectedRun) }}</strong></div>
|
||||||
@@ -1035,6 +1047,35 @@ function formatDuration(seconds) {
|
|||||||
return `${Math.round(value / 86400)}d`;
|
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) {
|
function timeWindowLabel(value) {
|
||||||
if (value === "1h") return "最近 1 小时";
|
if (value === "1h") return "最近 1 小时";
|
||||||
if (value === "24h") return "最近 24 小时";
|
if (value === "24h") return "最近 24 小时";
|
||||||
@@ -1248,6 +1289,9 @@ function detailSummaryRows(detail) {
|
|||||||
{ key: "run", label: "运行", value: shortId(run.id || run.runId || detail.runId) },
|
{ key: "run", label: "运行", value: shortId(run.id || run.runId || detail.runId) },
|
||||||
{ key: "scenario", label: "场景", value: run.scenarioId || run.scenario_id || "-" },
|
{ key: "scenario", label: "场景", value: run.scenarioId || run.scenario_id || "-" },
|
||||||
{ key: "status", label: "状态", value: run.status || "-" },
|
{ 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: "checks", label: "监测项类型", value: String(findingCount(run)) },
|
||||||
{ key: "alertSamples", label: "错误/告警样本", value: String(alertSampleCount(run)) },
|
{ key: "alertSamples", label: "错误/告警样本", value: String(alertSampleCount(run)) },
|
||||||
{ key: "allSamples", label: "全部样本", value: String(findingSampleCount(run)) },
|
{ key: "allSamples", label: "全部样本", value: String(findingSampleCount(run)) },
|
||||||
@@ -1282,3 +1326,12 @@ function number(value) {
|
|||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -619,6 +619,7 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
|||||||
const latestRunCounts = {
|
const latestRunCounts = {
|
||||||
runId: latestRun?.id || latestRun?.runId || null,
|
runId: latestRun?.id || latestRun?.runId || null,
|
||||||
typeCount: numberValue(latestRun?.findingTypeCount ?? latestRun?.findingCount ?? latestRun?.finding_count),
|
typeCount: numberValue(latestRun?.findingTypeCount ?? latestRun?.findingCount ?? latestRun?.finding_count),
|
||||||
|
durationMinutes: numberValue(latestRun?.runDurationMinutes ?? latestRun?.durationMinutes ?? latestRun?.timing?.durationMinutes),
|
||||||
error: 0,
|
error: 0,
|
||||||
warning: 0,
|
warning: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -661,6 +662,15 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
|||||||
latestRunCounts.warning = latestDetailSummary.warningTypeCount;
|
latestRunCounts.warning = latestDetailSummary.warningTypeCount;
|
||||||
latestRunCounts.total = latestDetailSummary.alertTypeCount;
|
latestRunCounts.total = latestDetailSummary.alertTypeCount;
|
||||||
latestRunCounts.all = latestDetailSummary.typeCount;
|
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 workspaceRect = workspace?.getBoundingClientRect();
|
||||||
const checksRect = checksPanel?.getBoundingClientRect();
|
const checksRect = checksPanel?.getBoundingClientRect();
|
||||||
const heightSummary = (rect) => {
|
const heightSummary = (rect) => {
|
||||||
@@ -851,11 +861,12 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
|||||||
badCardBodyCount: badCardBodies.length,
|
badCardBodyCount: badCardBodies.length,
|
||||||
trendCurve: Boolean(trend),
|
trendCurve: Boolean(trend),
|
||||||
trendDotCount: document.querySelectorAll(".trend-dot-hit").length,
|
trendDotCount: document.querySelectorAll(".trend-dot-hit").length,
|
||||||
trendTooltip: tooltipSummary(trendTooltip),
|
trendTooltip: trendTooltipSummary,
|
||||||
trendPanelText: text("#trend-heading"),
|
trendPanelText: text("#trend-heading"),
|
||||||
chartCounts,
|
chartCounts,
|
||||||
latestRunCounts,
|
latestRunCounts,
|
||||||
latestDetailSummary,
|
latestDetailSummary,
|
||||||
|
timingVisibility,
|
||||||
checkScope,
|
checkScope,
|
||||||
selectedRunTags,
|
selectedRunTags,
|
||||||
trendPanelCompact,
|
trendPanelCompact,
|
||||||
@@ -896,6 +907,7 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
|||||||
text: body.slice(0, 240),
|
text: body.slice(0, 240),
|
||||||
hasValues: /错误\s+\d+/u.test(body) && /告警\s+\d+/u.test(body) && /合计\s+\d+/u.test(body),
|
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),
|
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.checkScope?.fullWidth === true
|
||||||
&& dom.scopeLabels?.latestPointLegend === true
|
&& dom.scopeLabels?.latestPointLegend === true
|
||||||
&& dom.scopeLabels?.historicalSamples === 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.trendPanelCompact?.ok === true
|
||||||
&& (dom.checkScope?.expectsAlertRows !== true || (dom.checkRows > 0 && dom.checkDialog?.opened === true && dom.checkDialog?.large === true))
|
&& (dom.checkScope?.expectsAlertRows !== true || (dom.checkRows > 0 && dom.checkDialog?.opened === true && dom.checkDialog?.large === true))
|
||||||
&& dom.badCardTitleCount === 0
|
&& dom.badCardTitleCount === 0
|
||||||
@@ -1515,6 +1529,7 @@ function renderDashboardResult(result: Record<string, unknown>): string {
|
|||||||
const chartCounts = record(dom.chartCounts);
|
const chartCounts = record(dom.chartCounts);
|
||||||
const latestRunCounts = record(dom.latestRunCounts);
|
const latestRunCounts = record(dom.latestRunCounts);
|
||||||
const latestDetailSummary = record(dom.latestDetailSummary);
|
const latestDetailSummary = record(dom.latestDetailSummary);
|
||||||
|
const timingVisibility = record(dom.timingVisibility);
|
||||||
const checkScope = record(dom.checkScope);
|
const checkScope = record(dom.checkScope);
|
||||||
const selectedRunTags = record(dom.selectedRunTags);
|
const selectedRunTags = record(dom.selectedRunTags);
|
||||||
const trendPanelCompact = record(dom.trendPanelCompact);
|
const trendPanelCompact = record(dom.trendPanelCompact);
|
||||||
@@ -1569,6 +1584,13 @@ function renderDashboardResult(result: Record<string, unknown>): string {
|
|||||||
overviewSamples.warning ?? "-",
|
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"], [[
|
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.scope ?? "-",
|
||||||
checkScope.runId ?? "-",
|
checkScope.runId ?? "-",
|
||||||
|
|||||||
@@ -947,11 +947,14 @@ function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database
|
|||||||
const findingTypeCount = numberOr(row.finding_count, 0);
|
const findingTypeCount = numberOr(row.finding_count, 0);
|
||||||
const findingSampleCount = Object.values(severityCounts).reduce((sum, value) => sum + numberOr(value, 0), 0);
|
const findingSampleCount = Object.values(severityCounts).reduce((sum, value) => sum + numberOr(value, 0), 0);
|
||||||
const findingAlertSampleCount = alertSeveritySampleCount(severityCounts);
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
runId: id,
|
runId: id,
|
||||||
scenario_id: stringOrNull(row.scenario_id),
|
scenario_id: scenarioId,
|
||||||
scenarioId: stringOrNull(row.scenario_id),
|
scenarioId,
|
||||||
status: stringOrNull(row.status),
|
status: stringOrNull(row.status),
|
||||||
node: stringOrNull(row.node) ?? config.node,
|
node: stringOrNull(row.node) ?? config.node,
|
||||||
lane: stringOrNull(row.lane) ?? config.lane,
|
lane: stringOrNull(row.lane) ?? config.lane,
|
||||||
@@ -976,6 +979,18 @@ function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database
|
|||||||
updatedAt: stringOrNull(row.updated_at),
|
updatedAt: stringOrNull(row.updated_at),
|
||||||
interrupted_at: stringOrNull(row.interrupted_at),
|
interrupted_at: stringOrNull(row.interrupted_at),
|
||||||
interruptedAt: 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,
|
severityCounts,
|
||||||
maxSeverity,
|
maxSeverity,
|
||||||
traceability: runTraceability(config, row),
|
traceability: runTraceability(config, row),
|
||||||
@@ -983,6 +998,41 @@ function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dashboardRunTiming(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>, stored: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
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<string, number>): number {
|
function alertSeveritySampleCount(counts: Record<string, number>): number {
|
||||||
return numberOr(counts.red, 0)
|
return numberOr(counts.red, 0)
|
||||||
+ numberOr(counts.critical, 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;
|
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, number>): string | null {
|
function maxSeverityFromCounts(counts: Record<string, number>): string | null {
|
||||||
let best: string | null = null;
|
let best: string | null = null;
|
||||||
for (const [severity, count] of Object.entries(counts)) {
|
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 findings = findingsForRun(config, db, selectedRunId, 50);
|
||||||
const views = record(stored.views);
|
const views = record(stored.views);
|
||||||
const storedView = record(views[view]);
|
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) {
|
if (renderedText === null) {
|
||||||
return { ok: false, error: "report-view-not-indexed", runId: selectedRunId, view, availableViews: Object.keys(views), valuesRedacted: true };
|
return { ok: false, error: "report-view-not-indexed", runId: selectedRunId, view, availableViews: Object.keys(views), valuesRedacted: true };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
view,
|
view,
|
||||||
run: row,
|
run: dashboardRunSummary(config, db, row),
|
||||||
summary: record(stored.summary),
|
summary: record(stored.summary),
|
||||||
findings,
|
findings,
|
||||||
renderedText,
|
renderedText,
|
||||||
@@ -1622,14 +1709,16 @@ function formatStoredFindingTiming(item: Record<string, unknown>): string | null
|
|||||||
return `timing=${pieces.join(" ")}`;
|
return `timing=${pieces.join(" ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStoredSummary(row: Record<string, unknown>, stored: Record<string, unknown>, findings: readonly Record<string, unknown>[]): string {
|
function renderStoredSummary(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>, stored: Record<string, unknown>, findings: readonly Record<string, unknown>[]): string {
|
||||||
const summary = record(stored.summary);
|
const summary = record(stored.summary);
|
||||||
|
const timing = dashboardRunTiming(config, row, stored);
|
||||||
return [
|
return [
|
||||||
"Web Probe Sentinel Report",
|
"Web Probe Sentinel Report",
|
||||||
"=======================================================",
|
"=======================================================",
|
||||||
`run=${stringOrNull(row.id) ?? "-"} scenario=${stringOrNull(row.scenario_id) ?? "-"} status=${stringOrNull(row.status) ?? "-"}`,
|
`run=${stringOrNull(row.id) ?? "-"} scenario=${stringOrNull(row.scenario_id) ?? "-"} status=${stringOrNull(row.status) ?? "-"}`,
|
||||||
`observer=${stringOrNull(row.observer_id) ?? "-"} stateDir=${stringOrNull(row.state_dir) ?? "-"}`,
|
`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)}`,
|
`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) ?? "-"}`,
|
`publicOrigin=${stringOrNull(stored.publicOrigin) ?? "-"}`,
|
||||||
`analysisWindow=${formatStoredAnalysisWindow(summary.analysisWindow)}`,
|
`analysisWindow=${formatStoredAnalysisWindow(summary.analysisWindow)}`,
|
||||||
"",
|
"",
|
||||||
@@ -1658,10 +1747,25 @@ function formatStoredAnalysisWindow(value: unknown): string {
|
|||||||
return fields.length === 0 ? "-" : fields.map(([key, item]) => `${key}=${item}`).join(" ");
|
return fields.length === 0 ? "-" : fields.map(([key, item]) => `${key}=${item}`).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRunTimingSummary(value: Record<string, unknown>): 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<string, unknown>): number {
|
function thisMaintenanceFlag(input: Record<string, unknown>): number {
|
||||||
return input.maintenance === true ? 1 : 0;
|
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 {
|
function numberOr(value: unknown, fallback: number): number {
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user