feat: add sentinel error detail deep links
This commit is contained in:
@@ -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 || "";
|
||||
|
||||
Reference in New Issue
Block a user