mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 04:53:20 +08:00
feat: refine cutover operations board and monitoring performance
Protect previous wave coverage, normalize batch identities, cache shared metric evidence, and batch diff inserts. Add live continuity KPIs, server paging and filters, scoped business selection, and preserve legacy monitor rule metadata. Validation: 198 backend tests passed with 1 opt-in benchmark skipped; 14 frontend unit tests and 24 browser checks passed; TypeScript and production build passed.
This commit is contained in:
parent
fe4c1db763
commit
32d7d969f6
12 changed files with 731 additions and 151 deletions
|
|
@ -696,6 +696,55 @@ const en = {
|
|||
colChange: "Changes",
|
||||
},
|
||||
bizMigration: {
|
||||
latestSnapshot: "Latest snapshot",
|
||||
steadyOutside: "{{n}} stable items outside scope",
|
||||
"followLive": "Follow live",
|
||||
"historySnapshot": "Historical snapshot",
|
||||
"evaluatedAt": "Evaluated",
|
||||
"refreshBoard": "Refresh board",
|
||||
"oldestSample": "Oldest sample",
|
||||
"liveOverview": "Business migration overview",
|
||||
"remaining": "Remaining items",
|
||||
"anomalyInBatch": "Current wave anomalies",
|
||||
"anomalyOutsideBatch": "Outside wave anomalies",
|
||||
"coverageUnknown": "Uncertain coverage; acceptance cannot pass",
|
||||
"coverageUnknownHint": "Resolve missing, stale or misaligned samples and duplicate keys. Healthy samples cover their own monitor items.",
|
||||
"awaitingPeer": "Awaiting target sample",
|
||||
"migratedPrevious": "Previous wave received",
|
||||
"unfinishedPrevious": "Previous wave unfinished",
|
||||
"sample_time_missing": "Sample time missing",
|
||||
"sample_skew": "Samples too far apart",
|
||||
"sample_stale": "Stale samples",
|
||||
"duplicateKeys": "Duplicate business keys",
|
||||
"uncoveredScope": "Missing scope sheets",
|
||||
"refreshFailed": "Refresh failed; showing retained snapshot:",
|
||||
"lastAcceptance": "Last acceptance",
|
||||
"diffSearch": "Search business key / verdict / status",
|
||||
"verdictFilter": "Verdict filter",
|
||||
"allVerdicts": "All verdicts",
|
||||
"warningVerdict": "In progress / warning",
|
||||
"healthyVerdict": "Received / healthy",
|
||||
"unknownVerdict": "Uncertain",
|
||||
"loadingBoard": "Updating…",
|
||||
"rowsMatched": "{{n}} matching items",
|
||||
"showOutsideAnomalies": "Show outside anomalies",
|
||||
"scopeCurrent": "Current wave",
|
||||
"scopePrevious": "Previous wave",
|
||||
"scopeOutside": "Outside scope",
|
||||
"fieldChanges": "{{n}} changed fields",
|
||||
"outsideProtection": "Protect businesses outside this wave",
|
||||
"outsideProtectionHint": "Report drift outside this wave. Continue checking migrated businesses from previous waves without counting them in current progress.",
|
||||
"reason_service_not_received": "Source departed; target not healthy",
|
||||
"reason_target_unhealthy": "Target unhealthy",
|
||||
"reason_old_service_not_in_baseline": "Business missing from source baseline",
|
||||
"reason_service_content_changed": "Received business content differs",
|
||||
"reason_old_service_still_present": "Business still on source",
|
||||
"reason_target_sample_predates_source": "Target sample predates source departure",
|
||||
"reason_previous_batch_received": "Previous wave remains healthy",
|
||||
"reason_previous_batch_not_received": "Previous wave not received",
|
||||
"reason_unmapped_same_iface": "Drift on unmapped interface",
|
||||
"reason_out_of_expect:anomaly": "Business drift outside scope",
|
||||
"reason_out_of_expect:lost": "Business lost outside scope",
|
||||
title: "Cutover monitor",
|
||||
hint: "Separate from business compare: baseline drift, expect set, dual-device verdict. Reuses collect tasks and port mappings.",
|
||||
hintPort:
|
||||
|
|
@ -862,8 +911,8 @@ const en = {
|
|||
acceptFailedShort: "Fail",
|
||||
colMapped: "Mapped new",
|
||||
colDesc: "Description",
|
||||
colOldPort: "Old port",
|
||||
colNewPort: "New port",
|
||||
colOldPort: "Old business key",
|
||||
colNewPort: "New business key",
|
||||
colOldStatus: "Old admin/phy/prot",
|
||||
colNewStatus: "New admin/phy/prot",
|
||||
colOld: "Old",
|
||||
|
|
|
|||
|
|
@ -695,6 +695,55 @@ const zh = {
|
|||
colChange: "变更",
|
||||
},
|
||||
bizMigration: {
|
||||
latestSnapshot: "最新快照",
|
||||
steadyOutside: "范围外稳定 {{n}} 条",
|
||||
"followLive": "实时跟随",
|
||||
"historySnapshot": "历史快照",
|
||||
"evaluatedAt": "最近判定",
|
||||
"refreshBoard": "刷新看板",
|
||||
"oldestSample": "最早采集",
|
||||
"liveOverview": "业务迁移概览",
|
||||
"remaining": "待迁移条目",
|
||||
"anomalyInBatch": "本批异常",
|
||||
"anomalyOutsideBatch": "范围外异常",
|
||||
"coverageUnknown": "数据待确认,不能视为验收通过",
|
||||
"coverageUnknownHint": "采集缺失、过期、不同步或键冲突需要处理;正常样本仅代表对应监控条目。",
|
||||
"awaitingPeer": "等待新侧采集确认",
|
||||
"migratedPrevious": "前批已接续",
|
||||
"unfinishedPrevious": "前批未接续",
|
||||
"sample_time_missing": "采集时间缺失",
|
||||
"sample_skew": "两侧采集间隔过大",
|
||||
"sample_stale": "采集已过期",
|
||||
"duplicateKeys": "重复业务键",
|
||||
"uncoveredScope": "范围对应表已失效",
|
||||
"refreshFailed": "获取失败,当前显示保留快照:",
|
||||
"lastAcceptance": "上次终验",
|
||||
"diffSearch": "搜索业务键 / 判定 / 状态",
|
||||
"verdictFilter": "判定筛选",
|
||||
"allVerdicts": "全部判定",
|
||||
"warningVerdict": "迁移中 / 提示",
|
||||
"healthyVerdict": "已接续 / 正常",
|
||||
"unknownVerdict": "待确认",
|
||||
"loadingBoard": "正在更新…",
|
||||
"rowsMatched": "匹配 {{n}} 条",
|
||||
"showOutsideAnomalies": "查看范围外异常",
|
||||
"scopeCurrent": "本批范围",
|
||||
"scopePrevious": "前批范围",
|
||||
"scopeOutside": "范围之外",
|
||||
"fieldChanges": "字段变化 {{n}} 项",
|
||||
"outsideProtection": "范围外业务持续保护",
|
||||
"outsideProtectionHint": "本批之外的业务漂移仍报异常;前批已迁移业务持续检查新侧健康,不计入本批进度。",
|
||||
"reason_service_not_received": "老侧退出,新侧未健康接续",
|
||||
"reason_target_unhealthy": "新侧业务状态异常",
|
||||
"reason_old_service_not_in_baseline": "老基线中未找到业务",
|
||||
"reason_service_content_changed": "接续业务内容不一致",
|
||||
"reason_old_service_still_present": "老侧业务尚未退出",
|
||||
"reason_target_sample_predates_source": "新侧样本早于老侧退出",
|
||||
"reason_previous_batch_received": "前批业务持续健康",
|
||||
"reason_previous_batch_not_received": "前批业务未正常接续",
|
||||
"reason_unmapped_same_iface": "未映射端口发生迁移变化",
|
||||
"reason_out_of_expect:anomaly": "范围外业务漂移",
|
||||
"reason_out_of_expect:lost": "范围外业务丢失",
|
||||
title: "割接监控",
|
||||
hint: "与业务对比独立:相对基线漂移 + 本批预期 + 老/新双端判定。复用采集任务与端口映射。",
|
||||
hintPort: "当前监控项:端口状态(interface_brief / admin·phy·prot)。选基线 → 勾选本批端口 → 判定看板。",
|
||||
|
|
@ -857,8 +906,8 @@ const zh = {
|
|||
acceptFailedShort: "未过",
|
||||
colMapped: "映射新口",
|
||||
colDesc: "描述",
|
||||
colOldPort: "老端口",
|
||||
colNewPort: "新端口",
|
||||
colOldPort: "老侧业务标识",
|
||||
colNewPort: "新侧业务标识",
|
||||
colOldStatus: "老状态 admin/phy/prot",
|
||||
colNewStatus: "新状态 admin/phy/prot",
|
||||
colOld: "老侧",
|
||||
|
|
|
|||
|
|
@ -11175,6 +11175,54 @@ html.login-page--paused .login-page__flare {
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Cutover operations console: readable device samples, continuity and scope. */
|
||||
.bm-live-header, .bm-live-header > div, .bm-live-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.bm-live-header { justify-content: space-between; border-bottom: 1px solid rgba(148,163,184,.25); padding-bottom: 10px; }
|
||||
.bm-live-state { font-size: 11px; padding: 3px 8px; border: 1px solid rgba(148,163,184,.35); border-radius: 3px; }
|
||||
.bm-live-state.is-live { color: #34d399; border-color: rgba(52,211,153,.45); }
|
||||
.bm-live-state.is-live::before { content: ""; display: inline-block; width: 6px; height: 6px; background: currentColor; margin-right: 6px; border-radius: 50%; }
|
||||
.bm-live-sources { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; }
|
||||
.bm-live-sources > div { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; padding: 10px 12px; border: 1px solid rgba(148,163,184,.24); border-left: 3px solid #64748b; border-radius: 4px; min-width: 0; }
|
||||
.bm-live-sources > div:last-child { border-left-color: #38bdf8; }
|
||||
.bm-live-sources small { flex-basis: 100%; font-variant-numeric: tabular-nums; }
|
||||
.bm-live-sources strong { overflow-wrap: anywhere; }
|
||||
.bm-live-kpis { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; }
|
||||
.bm-live-kpis > div { display: flex; flex-direction: column; gap: 5px; padding: 12px 14px; border: 1px solid rgba(148,163,184,.25); border-radius: 4px; }
|
||||
.bm-live-kpis span { font-size: 12px; opacity: .8; }
|
||||
.bm-live-kpis strong { font: 600 26px/1.2 ui-monospace,monospace; font-variant-numeric: tabular-nums; }
|
||||
.bm-live-kpis strong small { font-size: 13px; opacity: .7; }
|
||||
.bm-live-kpis .is-ok strong { color: #34d399; }
|
||||
.bm-live-kpis .is-bad strong { color: #f87171; }
|
||||
.bm-live-warning { display: flex; flex-wrap: wrap; gap: 6px 14px; padding: 10px 12px; border: 1px solid rgba(251,191,36,.3); border-left: 3px solid #fbbf24; border-radius: 3px; background: rgba(251,191,36,.06); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.bm-live-warning strong { flex-basis: 100%; }
|
||||
.bm-live-filters > input { flex: 1 1 240px; min-width: 0; }
|
||||
.bm-live-filters > .ui-field-select { max-width: 230px; }
|
||||
.bm-diff-row.is-red > td:first-child { box-shadow: inset 3px 0 #f87171; }
|
||||
.bm-diff-row.is-yellow > td:first-child { box-shadow: inset 3px 0 #fbbf24; }
|
||||
.bm-scope-label { display: block; font-size: 10px; opacity: .65; padding-top: 3px; }
|
||||
.bm-field-changes { font-size: 11px; margin-top: 5px; }
|
||||
.bm-field-changes summary { cursor: pointer; color: #fbbf24; }
|
||||
.bm-field-changes div { padding-top: 5px; overflow-wrap: anywhere; }
|
||||
.bm-field-changes ins { color: #f87171; text-decoration: none; }
|
||||
.bm-board .bs-cmp-main { min-width: 0; }
|
||||
@media (max-width: 720px) {
|
||||
.bm-live-sources { grid-template-columns: 1fr; }
|
||||
.bm-live-kpis { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 6px; }
|
||||
.bm-live-header > .muted { flex: 1 1 100%; font-size: 11px; }
|
||||
.bm-board .bs-cmp-board__toolbar { display: grid; grid-template-columns: minmax(0,1fr); }
|
||||
.bm-board .bs-id-row { min-width: 0; }
|
||||
.bm-board .bs-cmp-board__body { display: flex; flex-direction: column; min-width: 0; }
|
||||
.bm-board .bs-cmp-nav { width: 100%; max-width: none; }
|
||||
.bm-board .bs-cmp-nav__list { max-height: 180px; }
|
||||
.bm-live-filters > .ui-field-select { max-width: none; width: 100%; }
|
||||
.bm-board__table { max-width: 100%; }
|
||||
}
|
||||
|
||||
/* Cutover create wizard */
|
||||
.bm-create {
|
||||
min-height: min(52vh, 520px);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
|
|
@ -237,6 +237,8 @@ type DiffRow = {
|
|||
old_status?: string;
|
||||
new_status?: string;
|
||||
rule_hit?: string;
|
||||
integrity_changes?: Array<{ field: string; before: unknown; after: unknown }>;
|
||||
previous_batch?: boolean;
|
||||
evidence?: {
|
||||
old?: {
|
||||
device?: { ne_name?: string; ne_ip?: string };
|
||||
|
|
@ -285,6 +287,13 @@ type SheetCard = {
|
|||
new_baseline_mode?: string;
|
||||
collect_skipped?: boolean;
|
||||
current_missing?: boolean;
|
||||
awaiting_peer?: number;
|
||||
sample_issues?: string[];
|
||||
old_collected_at?: string;
|
||||
new_collected_at?: string;
|
||||
duplicate_keys_before?: number;
|
||||
duplicate_keys_after?: number;
|
||||
age_limit_sec?: number;
|
||||
};
|
||||
type EvalRun = {
|
||||
id: string;
|
||||
|
|
@ -335,6 +344,9 @@ const VERDICT_I18N: Record<string, string> = {
|
|||
not_involved: "bizMigration.verdictNotInvolved",
|
||||
unexpected_new: "bizMigration.verdictUnexpected",
|
||||
unfinished: "bizMigration.verdictUnfinished",
|
||||
awaiting_peer: "bizMigration.awaitingPeer",
|
||||
migrated_previous: "bizMigration.migratedPrevious",
|
||||
unfinished_previous: "bizMigration.unfinishedPrevious",
|
||||
ok: "bizMigration.verdictOk",
|
||||
};
|
||||
|
||||
|
|
@ -343,13 +355,6 @@ function fmtTime(v?: string | null) {
|
|||
return formatSystemTime(v) || v;
|
||||
}
|
||||
|
||||
function taskLabel(x: TaskOpt) {
|
||||
const base = `${x.ne_name || x.id} (${x.ne_ip || "-"})`;
|
||||
const note = x.note ? ` · ${x.note}` : "";
|
||||
const iv = x.interval_sec ? ` · ${x.interval_sec}s` : "";
|
||||
return `${base}${note}${iv}`;
|
||||
}
|
||||
|
||||
export function BizMigrationPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
|
|
@ -416,11 +421,19 @@ export function BizMigrationPage() {
|
|||
const [board, setBoard] = useState<{
|
||||
run?: {
|
||||
id: string;
|
||||
created_at?: string;
|
||||
purpose?: string;
|
||||
summary?: {
|
||||
sheet_cards?: SheetCard[];
|
||||
progress?: { ok: number; total: number };
|
||||
anomaly?: number;
|
||||
expect_ports?: string[];
|
||||
coverage_complete?: boolean;
|
||||
anomaly_outside_expect?: number;
|
||||
steady_outside?: number;
|
||||
verdict_counts?: Record<string, number>;
|
||||
duplicate_keys?: number;
|
||||
uncovered_scope?: string[];
|
||||
};
|
||||
} | null;
|
||||
} | null>(null);
|
||||
|
|
@ -432,6 +445,7 @@ export function BizMigrationPage() {
|
|||
const [expectMetricId, setExpectMetricId] = useState("");
|
||||
const [selectedExpectKeys, setSelectedExpectKeys] = useState<Set<string>>(new Set());
|
||||
const [portFilter, setPortFilter] = useState("");
|
||||
const [expectPage, setExpectPage] = useState(1);
|
||||
const [redTickets, setRedTickets] = useState<RedTicket[]>([]);
|
||||
const [openRedCount, setOpenRedCount] = useState(0);
|
||||
const [acceptInfo, setAcceptInfo] = useState<MigBatch["accept_summary"] | null>(null);
|
||||
|
|
@ -439,6 +453,22 @@ export function BizMigrationPage() {
|
|||
const [pinNewBatchId, setPinNewBatchId] = useState("");
|
||||
const [evalRuns, setEvalRuns] = useState<EvalRun[]>([]);
|
||||
const [selectedRunId, setSelectedRunId] = useState("");
|
||||
const [followLatest, setFollowLatest] = useState(true);
|
||||
const [diffPage, setDiffPage] = useState(1);
|
||||
const [diffTotal, setDiffTotal] = useState(0);
|
||||
const [boardColor, setBoardColor] = useState("");
|
||||
const [boardKeyword, setBoardKeyword] = useState("");
|
||||
const boardSearch = useDebouncedValue(boardKeyword, 300);
|
||||
const [boardLoading, setBoardLoading] = useState(false);
|
||||
const [boardError, setBoardError] = useState("");
|
||||
const [boardClock, setBoardClock] = useState(Date.now());
|
||||
const boardRequest = useRef(0);
|
||||
const runsRequest = useRef(0);
|
||||
const activeBatchRef = useRef(batchId);
|
||||
activeBatchRef.current = batchId;
|
||||
const activeProjectRef = useRef(projectId);
|
||||
activeProjectRef.current = projectId;
|
||||
const historyRunId = followLatest ? "" : selectedRunId;
|
||||
|
||||
const project = useMemo(
|
||||
() => projects.find((p) => p.id === projectId) || null,
|
||||
|
|
@ -446,29 +476,14 @@ export function BizMigrationPage() {
|
|||
);
|
||||
const batch = useMemo(() => batches.find((b) => b.id === batchId) || null, [batches, batchId]);
|
||||
const sheetCards = board?.run?.summary?.sheet_cards || [];
|
||||
const visibleDiffs = useMemo(() => {
|
||||
let rows = diffs;
|
||||
if (boardMetricId) {
|
||||
rows = rows.filter(
|
||||
(d) => sheetIdentity(d) === boardMetricId || d.metric_id === boardMetricId,
|
||||
);
|
||||
}
|
||||
if (onlyExpect) rows = rows.filter((d) => d.in_expect);
|
||||
return rows;
|
||||
}, [diffs, onlyExpect, boardMetricId]);
|
||||
|
||||
const boardMetricOptions = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const c of sheetCards) {
|
||||
const id = sheetIdentity(c);
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
for (const d of diffs) {
|
||||
const id = sheetIdentity(d);
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
return [...ids];
|
||||
}, [sheetCards, diffs]);
|
||||
const visibleDiffs = diffs;
|
||||
const liveSummary = board?.run?.summary;
|
||||
const liveProgress = liveSummary?.progress;
|
||||
const monitorIsLive = followLatest && batch?.status === "active" && project?.status !== "done";
|
||||
const samplesStale = monitorIsLive && sheetCards.some((c) =>
|
||||
[c.old_collected_at, c.new_collected_at].some((at) => at &&
|
||||
boardClock - Date.parse(at) > (c.age_limit_sec || 180) * 1000));
|
||||
const coverageUnknown = Boolean(board?.run && (liveSummary?.coverage_complete !== true || samplesStale));
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const kw = debouncedListKw.trim().toLowerCase();
|
||||
|
|
@ -519,6 +534,9 @@ export function BizMigrationPage() {
|
|||
return blob.includes(kw);
|
||||
});
|
||||
}, [expectSheets, expectMetricId, portFilter]);
|
||||
const expectPages = Math.max(1, Math.ceil(filteredExpectItems.length / 100));
|
||||
const safeExpectPage = Math.min(expectPage, expectPages);
|
||||
const visibleExpectItems = filteredExpectItems.slice((safeExpectPage - 1) * 100, safeExpectPage * 100);
|
||||
|
||||
const reloadProjects = useCallback(async () => {
|
||||
const res = await cutoverCachedGet("bizMigration:projects", () => bizMigrationListProjects(), {
|
||||
|
|
@ -603,16 +621,20 @@ export function BizMigrationPage() {
|
|||
}
|
||||
try {
|
||||
const res = await bizMigrationListBaselineExpect(pid);
|
||||
if (pid !== activeProjectRef.current) return;
|
||||
const sheets = (res.sheets || []) as ExpectSheet[];
|
||||
setExpectSheets(sheets);
|
||||
setExpectMetricId((prev) => {
|
||||
if (prev && sheets.some((s) => sheetIdentity(s) === prev)) return prev;
|
||||
return sheets[0] ? sheetIdentity(sheets[0]) : "";
|
||||
});
|
||||
} catch {
|
||||
setExpectSheets([]);
|
||||
} catch (e) {
|
||||
if (pid === activeProjectRef.current) {
|
||||
setExpectSheets([]);
|
||||
showError(formatErr(e));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [showError]);
|
||||
|
||||
const loadRedTickets = useCallback(async (pid: string) => {
|
||||
if (!pid) {
|
||||
|
|
@ -622,13 +644,13 @@ export function BizMigrationPage() {
|
|||
}
|
||||
try {
|
||||
const res = await bizMigrationListRedTickets(pid);
|
||||
if (pid !== activeProjectRef.current) return;
|
||||
setRedTickets((res.items || []) as RedTicket[]);
|
||||
setOpenRedCount(Number(res.open_count || 0));
|
||||
} catch {
|
||||
setRedTickets([]);
|
||||
setOpenRedCount(0);
|
||||
} catch (e) {
|
||||
if (pid === activeProjectRef.current) showError(formatErr(e));
|
||||
}
|
||||
}, []);
|
||||
}, [showError]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
|
|
@ -690,6 +712,12 @@ export function BizMigrationPage() {
|
|||
}, [showError]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setBatchId("");
|
||||
setBatches([]);
|
||||
setAcceptInfo(null);
|
||||
setRedTickets([]);
|
||||
setOpenRedCount(0);
|
||||
if (!projectId) {
|
||||
setBatches([]);
|
||||
setBatchId("");
|
||||
|
|
@ -706,18 +734,29 @@ export function BizMigrationPage() {
|
|||
void (async () => {
|
||||
try {
|
||||
const res = await bizMigrationListBatches(projectId);
|
||||
if (cancelled || projectId !== activeProjectRef.current) return;
|
||||
const items = (res.items || []) as MigBatch[];
|
||||
setBatches(items);
|
||||
setBatchId((prev) => (items.find((b) => b.id === prev) ? prev : items[0]?.id || ""));
|
||||
setBatchId(items.find((b) => b.status === "active")?.id || items[0]?.id || "");
|
||||
await loadBaselineExpect(projectId);
|
||||
await loadRedTickets(projectId);
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
if (!cancelled) showError(formatErr(e));
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [projectId, showError, loadBaselineExpect, loadRedTickets]);
|
||||
|
||||
useEffect(() => {
|
||||
setBoard(null);
|
||||
setDiffs([]);
|
||||
setDiffTotal(0);
|
||||
setDiffPage(1);
|
||||
setBoardMetricId("");
|
||||
setFollowLatest(true);
|
||||
setBoardError("");
|
||||
boardRequest.current += 1;
|
||||
runsRequest.current += 1;
|
||||
if (!batchId) {
|
||||
setBoard(null);
|
||||
setDiffs([]);
|
||||
|
|
@ -726,10 +765,33 @@ export function BizMigrationPage() {
|
|||
return;
|
||||
}
|
||||
void loadRuns(batchId);
|
||||
void loadBoard(batchId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [batchId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchId || detailTab !== "board") return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const refresh = async () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
await loadBoard(batchId, historyRunId);
|
||||
if (!cancelled && followLatest) await loadRuns(batchId);
|
||||
}
|
||||
if (!cancelled && monitorIsLive) {
|
||||
timer = setTimeout(() => void refresh(), 10000);
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
boardRequest.current += 1;
|
||||
runsRequest.current += 1;
|
||||
};
|
||||
// Requests are serial within each refresh cycle; filters start a new cycle.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [batchId, detailTab, monitorIsLive, historyRunId, followLatest, boardMetricId, onlyExpect, boardColor, boardSearch, diffPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return;
|
||||
let cancelled = false;
|
||||
|
|
@ -757,84 +819,101 @@ export function BizMigrationPage() {
|
|||
}, [createOpen, createNeSource, debouncedCreateNeKw, createNePage, createNePageSize, showError]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!createOldNe) {
|
||||
setCreateOldBatches([]);
|
||||
setCreateOldHasPortrait(false);
|
||||
setCreateOldBaselineId("");
|
||||
return;
|
||||
}
|
||||
setCreateOldBaselineId("");
|
||||
setCreateOldBatches([]);
|
||||
void bizMigrationNePortrait({
|
||||
source: neSourceOf(createOldNe),
|
||||
ne_id: createOldNe.id,
|
||||
limit: 30,
|
||||
}).then((r) => {
|
||||
if (cancelled) return;
|
||||
setCreateOldHasPortrait(Boolean(r.task?.id));
|
||||
setCreateOldBatches(
|
||||
(r.batches || []).map((x) => ({
|
||||
(r.batches || []).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({
|
||||
id: String(x.id || ""),
|
||||
started_at: x.started_at || null,
|
||||
alias: String(x.alias || ""),
|
||||
})),
|
||||
);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [createOldNe]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!createNewNe) {
|
||||
setCreateNewBatches([]);
|
||||
setCreateNewHasPortrait(false);
|
||||
setCreateNewBaselineId("");
|
||||
return;
|
||||
}
|
||||
setCreateNewBaselineId("");
|
||||
setCreateNewBatches([]);
|
||||
void bizMigrationNePortrait({
|
||||
source: neSourceOf(createNewNe),
|
||||
ne_id: createNewNe.id,
|
||||
limit: 30,
|
||||
}).then((r) => {
|
||||
if (cancelled) return;
|
||||
setCreateNewHasPortrait(Boolean(r.task?.id));
|
||||
setCreateNewBatches(
|
||||
(r.batches || []).map((x) => ({
|
||||
(r.batches || []).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({
|
||||
id: String(x.id || ""),
|
||||
started_at: x.started_at || null,
|
||||
alias: String(x.alias || ""),
|
||||
})),
|
||||
);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [createNewNe]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!project?.old_task_id) {
|
||||
setOldBatches([]);
|
||||
return;
|
||||
}
|
||||
void bizStateListBatches(project.old_task_id, 30).then((r) => {
|
||||
if (cancelled) return;
|
||||
setOldBatches(
|
||||
((r.items || []) as Record<string, unknown>[]).map((x) => ({
|
||||
((r.items || []) as Record<string, unknown>[]).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({
|
||||
id: String(x.id || ""),
|
||||
started_at: (x.started_at as string) || null,
|
||||
alias: String(x.alias || ""),
|
||||
})),
|
||||
);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [project?.old_task_id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!project?.new_task_id) {
|
||||
setNewBatches([]);
|
||||
return;
|
||||
}
|
||||
void bizStateListBatches(project.new_task_id, 30).then((r) => {
|
||||
if (cancelled) return;
|
||||
setNewBatches(
|
||||
((r.items || []) as Record<string, unknown>[]).map((x) => ({
|
||||
((r.items || []) as Record<string, unknown>[]).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({
|
||||
id: String(x.id || ""),
|
||||
started_at: (x.started_at as string) || null,
|
||||
alias: String(x.alias || ""),
|
||||
})),
|
||||
);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [project?.new_task_id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const ids = [
|
||||
...(project?.old_hf_bindings || []).map((b) => b.task_id),
|
||||
project?.old_hf_task_id || "",
|
||||
|
|
@ -845,6 +924,7 @@ export function BizMigrationPage() {
|
|||
return;
|
||||
}
|
||||
void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => {
|
||||
if (cancelled) return;
|
||||
const seen = new Set<string>();
|
||||
const items: { id: string; started_at?: string | null; alias?: string }[] = [];
|
||||
for (const r of results) {
|
||||
|
|
@ -860,10 +940,12 @@ export function BizMigrationPage() {
|
|||
}
|
||||
}
|
||||
setHfOldBatches(items);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [project?.old_hf_task_id, project?.old_hf_bindings]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const ids = [
|
||||
...(project?.new_hf_bindings || []).map((b) => b.task_id),
|
||||
project?.new_hf_task_id || "",
|
||||
|
|
@ -874,6 +956,7 @@ export function BizMigrationPage() {
|
|||
return;
|
||||
}
|
||||
void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => {
|
||||
if (cancelled) return;
|
||||
const seen = new Set<string>();
|
||||
const items: { id: string; started_at?: string | null; alias?: string }[] = [];
|
||||
for (const r of results) {
|
||||
|
|
@ -889,7 +972,8 @@ export function BizMigrationPage() {
|
|||
}
|
||||
}
|
||||
setHfNewBatches(items);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [project?.new_hf_task_id, project?.new_hf_bindings]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -926,8 +1010,10 @@ export function BizMigrationPage() {
|
|||
]);
|
||||
|
||||
async function loadRuns(bid: string) {
|
||||
const request = ++runsRequest.current;
|
||||
try {
|
||||
const res = await bizMigrationListRuns(bid, 20);
|
||||
if (request !== runsRequest.current || bid !== activeBatchRef.current) return;
|
||||
const items = (res.items || []) as EvalRun[];
|
||||
setEvalRuns(items);
|
||||
setSelectedRunId((prev) => {
|
||||
|
|
@ -935,24 +1021,39 @@ export function BizMigrationPage() {
|
|||
return items[0]?.id || "";
|
||||
});
|
||||
} catch {
|
||||
setEvalRuns([]);
|
||||
if (request === runsRequest.current && bid === activeBatchRef.current) setEvalRuns([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBoard(bid: string, runId = "") {
|
||||
const request = ++boardRequest.current;
|
||||
setBoardLoading(true);
|
||||
try {
|
||||
const b = await bizMigrationGetBoard(bid, runId);
|
||||
setBoard(b as typeof board);
|
||||
if (request !== boardRequest.current || bid !== activeBatchRef.current) return;
|
||||
const rid = String((b as { run?: { id?: string } })?.run?.id || runId || "");
|
||||
if (rid) setSelectedRunId(rid);
|
||||
let rows: DiffRow[] = [];
|
||||
let total = 0;
|
||||
if (rid) {
|
||||
const d = await bizMigrationListDiffs({ runId: rid, limit: 500 });
|
||||
setDiffs((d.items || []) as DiffRow[]);
|
||||
} else {
|
||||
setDiffs([]);
|
||||
const d = await bizMigrationListDiffs({ runId: rid, sheetId: boardMetricId,
|
||||
onlyExpect, color: boardColor, kw: boardSearch, offset: (diffPage - 1) * 100, limit: 100 });
|
||||
rows = (d.items || []) as DiffRow[];
|
||||
total = Number(d.total || 0);
|
||||
}
|
||||
if (request !== boardRequest.current || bid !== activeBatchRef.current) return;
|
||||
setBoard(b as typeof board);
|
||||
setBoardClock(Date.now());
|
||||
if (rid) setSelectedRunId(rid);
|
||||
setDiffs(rows);
|
||||
setDiffTotal(total);
|
||||
setBoardError("");
|
||||
if (diffPage > Math.max(1, Math.ceil(total / 100))) setDiffPage(Math.max(1, Math.ceil(total / 100)));
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
if (request === boardRequest.current && bid === activeBatchRef.current) {
|
||||
setBoardError(formatErr(e));
|
||||
}
|
||||
} finally {
|
||||
if (request === boardRequest.current) setBoardLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1189,17 +1290,19 @@ export function BizMigrationPage() {
|
|||
async function onCreateBatch() {
|
||||
if (!projectId) return;
|
||||
// One item per selected key. A flat `keys: [a, b]` is parsed as one composite key.
|
||||
const items: Array<{ metric_id: string; sheet_id?: string; key: string }> = [];
|
||||
const items: Array<{ metric_id: string; sheet_id?: string; key: string[] }> = [];
|
||||
const ports: string[] = [];
|
||||
for (const sheet of expectSheets) {
|
||||
const keys = sheet.items.map((it) => it.key).filter((k) => selectedExpectKeys.has(k));
|
||||
const sid = sheetIdentity(sheet);
|
||||
for (const key of keys) {
|
||||
items.push({ metric_id: sheet.metric_id, sheet_id: sid, key });
|
||||
for (const item of sheet.items) {
|
||||
if (selectedExpectKeys.has(JSON.stringify([sid, item.key]))) {
|
||||
items.push({ metric_id: sheet.metric_id, sheet_id: sid, key: item.keys });
|
||||
if (sheet.metric_id === "interface_brief" && sheet.key_fields.length === 1 &&
|
||||
sheet.iface_fields.includes(sheet.key_fields[0])) ports.push(item.keys[0]);
|
||||
}
|
||||
}
|
||||
if (sheet.metric_id === "interface_brief") ports.push(...keys);
|
||||
}
|
||||
if (!items.length && !ports.length) {
|
||||
if (!items.length) {
|
||||
showError(t("bizMigration.needExpectPorts"));
|
||||
return;
|
||||
}
|
||||
|
|
@ -1405,7 +1508,8 @@ export function BizMigrationPage() {
|
|||
}
|
||||
}
|
||||
|
||||
function toggleExpectKey(key: string) {
|
||||
function toggleExpectKey(rawKey: string) {
|
||||
const key = JSON.stringify([expectMetricId || sheetIdentity(expectSheets[0]), rawKey]);
|
||||
setSelectedExpectKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
|
|
@ -2421,7 +2525,6 @@ export function BizMigrationPage() {
|
|||
<Input
|
||||
value={batchLabel}
|
||||
onChange={(e) => setBatchLabel(e.target.value)}
|
||||
size="sm"
|
||||
placeholder={t("bizMigration.defaultBatchLabel")}
|
||||
aria-label={t("bizMigration.batchLabel")}
|
||||
style={{ minWidth: 100 }}
|
||||
|
|
@ -2492,7 +2595,7 @@ export function BizMigrationPage() {
|
|||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`ct-editor__nav-item${active ? " is-active" : ""}`}
|
||||
onClick={() => setExpectMetricId(sid)}
|
||||
onClick={() => { setExpectMetricId(sid); setExpectPage(1); }}
|
||||
>
|
||||
<span className="ct-editor__nav-name">{sheetLabel(s)}</span>
|
||||
<span className="ct-editor__nav-tag">{s.items.length}</span>
|
||||
|
|
@ -2504,9 +2607,8 @@ export function BizMigrationPage() {
|
|||
<div className="ct-editor__pane">
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<Input
|
||||
size="sm"
|
||||
value={portFilter}
|
||||
onChange={(e) => setPortFilter(e.target.value)}
|
||||
onChange={(e) => { setPortFilter(e.target.value); setExpectPage(1); }}
|
||||
placeholder={t("bizMigration.portFilterPh")}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -2521,7 +2623,7 @@ export function BizMigrationPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredExpectItems.map((it) => {
|
||||
{visibleExpectItems.map((it) => {
|
||||
const row = it.row || {};
|
||||
const statusBits = ["admin", "phy", "prot"]
|
||||
.map((k) => (row[k] != null ? String(row[k]) : ""))
|
||||
|
|
@ -2539,12 +2641,14 @@ export function BizMigrationPage() {
|
|||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedExpectKeys.has(it.key)}
|
||||
aria-label={`${sheetLabel(expectSheets.find((s) => sheetIdentity(s) === expectMetricId) || expectSheets[0])} ${it.label || it.key}`}
|
||||
checked={selectedExpectKeys.has(JSON.stringify([expectMetricId || sheetIdentity(expectSheets[0]), it.key]))}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => toggleExpectKey(it.key)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<code>{it.key}</code>
|
||||
<code>{it.label || it.key}</code>
|
||||
</td>
|
||||
<td>{it.mapped_to || "—"}</td>
|
||||
<td className="bm-expect-desc">{desc || "—"}</td>
|
||||
|
|
@ -2561,6 +2665,8 @@ export function BizMigrationPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ListPager page={safeExpectPage} pages={expectPages} total={filteredExpectItems.length}
|
||||
pageSize={100} onPageChange={setExpectPage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2568,8 +2674,48 @@ export function BizMigrationPage() {
|
|||
|
||||
{detailTab === "board" ? (
|
||||
<div className="bs-cmp-board bm-board">
|
||||
<div className="bm-live-header">
|
||||
<div>
|
||||
<span className={`bm-live-state${monitorIsLive ? " is-live" : ""}`}>
|
||||
{followLatest ? t(monitorIsLive ? "bizMigration.followLive" : "bizMigration.latestSnapshot") : t("bizMigration.historySnapshot")}
|
||||
</span>
|
||||
<strong>{batch?.batch_label || "—"}</strong>
|
||||
</div>
|
||||
<span className="muted">{t("bizMigration.evaluatedAt")} {fmtTime(board?.run?.created_at)}</span>
|
||||
<Button size="sm" variant="secondary" isDisabled={boardLoading || !batchId}
|
||||
onPress={() => void loadBoard(batchId, historyRunId)}>{t("bizMigration.refreshBoard")}</Button>
|
||||
</div>
|
||||
<div className="bm-live-sources">
|
||||
{(["old", "new"] as const).map((side) => {
|
||||
const device = side === "old" ? project?.old_task : project?.new_task;
|
||||
const times = sheetCards.map((c) => side === "old" ? c.old_collected_at : c.new_collected_at)
|
||||
.filter((v): v is string => Boolean(v)).sort();
|
||||
return <div key={side}>
|
||||
<span className="bm-pair-card__label">{t(side === "old" ? "bizMigration.pickOldNe" : "bizMigration.pickNewNe")}</span>
|
||||
<strong>{device?.ne_name || "—"}</strong><code>{device?.ne_ip || "—"}</code>
|
||||
<small className="muted">{t("bizMigration.oldestSample")} {fmtTime(times[0])}</small>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<div className="bm-live-kpis" aria-label={t("bizMigration.liveOverview")}>
|
||||
<div className="is-ok"><span>{t("bizMigration.progress")}</span><strong>{liveProgress?.ok ?? 0}<small> / {liveProgress?.total ?? 0}</small></strong></div>
|
||||
<div><span>{t("bizMigration.remaining")}</span><strong>{Math.max(0, (liveProgress?.total ?? 0) - (liveProgress?.ok ?? 0))}</strong></div>
|
||||
<div className="is-bad"><span>{t("bizMigration.anomalyInBatch")}</span><strong>{Math.max(0, (liveSummary?.anomaly ?? 0) - (liveSummary?.anomaly_outside_expect ?? 0))}</strong></div>
|
||||
<div className="is-bad"><span>{t("bizMigration.anomalyOutsideBatch")}</span><strong>{liveSummary?.anomaly_outside_expect ?? 0}</strong></div>
|
||||
</div>
|
||||
{coverageUnknown ? <div className="bm-live-warning" role="status">
|
||||
<strong>{t("bizMigration.coverageUnknown")}</strong><span>{t("bizMigration.coverageUnknownHint")}</span>
|
||||
{samplesStale ? <span>{t("bizMigration.sample_stale")}</span> : null}
|
||||
{sheetCards.filter((c) => c.sample_issues?.length || c.awaiting_peer).map((c) =>
|
||||
<span key={sheetIdentity(c)}>{sheetLabel(c)}: {c.awaiting_peer ? t("bizMigration.awaitingPeer") :
|
||||
c.sample_issues?.map((issue) => t(`bizMigration.${issue}`)).join(" · ")}</span>)}
|
||||
{liveSummary?.duplicate_keys ? <span>{t("bizMigration.duplicateKeys")} {liveSummary.duplicate_keys}</span> : null}
|
||||
{liveSummary?.uncovered_scope?.length ? <span>{t("bizMigration.uncoveredScope")}: {liveSummary.uncovered_scope.join(", ")}</span> : null}
|
||||
</div> : null}
|
||||
{boardError ? <div className="form-error bm-live-warning" role="alert">{t("bizMigration.refreshFailed")} {boardError}</div> : null}
|
||||
{acceptInfo ? (
|
||||
<div className="bm-board__status">
|
||||
<span className="muted">{t("bizMigration.lastAcceptance")}</span>
|
||||
<span
|
||||
className={`bm-board__verdict${acceptInfo.passed ? " is-ok" : " is-bad"}`}
|
||||
>
|
||||
|
|
@ -2624,17 +2770,16 @@ export function BizMigrationPage() {
|
|||
{batchOptions(hfNewBatches)}
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
value={selectedRunId}
|
||||
value={followLatest ? "" : selectedRunId}
|
||||
onChange={(e) => {
|
||||
const rid = e.target.value;
|
||||
setFollowLatest(!rid);
|
||||
setDiffPage(1);
|
||||
setSelectedRunId(rid);
|
||||
if (batchId && rid) void loadBoard(batchId, rid);
|
||||
}}
|
||||
aria-label={t("bizMigration.runHistory")}
|
||||
>
|
||||
{!evalRuns.length ? (
|
||||
<option value="">{t("bizMigration.runLatest")}</option>
|
||||
) : null}
|
||||
<option value="">{t("bizMigration.followLive")}</option>
|
||||
{evalRuns.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{fmtTime(r.created_at)} · {r.purpose || "manual"}
|
||||
|
|
@ -2659,12 +2804,29 @@ export function BizMigrationPage() {
|
|||
<input
|
||||
type="checkbox"
|
||||
checked={onlyExpect}
|
||||
onChange={(e) => setOnlyExpect(e.target.checked)}
|
||||
onChange={(e) => { setOnlyExpect(e.target.checked); setDiffPage(1); }}
|
||||
/>
|
||||
<span>{t("bizMigration.onlyExpect")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="bm-live-filters">
|
||||
<Input value={boardKeyword} onChange={(e) => { setBoardKeyword(e.target.value); setDiffPage(1); }}
|
||||
placeholder={t("bizMigration.diffSearch")} aria-label={t("bizMigration.diffSearch")} />
|
||||
<FieldSelect value={boardColor} aria-label={t("bizMigration.verdictFilter")}
|
||||
onChange={(e) => { setBoardColor(e.target.value); setDiffPage(1); }}>
|
||||
<option value="">{t("bizMigration.allVerdicts")}</option>
|
||||
<option value="red">{t("bizMigration.anomaly")}</option>
|
||||
<option value="yellow">{t("bizMigration.warningVerdict")}</option>
|
||||
<option value="green">{t("bizMigration.healthyVerdict")}</option>
|
||||
<option value="gray">{t("bizMigration.unknownVerdict")}</option>
|
||||
</FieldSelect>
|
||||
<span className="muted" role="status">{boardLoading ? t("bizMigration.loadingBoard") : t("bizMigration.rowsMatched", { n: String(diffTotal) })}</span>
|
||||
{liveSummary?.steady_outside ? <span className="muted">{t("bizMigration.steadyOutside", { n: String(liveSummary.steady_outside) })}</span> : null}
|
||||
{onlyExpect && (liveSummary?.anomaly_outside_expect ?? 0) > 0 ?
|
||||
<Button size="sm" variant="danger-soft" onPress={() => { setOnlyExpect(false); setBoardColor("red"); setBoardMetricId(""); setDiffPage(1); }}>
|
||||
{t("bizMigration.showOutsideAnomalies")}
|
||||
</Button> : null}
|
||||
</div>
|
||||
{sheetCards.some((c) => c.new_baseline_missing) ? (
|
||||
<div className="form-error bm-hint">
|
||||
{t("bizMigration.newBaselineMissingBanner", {
|
||||
|
|
@ -2705,7 +2867,7 @@ export function BizMigrationPage() {
|
|||
onClick={() => setBoardSubTab("diffs")}
|
||||
>
|
||||
{t("bizMigration.board")}
|
||||
<span className="mt-rule-tab__n">{visibleDiffs.length}</span>
|
||||
<span className="mt-rule-tab__n">{diffTotal}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -2784,7 +2946,7 @@ export function BizMigrationPage() {
|
|||
role="tab"
|
||||
aria-selected={!boardMetricId}
|
||||
className={`bs-cmp-nav__item${!boardMetricId ? " is-active" : ""}`}
|
||||
onClick={() => setBoardMetricId("")}
|
||||
onClick={() => { setBoardMetricId(""); setDiffPage(1); }}
|
||||
>
|
||||
<span className="bs-cmp-nav__dot" />
|
||||
<span className="bs-cmp-nav__name">{t("bizMigration.allMetrics")}</span>
|
||||
|
|
@ -2807,7 +2969,7 @@ export function BizMigrationPage() {
|
|||
mutedCard ? " is-clean" : hot ? " has-diff" : " is-clean"
|
||||
}`}
|
||||
style={mutedCard ? { opacity: 0.55 } : undefined}
|
||||
onClick={() => setBoardMetricId(sid)}
|
||||
onClick={() => { setBoardMetricId(sid); setDiffPage(1); }}
|
||||
>
|
||||
<span className="bs-cmp-nav__dot" />
|
||||
<span className="bs-cmp-nav__name">
|
||||
|
|
@ -2882,8 +3044,8 @@ export function BizMigrationPage() {
|
|||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return (
|
||||
<tr key={d.id} title={cmdTitle || undefined}>
|
||||
<td>{sheetLabel(d)}</td>
|
||||
<tr key={d.id} title={cmdTitle || undefined} className={`bm-diff-row is-${d.color}`}>
|
||||
<td>{sheetLabel(sheetCards.find((c) => sheetIdentity(c) === sheetIdentity(d)) || d)}<small className="bm-scope-label">{t(d.in_expect ? "bizMigration.scopeCurrent" : d.previous_batch ? "bizMigration.scopePrevious" : "bizMigration.scopeOutside")}</small></td>
|
||||
<td>
|
||||
<code>{oldKey}</code>
|
||||
</td>
|
||||
|
|
@ -2904,7 +3066,13 @@ export function BizMigrationPage() {
|
|||
{verdictLabel(d.verdict)}
|
||||
</td>
|
||||
<td className="muted">
|
||||
<code style={{ fontSize: 11 }}>{d.rule_hit || "—"}</code>
|
||||
<span>{d.rule_hit ? t(`bizMigration.reason_${d.rule_hit}`) === `bizMigration.reason_${d.rule_hit}` ? d.rule_hit : t(`bizMigration.reason_${d.rule_hit}`) : "—"}</span>
|
||||
{d.integrity_changes?.length ? <details className="bm-field-changes">
|
||||
<summary>{t("bizMigration.fieldChanges", { n: String(d.integrity_changes.length) })}</summary>
|
||||
{d.integrity_changes.map((change) => <div key={change.field}>
|
||||
<code>{change.field}</code>: <del>{String(change.before ?? "—")}</del> → <ins>{String(change.after ?? "—")}</ins>
|
||||
</div>)}
|
||||
</details> : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
|
@ -2925,6 +3093,9 @@ export function BizMigrationPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{boardSubTab === "diffs" && board?.run ? <ListPager page={diffPage}
|
||||
pages={Math.max(1, Math.ceil(diffTotal / 100))} total={diffTotal} pageSize={100}
|
||||
onPageChange={setDiffPage} disabled={boardLoading} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ function normalizeGroups(
|
|||
.map((g) =>
|
||||
(g || [])
|
||||
.filter(Boolean)
|
||||
.map((c) => migrateLegacyCond(c as unknown as Record<string, unknown>)),
|
||||
.map((c) => typeof c === "string" ? tokenToCond(c) : migrateLegacyCond(c as unknown as Record<string, unknown>)),
|
||||
)
|
||||
.filter((g) => g.length > 0);
|
||||
}
|
||||
|
|
@ -251,9 +251,10 @@ function normalizePat(pat: DualPat, opts?: { allowEmptySide?: boolean }): DualPa
|
|||
const old_groups = normalizeGroups(pat.old_groups, pat.old_conds, pat.old_mode, pat.old);
|
||||
const new_groups = normalizeGroups(pat.new_groups, pat.new_conds, pat.new_mode, pat.new);
|
||||
if (allowEmpty) {
|
||||
return { old_groups, new_groups };
|
||||
return { ...pat, old_groups, new_groups };
|
||||
}
|
||||
return {
|
||||
...pat,
|
||||
old_groups: old_groups.length ? old_groups : [emptyGroup("presence")],
|
||||
new_groups: new_groups.length ? new_groups : [emptyGroup("presence")],
|
||||
};
|
||||
|
|
@ -819,7 +820,7 @@ export function BizMonitorTemplatesPage() {
|
|||
const [name, setName] = useState("");
|
||||
const [compareId, setCompareId] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [outOfExpect, setOutOfExpect] = useState("strict");
|
||||
const [defaultsDraft, setDefaultsDraft] = useState<Record<string, unknown>>({});
|
||||
const [collectIds, setCollectIds] = useState<string[]>([]);
|
||||
const [overrides, setOverrides] = useState<SheetOverride[]>([]);
|
||||
const [activeSheetIdx, setActiveSheetIdx] = useState(0);
|
||||
|
|
@ -908,8 +909,6 @@ export function BizMonitorTemplatesPage() {
|
|||
return schema?.fields || [];
|
||||
}, [metricSchemas, activeMetricId]);
|
||||
|
||||
const sheetKey = useMemo(() => sheets.map((s) => sheetIdentity(s)).join("|"), [sheets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sheets.length) {
|
||||
setActiveSheetIdx(0);
|
||||
|
|
@ -918,18 +917,6 @@ export function BizMonitorTemplatesPage() {
|
|||
if (activeSheetIdx >= sheets.length) setActiveSheetIdx(0);
|
||||
}, [sheets, activeSheetIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sheetKey) return;
|
||||
setOverrides((prev) => {
|
||||
const byId = new Map(prev.map((o) => [o.sheet_id || o.metric_id, o]));
|
||||
return sheets.map(
|
||||
(s) =>
|
||||
byId.get(sheetIdentity(s)) ||
|
||||
emptyOverride(s.metric_id, sheetIdentity(s)),
|
||||
);
|
||||
});
|
||||
}, [compareId, sheetKey]);
|
||||
|
||||
const syncOverride = (metricId: string, patch: Partial<SheetOverride>, sheetId = "") => {
|
||||
const sid = sheetId || sheetIdentity({ metric_id: metricId, sheet_id: sheetId });
|
||||
setOverrides((prev) => {
|
||||
|
|
@ -944,7 +931,7 @@ export function BizMonitorTemplatesPage() {
|
|||
setName("");
|
||||
setCompareId(compareTpls[0]?.id || "");
|
||||
setNote("");
|
||||
setOutOfExpect("strict");
|
||||
setDefaultsDraft({});
|
||||
setCollectIds([]);
|
||||
setOverrides([]);
|
||||
setActiveSheetIdx(0);
|
||||
|
|
@ -960,7 +947,7 @@ export function BizMonitorTemplatesPage() {
|
|||
setName(row.name || "");
|
||||
setCompareId(row.compare_template_id || "");
|
||||
setNote(row.note || "");
|
||||
setOutOfExpect(String((row.defaults || {}).out_of_expect || "strict"));
|
||||
setDefaultsDraft({ ...(row.defaults || {}) });
|
||||
setCollectIds(Array.isArray(row.collect_metric_ids) ? [...row.collect_metric_ids] : []);
|
||||
setOverrides(Array.isArray(row.sheet_overrides) ? (row.sheet_overrides as SheetOverride[]) : []);
|
||||
setActiveSheetIdx(0);
|
||||
|
|
@ -993,7 +980,7 @@ export function BizMonitorTemplatesPage() {
|
|||
showError(t("bizMonitorTpl.needName"));
|
||||
return;
|
||||
}
|
||||
let sheet_overrides = overrides
|
||||
let sheet_overrides: SheetOverride[] = overrides
|
||||
.filter((o) => o.metric_id)
|
||||
.map((o) => ({
|
||||
...o,
|
||||
|
|
@ -1004,11 +991,8 @@ export function BizMonitorTemplatesPage() {
|
|||
try {
|
||||
const parsed = JSON.parse(overridesText || "[]") as unknown[];
|
||||
if (!Array.isArray(parsed)) throw new Error("overrides");
|
||||
sheet_overrides = (parsed as SheetOverride[]).map((o) => ({
|
||||
...o,
|
||||
success: (o.success || []).map((p) => normalizePat(p)),
|
||||
anomaly: (o.anomaly || []).map((p) => normalizePat(p, { allowEmptySide: true })),
|
||||
}));
|
||||
// Advanced JSON is authoritative; the API validates its complete shape.
|
||||
sheet_overrides = parsed as SheetOverride[];
|
||||
} catch {
|
||||
showError(t("bizMonitorTpl.overridesInvalid"));
|
||||
return;
|
||||
|
|
@ -1018,7 +1002,7 @@ export function BizMonitorTemplatesPage() {
|
|||
name: name.trim(),
|
||||
compare_template_id: compareId,
|
||||
collect_metric_ids: collectIds,
|
||||
defaults: { dual_mode: "migrate_pair", out_of_expect: outOfExpect || "strict" },
|
||||
defaults: { ...defaultsDraft, dual_mode: "migrate_pair", out_of_expect: "strict" },
|
||||
sheet_overrides,
|
||||
note: note.trim(),
|
||||
};
|
||||
|
|
@ -1251,16 +1235,7 @@ export function BizMonitorTemplatesPage() {
|
|||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
label={t("bizMonitorTpl.outOfExpect")}
|
||||
value={outOfExpect}
|
||||
onChange={(e) => setOutOfExpect(e.target.value)}
|
||||
fullWidth
|
||||
>
|
||||
<option value="strict">{t("bizMonitorTpl.ooeStrict")}</option>
|
||||
<option value="warn">{t("bizMonitorTpl.ooeWarn")}</option>
|
||||
<option value="ignore">{t("bizMonitorTpl.ooeIgnore")}</option>
|
||||
</FieldSelect>
|
||||
<div className="bm-live-warning"><strong>{t("bizMigration.outsideProtection")}</strong><span>{t("bizMigration.outsideProtectionHint")}</span></div>
|
||||
</div>
|
||||
|
||||
{!sheets.length ? (
|
||||
|
|
|
|||
|
|
@ -2380,6 +2380,8 @@ export const bizMigrationGetBoard = (batchId: string, runId = "") => {
|
|||
export const bizMigrationListDiffs = (params: {
|
||||
runId: string;
|
||||
metricId?: string;
|
||||
sheetId?: string;
|
||||
onlyExpect?: boolean;
|
||||
verdict?: string;
|
||||
color?: string;
|
||||
kw?: string;
|
||||
|
|
@ -2388,6 +2390,8 @@ export const bizMigrationListDiffs = (params: {
|
|||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (params.metricId) p.set("metric_id", params.metricId);
|
||||
if (params.sheetId) p.set("sheet_id", params.sheetId);
|
||||
if (params.onlyExpect) p.set("only_expect", "true");
|
||||
if (params.verdict) p.set("verdict", params.verdict);
|
||||
if (params.color) p.set("color", params.color);
|
||||
if (params.kw) p.set("kw", params.kw);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue