921 lines
40 KiB
JavaScript
921 lines
40 KiB
JavaScript
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p12-cadence-scheduler-monitor-web.
|
|
// Responsibility: Vue monitor-web runtime for sentinel trend, timeline, detail and finding observability.
|
|
import { createApp, computed, onMounted, ref, watch } from "./vendor/vue.esm-browser.prod.js";
|
|
|
|
const bootstrap = readBootstrap();
|
|
|
|
createApp({
|
|
setup() {
|
|
const loading = ref(true);
|
|
const error = ref("");
|
|
const overview = ref(null);
|
|
const runs = ref([]);
|
|
const findings = ref([]);
|
|
const selectedRunId = ref("");
|
|
const selectedDetail = ref(null);
|
|
const runFilter = ref("");
|
|
const severityFilter = ref("");
|
|
const checkScope = ref("run");
|
|
const checkRunId = ref("");
|
|
const checkTimeWindow = ref("24h");
|
|
const checkSeverityFilter = ref("alert");
|
|
const findingFilter = ref("");
|
|
const autoRefresh = ref(true);
|
|
const refreshSeconds = ref(30);
|
|
const lastLoadedAt = ref("");
|
|
const hoveredTrendDot = ref(null);
|
|
let lastAutoRefreshAt = 0;
|
|
|
|
const sentinels = computed(() => {
|
|
const rows = Array.isArray(bootstrap.sentinels) ? bootstrap.sentinels : [];
|
|
return rows.length > 0 ? rows : [{ id: bootstrap.sentinelId, enabled: true }];
|
|
});
|
|
|
|
const currentStatus = computed(() => String(overview.value?.status || "idle"));
|
|
const latestRun = computed(() => overview.value?.latestRun || null);
|
|
const severityTotals = computed(() => overview.value?.severityCounts || {});
|
|
const filteredRuns = computed(() => {
|
|
const needle = runFilter.value.trim().toLowerCase();
|
|
return runs.value.filter((run) => {
|
|
if (severityFilter.value && severityClass(run) !== severityFilter.value) return false;
|
|
if (!needle) return true;
|
|
return [run.id, run.scenarioId, run.status, run.observerId].some((item) => String(item || "").toLowerCase().includes(needle));
|
|
});
|
|
});
|
|
const selectedRun = computed(() => runs.value.find((run) => run.id === selectedRunId.value) || latestRun.value);
|
|
const trendRows = computed(() => runs.value.slice().sort((a, b) => Date.parse(a.updatedAt || a.createdAt || "") - Date.parse(b.updatedAt || b.createdAt || "")).slice(-48));
|
|
const latestTrendRun = computed(() => trendRows.value.length > 0 ? trendRows.value[trendRows.value.length - 1] : latestRun.value);
|
|
const trendMax = computed(() => Math.max(1, ...trendRows.value.flatMap((run) => [trendErrorCount(run), trendWarningCount(run), trendTotalCount(run)])));
|
|
const trendPolylines = computed(() => ({
|
|
red: trendPolyline((run) => trendErrorCount(run)),
|
|
warning: trendPolyline((run) => trendWarningCount(run)),
|
|
total: trendPolyline((run) => trendTotalCount(run)),
|
|
}));
|
|
const trendDots = computed(() => trendRows.value.map((run, index) => {
|
|
const red = trendErrorCount(run);
|
|
const warning = trendWarningCount(run);
|
|
const total = trendTotalCount(run);
|
|
const x = trendX(index, trendRows.value.length);
|
|
const redY = trendY(red);
|
|
const warningY = trendY(warning);
|
|
const rawTime = run.updatedAt || run.createdAt || "";
|
|
return {
|
|
id: run.id || String(index),
|
|
runId: run.id || "",
|
|
x,
|
|
redY,
|
|
warningY,
|
|
tooltipLeft: `${clamp((x / 720) * 100, 16, 84)}%`,
|
|
tooltipTop: `${clamp(((Math.min(redY, warningY) + 18) / 142) * 100, 24, 76)}%`,
|
|
red,
|
|
warning,
|
|
total,
|
|
status: run.status || "-",
|
|
severity: severityClass(run),
|
|
rawTime,
|
|
timeLabel: formatDate(rawTime),
|
|
absoluteTime: formatAbsoluteDate(rawTime),
|
|
reportSha: shortHash(run.reportJsonSha256 || run.report_json_sha256 || run.reportSha256 || ""),
|
|
title: `${shortId(run.id)} ${formatAbsoluteDate(rawTime)} 错误 ${red} 警告 ${warning} 合计 ${total}`,
|
|
};
|
|
}));
|
|
const timelineRuns = computed(() => runs.value.slice(0, 16));
|
|
const historicalCheckFindings = computed(() => {
|
|
const rows = findings.value.filter((item) => item.checkRegistered !== false || item.rootCause || item.nextAction || ["red", "warning", "info"].includes(severityClass(item)));
|
|
return rows.slice(0, 200);
|
|
});
|
|
const runDetailCheckFindings = computed(() => {
|
|
const rows = selectedDetail.value?.findings;
|
|
return Array.isArray(rows) ? rows : [];
|
|
});
|
|
const scopedCheckFindings = computed(() => {
|
|
if (checkScope.value === "history") return historicalCheckFindings.value;
|
|
return runDetailCheckFindings.value;
|
|
});
|
|
const scopedCheckSummary = computed(() => summarizeCheckRows(scopedCheckFindings.value));
|
|
const visibleCheckFindings = computed(() => {
|
|
const needle = findingFilter.value.trim().toLowerCase();
|
|
const rows = scopedCheckFindings.value.filter((item) => checkMatchesLevel(item, checkSeverityFilter.value));
|
|
const filtered = needle.length === 0 ? rows : rows.filter((item) => findingSearchText(item).includes(needle));
|
|
return filtered.slice(0, 64);
|
|
});
|
|
const visibleCheckSummary = computed(() => summarizeCheckRows(visibleCheckFindings.value));
|
|
const checkScopeRun = computed(() => runs.value.find((run) => run.id === selectedRunId.value) || selectedRun.value || latestRun.value);
|
|
const checkScopeText = computed(() => {
|
|
if (checkScope.value === "history") return `历史聚合 · ${timeWindowLabel(checkTimeWindow.value)}`;
|
|
return `运行记录 · ${checkScopeRun.value?.id || "未选择"}`;
|
|
});
|
|
const cadence = computed(() => {
|
|
const intervalMs = Number(overview.value?.scheduler?.intervalMs || 0);
|
|
const latestAge = Number(overview.value?.freshness?.latestRunAgeSeconds ?? -1);
|
|
const heartbeatAge = Number(overview.value?.freshness?.schedulerHeartbeatAgeSeconds ?? -1);
|
|
const intervalSeconds = intervalMs > 0 ? Math.round(intervalMs / 1000) : 0;
|
|
const stale = intervalSeconds > 0 && latestAge > intervalSeconds * 2;
|
|
return {
|
|
intervalSeconds,
|
|
latestAge,
|
|
heartbeatAge,
|
|
stale,
|
|
label: intervalSeconds > 0 ? `${formatDuration(intervalSeconds)} 间隔` : "未配置",
|
|
alert: stale ? `最近运行 ${formatDuration(latestAge)} 前,超过预设间隔 2 倍;按 SPEC 作为非阻塞报警展示。` : "运行新鲜度在预设窗口内",
|
|
};
|
|
});
|
|
const healthChecks = computed(() => {
|
|
const checks = overview.value?.health?.checks || {};
|
|
return Object.entries(checks).map(([key, value]) => ({
|
|
key,
|
|
ok: value?.ok !== false,
|
|
text: `${key} ${value?.ok === false ? "异常" : "ok"}`,
|
|
}));
|
|
});
|
|
|
|
async function loadAll(options = {}) {
|
|
if (!options.silent) loading.value = true;
|
|
error.value = "";
|
|
setReady(false);
|
|
try {
|
|
const overviewPayload = await fetchJson("/api/overview");
|
|
const [runsResult, findingsResult] = await Promise.allSettled([
|
|
fetchJson("/api/runs?limit=30&sort=updated"),
|
|
fetchJson(findingsApiPath(checkTimeWindow.value)),
|
|
]);
|
|
overview.value = overviewPayload;
|
|
if (runsResult.status === "fulfilled") {
|
|
const runsPayload = runsResult.value;
|
|
runs.value = Array.isArray(runsPayload.runs) ? runsPayload.runs : Array.isArray(runsPayload.items) ? runsPayload.items : [];
|
|
} else if (runs.value.length === 0) {
|
|
throw runsResult.reason;
|
|
}
|
|
if (findingsResult.status === "fulfilled") {
|
|
const findingsPayload = findingsResult.value;
|
|
findings.value = Array.isArray(findingsPayload.findings) ? findingsPayload.findings : Array.isArray(findingsPayload.groups) ? findingsPayload.groups : [];
|
|
} else {
|
|
console.warn("monitor-web findings refresh failed", findingsResult.reason);
|
|
}
|
|
lastLoadedAt.value = new Date().toISOString();
|
|
lastAutoRefreshAt = Date.now();
|
|
const keepSelected = runs.value.find((run) => run.id === selectedRunId.value);
|
|
const nextRun = keepSelected || runs.value[0] || latestRun.value;
|
|
if (nextRun?.id) void selectRun(nextRun, true);
|
|
} catch (cause) {
|
|
const message = String(cause?.message || cause);
|
|
if (!options.silent || runs.value.length === 0) error.value = message;
|
|
else console.warn("monitor-web silent refresh failed", message);
|
|
} finally {
|
|
loading.value = false;
|
|
setReady(true);
|
|
}
|
|
}
|
|
|
|
async function selectRun(run, silent = false) {
|
|
const runId = typeof run === "string" ? run : run?.id;
|
|
if (!runId) return;
|
|
selectedRunId.value = runId;
|
|
checkRunId.value = runId;
|
|
if (!silent) selectedDetail.value = null;
|
|
try {
|
|
selectedDetail.value = await fetchJson(`/api/runs/${encodeURIComponent(runId)}`);
|
|
} catch (cause) {
|
|
selectedDetail.value = { ok: false, error: String(cause?.message || cause), runId };
|
|
}
|
|
}
|
|
|
|
function refreshNow() {
|
|
void loadAll();
|
|
}
|
|
|
|
function selectCheckRun() {
|
|
const run = runs.value.find((item) => item.id === checkRunId.value);
|
|
if (run) void selectRun(run);
|
|
}
|
|
|
|
async function refreshHistoricalFindings() {
|
|
try {
|
|
const findingsPayload = await fetchJson(findingsApiPath(checkTimeWindow.value));
|
|
findings.value = Array.isArray(findingsPayload.findings) ? findingsPayload.findings : Array.isArray(findingsPayload.groups) ? findingsPayload.groups : [];
|
|
} catch (cause) {
|
|
console.warn("monitor-web historical findings refresh failed", cause);
|
|
}
|
|
}
|
|
|
|
function findingsApiPath(windowValue) {
|
|
if (windowValue === "all") return "/api/findings?limit=200";
|
|
return `/api/findings?limit=200&window=${encodeURIComponent(windowValue || "24h")}`;
|
|
}
|
|
|
|
function currentHref(item) {
|
|
if (!item || item.id === bootstrap.sentinelId) return bootstrap.basePath || "/";
|
|
if (item.monitorRoot === true) return "/";
|
|
return `/sentinels/${encodeURIComponent(item.id)}/`;
|
|
}
|
|
|
|
function trendPolyline(accessor) {
|
|
if (trendRows.value.length < 2) return "";
|
|
return trendRows.value.map((run, index) => `${trendX(index, trendRows.value.length)},${trendY(accessor(run))}`).join(" ");
|
|
}
|
|
|
|
function trendX(index, total) {
|
|
if (total <= 1) return 24;
|
|
return Math.round(24 + index * (672 / (total - 1)));
|
|
}
|
|
|
|
function trendY(value) {
|
|
return Math.round(126 - (Number(value || 0) / trendMax.value) * 102);
|
|
}
|
|
|
|
function showTrendTooltip(dot) {
|
|
hoveredTrendDot.value = dot;
|
|
}
|
|
|
|
function hideTrendTooltip() {
|
|
hoveredTrendDot.value = null;
|
|
}
|
|
|
|
onMounted(() => {
|
|
void loadAll();
|
|
window.setInterval(() => {
|
|
if (!autoRefresh.value) return;
|
|
if (Date.now() - lastAutoRefreshAt >= Math.max(5, Number(refreshSeconds.value || 30)) * 1000) void loadAll({ silent: true });
|
|
}, 1000);
|
|
});
|
|
|
|
watch(checkTimeWindow, () => {
|
|
if (checkScope.value === "history") void refreshHistoricalFindings();
|
|
});
|
|
|
|
watch(checkScope, () => {
|
|
if (checkScope.value === "history") void refreshHistoricalFindings();
|
|
});
|
|
|
|
return {
|
|
bootstrap,
|
|
loading,
|
|
error,
|
|
overview,
|
|
runs,
|
|
findings,
|
|
selectedRunId,
|
|
selectedDetail,
|
|
runFilter,
|
|
severityFilter,
|
|
checkScope,
|
|
checkRunId,
|
|
checkTimeWindow,
|
|
checkSeverityFilter,
|
|
findingFilter,
|
|
autoRefresh,
|
|
refreshSeconds,
|
|
lastLoadedAt,
|
|
hoveredTrendDot,
|
|
sentinels,
|
|
currentStatus,
|
|
latestRun,
|
|
severityTotals,
|
|
filteredRuns,
|
|
selectedRun,
|
|
trendRows,
|
|
latestTrendRun,
|
|
trendPolylines,
|
|
trendDots,
|
|
timelineRuns,
|
|
historicalCheckFindings,
|
|
runDetailCheckFindings,
|
|
scopedCheckFindings,
|
|
scopedCheckSummary,
|
|
visibleCheckFindings,
|
|
visibleCheckSummary,
|
|
checkScopeRun,
|
|
checkScopeText,
|
|
cadence,
|
|
healthChecks,
|
|
loadAll,
|
|
selectRun,
|
|
selectCheckRun,
|
|
refreshNow,
|
|
currentHref,
|
|
showTrendTooltip,
|
|
hideTrendTooltip,
|
|
redCount,
|
|
warningCount,
|
|
findingCount,
|
|
findingSampleCount,
|
|
alertSampleCount,
|
|
trendTotalCount,
|
|
trendErrorCount,
|
|
trendWarningCount,
|
|
severityClass,
|
|
formatDate,
|
|
formatAbsoluteDate,
|
|
formatDuration,
|
|
shortId,
|
|
rootCauseText,
|
|
findingTitle,
|
|
findingCode,
|
|
levelLabel,
|
|
findingGroupCountLabel,
|
|
timeWindowLabel,
|
|
detailSummaryText,
|
|
detailSummaryRows,
|
|
commandSummary,
|
|
statusLabel,
|
|
};
|
|
},
|
|
template: `
|
|
<div class="monitor-shell" data-monitor-shell="true" :data-monitor-status="currentStatus">
|
|
<header class="topbar">
|
|
<div class="title-block">
|
|
<div class="mark" aria-hidden="true"></div>
|
|
<div>
|
|
<h1>HWLAB Web哨兵</h1>
|
|
<p class="subtitle">
|
|
<span>{{ bootstrap.node }} / {{ bootstrap.lane }}</span>
|
|
<span class="mono">{{ bootstrap.sentinelId }}</span>
|
|
<span>Vue monitor-web</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="toolbar" aria-label="观察面板控制">
|
|
<span class="pill" :class="currentStatus">{{ statusLabel(currentStatus) }}</span>
|
|
<select v-model.number="refreshSeconds" aria-label="刷新间隔">
|
|
<option :value="5">5s</option>
|
|
<option :value="10">10s</option>
|
|
<option :value="30">30s</option>
|
|
</select>
|
|
<button class="toolbar-button" type="button" @click="autoRefresh = !autoRefresh">{{ autoRefresh ? "自动" : "手动" }}</button>
|
|
<button class="toolbar-button" type="button" @click="refreshNow">刷新</button>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="entry-strip" aria-label="哨兵入口">
|
|
<div class="entry-copy">
|
|
<strong>哨兵入口</strong>
|
|
<span>{{ sentinels.length }} 个实例,当前入口固定在第一屏</span>
|
|
</div>
|
|
<div class="entry-links">
|
|
<a
|
|
v-for="item in sentinels"
|
|
:key="item.id"
|
|
class="entry-link"
|
|
:class="{ current: item.id === bootstrap.sentinelId, disabled: item.enabled === false }"
|
|
:href="currentHref(item)"
|
|
>
|
|
<span>{{ item.id }}</span>
|
|
<small>{{ item.id === bootstrap.sentinelId ? "当前" : item.enabled === false ? "停用" : "查看" }}</small>
|
|
</a>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="trend-stage" aria-label="运行趋势与时间线">
|
|
<section class="trend-panel" aria-labelledby="trend-heading">
|
|
<div class="panel-header">
|
|
<div>
|
|
<h2 id="trend-heading">错误 / 警告样本曲线</h2>
|
|
<p>按运行更新时间展示最近 {{ trendRows.length }} 次变化,点位为单次运行样本数</p>
|
|
</div>
|
|
<span class="pill" :class="cadence.stale ? 'warning' : 'healthy'">{{ cadence.stale ? "非阻塞报警" : "新鲜" }}</span>
|
|
</div>
|
|
<div class="trend-chart-wrap">
|
|
<svg class="trend-chart" viewBox="0 0 720 142" role="img" aria-label="错误和警告样本数量趋势" data-monitor-trend-curve="true">
|
|
<line class="trend-grid-line" x1="24" x2="696" y1="24" y2="24"></line>
|
|
<line class="trend-grid-line" x1="24" x2="696" y1="75" y2="75"></line>
|
|
<line class="trend-grid-line" x1="24" x2="696" y1="126" y2="126"></line>
|
|
<polyline v-if="trendPolylines.total" class="trend-total" :points="trendPolylines.total"></polyline>
|
|
<polyline v-if="trendPolylines.warning" class="trend-warning" :points="trendPolylines.warning"></polyline>
|
|
<polyline v-if="trendPolylines.red" class="trend-red" :points="trendPolylines.red"></polyline>
|
|
<g
|
|
v-for="dot in trendDots"
|
|
:key="dot.id"
|
|
class="trend-dot-hit"
|
|
tabindex="0"
|
|
role="button"
|
|
:aria-label="dot.title"
|
|
@mouseenter="showTrendTooltip(dot)"
|
|
@focusin="showTrendTooltip(dot)"
|
|
@mouseleave="hideTrendTooltip"
|
|
@focusout="hideTrendTooltip"
|
|
>
|
|
<circle class="trend-hit-area" :cx="dot.x" :cy="Math.min(dot.redY, dot.warningY)" r="12"></circle>
|
|
<circle class="trend-dot-warning" :cx="dot.x" :cy="dot.warningY" r="3">
|
|
<title>{{ dot.title }}</title>
|
|
</circle>
|
|
<circle class="trend-dot-red" :cx="dot.x" :cy="dot.redY" r="3">
|
|
<title>{{ dot.title }}</title>
|
|
</circle>
|
|
</g>
|
|
</svg>
|
|
<div
|
|
v-if="hoveredTrendDot"
|
|
class="trend-tooltip"
|
|
data-monitor-trend-tooltip="true"
|
|
:style="{ left: hoveredTrendDot.tooltipLeft, top: hoveredTrendDot.tooltipTop }"
|
|
>
|
|
<strong>{{ shortId(hoveredTrendDot.runId) }}</strong>
|
|
<span>{{ hoveredTrendDot.absoluteTime }}</span>
|
|
<span>状态 {{ hoveredTrendDot.status }}</span>
|
|
<span>错误 {{ hoveredTrendDot.red }} / 警告 {{ hoveredTrendDot.warning }} / 合计 {{ hoveredTrendDot.total }}</span>
|
|
<span v-if="hoveredTrendDot.reportSha">report {{ hoveredTrendDot.reportSha }}</span>
|
|
</div>
|
|
<div v-if="trendRows.length === 0" class="trend-empty">暂无运行数据</div>
|
|
</div>
|
|
<div class="trend-legend">
|
|
<span class="legend-item"><span class="legend-swatch red"></span>最新点错误 {{ trendErrorCount(latestTrendRun) }}</span>
|
|
<span class="legend-item"><span class="legend-swatch warning"></span>最新点警告 {{ trendWarningCount(latestTrendRun) }}</span>
|
|
<span class="legend-item"><span class="legend-swatch total"></span>最新点错误+警告合计 {{ trendTotalCount(latestTrendRun) }}</span>
|
|
<span class="legend-item">历史样本累计 错误 {{ redCount({ severityCounts: severityTotals }) }} / 警告 {{ warningCount({ severityCounts: severityTotals }) }}</span>
|
|
<span class="legend-item">{{ cadence.alert }}</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="timeline-panel" aria-labelledby="timeline-heading" data-monitor-timeline="true">
|
|
<div class="panel-header">
|
|
<div>
|
|
<h2 id="timeline-heading">运行时间线</h2>
|
|
<p>位于哨兵入口下方,方便首屏确认最近运行</p>
|
|
</div>
|
|
<span class="tag">{{ cadence.label }}</span>
|
|
</div>
|
|
<div class="timeline-list">
|
|
<button
|
|
v-for="run in timelineRuns"
|
|
:key="run.id"
|
|
class="timeline-item"
|
|
:class="severityClass(run)"
|
|
type="button"
|
|
@click="selectRun(run)"
|
|
>
|
|
<span class="muted">{{ formatDate(run.updatedAt || run.createdAt) }}</span>
|
|
<span class="severity-line"><span class="timeline-marker"></span><strong>{{ run.scenarioId || shortId(run.id) }}</strong></span>
|
|
<span class="tag" :class="severityClass(run)">{{ findingCount(run) }} 项</span>
|
|
</button>
|
|
<div v-if="timelineRuns.length === 0" class="empty">暂无时间线记录</div>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
|
|
<section class="status-strip" aria-label="状态指标">
|
|
<div class="metric">
|
|
<span>最近运行</span>
|
|
<strong>{{ latestRun ? formatDate(latestRun.updatedAt || latestRun.createdAt) : "-" }}</strong>
|
|
</div>
|
|
<div class="metric" :class="{ warning: cadence.stale }">
|
|
<span>调度新鲜度</span>
|
|
<strong>{{ cadence.latestAge >= 0 ? formatDuration(cadence.latestAge) : "-" }}</strong>
|
|
</div>
|
|
<div class="metric">
|
|
<span>历史错误样本</span>
|
|
<strong>{{ redCount({ severityCounts: severityTotals }) }}</strong>
|
|
</div>
|
|
<div class="metric">
|
|
<span>历史警告样本</span>
|
|
<strong>{{ warningCount({ severityCounts: severityTotals }) }}</strong>
|
|
</div>
|
|
<div class="metric">
|
|
<span>最后刷新</span>
|
|
<strong>{{ lastLoadedAt ? formatDate(lastLoadedAt) : "-" }}</strong>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="error" class="banner error" id="monitor-web-error">{{ error }}</section>
|
|
|
|
<section class="workspace-grid" aria-label="哨兵工作区" data-monitor-independent-scroll="true">
|
|
<aside class="pane pane-runs" aria-labelledby="runs-heading">
|
|
<div class="pane-header">
|
|
<div>
|
|
<h2 id="runs-heading">运行记录</h2>
|
|
<p class="muted">{{ filteredRuns.length }} / {{ runs.length }}</p>
|
|
</div>
|
|
<span v-if="loading" class="tag">加载中</span>
|
|
</div>
|
|
<div class="filter-row">
|
|
<input v-model="runFilter" type="search" placeholder="搜索 run / scenario" aria-label="搜索运行">
|
|
<select v-model="severityFilter" aria-label="严重级别筛选">
|
|
<option value="">全部</option>
|
|
<option value="red">错误</option>
|
|
<option value="warning">警告</option>
|
|
<option value="info">信息</option>
|
|
<option value="healthy">正常</option>
|
|
</select>
|
|
</div>
|
|
<div class="run-list">
|
|
<button
|
|
v-for="run in filteredRuns"
|
|
:key="run.id"
|
|
class="run-row"
|
|
:class="[severityClass(run), { selected: run.id === selectedRunId }]"
|
|
type="button"
|
|
@click="selectRun(run)"
|
|
>
|
|
<span class="row-line">
|
|
<span class="severity-line"><span class="severity-dot"></span><strong>{{ run.scenarioId || shortId(run.id) }}</strong></span>
|
|
<span class="tag" :class="severityClass(run)">{{ findingCount(run) }}</span>
|
|
</span>
|
|
<span class="row-line muted">
|
|
<span>{{ run.status || "-" }}</span>
|
|
<span>{{ formatDate(run.updatedAt || run.createdAt) }}</span>
|
|
</span>
|
|
</button>
|
|
<div v-if="filteredRuns.length === 0" class="empty">没有匹配的运行记录</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="pane pane-detail" aria-labelledby="detail-heading">
|
|
<div class="pane-header">
|
|
<div>
|
|
<h2 id="detail-heading">运行详情</h2>
|
|
<p class="muted">{{ selectedRun ? selectedRun.id : "未选择" }}</p>
|
|
</div>
|
|
<span v-if="selectedRun" class="tag" :class="severityClass(selectedRun)">{{ severityClass(selectedRun) }}</span>
|
|
</div>
|
|
<div v-if="selectedRun" class="detail-stack">
|
|
<section class="detail-card">
|
|
<h3>摘要</h3>
|
|
<div class="detail-grid">
|
|
<div class="metric"><span>状态</span><strong>{{ selectedRun.status || "-" }}</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>{{ findingSampleCount(selectedRun) }}</strong></div>
|
|
<div class="metric"><span>Observer</span><strong>{{ selectedRun.observerId || "-" }}</strong></div>
|
|
<div class="metric"><span>更新时间</span><strong>{{ formatDate(selectedRun.updatedAt || selectedRun.createdAt) }}</strong></div>
|
|
</div>
|
|
</section>
|
|
<section class="detail-card">
|
|
<h3>健康检查</h3>
|
|
<div class="check-grid">
|
|
<span v-for="check in healthChecks" :key="check.key" class="check-chip" :class="check.ok ? 'ok' : 'bad'">{{ check.text }}</span>
|
|
<span v-if="healthChecks.length === 0" class="check-chip">无检查数据</span>
|
|
</div>
|
|
</section>
|
|
<section class="detail-card">
|
|
<h3>报告摘要</h3>
|
|
<div class="summary-list">
|
|
<div v-for="row in detailSummaryRows(selectedDetail)" :key="row.key" class="summary-row">
|
|
<span>{{ row.label }}</span>
|
|
<strong>{{ row.value }}</strong>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section class="detail-card">
|
|
<h3>复现命令</h3>
|
|
<pre>{{ commandSummary(selectedDetail) }}</pre>
|
|
</section>
|
|
</div>
|
|
<div v-else class="empty">选择一条运行记录查看详情</div>
|
|
</main>
|
|
</section>
|
|
|
|
<section
|
|
class="pane pane-findings checks-panel"
|
|
aria-labelledby="findings-heading"
|
|
data-monitor-checks="true"
|
|
:data-check-scope="checkScope"
|
|
:data-check-run-id="checkScopeRun ? checkScopeRun.id : ''"
|
|
:data-check-type-count="scopedCheckSummary.typeCount"
|
|
:data-check-alert-type-count="scopedCheckSummary.alertTypeCount"
|
|
:data-check-error-samples="scopedCheckSummary.errorSamples"
|
|
:data-check-warning-samples="scopedCheckSummary.warningSamples"
|
|
:data-check-alert-samples="scopedCheckSummary.alertSamples"
|
|
:data-visible-check-count="visibleCheckFindings.length"
|
|
:data-visible-check-alert-samples="visibleCheckSummary.alertSamples"
|
|
>
|
|
<div class="pane-header">
|
|
<div>
|
|
<h2 id="findings-heading">监测项</h2>
|
|
<p class="muted">{{ checkScopeText }}</p>
|
|
</div>
|
|
<span class="tag">错误/警告样本 {{ scopedCheckSummary.alertSamples }}</span>
|
|
</div>
|
|
<div class="filter-row">
|
|
<select v-model="checkScope" aria-label="监测项作用域">
|
|
<option value="run">按运行记录</option>
|
|
<option value="history">按时间聚合</option>
|
|
</select>
|
|
<select v-model="checkRunId" :disabled="checkScope === 'history'" aria-label="选择运行记录" @change="selectCheckRun">
|
|
<option v-for="run in runs" :key="run.id" :value="run.id">{{ shortId(run.id) }} · {{ formatDate(run.updatedAt || run.createdAt) }}</option>
|
|
</select>
|
|
<select v-model="checkTimeWindow" :disabled="checkScope !== 'history'" aria-label="监测项时间窗口">
|
|
<option value="1h">最近 1 小时</option>
|
|
<option value="24h">最近 24 小时</option>
|
|
<option value="7d">最近 7 天</option>
|
|
<option value="all">全部历史</option>
|
|
</select>
|
|
<select v-model="checkSeverityFilter" aria-label="监测项等级筛选">
|
|
<option value="alert">错误+警告</option>
|
|
<option value="red">错误</option>
|
|
<option value="warning">警告</option>
|
|
<option value="info">信息</option>
|
|
<option value="all">全部</option>
|
|
</select>
|
|
<input v-model="findingFilter" type="search" placeholder="搜索编号 / 标题 / 处理建议" aria-label="搜索监测项">
|
|
</div>
|
|
<div class="check-summary" aria-label="监测项摘要">
|
|
<div class="metric"><span>当前作用域</span><strong>{{ checkScope === "history" ? timeWindowLabel(checkTimeWindow) : shortId(checkScopeRun && checkScopeRun.id) }}</strong></div>
|
|
<div class="metric"><span>监测项类型</span><strong>{{ scopedCheckSummary.typeCount }}</strong></div>
|
|
<div class="metric"><span>错误/警告类型</span><strong>{{ scopedCheckSummary.alertTypeCount }}</strong></div>
|
|
<div class="metric"><span>错误样本</span><strong>{{ scopedCheckSummary.errorSamples }}</strong></div>
|
|
<div class="metric"><span>警告样本</span><strong>{{ scopedCheckSummary.warningSamples }}</strong></div>
|
|
<div class="metric"><span>错误/警告样本</span><strong>{{ scopedCheckSummary.alertSamples }}</strong></div>
|
|
</div>
|
|
<div class="finding-list">
|
|
<article
|
|
v-for="item in visibleCheckFindings"
|
|
:key="item.code || item.findingId || item.latestRunId"
|
|
class="finding-card"
|
|
:class="severityClass(item)"
|
|
>
|
|
<span class="row-line">
|
|
<span class="severity-line"><span class="severity-dot"></span><span class="check-code">{{ findingCode(item) }}</span><strong>{{ findingTitle(item) }}</strong></span>
|
|
<span class="tag" :class="severityClass(item)">{{ levelLabel(item) }} · {{ findingGroupCountLabel(item) }}</span>
|
|
</span>
|
|
<p class="muted">{{ rootCauseText(item) }}</p>
|
|
<p v-if="item.nextAction" class="muted">处理: {{ item.nextAction }}</p>
|
|
</article>
|
|
<div v-if="visibleCheckFindings.length === 0" class="empty">暂无匹配监测项</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
`,
|
|
}).mount("#monitor-web-root");
|
|
|
|
function readBootstrap() {
|
|
const root = document.querySelector("#monitor-web-root");
|
|
const script = document.querySelector("#monitor-web-bootstrap");
|
|
let parsed = {};
|
|
try {
|
|
parsed = script?.textContent ? JSON.parse(script.textContent) : {};
|
|
} catch {
|
|
parsed = {};
|
|
}
|
|
return {
|
|
node: root?.getAttribute("data-node") || parsed.node || "",
|
|
lane: root?.getAttribute("data-lane") || parsed.lane || "",
|
|
sentinelId: root?.getAttribute("data-sentinel-id") || parsed.sentinelId || "",
|
|
basePath: root?.getAttribute("data-base-path") || parsed.basePath || "",
|
|
publicOrigin: root?.getAttribute("data-public-origin") || parsed.publicOrigin || "",
|
|
contractVersion: root?.getAttribute("data-contract-version") || parsed.contractVersion || "",
|
|
sentinels: Array.isArray(parsed.sentinels) ? parsed.sentinels : [],
|
|
};
|
|
}
|
|
|
|
async function fetchJson(path) {
|
|
const response = await fetch(apiUrl(path), { cache: "no-store" });
|
|
if (!response.ok) throw new Error(`${path} HTTP ${response.status}`);
|
|
return await response.json();
|
|
}
|
|
|
|
function apiUrl(path) {
|
|
const prefix = bootstrap.basePath || "";
|
|
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
return `${prefix}${suffix}`;
|
|
}
|
|
|
|
function setReady(value) {
|
|
document.querySelector("#monitor-web-root")?.setAttribute("data-monitor-ready", value ? "true" : "false");
|
|
}
|
|
|
|
function redCount(item) {
|
|
const counts = item?.severityCounts || item || {};
|
|
return number(counts.red) + number(counts.critical) + number(counts.error);
|
|
}
|
|
|
|
function warningCount(item) {
|
|
const counts = item?.severityCounts || item || {};
|
|
return number(counts.warning) + number(counts.warn) + number(counts.amber);
|
|
}
|
|
|
|
function trendSeverityCounts(item) {
|
|
const counts = item?.severityCounts;
|
|
return counts && typeof counts === "object" && !Array.isArray(counts) ? counts : null;
|
|
}
|
|
|
|
function trendErrorCount(item) {
|
|
const counts = trendSeverityCounts(item);
|
|
if (!counts) return 0;
|
|
return number(counts.red) + number(counts.critical) + number(counts.error);
|
|
}
|
|
|
|
function trendWarningCount(item) {
|
|
const counts = trendSeverityCounts(item);
|
|
if (!counts) return 0;
|
|
return number(counts.warning) + number(counts.warn) + number(counts.amber);
|
|
}
|
|
|
|
function trendTotalCount(item) {
|
|
return trendErrorCount(item) + trendWarningCount(item);
|
|
}
|
|
|
|
function findingCount(item) {
|
|
if (Number.isFinite(Number(item?.findingTypeCount))) return Number(item.findingTypeCount);
|
|
if (Number.isFinite(Number(item?.findingCount))) return Number(item.findingCount);
|
|
if (Number.isFinite(Number(item?.finding_count))) return Number(item.finding_count);
|
|
if (Number.isFinite(Number(item?.count))) return Number(item.count);
|
|
const counts = item?.severityCounts;
|
|
if (counts && typeof counts === "object") return Object.keys(counts).length;
|
|
return Object.values(item || {}).reduce((sum, value) => sum + number(value), 0);
|
|
}
|
|
|
|
function findingSampleCount(item) {
|
|
if (Number.isFinite(Number(item?.findingSampleCount))) return Number(item.findingSampleCount);
|
|
const counts = item?.severityCounts;
|
|
if (counts && typeof counts === "object") return Object.values(counts).reduce((sum, value) => sum + number(value), 0);
|
|
return 0;
|
|
}
|
|
|
|
function alertSampleCount(item) {
|
|
if (Number.isFinite(Number(item?.findingAlertSampleCount))) return Number(item.findingAlertSampleCount);
|
|
return redCount(item) + warningCount(item);
|
|
}
|
|
|
|
function summarizeCheckRows(rows) {
|
|
const items = Array.isArray(rows) ? rows : [];
|
|
const errorItems = items.filter((item) => severityBucket(item) === "red");
|
|
const warningItems = items.filter((item) => severityBucket(item) === "warning");
|
|
const sampleCount = (entry) => Number.isFinite(Number(entry?.count)) ? Number(entry.count) : 1;
|
|
const sumSamples = (entries) => entries.reduce((sum, entry) => sum + sampleCount(entry), 0);
|
|
return {
|
|
typeCount: items.length,
|
|
errorTypeCount: errorItems.length,
|
|
warningTypeCount: warningItems.length,
|
|
alertTypeCount: errorItems.length + warningItems.length,
|
|
errorSamples: sumSamples(errorItems),
|
|
warningSamples: sumSamples(warningItems),
|
|
alertSamples: sumSamples(errorItems) + sumSamples(warningItems),
|
|
allSamples: sumSamples(items),
|
|
};
|
|
}
|
|
|
|
function checkMatchesLevel(item, filter) {
|
|
const value = String(filter || "alert");
|
|
const bucket = severityBucket(item);
|
|
if (value === "all") return true;
|
|
if (value === "alert") return bucket === "red" || bucket === "warning";
|
|
return bucket === value;
|
|
}
|
|
|
|
function severityBucket(item) {
|
|
const explicit = String(item?.maxSeverity || item?.checkLevel || item?.severity || item?.level || "").toLowerCase();
|
|
if (["red", "critical", "error", "blocked", "failed"].includes(explicit)) return "red";
|
|
if (["warning", "warn", "amber"].includes(explicit)) return "warning";
|
|
if (["info", "notice"].includes(explicit)) return "info";
|
|
if (redCount(item) > 0) return "red";
|
|
if (warningCount(item) > 0) return "warning";
|
|
return "healthy";
|
|
}
|
|
|
|
function severityClass(item) {
|
|
return severityBucket(item);
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return "-";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
const now = Date.now();
|
|
const seconds = Math.max(0, Math.round((now - date.getTime()) / 1000));
|
|
if (seconds < 90) return `${seconds}s前`;
|
|
if (seconds < 7200) return `${Math.round(seconds / 60)}m前`;
|
|
if (seconds < 172800) return `${Math.round(seconds / 3600)}h前`;
|
|
return date.toISOString().slice(5, 16).replace("T", " ");
|
|
}
|
|
|
|
function formatAbsoluteDate(value) {
|
|
if (!value) return "-";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
return `${date.toISOString().slice(0, 19).replace("T", " ")} UTC`;
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
const value = Math.max(0, Number(seconds || 0));
|
|
if (value < 90) return `${Math.round(value)}s`;
|
|
if (value < 7200) return `${Math.round(value / 60)}m`;
|
|
if (value < 172800) return `${Math.round(value / 3600)}h`;
|
|
return `${Math.round(value / 86400)}d`;
|
|
}
|
|
|
|
function timeWindowLabel(value) {
|
|
if (value === "1h") return "最近 1 小时";
|
|
if (value === "24h") return "最近 24 小时";
|
|
if (value === "7d") return "最近 7 天";
|
|
if (value === "all") return "全部历史";
|
|
return String(value || "未配置");
|
|
}
|
|
|
|
function shortId(value) {
|
|
const text = String(value || "");
|
|
return text.length > 18 ? `${text.slice(0, 10)}...${text.slice(-6)}` : text || "-";
|
|
}
|
|
|
|
function shortHash(value) {
|
|
const text = String(value || "");
|
|
if (text.length === 0) return "";
|
|
return text.length > 12 ? text.slice(0, 12) : text;
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function rootCauseText(item) {
|
|
if (item?.checkSummaryZh) return item.checkSummaryZh;
|
|
if (item?.summary) return item.summary;
|
|
if (item?.evidenceSummary) return item.evidenceSummary;
|
|
if (item?.rootCause) return "已记录内部根因,见报告详情。";
|
|
return "尚未记录用户可见问题摘要。";
|
|
}
|
|
|
|
function findingTitle(item) {
|
|
return item?.checkTitleZh || item?.check?.titleZh || item?.findingId || item?.code || item?.scenarioId || item?.latestRunId || "未登记监测项";
|
|
}
|
|
|
|
function findingCode(item) {
|
|
return item?.checkCode || item?.check?.code || "未登记";
|
|
}
|
|
|
|
function levelLabel(item) {
|
|
const value = String(item?.checkLevel || item?.severity || item?.level || "").toLowerCase();
|
|
if (["critical", "red"].includes(value)) return "严重";
|
|
if (["error", "blocked", "failed"].includes(value)) return "错误";
|
|
if (["warning", "warn", "amber"].includes(value)) return "警告";
|
|
if (["info", "notice"].includes(value)) return "信息";
|
|
return "未知";
|
|
}
|
|
|
|
function findingGroupCountLabel(item) {
|
|
const runCount = Number.isFinite(Number(item?.runCount)) ? Number(item.runCount) : null;
|
|
const sampleCount = Number.isFinite(Number(item?.count)) ? Number(item.count) : null;
|
|
if (runCount !== null && sampleCount !== null) return `${runCount} 次运行 / ${sampleCount} 样本`;
|
|
if (sampleCount !== null) return `${sampleCount} 样本`;
|
|
if (runCount !== null) return `${runCount} 次运行`;
|
|
return "1 样本";
|
|
}
|
|
|
|
function findingSearchText(item) {
|
|
return [
|
|
item?.checkCode,
|
|
item?.check?.code,
|
|
item?.checkTitleZh,
|
|
item?.check?.titleZh,
|
|
item?.checkLevel,
|
|
item?.severity,
|
|
item?.findingId,
|
|
item?.code,
|
|
item?.summary,
|
|
item?.checkSummaryZh,
|
|
item?.rootCause,
|
|
item?.evidenceSummary,
|
|
item?.nextAction,
|
|
item?.scenarioId,
|
|
item?.latestRunId,
|
|
].filter((value) => value !== null && value !== undefined).join(" ").toLowerCase();
|
|
}
|
|
|
|
function detailSummaryText(detail) {
|
|
return detailSummaryRows(detail).map((row) => `${row.label}: ${row.value}`).join("\n");
|
|
}
|
|
|
|
function detailSummaryRows(detail) {
|
|
if (!detail) return [{ key: "loading", label: "状态", value: "加载详情中" }];
|
|
if (detail.ok === false) return [{ key: "error", label: "状态", value: detail.error || "详情不可用" }];
|
|
const run = detail.run || {};
|
|
const summary = detail.summary || {};
|
|
const artifacts = detail.artifacts || {};
|
|
const counts = run.severityCounts || {};
|
|
const rows = [
|
|
{ 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: "checks", label: "监测项类型", value: String(findingCount(run)) },
|
|
{ key: "alertSamples", label: "错误/警告样本", value: String(alertSampleCount(run)) },
|
|
{ key: "allSamples", label: "全部样本", value: String(findingSampleCount(run)) },
|
|
{ key: "error", label: "错误样本", value: String(redCount({ severityCounts: counts })) },
|
|
{ key: "warning", label: "警告样本", value: String(warningCount({ severityCounts: counts })) },
|
|
{ key: "observer", label: "Observer", value: run.observerId || run.observer_id || "-" },
|
|
{ key: "updated", label: "更新时间", value: formatAbsoluteDate(run.updatedAt || run.updated_at || run.createdAt || run.created_at) },
|
|
{ key: "report", label: "报告", value: shortHash(artifacts.reportJsonSha256 || run.reportJsonSha256 || run.report_json_sha256 || "") || "-" },
|
|
];
|
|
const windowStart = summary.analysisWindow?.start || summary.window?.start;
|
|
const windowEnd = summary.analysisWindow?.end || summary.window?.end;
|
|
if (windowStart || windowEnd) rows.push({ key: "window", label: "窗口", value: `${formatDate(windowStart)} - ${formatDate(windowEnd)}` });
|
|
return rows;
|
|
}
|
|
|
|
function commandSummary(detail) {
|
|
const commands = detail?.commands || {};
|
|
const lines = Object.entries(commands).map(([key, value]) => `${key}: ${value}`);
|
|
return lines.length > 0 ? lines.join("\n") : "暂无命令";
|
|
}
|
|
|
|
function statusLabel(status) {
|
|
const value = String(status || "");
|
|
if (value === "blocked") return "阻塞";
|
|
if (value === "degraded") return "降级";
|
|
if (value === "warning") return "警告";
|
|
if (value === "healthy") return "健康";
|
|
return "空闲";
|
|
}
|
|
|
|
function number(value) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|