mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 13:00:57 +08:00
Fix business compare history, search, sampling and exports
This commit is contained in:
parent
afa8e06b2d
commit
4d5101ec3a
19 changed files with 1598 additions and 274 deletions
|
|
@ -474,6 +474,10 @@ const en = {
|
|||
sideAfter: "After",
|
||||
sidesVs: "vs",
|
||||
sidesTitle: "Compare sides",
|
||||
configSnapshot: "Execution settings saved",
|
||||
configSnapshotHint: "Template rules and port mapping are fixed for this run. Later edits do not affect historical lookup. The downloaded report includes the execution settings.",
|
||||
configLegacy: "Legacy run: settings not saved",
|
||||
configLegacyHint: "This run has no configuration snapshot. Success lookup and compact row reconstruction use the current settings, which may differ from the original execution.",
|
||||
pick: "Select…",
|
||||
sameAsBefore: "Same as before / select…",
|
||||
autoHint: "auto: new after-task batches trigger compare",
|
||||
|
|
@ -532,8 +536,14 @@ const en = {
|
|||
ranWithDuration: "Compare finished ({{s}}s)",
|
||||
unchangedNotStored: "Success rows were counted but not stored. Set “Store success rows” to sample and re-run for spot-check.",
|
||||
unchangedSampleHint: "{{total}} success rows total; browsing a stratified sample of {{listed}}. Search any route for live source lookup. Pass rate uses all {{total}}.",
|
||||
liveSearchHint: "Field filters look up source batches live; tabs only filter kind. E.g. direction=out, network=1.1.1.1.",
|
||||
liveSearchTruncatedHint: "Search results truncated — narrow the field filters.",
|
||||
liveSearchHint: "Pairs are completed from source batches before filtering. Pass rate still uses the full sheet.",
|
||||
liveSearchTruncatedHint: "Search limit reached; some entries are omitted. Narrow the filters. Incomplete key groups were skipped.",
|
||||
clearSearch: "Clear filters",
|
||||
resultLoading: "Loading comparison details…",
|
||||
resultRange: "{{from}}–{{to}} of {{total}}",
|
||||
resultLoadFailed: "Could not load comparison details",
|
||||
retry: "Retry",
|
||||
searchEmpty: "No results match these filters. Clear filters or select another result type.",
|
||||
clearKeyFilters: "Clear fields",
|
||||
storeUnchanged: "Store success rows",
|
||||
storeUnchangedAuto: "Auto (full if small / sample 5k + hydrate if large)",
|
||||
|
|
@ -567,8 +577,6 @@ const en = {
|
|||
pickBatchRun: "Select compare run",
|
||||
pickRun: "Select run…",
|
||||
runCount: "{{n}} runs",
|
||||
resultEmpty: "No matching diff rows",
|
||||
resultFilterPh: "Free text, or direction:out network:1.1.1.1",
|
||||
kindAll: "All",
|
||||
kindDiff: "Fail",
|
||||
kindAdded: "Added",
|
||||
|
|
@ -712,7 +720,6 @@ const en = {
|
|||
newBaseline: "New baseline batch",
|
||||
needProjectFields: "Enter a name and pick old/new portrait tasks",
|
||||
needExpectPorts: "Select at least one expected object for this batch",
|
||||
needBaselineFirst: "Save an old baseline batch first to list expect objects",
|
||||
needBothBaselines: "Save both old and new baseline batches before evaluating",
|
||||
errOldBaselineRequired: "Old baseline batch is required",
|
||||
errNewBaselineRequired: "New baseline batch is required",
|
||||
|
|
|
|||
|
|
@ -473,6 +473,10 @@ const zh = {
|
|||
sideAfter: "操作后",
|
||||
sidesVs: "比对",
|
||||
sidesTitle: "比对双方",
|
||||
configSnapshot: "执行配置已保存",
|
||||
configSnapshotHint: "此记录的模板规则和端口映射已固定;后续编辑不影响历史回查,下载报告包含执行配置。",
|
||||
configLegacy: "旧记录:未保存执行配置",
|
||||
configLegacyHint: "此记录创建时未保存配置快照;成功行回查和精简明细还原使用当前配置,修改配置后可能与当时结果不同。",
|
||||
pick: "选择…",
|
||||
sameAsBefore: "同操作前 / 选择…",
|
||||
autoHint: "auto:操作后任务新批次到达会自动跑比对",
|
||||
|
|
@ -531,8 +535,14 @@ const zh = {
|
|||
ranWithDuration: "比对完成(耗时 {{s}} 秒)",
|
||||
unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。",
|
||||
unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(分层抽查)。要查任意路由请输入筛选条件——将按原表即时判定。通过率按全部 {{total}} 计。",
|
||||
liveSearchHint: "按字段回查原表即时判定;页签只过滤种类。例:direction=out,network=1.1.1.1。",
|
||||
liveSearchTruncatedHint: "搜索结果已截断,请再收窄字段条件。",
|
||||
liveSearchHint: "从原始批次补齐同一业务的前后数据后筛选;通过率仍按全表统计。",
|
||||
liveSearchTruncatedHint: "搜索已达上限,部分业务未展示。请收窄条件;不完整的业务分组已跳过。",
|
||||
clearSearch: "清空筛选",
|
||||
resultLoading: "正在加载对比明细…",
|
||||
resultRange: "{{from}}–{{to}} / {{total}} 条",
|
||||
resultLoadFailed: "对比明细加载失败",
|
||||
retry: "重试",
|
||||
searchEmpty: "当前筛选条件下没有匹配结果,可清空筛选或切换结果类型。",
|
||||
clearKeyFilters: "清空字段",
|
||||
storeUnchanged: "成功行保存",
|
||||
storeUnchangedAuto: "自动(小表全量 / 大表抽样 5000+原表补全)",
|
||||
|
|
@ -778,7 +788,6 @@ const zh = {
|
|||
metaBaseline: "基线",
|
||||
baselineOk: "已设",
|
||||
baselineMissing: "缺",
|
||||
needBaselineFirst: "先设基线",
|
||||
enableHighfreq: "开启高频",
|
||||
highfreqReady: "高频已就绪",
|
||||
collectNow: "立即采集",
|
||||
|
|
@ -830,10 +839,6 @@ const zh = {
|
|||
currentMissingBanner: "缺当前批次:{{metrics}}",
|
||||
highfreqReadyInactive: "高频已绑定(窗口未开,未立即采集)",
|
||||
collectPartial: "部分失败(成功 {{ok}} / 失败 {{fail}})",
|
||||
confirmProjectDone: "确认完成项目并暂停全部高频?",
|
||||
projectMarkedDone: "已完成,高频已停",
|
||||
confirmDeleteProject: "确认删除项目?将停高频并清批次记录。",
|
||||
projectDeleted: "已删除",
|
||||
errMetricNeedsBindings: "监控项「{{metric}}」需要参数绑定({{params}}),割接高频暂不支持,请去掉该项",
|
||||
errNoProfileForMetric: "监控项「{{metric}}」在当前设备厂商下无可用采集 profile",
|
||||
acceptTitle: "本批验收",
|
||||
|
|
|
|||
|
|
@ -11931,6 +11931,22 @@ body:has(.bs-cmp-board:fullscreen) {
|
|||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.bs-cmp-board__config {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--bs-cmp-border, rgba(148, 163, 184, 0.22));
|
||||
border-radius: 6px;
|
||||
color: var(--nm-muted, #94a3b8);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.bs-cmp-board__config.is-legacy {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.bs-cmp-board__actions {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
|
|
@ -12497,7 +12513,7 @@ body:has(.bs-cmp-board:fullscreen) {
|
|||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1 1 280px;
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
padding: 2px;
|
||||
border-radius: 8px;
|
||||
|
|
@ -12615,13 +12631,13 @@ body:has(.bs-cmp-board:fullscreen) {
|
|||
|
||||
.bs-cmp-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex: 1 1 200px;
|
||||
min-width: 160px;
|
||||
max-width: 320px;
|
||||
margin-left: auto;
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
margin-left: 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
|
|
@ -12630,6 +12646,8 @@ body:has(.bs-cmp-board:fullscreen) {
|
|||
.bs-cmp-filter-bar input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex: 1 1 220px;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.bs-cmp-filter-bar .bs-sheet-count {
|
||||
|
|
@ -12717,11 +12735,57 @@ body:has(.bs-cmp-board:fullscreen) {
|
|||
}
|
||||
|
||||
.bs-cmp-result-table.is-loading {
|
||||
opacity: 0.55;
|
||||
opacity: 1;
|
||||
/* Keep header/filters clickable; only mute row hit-testing via opacity cue */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.bs-cmp-result-state {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--bs-cmp-muted);
|
||||
}
|
||||
|
||||
.bs-cmp-result-state strong {
|
||||
color: var(--bs-cmp-ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bs-cmp-result-state.is-error strong {
|
||||
color: var(--danger, #fca5a5);
|
||||
}
|
||||
|
||||
.bs-cmp-strip__kind:focus-visible,
|
||||
.bs-cmp-strip__toggle:focus-visible,
|
||||
.bs-cmp-nav__item:focus-visible {
|
||||
outline: 2px solid var(--accent, #60a5fa);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.bs-cmp-strip__sheet {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bs-cmp-filter-bar input {
|
||||
flex-basis: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.bs-cmp-key-filter {
|
||||
flex: 1 1 120px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bs-cmp-board.is-fullscreen .bs-cmp-result-table {
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
|
|
@ -12752,6 +12816,7 @@ body:has(.bs-cmp-board:fullscreen) {
|
|||
@media (max-width: 960px) {
|
||||
.bs-cmp-board__body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.bs-cmp-board__body.is-nav-collapsed {
|
||||
|
|
@ -12760,6 +12825,7 @@ body:has(.bs-cmp-board:fullscreen) {
|
|||
|
||||
.bs-cmp-nav {
|
||||
max-height: 180px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.bs-cmp-nav.is-collapsed {
|
||||
|
|
@ -12769,6 +12835,8 @@ body:has(.bs-cmp-board:fullscreen) {
|
|||
.bs-cmp-nav__list {
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
flex: 0 0 auto;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
|
@ -12779,8 +12847,9 @@ body:has(.bs-cmp-board:fullscreen) {
|
|||
|
||||
.bs-cmp-nav__item {
|
||||
flex: 0 0 auto;
|
||||
min-width: 180px;
|
||||
grid-template-columns: 8px minmax(0, 1fr);
|
||||
width: auto;
|
||||
min-width: 240px;
|
||||
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -845,7 +845,7 @@ function TplRowFiltersEditor({
|
|||
Array.isArray(filt.value) ? filt.value.join(",") : String(filt.value ?? "")
|
||||
}
|
||||
placeholder={t("bizCompare.filterValue")}
|
||||
isDisabled={["empty", "not_empty", "age_timer"].includes(filt.op || "")}
|
||||
disabled={["empty", "not_empty", "age_timer"].includes(filt.op || "")}
|
||||
onChange={(e) => {
|
||||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||||
const op = next[gi][fi]?.op || "eq";
|
||||
|
|
@ -893,10 +893,6 @@ function TplRowFiltersEditor({
|
|||
);
|
||||
}
|
||||
|
||||
function metricLabel(id: string) {
|
||||
return id;
|
||||
}
|
||||
|
||||
export type BizComparePageMode = "jobs" | "templates" | "all";
|
||||
|
||||
export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePageMode }) {
|
||||
|
|
@ -988,6 +984,12 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const [diffsLoading, setDiffsLoading] = useState(false);
|
||||
const [diffsSource, setDiffsSource] = useState<DiffListSource>("");
|
||||
const [diffsTruncated, setDiffsTruncated] = useState(false);
|
||||
const [diffsError, setDiffsError] = useState("");
|
||||
const [diffsRetry, setDiffsRetry] = useState(0);
|
||||
const jobRequestRef = useRef(0);
|
||||
const runRequestRef = useRef(0);
|
||||
const listsRequestRef = useRef(0);
|
||||
const diffFiltersRef = useRef("");
|
||||
const boardRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableScrollPosRef = useRef({ top: 0, left: 0 });
|
||||
|
|
@ -996,6 +998,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const tplImportRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const refresh = useCallback(async (opts?: { force?: boolean }) => {
|
||||
const request = ++listsRequestRef.current;
|
||||
type Bundle = {
|
||||
taskRes: Awaited<ReturnType<typeof bizStateListTasks>>;
|
||||
tpl: Awaited<ReturnType<typeof bizCompareListTemplates>>;
|
||||
|
|
@ -1014,6 +1017,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
return { taskRes, tpl, maps, j, met };
|
||||
};
|
||||
const apply = (b: Bundle) => {
|
||||
if (request !== listsRequestRef.current) return;
|
||||
setTasks((b.taskRes.items || []) as TaskOpt[]);
|
||||
setTemplates((b.tpl.items || []) as Template[]);
|
||||
setMappings((b.maps.items || []) as Mapping[]);
|
||||
|
|
@ -1038,8 +1042,12 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => () => { listsRequestRef.current += 1; }, []);
|
||||
|
||||
// Keep local tab + dismiss overlays when route mode flips (component may be reused).
|
||||
useEffect(() => {
|
||||
jobRequestRef.current += 1;
|
||||
runRequestRef.current += 1;
|
||||
if (pageMode === "templates") setPageTab("templates");
|
||||
else if (pageMode === "jobs") setPageTab("jobs");
|
||||
setTplOpen(false);
|
||||
|
|
@ -1053,39 +1061,55 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
void document.exitFullscreen().catch(() => undefined);
|
||||
}
|
||||
setBoardFs(false);
|
||||
return () => {
|
||||
jobRequestRef.current += 1;
|
||||
runRequestRef.current += 1;
|
||||
};
|
||||
}, [pageMode]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setBeforeBatches([]);
|
||||
void (async () => {
|
||||
if (!beforeTaskId) {
|
||||
setBeforeBatches([]);
|
||||
return;
|
||||
if (!beforeTaskId) return;
|
||||
try {
|
||||
const b = await cutoverCachedGet(`bizCompare:batches:${beforeTaskId}`,
|
||||
() => bizStateListBatches(beforeTaskId), { ttlMs: 5000 });
|
||||
if (!cancelled) setBeforeBatches((b.items || []) as BatchOpt[]);
|
||||
} catch (e) {
|
||||
if (!cancelled) showError(formatErr(e));
|
||||
}
|
||||
const b = await bizStateListBatches(beforeTaskId);
|
||||
setBeforeBatches((b.items || []) as BatchOpt[]);
|
||||
})();
|
||||
}, [beforeTaskId]);
|
||||
return () => { cancelled = true; };
|
||||
}, [beforeTaskId, showError]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setAfterBatches([]);
|
||||
void (async () => {
|
||||
if (!afterTaskId) {
|
||||
setAfterBatches([]);
|
||||
return;
|
||||
if (!afterTaskId) return;
|
||||
try {
|
||||
const b = await cutoverCachedGet(`bizCompare:batches:${afterTaskId}`,
|
||||
() => bizStateListBatches(afterTaskId), { ttlMs: 5000 });
|
||||
if (!cancelled) setAfterBatches((b.items || []) as BatchOpt[]);
|
||||
} catch (e) {
|
||||
if (!cancelled) showError(formatErr(e));
|
||||
}
|
||||
const b = await bizStateListBatches(afterTaskId);
|
||||
setAfterBatches((b.items || []) as BatchOpt[]);
|
||||
})();
|
||||
}, [afterTaskId]);
|
||||
return () => { cancelled = true; };
|
||||
}, [afterTaskId, showError]);
|
||||
|
||||
const templatesById = useMemo(() => new Map(templates.map((x) => [x.id, x])), [templates]);
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
const kw = debouncedListKw.trim().toLowerCase();
|
||||
if (!kw) return jobs;
|
||||
return jobs.filter((j) => {
|
||||
const tpl = templates.find((x) => x.id === j.template_id);
|
||||
const tpl = templatesById.get(j.template_id);
|
||||
const mids = (tpl?.metric_ids || templateSheets(tpl).map((s) => s.metric_id)).join(" ");
|
||||
return `${j.name} ${j.mode} ${j.status} ${tpl?.name || ""} ${mids}`.toLowerCase().includes(kw);
|
||||
});
|
||||
}, [jobs, templates, debouncedListKw]);
|
||||
}, [jobs, templatesById, debouncedListKw]);
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const kw = debouncedListKw.trim().toLowerCase();
|
||||
|
|
@ -1135,10 +1159,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const next = currentlyOn.includes(sid)
|
||||
? currentlyOn.filter((id) => id !== sid)
|
||||
: [...currentlyOn, sid];
|
||||
if (!next.length) {
|
||||
showError(t("bizCompare.needSheets"));
|
||||
return;
|
||||
}
|
||||
// Empty list means "all on" (new template sheets auto-included)
|
||||
setEnabledSheetIds(next.length === all.length ? [] : next);
|
||||
},
|
||||
[jobSheetAllIds, enabledSheetIds],
|
||||
[jobSheetAllIds, enabledSheetIds, showError, t],
|
||||
);
|
||||
|
||||
const setJobTemplateAndSheets = useCallback(
|
||||
|
|
@ -1195,28 +1223,18 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
() => runSheets.find((s) => sheetIdentity(s) === resultSheetId) || runSheets[0] || null,
|
||||
[runSheets, resultSheetId],
|
||||
);
|
||||
const activeRunSheetId = sheetIdentity(activeRunSheet);
|
||||
const activeRunSheetStatus = String(activeRunSheet?.status || "");
|
||||
|
||||
// Clear field filters when switching sheet
|
||||
useEffect(() => {
|
||||
setResultKeyFilters({});
|
||||
}, [resultSheetId]);
|
||||
|
||||
// Reset page when sheet / filter / page size changes
|
||||
useEffect(() => {
|
||||
setResultPage(1);
|
||||
}, [
|
||||
resultSheetId,
|
||||
kindFilter,
|
||||
debouncedResultKw,
|
||||
debouncedKeyFiltersJson,
|
||||
resultPageSize,
|
||||
runDetail?.id,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const runId = String(runDetail?.id || "");
|
||||
const mid = resultSheetId || sheetIdentity(activeRunSheet) || "";
|
||||
const sheetSt = String(activeRunSheet?.status || "");
|
||||
const mid = resultSheetId || activeRunSheetId || "";
|
||||
const sheetSt = activeRunSheetStatus;
|
||||
// Block only while *this* sheet is still in flight — done sheets are readable mid-run
|
||||
const sheetStillRunning = ["pending", "running", "queued"].includes(sheetSt);
|
||||
if (!runId || !mid || jobDetailTab !== "result" || sheetStillRunning) {
|
||||
|
|
@ -1224,11 +1242,30 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
setResultTotal(0);
|
||||
setDiffsSource("");
|
||||
setDiffsTruncated(false);
|
||||
setDiffsLoading(false);
|
||||
setDiffsError("");
|
||||
return;
|
||||
}
|
||||
const filterKey = JSON.stringify([runId, mid, kindFilter, debouncedResultKw,
|
||||
debouncedKeyFiltersJson, resultPageSize]);
|
||||
if (diffFiltersRef.current !== filterKey) {
|
||||
diffFiltersRef.current = filterKey;
|
||||
if (resultPage !== 1) {
|
||||
setPagedDiffs([]);
|
||||
setResultTotal(0);
|
||||
setDiffsLoading(true);
|
||||
setResultPage(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setDiffsLoading(true);
|
||||
setDiffsError("");
|
||||
setPagedDiffs([]);
|
||||
setResultTotal(0);
|
||||
setDiffsSource("");
|
||||
setDiffsTruncated(false);
|
||||
try {
|
||||
const res = await bizCompareListRunDiffs({
|
||||
runId,
|
||||
|
|
@ -1253,8 +1290,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
if (resultPage > pages) setResultPage(pages);
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
// Keep previous rows to avoid strip/table jump; only clear on hard empty run
|
||||
showError(formatErr(e));
|
||||
setDiffsError(formatErr(e));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setDiffsLoading(false);
|
||||
|
|
@ -1267,15 +1303,16 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
runDetail?.id,
|
||||
runDetail?.status,
|
||||
resultSheetId,
|
||||
activeRunSheet?.metric_id,
|
||||
activeRunSheet?.status,
|
||||
activeRunSheetId,
|
||||
activeRunSheetStatus,
|
||||
kindFilter,
|
||||
debouncedResultKw,
|
||||
debouncedKeyFiltersJson,
|
||||
debouncedKeyFilters,
|
||||
resultPage,
|
||||
resultPageSize,
|
||||
jobDetailTab,
|
||||
showError,
|
||||
diffsRetry,
|
||||
]);
|
||||
|
||||
const resultColumns = useMemo(() => {
|
||||
|
|
@ -1991,9 +2028,12 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
};
|
||||
|
||||
const openJob = async (id: string) => {
|
||||
const request = ++jobRequestRef.current;
|
||||
runRequestRef.current += 1;
|
||||
setJobId(id);
|
||||
setJobDetailTab("config");
|
||||
setRunDetail(null);
|
||||
setRuns([]);
|
||||
setResultSheetId("");
|
||||
setKindFilter("diff");
|
||||
setResultKw("");
|
||||
|
|
@ -2001,20 +2041,24 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
if (job) resetJobForm(job);
|
||||
try {
|
||||
const r = await bizCompareListRuns(id, 50);
|
||||
if (request !== jobRequestRef.current) return;
|
||||
const items = r.items || [];
|
||||
setRuns(items);
|
||||
if (items.length) {
|
||||
const latest = await bizCompareGetRun(String((items as any[])[0].id));
|
||||
if (request !== jobRequestRef.current) return;
|
||||
setRunDetail(latest);
|
||||
// Land on batch list so stuck/cancelled runs are visible and actionable
|
||||
setJobDetailTab("runs");
|
||||
}
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
if (request === jobRequestRef.current) showError(formatErr(e));
|
||||
}
|
||||
};
|
||||
|
||||
const closeJob = () => {
|
||||
jobRequestRef.current += 1;
|
||||
runRequestRef.current += 1;
|
||||
if (document.fullscreenElement === boardRef.current) {
|
||||
void document.exitFullscreen().catch(() => undefined);
|
||||
}
|
||||
|
|
@ -2061,20 +2105,26 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
setJobDetailTab("runs");
|
||||
return;
|
||||
}
|
||||
const request = jobRequestRef.current;
|
||||
setBusy(true);
|
||||
try {
|
||||
await bizCompareUpdateJob(jobId, jobConfigBody());
|
||||
// Async enqueue — returns immediately with status=running; poll below.
|
||||
const run = await bizCompareRunJob(jobId);
|
||||
if (request !== jobRequestRef.current) return;
|
||||
runRequestRef.current += 1;
|
||||
setRunDetail(run);
|
||||
setJobDetailTab("runs");
|
||||
showOk(t("bizCompare.runStarted"));
|
||||
const r = await bizCompareListRuns(jobId, 50);
|
||||
if (request !== jobRequestRef.current) return;
|
||||
setRuns(r.items || []);
|
||||
await refresh({ force: true });
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
setJobDetailTab("runs");
|
||||
if (request === jobRequestRef.current) {
|
||||
showError(formatErr(e));
|
||||
setJobDetailTab("runs");
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
|
@ -2082,20 +2132,25 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const runProgress = (runDetail?.summary?.progress || {}) as RunProgressInfo;
|
||||
const runEngine = String(runProgress.engine || "").toLowerCase();
|
||||
|
||||
// Poll active compare runs so the modal can be closed and reopened safely.
|
||||
// One poller; schedule the next tick after requests settle to prevent overlap.
|
||||
useEffect(() => {
|
||||
if (!runIsActive || !runDetail?.id) return;
|
||||
if (!jobId || (!runIsActive && !(jobHasActiveRun && jobDetailTab === "runs"))) return;
|
||||
let cancelled = false;
|
||||
let notified = false;
|
||||
let timer: number | undefined;
|
||||
const jobRequest = jobRequestRef.current;
|
||||
const runId = String(runDetail?.id || "");
|
||||
const tick = async () => {
|
||||
const runRequest = runRequestRef.current;
|
||||
try {
|
||||
const d = await bizCompareGetRun(String(runDetail.id));
|
||||
if (cancelled) return;
|
||||
setRunDetail(d);
|
||||
if (jobId) {
|
||||
const r = await bizCompareListRuns(jobId);
|
||||
if (!cancelled) setRuns(r.items || []);
|
||||
}
|
||||
const [r, d] = await Promise.all([
|
||||
bizCompareListRuns(jobId, 50),
|
||||
runIsActive && runId ? bizCompareGetRun(runId) : Promise.resolve(null),
|
||||
]);
|
||||
if (cancelled || jobRequest !== jobRequestRef.current) return;
|
||||
setRuns(r.items || []);
|
||||
if (!d || runRequest !== runRequestRef.current) return;
|
||||
setRunDetail((prev: typeof runDetail) => String(prev?.id || "") === runId ? d : prev);
|
||||
const st = String(d.status || "");
|
||||
if (!notified && st === "success") {
|
||||
notified = true;
|
||||
|
|
@ -2111,62 +2166,45 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) showError(formatErr(e));
|
||||
} finally {
|
||||
if (!cancelled) timer = window.setTimeout(() => void tick(), 2000);
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const id = window.setInterval(() => void tick(), 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [runDetail?.id, runIsActive, jobId, showOk, showError, t]);
|
||||
|
||||
// Keep the runs list fresh while any batch on this job is active
|
||||
useEffect(() => {
|
||||
if (!jobId || !jobHasActiveRun || jobDetailTab !== "runs") return;
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const r = await bizCompareListRuns(jobId, 50);
|
||||
if (!cancelled) setRuns(r.items || []);
|
||||
const curId = String(runDetail?.id || "");
|
||||
if (curId) {
|
||||
const d = await bizCompareGetRun(curId);
|
||||
if (!cancelled) setRunDetail(d);
|
||||
}
|
||||
} catch {
|
||||
/* ignore list poll errors */
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const id = window.setInterval(() => void tick(), 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [jobId, jobHasActiveRun, jobDetailTab, runDetail?.id]);
|
||||
}, [runDetail?.id, runIsActive, jobId, jobHasActiveRun, jobDetailTab, showOk, showError, t]);
|
||||
|
||||
const loadRun = async (runId: string) => {
|
||||
const request = ++runRequestRef.current;
|
||||
const jobRequest = jobRequestRef.current;
|
||||
try {
|
||||
const d = await bizCompareGetRun(runId);
|
||||
if (request !== runRequestRef.current || jobRequest !== jobRequestRef.current) return;
|
||||
setRunDetail(d);
|
||||
setKindFilter("diff");
|
||||
setResultKw("");
|
||||
setResultKeyFilters({});
|
||||
setJobDetailTab("result");
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
if (request === runRequestRef.current && jobRequest === jobRequestRef.current) showError(formatErr(e));
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRun = async (runId: string) => {
|
||||
if (!runId) return;
|
||||
if (!window.confirm(t("bizCompare.confirmCancelRun"))) return;
|
||||
const request = jobRequestRef.current;
|
||||
setBusy(true);
|
||||
try {
|
||||
const d = await bizCompareCancelRun(runId);
|
||||
if (String(runDetail?.id || "") === runId) setRunDetail(d);
|
||||
if (request !== jobRequestRef.current) return;
|
||||
setRunDetail((prev: typeof runDetail) => String(prev?.id || "") === runId ? d : prev);
|
||||
if (jobId) {
|
||||
const r = await bizCompareListRuns(jobId, 50);
|
||||
if (request !== jobRequestRef.current) return;
|
||||
setRuns(r.items || []);
|
||||
}
|
||||
showOk(t("bizCompare.runCancelled"));
|
||||
|
|
@ -2180,19 +2218,24 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const removeRun = async (runId: string) => {
|
||||
if (!runId) return;
|
||||
if (!window.confirm(t("bizCompare.confirmDeleteRun"))) return;
|
||||
const request = jobRequestRef.current;
|
||||
const runRequest = runRequestRef.current;
|
||||
setBusy(true);
|
||||
try {
|
||||
const wasCurrent = String(runDetail?.id || "") === runId;
|
||||
await bizCompareDeleteRun(runId);
|
||||
if (request !== jobRequestRef.current) return;
|
||||
let nextRuns: typeof runs = [];
|
||||
if (jobId) {
|
||||
const r = await bizCompareListRuns(jobId, 50);
|
||||
if (request !== jobRequestRef.current) return;
|
||||
nextRuns = r.items || [];
|
||||
} else {
|
||||
nextRuns = (runs || []).filter((r) => String(r.id) !== runId);
|
||||
}
|
||||
setRuns(nextRuns);
|
||||
if (wasCurrent) {
|
||||
if (wasCurrent && runRequest === runRequestRef.current) {
|
||||
runRequestRef.current += 1;
|
||||
setRunDetail(null);
|
||||
setResultSheetId("");
|
||||
setJobDetailTab("runs");
|
||||
|
|
@ -2542,7 +2585,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
</thead>
|
||||
<tbody>
|
||||
{filteredJobs.map((j) => {
|
||||
const tpl = templates.find((x) => x.id === j.template_id);
|
||||
const tpl = templatesById.get(j.template_id);
|
||||
const n = templateSheets(tpl).length;
|
||||
return (
|
||||
<tr key={j.id}>
|
||||
|
|
@ -2882,7 +2925,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
: ""
|
||||
}
|
||||
placeholder={cmpMode === "percent" ? "%" : ""}
|
||||
isDisabled={!isCompare || !needsTol}
|
||||
disabled={!isCompare || !needsTol}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
const tol = raw === "" ? undefined : Number(raw);
|
||||
|
|
@ -3113,7 +3156,12 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
className={jobDetailTab === "runs" ? "is-active" : undefined}
|
||||
onPress={() => {
|
||||
setJobDetailTab("runs");
|
||||
if (jobId) void bizCompareListRuns(jobId, 50).then((r) => setRuns(r.items || []));
|
||||
const request = jobRequestRef.current;
|
||||
if (jobId) void bizCompareListRuns(jobId, 50).then((r) => {
|
||||
if (request === jobRequestRef.current) setRuns(r.items || []);
|
||||
}).catch((e) => {
|
||||
if (request === jobRequestRef.current) showError(formatErr(e));
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("bizCompare.tabRuns")}
|
||||
|
|
@ -3354,6 +3402,18 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
{runDetail ? (
|
||||
<span
|
||||
className={`bs-cmp-board__config${runDetail.config_snapshot_version ? "" : " is-legacy"}`}
|
||||
title={t(runDetail.config_snapshot_version
|
||||
? "bizCompare.configSnapshotHint"
|
||||
: "bizCompare.configLegacyHint")}
|
||||
>
|
||||
{t(runDetail.config_snapshot_version
|
||||
? "bizCompare.configSnapshot"
|
||||
: "bizCompare.configLegacy")}
|
||||
</span>
|
||||
) : null}
|
||||
{!boardFs ? (
|
||||
<>
|
||||
<select
|
||||
|
|
@ -3757,6 +3817,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
className={`bs-cmp-strip__kind bs-cmp-strip__kind--${cls}${
|
||||
kindFilter === id ? " is-active" : ""
|
||||
}`}
|
||||
aria-pressed={kindFilter === id}
|
||||
onClick={() => setKindFilter(id as KindFilter)}
|
||||
>
|
||||
{id === "diff"
|
||||
|
|
@ -3782,16 +3843,22 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<div className="bs-cmp-filter-bar">
|
||||
<Input
|
||||
value={resultKw}
|
||||
aria-label={t("bizCompare.resultFilterPh")}
|
||||
placeholder={t("bizCompare.resultFilterPh")}
|
||||
onChange={(e) => setResultKw(e.target.value)}
|
||||
/>
|
||||
<span className="muted bs-sheet-count">
|
||||
{diffsLoading ? "…" : `${pagedDiffs.length}/${resultTotal}`}
|
||||
<span className="muted bs-sheet-count" role="status" aria-live="polite">
|
||||
{diffsLoading ? "…" : t("bizCompare.resultRange", {
|
||||
from: resultTotal ? (resultPage - 1) * resultPageSize + 1 : 0,
|
||||
to: Math.min(resultPage * resultPageSize, resultTotal),
|
||||
total: resultTotal,
|
||||
})}
|
||||
</span>
|
||||
{resultSearchKeyFields.length ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`bs-cmp-strip__toggle${keyFiltersVisible ? " is-active" : ""}`}
|
||||
aria-expanded={keyFiltersVisible}
|
||||
onClick={() => setKeyFiltersOpen((v) => !v)}
|
||||
>
|
||||
{t("bizCompare.keyFiltersToggle")}
|
||||
|
|
@ -3809,6 +3876,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
>
|
||||
{t("bizCompare.displayColsToggle")}
|
||||
</button>
|
||||
{(resultKw.trim() || activeKeyFilterCount > 0) ? (
|
||||
<Button size="sm" variant="ghost" onPress={() => {
|
||||
setResultKw("");
|
||||
setResultKeyFilters({});
|
||||
}}>
|
||||
{t("bizCompare.clearSearch")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{resultSearchKeyFields.length && keyFiltersVisible ? (
|
||||
<div className="bs-cmp-key-filters">
|
||||
|
|
@ -3856,10 +3931,10 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
) : null}
|
||||
{!isLiveSearch &&
|
||||
kindFilter === "unchanged" &&
|
||||
(runDetail?.summary?.unchanged_truncated ||
|
||||
(activeRunSheet?.summary?.unchanged_truncated ||
|
||||
(Number(
|
||||
(activeRunSheet?.summary as any)?.unchanged ||
|
||||
runDetail?.summary?.unchanged ||
|
||||
(activeRunSheet?.summary as any)?.unchanged ??
|
||||
runDetail?.summary?.unchanged ??
|
||||
0,
|
||||
) >
|
||||
Number(
|
||||
|
|
@ -3899,6 +3974,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
className={`pt-list-table-wrap bs-sheet-table bs-cmp-result-table${
|
||||
diffsLoading ? " is-loading" : ""
|
||||
}`}
|
||||
aria-busy={diffsLoading}
|
||||
>
|
||||
<table className="data-table pt-list-table bs-cmp-diff-table">
|
||||
<thead>
|
||||
|
|
@ -4004,6 +4080,22 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
})()}
|
||||
</thead>
|
||||
<tbody>
|
||||
{diffsLoading || diffsError ? (
|
||||
<tr>
|
||||
<td colSpan={resultEmptyColSpan}>
|
||||
<div className={`bs-cmp-result-state${diffsError ? " is-error" : ""}`}
|
||||
role={diffsError ? "alert" : "status"}>
|
||||
<strong>{t(diffsError ? "bizCompare.resultLoadFailed" : "bizCompare.resultLoading")}</strong>
|
||||
{diffsError ? <>
|
||||
<span>{diffsError}</span>
|
||||
<Button size="sm" variant="secondary" onPress={() => setDiffsRetry((n) => n + 1)}>
|
||||
{t("bizCompare.retry")}
|
||||
</Button>
|
||||
</> : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{pagedDiffs.map((d, i) => {
|
||||
const pre = pickSideRow(
|
||||
d.mapped_before as Record<string, unknown> | null | undefined,
|
||||
|
|
@ -4081,13 +4173,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
</tr>
|
||||
);
|
||||
})}
|
||||
{runDetail && !diffsLoading && !pagedDiffs.length ? (
|
||||
{runDetail && !diffsLoading && !diffsError && !pagedDiffs.length ? (
|
||||
<tr>
|
||||
<td colSpan={resultEmptyColSpan}>
|
||||
<div className="pt-list-empty">
|
||||
{kindFilter === "unchanged"
|
||||
? Number(runDetail?.summary?.unchanged || 0) > 0 &&
|
||||
!Number(runDetail?.summary?.unchanged_listed || 0)
|
||||
{hasResultSearch ? t("bizCompare.searchEmpty") : kindFilter === "unchanged"
|
||||
? Number(activeRunSheet?.summary?.unchanged || 0) > 0 &&
|
||||
!Number(activeRunSheet?.summary?.unchanged_listed || 0)
|
||||
? t("bizCompare.unchangedNotStored")
|
||||
: t("bizCompare.resultEmpty")
|
||||
: activeSheetPending
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
type Entry = { at: number; data: unknown };
|
||||
|
||||
const store = new Map<string, Entry>();
|
||||
const inFlight = new Map<string, Promise<unknown>>();
|
||||
const DEFAULT_TTL_MS = 45_000;
|
||||
|
||||
export async function cutoverCachedGet<T>(
|
||||
|
|
@ -21,9 +22,17 @@ export async function cutoverCachedGet<T>(
|
|||
return hit.data as T;
|
||||
}
|
||||
}
|
||||
const data = await fetcher();
|
||||
store.set(key, { at: Date.now(), data });
|
||||
return data;
|
||||
const existing = inFlight.get(key);
|
||||
if (!opts?.force && existing) return existing as Promise<T>;
|
||||
const request = Promise.resolve().then(fetcher).then((data) => {
|
||||
// An invalidation or forced refresh may supersede this request.
|
||||
if (inFlight.get(key) === request) store.set(key, { at: Date.now(), data });
|
||||
return data;
|
||||
}).finally(() => {
|
||||
if (inFlight.get(key) === request) inFlight.delete(key);
|
||||
});
|
||||
inFlight.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Return stale immediately (if any), refresh in background and notify. */
|
||||
|
|
@ -34,27 +43,28 @@ export async function cutoverCachedGetSWR<T>(
|
|||
): Promise<T> {
|
||||
const hit = store.get(key);
|
||||
if (hit) {
|
||||
void fetcher()
|
||||
void cutoverCachedGet(key, fetcher, { ttlMs: 0 })
|
||||
.then((data) => {
|
||||
store.set(key, { at: Date.now(), data });
|
||||
onFresh?.(data);
|
||||
if (store.get(key)?.data === data) onFresh?.(data);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep stale */
|
||||
});
|
||||
return hit.data as T;
|
||||
}
|
||||
const data = await fetcher();
|
||||
store.set(key, { at: Date.now(), data });
|
||||
return data;
|
||||
return cutoverCachedGet(key, fetcher);
|
||||
}
|
||||
|
||||
export function invalidateCutoverCache(prefix = ""): void {
|
||||
if (!prefix) {
|
||||
store.clear();
|
||||
inFlight.clear();
|
||||
return;
|
||||
}
|
||||
for (const k of [...store.keys()]) {
|
||||
if (k.startsWith(prefix)) store.delete(k);
|
||||
}
|
||||
for (const k of [...inFlight.keys()]) {
|
||||
if (k.startsWith(prefix)) inFlight.delete(k);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
133
web/tests/bizCompare.browser.mjs
Normal file
133
web/tests/bizCompare.browser.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// Run against a local Vite server. All API responses are fixtures; no device or DB is touched.
|
||||
// NETX_PLAYWRIGHT_MODULE can point at a bundled Playwright package.
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require(process.env.NETX_PLAYWRIGHT_MODULE || "playwright");
|
||||
const base = process.env.NETX_TEST_URL || "http://127.0.0.1:5179";
|
||||
const output = path.resolve(process.env.NETX_TEST_OUTPUT || "../docs/reviews/assets");
|
||||
await mkdir(output, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, channel: process.env.NETX_TEST_BROWSER || "chrome" });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const fields = ["neighbor", "vrf", "remote_as", "state"];
|
||||
const sheet = {
|
||||
sheet_id: "peer", title: "BGP 邻居", metric_id: "bgp_peer",
|
||||
key_fields: ["neighbor", "vrf"], iface_fields: [], compare_fields: ["remote_as", "state"],
|
||||
status: "done", summary: { changed: 1, removed: 1, added: 1, unchanged: 2, unchanged_listed: 2 },
|
||||
};
|
||||
const side = (label, ne_name, time) => ({ label, ne_name, started_at: time, ne_ip: "10.0.0.1" });
|
||||
const run = {
|
||||
id: "run-1", status: "success", job_id: "job-1", config_snapshot_version: 1, created_at: "2026-10-10T10:08:00Z",
|
||||
before: side("before", "PE-BEFORE", "2026-10-10T09:00:00Z"),
|
||||
after: side("after", "PE-AFTER", "2026-10-10T10:00:00Z"),
|
||||
sheets: [sheet],
|
||||
summary: { ...sheet.summary, before_count: 4, after_count: 4, pass_rate: 50, duration_ms: 1250,
|
||||
sheet_cards: [{ ...sheet, ...sheet.summary, before_count: 4, after_count: 4, pass_rate: 50 }] },
|
||||
};
|
||||
const job = { id: "job-1", name: "割接前后 · 业务验收", template_id: "tpl", mode: "manual", status: "ready",
|
||||
before_task_id: "t1", after_task_id: "t2", before_batch_id: "b", after_batch_id: "a" };
|
||||
const row = (neighbor, state, remote_as = "64512") => ({ neighbor, vrf: "core", state, remote_as });
|
||||
const diffs = [
|
||||
{ kind: "changed", key: { neighbor: "10.2.1.1", vrf: "core" },
|
||||
before: row("10.2.1.1", "Established"), after: row("10.2.1.1", "Idle"),
|
||||
changes: { state: { before: "Established", after: "Idle" } } },
|
||||
{ kind: "removed", key: { neighbor: "10.2.1.2", vrf: "core" }, before: row("10.2.1.2", "Established"), after: null, changes: {} },
|
||||
{ kind: "added", key: { neighbor: "10.2.1.3", vrf: "core" }, before: null, after: row("10.2.1.3", "Established"), changes: {} },
|
||||
{ kind: "unchanged", key: { neighbor: "10.2.1.4", vrf: "core" }, before: row("10.2.1.4", "Established"), after: row("10.2.1.4", "Established"), changes: {} },
|
||||
];
|
||||
let failDiffs = false;
|
||||
let active = false;
|
||||
let legacy = false;
|
||||
let pollCounts = { runs: 0, detail: 0 };
|
||||
await page.route("**/v1/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const p = url.pathname;
|
||||
let data = {};
|
||||
if (p === "/v1/auth/me") data = { user: { id: "test", username: "test", role: "admin", is_active: true } };
|
||||
else if (p.endsWith("/compare/jobs")) data = { items: [job, { ...job, id: "job-slow", name: "慢任务" }] };
|
||||
else if (p.endsWith("/compare/templates")) data = { items: [{ id: "tpl", name: "割接验收模板", metrics: [sheet] }] };
|
||||
else if (p.endsWith("/compare/metrics")) data = { items: [{ metric_id: "bgp_peer", fields: fields.map((name) => ({ name, display_name: name, dtype: "string", is_key: sheet.key_fields.includes(name) })) }] };
|
||||
else if (p.endsWith("/tasks")) data = { items: [{ id: "t1", ne_name: "PE-BEFORE", ne_ip: "10.0.0.1" }, { id: "t2", ne_name: "PE-AFTER", ne_ip: "10.0.0.2" }] };
|
||||
else if (p.endsWith("/batches")) data = { items: [{ id: p.includes("t1") ? "b" : "a", status: "success", row_count: 4 }] };
|
||||
else if (p.endsWith("/runs") && p.includes("/jobs/")) {
|
||||
pollCounts.runs++;
|
||||
if (p.includes("job-slow")) await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
data = { items: [{ ...run, status: active ? "running" : "success" }] };
|
||||
} else if (p.endsWith("/diffs")) {
|
||||
if (failDiffs) { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ detail: "测试:连接暂时中断" }) }); return; }
|
||||
const kind = url.searchParams.get("kind") || "diff";
|
||||
const kw = url.searchParams.get("kw") || "";
|
||||
const items = diffs.filter((d) => (kind === "all" || (kind === "diff" ? ["removed", "changed"].includes(d.kind) : d.kind === kind)) && JSON.stringify(d).includes(kw));
|
||||
data = { items, total: items.length, page: 1, page_size: 100, source: "stored", truncated: false };
|
||||
} else if (p.endsWith("/runs/run-1")) {
|
||||
pollCounts.detail++;
|
||||
data = { ...run, config_snapshot_version: legacy ? null : 1, status: active ? "running" : "success" };
|
||||
} else if (p.endsWith("/mappings")) data = { items: [] };
|
||||
else data = { items: [] };
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(data) });
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${base}/network/cutover/biz-compare`);
|
||||
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
|
||||
await page.getByRole("button", { name: "比对结果", exact: true }).click();
|
||||
await page.locator(".bs-cmp-diff-table tbody tr.bs-cmp-row").first().waitFor();
|
||||
assert.equal(await page.locator(".bs-cmp-diff-table tbody tr.bs-cmp-row").count(), 2);
|
||||
assert.equal(await page.locator(".bs-cmp-board__config").textContent(), "执行配置已保存");
|
||||
await page.screenshot({ path: path.join(output, "biz-compare-desktop.png"), fullPage: true });
|
||||
|
||||
const search = page.locator(".bs-cmp-filter-bar input");
|
||||
failDiffs = true;
|
||||
await search.fill("故障");
|
||||
await page.getByText("对比明细加载失败", { exact: true }).waitFor();
|
||||
assert.equal(await page.locator(".bs-cmp-row").count(), 0);
|
||||
await page.screenshot({ path: path.join(output, "biz-compare-error.png"), fullPage: true });
|
||||
failDiffs = false;
|
||||
await page.getByRole("button", { name: "重试", exact: true }).click();
|
||||
await page.getByText("当前筛选条件下没有匹配结果,可清空筛选或切换结果类型。", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "清空筛选", exact: true }).click();
|
||||
await page.locator(".bs-cmp-row").first().waitFor();
|
||||
|
||||
await page.setViewportSize({ width: 768, height: 1000 });
|
||||
await page.screenshot({ path: path.join(output, "biz-compare-narrow.png"), fullPage: true });
|
||||
const fits = await page.locator(".bs-cmp-strip").evaluate((element) => element.scrollWidth <= element.clientWidth + 1);
|
||||
assert.ok(fits, "filter toolbar must fit its available width");
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
assert.ok(await page.locator(".bs-cmp-strip").evaluate((element) => element.scrollWidth <= element.clientWidth + 1));
|
||||
assert.ok(await page.locator(".bs-cmp-board__toolbar").evaluate((element) => element.scrollWidth <= element.clientWidth + 1));
|
||||
await page.setViewportSize({ width: 768, height: 1000 });
|
||||
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
await page.getByRole("row").filter({ hasText: "慢任务" }).getByRole("button", { name: "详情", exact: true }).click();
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
|
||||
await page.getByRole("button", { name: "比对结果", exact: true }).click();
|
||||
await page.locator(".bs-cmp-row").first().waitFor();
|
||||
await page.waitForTimeout(1100);
|
||||
assert.equal(await page.locator(".bs-cmp-row").count(), 2, "slow old job must not change the current tab");
|
||||
|
||||
legacy = true;
|
||||
await page.reload();
|
||||
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
|
||||
await page.getByRole("button", { name: "比对结果", exact: true }).click();
|
||||
await page.getByText("旧记录:未保存执行配置", { exact: true }).waitFor();
|
||||
await page.screenshot({ path: path.join(output, "biz-compare-legacy.png"), fullPage: true });
|
||||
|
||||
active = true;
|
||||
await page.reload();
|
||||
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
|
||||
await page.locator(".bs-cmp-runs").waitFor();
|
||||
pollCounts = { runs: 0, detail: 0 };
|
||||
await page.waitForTimeout(5200);
|
||||
assert.ok(pollCounts.runs <= 3 && pollCounts.detail <= 3, JSON.stringify(pollCounts));
|
||||
assert.ok(pollCounts.runs >= 2 && pollCounts.detail >= 2, "active run should keep polling");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ passed: ["result layout", "saved execution settings", "legacy settings warning", "error clears stale rows", "retry", "clear filters", "768px toolbar", "old job response ignored", "single poller", "no runtime errors"], pollCounts, output }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
72
web/tests/cutoverDataCache.test.mjs
Normal file
72
web/tests/cutoverDataCache.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { beforeEach, test } from "node:test";
|
||||
import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "../src/pages/network/cutoverDataCache.ts";
|
||||
|
||||
const deferred = () => {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
beforeEach(() => invalidateCutoverCache());
|
||||
|
||||
test("concurrent readers share one fetch", async () => {
|
||||
const pending = deferred();
|
||||
let calls = 0;
|
||||
const fetcher = () => { calls++; return pending.promise; };
|
||||
const first = cutoverCachedGet("batches", fetcher);
|
||||
const second = cutoverCachedGet("batches", fetcher);
|
||||
pending.resolve("fresh");
|
||||
assert.deepEqual(await Promise.all([first, second]), ["fresh", "fresh"]);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("invalidated pending requests cannot repopulate the cache", async () => {
|
||||
const pending = deferred();
|
||||
const old = cutoverCachedGet("batches", () => pending.promise);
|
||||
invalidateCutoverCache("batches");
|
||||
pending.resolve("stale");
|
||||
await old;
|
||||
assert.equal(await cutoverCachedGet("batches", async () => "fresh"), "fresh");
|
||||
});
|
||||
|
||||
test("forced refresh supersedes a slow older request", async () => {
|
||||
const pending = deferred();
|
||||
const old = cutoverCachedGet("lists", () => pending.promise);
|
||||
assert.equal(await cutoverCachedGet("lists", async () => "fresh", { force: true }), "fresh");
|
||||
pending.resolve("old");
|
||||
await old;
|
||||
assert.equal(await cutoverCachedGet("lists", async () => "unexpected"), "fresh");
|
||||
});
|
||||
|
||||
test("a failed fetch can be retried", async () => {
|
||||
await assert.rejects(cutoverCachedGet("lists", async () => { throw new Error("offline"); }));
|
||||
assert.equal(await cutoverCachedGet("lists", async () => "recovered"), "recovered");
|
||||
});
|
||||
|
||||
test("SWR readers share background refresh and each receives the fresh value", async () => {
|
||||
await cutoverCachedGet("lists", async () => "cached");
|
||||
const pending = deferred();
|
||||
let calls = 0;
|
||||
const notifications = [];
|
||||
const fetcher = () => { calls++; return pending.promise; };
|
||||
const first = await cutoverCachedGetSWR("lists", fetcher, (x) => notifications.push(x));
|
||||
const second = await cutoverCachedGetSWR("lists", fetcher, (x) => notifications.push(x));
|
||||
assert.equal(first, "cached");
|
||||
assert.equal(second, "cached");
|
||||
pending.resolve("fresh");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(notifications, ["fresh", "fresh"]);
|
||||
});
|
||||
|
||||
test("invalidated SWR requests do not notify with stale data", async () => {
|
||||
await cutoverCachedGet("lists", async () => "cached");
|
||||
const pending = deferred();
|
||||
const notifications = [];
|
||||
await cutoverCachedGetSWR("lists", () => pending.promise, (x) => notifications.push(x));
|
||||
invalidateCutoverCache("lists");
|
||||
pending.resolve("stale");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue