feat: add sentinel error detail deep links

This commit is contained in:
Codex
2026-07-07 00:36:26 +00:00
parent 46423e046b
commit 45c834004a
12 changed files with 595 additions and 17 deletions
@@ -986,6 +986,8 @@ select {
display: flex;
flex: 0 0 auto;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
@@ -999,6 +1001,27 @@ select {
cursor: pointer;
}
.copy-button {
white-space: nowrap;
}
.copy-status {
margin-top: 8px;
color: var(--green);
font-size: 12px;
font-weight: 700;
}
.error-id-pill {
display: inline-block;
max-width: 100%;
margin-left: 8px;
overflow-wrap: anywhere;
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
}
.check-dialog-body {
display: grid;
grid-template-columns: minmax(300px, 0.82fr) minmax(420px, 1fr);
@@ -37,12 +37,14 @@ createApp({
const checkSeverityFilter = ref("alert");
const findingFilter = ref("");
const activeCheckItem = ref(null);
const detailCopyState = ref("");
const autoRefresh = ref(true);
const refreshSeconds = ref(30);
const lastLoadedAt = ref("");
const hoveredTrendDot = ref(null);
const deepLinkRunId = ref(initialDeepLink.runId);
const deepLinkFocus = ref(initialDeepLink.focus);
let pendingInitialErrorId = initialDeepLink.errorId;
let pendingInitialFocus = initialDeepLink.focus;
let lastAutoRefreshAt = 0;
@@ -279,6 +281,13 @@ createApp({
void refreshRunCheckSummaries(runs.value);
lastLoadedAt.value = new Date().toISOString();
lastAutoRefreshAt = Date.now();
if (pendingInitialErrorId) {
const errorId = pendingInitialErrorId;
pendingInitialErrorId = "";
await openErrorById(errorId, { initial: true });
pendingInitialFocus = "";
return;
}
const deepLinkedRun = deepLinkRunId.value ? runs.value.find((run) => run.id === deepLinkRunId.value) : null;
const keepSelected = runs.value.find((run) => run.id === selectedRunId.value);
const nextRun = deepLinkedRun || keepSelected || runs.value[0] || latestRun.value;
@@ -303,7 +312,7 @@ createApp({
deepLinkRunId.value = runId;
const nextFocus = normalizeDeepLinkFocus(options.focus || deepLinkFocus.value || "");
deepLinkFocus.value = nextFocus;
syncMonitorDeepLink(runId, nextFocus);
syncMonitorDeepLink(runId, nextFocus, options.errorId || "");
if (!silent) selectedDetail.value = null;
try {
selectedDetail.value = await fetchJson(`/api/runs/${encodeURIComponent(runId)}`);
@@ -407,12 +416,48 @@ createApp({
return number(run?.runDurationMinutes ?? run?.durationMinutes ?? run?.timing?.durationMinutes);
}
function openCheckDetail(item) {
async function openErrorById(errorId, options = {}) {
if (!errorId) return;
const payload = await fetchJson(`/api/errors/${encodeURIComponent(errorId)}`);
const runId = payload?.run?.id || payload?.detail?.identity?.runId || "";
if (runId) await selectRun(runId, true, { focus: "detail", errorId });
const rows = Array.isArray(selectedDetail.value?.findings) ? selectedDetail.value.findings : [];
const row = rows.find((item) => checkErrorId(item) === errorId) || payload.finding || {};
activeCheckItem.value = { ...row, ...(payload.finding || {}), errorId, detail: payload.detail || row.detail || null };
deepLinkFocus.value = "detail";
if (runId) syncMonitorDeepLink(runId, "detail", errorId);
if (options.initial === true) await nextTick();
}
async function openCheckDetail(item) {
activeCheckItem.value = item || null;
detailCopyState.value = "";
const errorId = checkErrorId(item);
const runId = item?.latestRunId || item?.runId || item?.run?.id || selectedRunId.value || "";
if (runId) syncMonitorDeepLink(runId, "detail", errorId);
if (!errorId) return;
try {
const payload = await fetchJson(`/api/errors/${encodeURIComponent(errorId)}`);
activeCheckItem.value = { ...item, ...(payload.finding || {}), errorId, detail: payload.detail || item.detail || null };
} catch (cause) {
activeCheckItem.value = { ...item, errorId, detail: { ok: false, error: String(cause?.message || cause), identity: { errorId }, valuesRedacted: true } };
}
}
function closeCheckDetail() {
activeCheckItem.value = null;
detailCopyState.value = "";
if (selectedRunId.value) syncMonitorDeepLink(selectedRunId.value, deepLinkFocus.value === "detail" ? "" : deepLinkFocus.value, "");
}
async function copyErrorId(item) {
await copyText(checkErrorId(item));
detailCopyState.value = "error-id";
}
async function copyDetailLink(item) {
await copyText(checkDeepLink(item));
detailCopyState.value = "link";
}
async function refreshHistoricalFindings() {
@@ -554,6 +599,7 @@ createApp({
checkSeverityFilter,
findingFilter,
activeCheckItem,
detailCopyState,
autoRefresh,
refreshSeconds,
lastLoadedAt,
@@ -604,6 +650,8 @@ createApp({
selectCheckRun,
openCheckDetail,
closeCheckDetail,
copyErrorId,
copyDetailLink,
refreshNow,
triggerQuickVerify,
currentHref,
@@ -646,6 +694,10 @@ createApp({
checkActionText,
checkDetailRows,
checkEvidenceRows,
checkErrorId,
checkDeepLink,
checkTraceRows,
checkOtelRows,
levelLabel,
findingGroupCountLabel,
timeWindowLabel,
@@ -1116,6 +1168,8 @@ createApp({
role="button"
:aria-label="'查看监测项详情 ' + findingCode(item) + ' ' + findingTitle(item)"
:data-check-code="findingCode(item)"
:data-error-id="checkErrorId(item)"
:data-check-deep-link="checkDeepLink(item)"
@click="openCheckDetail(item)"
@keydown.enter.prevent="openCheckDetail(item)"
@keydown.space.prevent="openCheckDetail(item)"
@@ -1139,6 +1193,7 @@ createApp({
v-if="activeCheckItem"
class="check-dialog-backdrop"
data-check-dialog="true"
:data-error-id="checkErrorId(activeCheckItem)"
@click.self="closeCheckDetail"
@keydown.esc="closeCheckDetail"
>
@@ -1147,9 +1202,14 @@ createApp({
<div>
<span class="check-code">{{ findingCode(activeCheckItem) }}</span>
<h2 id="check-dialog-title">{{ findingTitle(activeCheckItem) }}</h2>
<p class="muted">{{ checkScopeText }}</p>
<p class="muted">
<span>{{ checkScopeText }}</span>
<span v-if="checkErrorId(activeCheckItem)" class="error-id-pill" data-error-id="true">{{ checkErrorId(activeCheckItem) }}</span>
</p>
</div>
<div class="check-dialog-actions">
<button v-if="checkErrorId(activeCheckItem)" class="dialog-close copy-button" type="button" data-copy-error-id="true" @click="copyErrorId(activeCheckItem)">复制ID</button>
<button v-if="checkErrorId(activeCheckItem)" class="dialog-close copy-button" type="button" data-copy-detail-link="true" @click="copyDetailLink(activeCheckItem)">复制链接</button>
<span class="tag" :class="severityClass(activeCheckItem)">{{ levelLabel(activeCheckItem) }} · {{ findingGroupCountLabel(activeCheckItem) }}</span>
<button class="dialog-close" type="button" aria-label="关闭监测项详情" @click="closeCheckDetail">关闭</button>
</div>
@@ -1165,12 +1225,13 @@ createApp({
</div>
</section>
<section class="detail-card">
<h3>用户影响</h3>
<h3>详情</h3>
<p>{{ rootCauseText(activeCheckItem) }}</p>
<p class="muted">处理建议: {{ checkActionText(activeCheckItem) }}</p>
<p v-if="detailCopyState" class="copy-status">{{ detailCopyState === "link" ? "已复制深链" : "已复制错误ID" }}</p>
</section>
<section class="detail-card detail-card-wide">
<h3>关联证据</h3>
<h3>哨兵证据</h3>
<div class="summary-list">
<div v-for="row in checkEvidenceRows(activeCheckItem)" :key="row.key" class="summary-row">
<span>{{ row.label }}</span>
@@ -1178,6 +1239,24 @@ createApp({
</div>
</div>
</section>
<section class="detail-card">
<h3>Trace 引用</h3>
<div class="summary-list" data-sentinel-evidence-summary="true">
<div v-for="row in checkTraceRows(activeCheckItem)" :key="row.key" class="summary-row">
<span>{{ row.label }}</span>
<strong>{{ row.value }}</strong>
</div>
</div>
</section>
<section class="detail-card">
<h3>OTel 追溯</h3>
<div class="summary-list" data-otel-trace-summary="true">
<div v-for="row in checkOtelRows(activeCheckItem)" :key="row.key" class="summary-row">
<span>{{ row.label }}</span>
<strong>{{ row.value }}</strong>
</div>
</div>
</section>
</div>
</section>
</div>
@@ -1208,23 +1287,34 @@ function readBootstrap() {
function readMonitorDeepLink() {
const params = new URLSearchParams(window.location.search || "");
const runId = String(params.get("run") || params.get("runId") || "").trim();
const errorId = String(params.get("error") || params.get("errorId") || "").trim();
const focus = normalizeDeepLinkFocus(params.get("focus") || params.get("panel") || window.location.hash.replace(/^#/u, ""));
return { runId, focus };
return { runId, focus, errorId };
}
function syncMonitorDeepLink(runId, focus) {
function syncMonitorDeepLink(runId, focus, errorId = "") {
if (!window.history?.replaceState) return;
const nextFocus = normalizeDeepLinkFocus(focus);
const url = new URL(window.location.href);
url.searchParams.set("run", runId);
if (runId) url.searchParams.set("run", runId);
else url.searchParams.delete("run");
if (nextFocus) url.searchParams.set("focus", nextFocus);
else url.searchParams.delete("focus");
const nextErrorId = String(errorId || "").trim();
if (nextErrorId) {
url.searchParams.set("error", nextErrorId);
url.searchParams.set("panel", "detail");
} else {
url.searchParams.delete("error");
url.searchParams.delete("panel");
}
window.history.replaceState(null, "", url.toString());
}
function normalizeDeepLinkFocus(value) {
const text = String(value || "").trim().toLowerCase();
if (["memory", "memory-chart", "run-memory", "page-memory"].includes(text)) return "memory";
if (["detail", "error", "finding"].includes(text)) return "detail";
return "";
}
@@ -1259,6 +1349,24 @@ function apiUrl(path) {
return `${prefix}${suffix}`;
}
async function copyText(value) {
const text = String(value || "");
if (!text) return;
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const input = document.createElement("textarea");
input.value = text;
input.setAttribute("readonly", "true");
input.style.position = "fixed";
input.style.left = "-1000px";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}
function setReady(value) {
document.querySelector("#monitor-web-root")?.setAttribute("data-monitor-ready", value ? "true" : "false");
}
@@ -1559,15 +1667,34 @@ function checkActionText(item) {
return display.action || safeUserText(item?.checkActionZh || item?.check?.actionZh) || "查看详情后处理";
}
function checkErrorId(item) {
return safeDetailValue(item?.errorId || item?.detail?.identity?.errorId) === "-" ? "" : safeDetailValue(item?.errorId || item?.detail?.identity?.errorId);
}
function checkDeepLink(item) {
const direct = item?.detail?.links?.canonicalUrl || item?.detail?.links?.canonicalPath || "";
if (direct) return String(direct);
const errorId = checkErrorId(item);
if (!errorId) return window.location.href;
const url = new URL(window.location.href);
url.searchParams.set("error", errorId);
url.searchParams.set("panel", "detail");
const runId = item?.latestRunId || item?.runId || item?.run?.id || item?.detail?.identity?.runId || "";
if (runId) url.searchParams.set("run", runId);
return url.toString();
}
function checkDetailRows(item) {
if (!item) return [{ key: "empty", label: "状态", value: "未选择监测项" }];
const identity = item?.detail?.identity || {};
return [
{ key: "errorId", label: "错误ID", value: checkErrorId(item) || "-" },
{ key: "code", label: "编号", value: findingCode(item) },
{ key: "level", label: "等级", value: levelLabel(item) },
{ key: "samples", label: "样本", value: findingGroupCountLabel(item) },
{ key: "run", label: "运行记录", value: checkRunText(item) },
{ key: "run", label: "运行记录", value: safeDetailValue(identity.runId || checkRunText(item)) },
{ key: "time", label: "时间", value: checkTimeText(item) },
{ key: "scenario", label: "场景", value: safeDetailValue(item?.scenarioId || item?.scenario_id) },
{ key: "scenario", label: "场景", value: safeDetailValue(identity.scenarioId || item?.scenarioId || item?.scenario_id) },
{ key: "observer", label: "观察任务", value: safeDetailValue(item?.observerId || item?.observer_id) },
{ key: "timing", label: "计时来源", value: checkTimingText(item) },
{ key: "report", label: "报告", value: shortHash(item?.reportJsonSha256 || item?.report_json_sha256 || item?.reportSha256 || "") || "-" },
@@ -1577,8 +1704,11 @@ function checkDetailRows(item) {
function checkEvidenceRows(item) {
if (!item) return [{ key: "empty", label: "状态", value: "未选择监测项" }];
const evidence = item?.evidence && typeof item.evidence === "object" && !Array.isArray(item.evidence) ? item.evidence : {};
const detailEvidence = item?.detail?.sentinelEvidence && typeof item.detail.sentinelEvidence === "object" ? item.detail.sentinelEvidence : {};
const rows = [
{ key: "summary", label: "证据摘要", value: rootCauseText(item) || safeUserText(item?.evidenceSummary || evidence.summary) },
{ key: "summary", label: "证据摘要", value: safeDetailValue(detailEvidence.evidenceSummary || item?.evidenceSummary || evidence.summary || rootCauseText(item)) },
{ key: "rootCause", label: "根因", value: safeDetailValue(detailEvidence.rootCause || item?.rootCause) },
{ key: "rootCauseStatus", label: "根因状态", value: safeDetailValue(detailEvidence.rootCauseStatus || item?.rootCauseStatus) },
{ key: "sample", label: "样本序号", value: safeDetailValue(item?.sampleSeq ?? evidence.sampleSeq) },
{ key: "page", label: "页面", value: safeDetailValue(item?.pageRole || evidence.pageRole) },
{ key: "command", label: "命令编号", value: safeDetailValue(item?.commandId || evidence.commandId) },
@@ -1589,6 +1719,33 @@ function checkEvidenceRows(item) {
return rows.length > 0 ? rows : [{ key: "none", label: "证据摘要", value: "已记录到报告详情。" }];
}
function checkTraceRows(item) {
const refs = Array.isArray(item?.detail?.traceRefs) ? item.detail.traceRefs : Array.isArray(item?.traceRefs) ? item.traceRefs : [];
if (refs.length === 0) return [{ key: "none", label: "状态", value: "未发现 trace 引用" }];
return refs.slice(0, 8).map((ref, index) => ({
key: `${index}-${ref.traceId || ""}`,
label: ref.type || "trace",
value: safeDetailValue(ref.traceId),
}));
}
function checkOtelRows(item) {
const otel = item?.detail?.otel || {};
const summaries = Array.isArray(otel.summaries) ? otel.summaries : [];
const rows = [{ key: "status", label: "状态", value: safeDetailValue(otel.status) }];
if (summaries.length === 0) return rows;
return rows.concat(summaries.slice(0, 6).map((summary, index) => ({
key: `otel-${index}`,
label: safeDetailValue(summary.traceId || `trace ${index + 1}`),
value: [
summary.status || "-",
summary.spanCount !== undefined ? `spans=${summary.spanCount}` : "",
summary.errorSpanCount !== undefined ? `errors=${summary.errorSpanCount}` : "",
summary.httpStatus !== undefined ? `http=${summary.httpStatus}` : "",
].filter(Boolean).join(" "),
})));
}
function checkTimingText(item) {
const status = item?.timingStatus || "";
const source = item?.timingSourceOfTruth || item?.expectedElapsedSource || item?.evidenceKind || "";