feat(cutover-ui): show paired timing and manual route configuration

This commit is contained in:
oliver 2026-10-11 08:48:20 +08:00
parent d3618deff6
commit b9d5e27cc4
7 changed files with 161 additions and 30 deletions

View file

@ -696,6 +696,29 @@ const en = {
colChange: "Changes",
},
bizMigration: {
pairCollectionHint: "Each metric has a paired collection round. Saving settings updates task bindings. Route tables are manual-only for final checks; use a separate business comparison for millions of routes. Actual cadence depends on command duration and device availability.",
pairedSampling: "Paired sampling · Timeliness",
manualRoute: "Manual final check",
pairedPeriodic: "Paired periodic",
samplingMode: "Sampling mode",
samplingDuration: "Duration old / new",
samplingQueue: "Queue old / new",
startSkew: "Start skew",
sameRound: "Round",
metricBaselineMissing: "This metric needs a matching baseline receipt",
samplePhase_idle: "Not collected",
samplePhase_queued: "Queued",
samplePhase_running: "Collecting",
samplePhase_success: "Complete",
samplePhase_partial: "Partial",
samplePhase_failed: "Failed",
samplePhase_cancelled: "Stopped",
collect_pair_invalid: "Incomplete collection pair",
collect_pair_missing: "No complete paired sample",
collect_pair_pending: "Waiting for both sides of this round",
collect_pair_failed: "Current round failed; older samples cannot replace it",
collect_round_mismatch: "Samples belong to different rounds",
sample_start_skew: "Actual collection starts are too far apart",
latestSnapshot: "Latest snapshot",
steadyOutside: "{{n}} stable items outside scope",
"followLive": "Follow live",
@ -848,6 +871,10 @@ const en = {
tabBoard: "Board",
saveBaseline: "Save",
baselineSaved: "Baseline saved",
errPairCapacity: "Paired sampling needs at least two collection slots. Check task concurrency, collection threads and the CLI budget.",
errPairBusy: "This metric is still collecting or queued on either side. Wait for the round to finish.",
errPairInvalid: "The collector pair is incomplete. Save configuration again to restore it.",
errPairSameDevice: "Cutover monitoring requires two different devices.",
pickExpectPorts: "Expect",
portFilterPh: "Filter",
emptyBaselinePorts: "No baseline data",

View file

@ -695,6 +695,29 @@ const zh = {
colChange: "变更",
},
bizMigration: {
pairCollectionHint: "每个指标两侧同轮关联采集。保存配置会同步增删采集任务;路由项仅手动采集,收尾时再选择,百万路由建议使用独立业务对比任务。频率是目标间隔,实际周期受命令耗时与设备占用影响。",
pairedSampling: "双端关联采集 · 及时性",
manualRoute: "手动收尾",
pairedPeriodic: "关联周期采集",
samplingMode: "采集方式",
samplingDuration: "采集耗时 老 / 新",
samplingQueue: "排队耗时 老 / 新",
startSkew: "启动时差",
sameRound: "同轮",
metricBaselineMissing: "基线未覆盖此指标,需要补充对应基线",
samplePhase_idle: "尚未采集",
samplePhase_queued: "排队",
samplePhase_running: "采集中",
samplePhase_success: "完成",
samplePhase_partial: "部分完成",
samplePhase_failed: "采集失败",
samplePhase_cancelled: "已停止",
collect_pair_invalid: "双端关联配置不完整",
collect_pair_missing: "尚无完整同轮样本",
collect_pair_pending: "等待两侧同轮采集完成",
collect_pair_failed: "本轮采集失败,不能复用旧样本代替",
collect_round_mismatch: "两侧样本不属于同一轮次",
sample_start_skew: "两侧实际启动时差过大",
latestSnapshot: "最新快照",
steadyOutside: "范围外稳定 {{n}} 条",
"followLive": "实时跟随",
@ -844,6 +867,10 @@ const zh = {
tabBoard: "判定看板",
saveBaseline: "保存配置",
baselineSaved: "基线已保存",
errPairCapacity: "双端关联采集至少需要两个采集并发槽位,请检查任务并发、采集线程和 CLI 预算配置。",
errPairBusy: "该指标的两侧仍在采集或排队,请等待本轮结束。",
errPairInvalid: "双端采集关联不完整,请重新保存配置建立关联。",
errPairSameDevice: "割接监控需要选择两台不同设备。",
pickExpectPorts: "本批预期",
portFilterPh: "筛选",
emptyBaselinePorts: "基线无数据",

View file

@ -11210,6 +11210,9 @@ html.login-page--paused .login-page__flare {
.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; }
.bm-sampling { border: 1px solid rgba(148,163,184,.25); border-radius: 4px; min-width: 0; }
.bm-sampling > summary { padding: 8px 12px; cursor: pointer; font-size: 12px; }
.app-heroui-modal .bm-sampling .pt-list-table-wrap { max-width: 100%; min-height: 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; }

View file

@ -50,6 +50,8 @@ type CreateStep = 0 | 1 | 2 | 3;
const CREATE_NE_PAGE_SIZE_DEFAULT = 20;
const CREATE_NE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const CREATE_STEPS = 4;
const MANUAL_ROUTE_METRICS = new Set(["ip_route", "ipv6_route", "bgp_route"]);
const isManualMetric = (mid: string) => MANUAL_ROUTE_METRICS.has(mid);
function neSourceOf(row: CliTargetItem): "managed" | "ume" {
return row.source === "ume" ? "ume" : "managed";
@ -75,12 +77,13 @@ function monitorTplMetrics(tpl: MonitorTplOpt | null | undefined): string[] {
return mids.length ? mids : ["interface_brief"];
}
/** Empty override = use monitor-template default (full effective set). */
function collectOverridePayload(selected: string[], available: string[]): string[] {
if (!available.length) return selected.filter(Boolean);
const sel = new Set(selected.filter(Boolean));
if (sel.size === available.length && available.every((m) => sel.has(m))) return [];
return [...sel];
// Freeze the operator selection; later template additions stay opt-in.
function collectOverridePayload(selected: string[], _available: string[]): string[] {
return [...new Set(selected.filter(Boolean))];
}
function availableTplMetrics(tpl: MonitorTplOpt | null | undefined): string[] {
return tpl?.available_metric_ids?.length ? tpl.available_metric_ids : monitorTplMetrics(tpl);
}
function metricIntervalPayload(
@ -90,7 +93,7 @@ function metricIntervalPayload(
const out: Record<string, number> = {};
for (const mid of selected) {
const raw = intervals[mid];
if (raw && Number(raw) >= 60) out[mid] = Math.max(60, Number(raw));
if (!isManualMetric(mid) && raw && Number(raw) >= 60) out[mid] = Math.max(60, Number(raw));
}
return out;
}
@ -139,15 +142,24 @@ function mapMigrationApiErr(
}
if (detail === "old_baseline_required") return t("bizMigration.errOldBaselineRequired");
if (detail === "new_baseline_required") return t("bizMigration.errNewBaselineRequired");
if (detail === "collect_pair_same_device") return t("bizMigration.errPairSameDevice");
return formatErr(e);
}
function collectErrorMessage(code: string, t: (key: string) => string): string {
const keys: Record<string, string> = {
collect_pair_capacity: "errPairCapacity", collect_pair_busy: "errPairBusy",
collect_pair_invalid: "errPairInvalid", hf_task_missing: "hfNotBound",
};
return keys[code] ? t(`bizMigration.${keys[code]}`) : code || "collect_failed";
}
function formatHfBindLine(
binds?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number }>,
binds?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number; manual_only?: boolean; collect_group_id?: string }>,
): string {
if (!binds?.length) return "";
return binds
.map((b) => `${b.interval_sec || 60}s·${(b.metric_ids || []).join("+") || "—"}`)
.map((b) => `${b.manual_only ? "manual" : `${b.interval_sec || 60}s`}·${(b.metric_ids || []).join("+") || "—"}`)
.join(" · ");
}
@ -172,6 +184,7 @@ type MonitorTplOpt = {
compare_template_name?: string;
collect_metric_ids?: string[];
collect_metric_ids_effective?: string[];
available_metric_ids?: string[];
};
type Project = {
id: string;
@ -180,8 +193,8 @@ type Project = {
new_task_id: string;
old_hf_task_id?: string;
new_hf_task_id?: string;
old_hf_bindings?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number }>;
new_hf_bindings?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number }>;
old_hf_bindings?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number; manual_only?: boolean; collect_group_id?: string }>;
new_hf_bindings?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number; manual_only?: boolean; collect_group_id?: string }>;
old_baseline_batch_id: string;
new_baseline_batch_id: string;
mapping_id: string;
@ -276,6 +289,18 @@ type RedTicket = {
status: string;
evidence?: DiffRow["evidence"];
};
type SamplingPair = {
metric_id: string;
manual_only?: boolean;
collect_round_id?: string;
old_collect_status?: string;
new_collect_status?: string;
old_duration_sec?: number;
new_duration_sec?: number;
old_queue_sec?: number;
new_queue_sec?: number;
start_skew_sec?: number;
};
type SheetCard = {
metric_id: string;
sheet_id?: string;
@ -294,6 +319,7 @@ type SheetCard = {
duplicate_keys_before?: number;
duplicate_keys_after?: number;
age_limit_sec?: number;
baseline_missing_sides?: string[];
};
type EvalRun = {
id: string;
@ -419,6 +445,7 @@ export function BizMigrationPage() {
const [batches, setBatches] = useState<MigBatch[]>([]);
const [batchId, setBatchId] = useState("");
const [board, setBoard] = useState<{
sampling?: SamplingPair[];
run?: {
id: string;
created_at?: string;
@ -483,7 +510,9 @@ export function BizMigrationPage() {
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 pairedPending = followLatest && board?.sampling?.some((pair) =>
[pair.old_collect_status, pair.new_collect_status].some((s) => !["success", "partial"].includes(s || "idle")));
const coverageUnknown = Boolean(board?.run && (liveSummary?.coverage_complete !== true || samplesStale || pairedPending));
const filteredProjects = useMemo(() => {
const kw = debouncedListKw.trim().toLowerCase();
@ -693,6 +722,9 @@ export function BizMigrationPage() {
collect_metric_ids_effective: Array.isArray(x.collect_metric_ids_effective)
? (x.collect_metric_ids_effective as string[])
: [],
available_metric_ids: Array.isArray(x.available_metric_ids)
? (x.available_metric_ids as string[])
: [],
}));
setMonitorTpls(mts);
if (!createMonitorTplId && mts.length) {
@ -701,7 +733,7 @@ export function BizMigrationPage() {
mts.find((x) => /默认|default|状态|status/i.test(x.name)) ||
mts[0];
setCreateMonitorTplId(preferred.id);
setCreateCollectMetricIds(monitorTplMetrics(preferred));
setCreateCollectMetricIds(monitorTplMetrics(preferred).filter((mid) => !isManualMetric(mid)));
}
};
apply(await cutoverCachedGetSWR("bizMigration:bootstrap", fetchBundle, apply));
@ -1086,7 +1118,7 @@ export function BizMigrationPage() {
monitorTpls[0];
const tplId = preferred?.id || "";
setCreateMonitorTplId(tplId);
setCreateCollectMetricIds(monitorTplMetrics(preferred));
setCreateCollectMetricIds(monitorTplMetrics(preferred).filter((mid) => !isManualMetric(mid)));
}
const openCreate = () => {
@ -1443,7 +1475,7 @@ export function BizMigrationPage() {
} else if (sum.kind === "partial") {
showError(t("bizMigration.collectPartial", { ok: String(sum.okN), fail: String(sum.failN) }));
} else if (sum.kind === "fail" || sum.kind === "missing") {
showError(sum.err === "hf_task_missing" ? t("bizMigration.hfNotBound") : sum.err);
showError(collectErrorMessage(sum.err, t));
} else {
showOk(t("bizMigration.highfreqReady"));
}
@ -1468,7 +1500,7 @@ export function BizMigrationPage() {
else if (sum.kind === "missing") showError(t("bizMigration.hfNotBound"));
else if (sum.kind === "partial") {
showError(t("bizMigration.collectPartial", { ok: String(sum.okN), fail: String(sum.failN) }));
} else if (sum.kind === "fail") showError(sum.err || "collect_failed");
} else if (sum.kind === "fail") showError(collectErrorMessage(sum.err, t));
else showOk(t("bizMigration.collectTriggered"));
} catch (e) {
showError(formatErr(e));
@ -1525,7 +1557,7 @@ export function BizMigrationPage() {
const collectMetricOptions = useMemo(() => {
const set = new Set<string>();
for (const m of monitorTplMetrics(selectedMonitorTpl as MonitorTplOpt | null)) {
for (const m of availableTplMetrics(selectedMonitorTpl as MonitorTplOpt | null)) {
if (m) set.add(m);
}
for (const m of project?.collect_metric_ids_effective || []) {
@ -1557,7 +1589,7 @@ export function BizMigrationPage() {
const createTplMetrics = useMemo(() => {
const tpl = monitorTpls.find((m) => m.id === createMonitorTplId);
const base = monitorTplMetrics(tpl);
const base = availableTplMetrics(tpl);
const set = new Set([...base, ...createCollectMetricIds]);
return [...set];
}, [monitorTpls, createMonitorTplId, createCollectMetricIds]);
@ -1942,7 +1974,7 @@ export function BizMigrationPage() {
const id = e.target.value;
setCreateMonitorTplId(id);
const tpl = monitorTpls.find((m) => m.id === id);
setCreateCollectMetricIds(monitorTplMetrics(tpl));
setCreateCollectMetricIds(monitorTplMetrics(tpl).filter((mid) => !isManualMetric(mid)));
setCreateMetricIntervalSec({});
}}
fullWidth
@ -1960,6 +1992,7 @@ export function BizMigrationPage() {
{createCollectMetricIds.length}/{createTplMetrics.length}
</span>
</div>
<p className="muted bm-hint">{t("bizMigration.pairCollectionHint")}</p>
<div className="bm-metric-chips">
{createTplMetrics.map((mid) => {
const on = createCollectMetricIds.includes(mid);
@ -1974,7 +2007,7 @@ export function BizMigrationPage() {
);
}}
>
{mid}
{mid}{isManualMetric(mid) ? <small> · {t("bizMigration.manualRoute")}</small> : null}
</button>
);
})}
@ -2035,7 +2068,7 @@ export function BizMigrationPage() {
<tr key={mid}>
<td>{mid}</td>
<td>
<Input
{isManualMetric(mid) ? <span>{t("bizMigration.manualRoute")}</span> : <Input
type="number"
min={60}
placeholder={String(createHfIntervalSec)}
@ -2047,7 +2080,7 @@ export function BizMigrationPage() {
}))
}
aria-label={`${mid} interval`}
/>
/>}
</td>
</tr>
))}
@ -2330,6 +2363,7 @@ export function BizMigrationPage() {
</div>
<div className="bm-hf-collect">
<div className="bm-mapping__label">{t("bizMigration.projectCollectMetrics")}</div>
<p className="muted bm-hint">{t("bizMigration.pairCollectionHint")}</p>
<div className="bm-metric-chips">
{collectMetricOptions.map((mid) => {
const on = collectMetricIds.includes(mid);
@ -2344,7 +2378,7 @@ export function BizMigrationPage() {
);
}}
>
{mid}
{mid}{isManualMetric(mid) ? <small> · {t("bizMigration.manualRoute")}</small> : null}
</button>
);
})}
@ -2366,7 +2400,7 @@ export function BizMigrationPage() {
<tr key={mid}>
<td>{mid}</td>
<td>
<Input
{isManualMetric(mid) ? <span>{t("bizMigration.manualRoute")}</span> : <Input
type="number"
min={60}
placeholder={String(hfIntervalSec)}
@ -2378,7 +2412,7 @@ export function BizMigrationPage() {
}))
}
aria-label={`${mid} interval`}
/>
/>}
</td>
</tr>
))}
@ -2703,9 +2737,27 @@ export function BizMigrationPage() {
<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>
{followLatest && board?.sampling?.length ? <details className="bm-sampling" open>
<summary>{t("bizMigration.pairedSampling")}</summary>
<div className="pt-list-table-wrap"><table className="data-table pt-list-table">
<thead><tr><th>{t("bizMigration.sectionMetrics")}</th><th>{t("bizMigration.samplingMode")}</th><th>{t("bizMigration.colOld")}</th><th>{t("bizMigration.colNew")}</th><th>{t("bizMigration.samplingDuration")}</th><th>{t("bizMigration.samplingQueue")}</th><th>{t("bizMigration.startSkew")}</th></tr></thead>
<tbody>{board.sampling.map((pair) => <tr key={pair.metric_id}>
<td><code>{pair.metric_id}</code><small className="bm-scope-label">{pair.collect_round_id ? t("bizMigration.sameRound") + " " + pair.collect_round_id.slice(0, 8) : "—"}</small></td>
<td>{t(pair.manual_only ? "bizMigration.manualRoute" : "bizMigration.pairedPeriodic")}</td>
<td>{t("bizMigration.samplePhase_" + (pair.old_collect_status || "idle"))}</td>
<td>{t("bizMigration.samplePhase_" + (pair.new_collect_status || "idle"))}</td>
<td>{Math.round(pair.old_duration_sec || 0)} / {Math.round(pair.new_duration_sec || 0)} s</td>
<td>{Math.round(pair.old_queue_sec || 0)} / {Math.round(pair.new_queue_sec || 0)} s</td>
<td>{pair.start_skew_sec == null ? "—" : pair.start_skew_sec.toFixed(1) + " s"}</td>
</tr>)}</tbody>
</table></div>
</details> : null}
{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}
{pairedPending ? <span>{t("bizMigration.collect_pair_pending")}</span> : null}
{sheetCards.filter((c) => c.baseline_missing_sides?.length).map((c) =>
<span key={sheetIdentity(c)}>{sheetLabel(c)}: {t("bizMigration.metricBaselineMissing")}</span>)}
{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>)}