mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 15:30:45 +08:00
feat(cutover-ui): show paired timing and manual route configuration
This commit is contained in:
parent
d3618deff6
commit
b9d5e27cc4
7 changed files with 161 additions and 30 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "基线无数据",
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>)}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ page.on("pageerror", (e) => errors.push(e.message));
|
|||
let liveRunId = "live-1", uncertain = false, failBoard = false, delayedSheet = "";
|
||||
const now = new Date().toISOString();
|
||||
const project = { id: "p", name: "核心 PE 替换", old_task_id: "op", new_task_id: "np", old_hf_task_id: "oh", new_hf_task_id: "nh", old_baseline_batch_id: "ob", new_baseline_batch_id: "nb", monitor_template_id: "mt", hf_interval_sec: 60, status: "active", created_at: now,
|
||||
old_task: { id: "op", ne_name: "PE-OLD", ne_ip: "10.0.0.1" }, new_task: { id: "np", ne_name: "PE-NEW", ne_ip: "10.0.0.2" } };
|
||||
collect_metric_ids_effective: ["arp"], old_task: { id: "op", ne_name: "PE-OLD", ne_ip: "10.0.0.1" }, new_task: { id: "np", ne_name: "PE-NEW", ne_ip: "10.0.0.2" } };
|
||||
const second = { ...project, id: "p2", name: "第二割接项目" };
|
||||
const wave = { id: "wave", batch_label: "第一批 · 客户专线", status: "active", accept_status: "none" };
|
||||
const sheets = ["s1", "s2"].map((sid, i) => ({ sheet_id: sid, metric_id: "arp", title: `ARP · ${i ? "专线" : "公网"}`, key_fields: ["ip"], compare_fields: ["mac"], iface_fields: [] }));
|
||||
const monitor = { id: "mt", name: "业务连续性", compare_template_id: "ct", compare_template_name: "业务模板", collect_metric_ids: ["arp"], collect_metric_ids_effective: ["arp"], defaults: { custom_setting: false, threshold: 7, out_of_expect: "ignore" }, sheet_overrides: [{ metric_id: "arp", success: [{ old_groups: [["removed"]], new_groups: [["added"]], name: "客户迁移规则" }], anomaly: [] }] };
|
||||
const monitor = { id: "mt", name: "业务连续性", compare_template_id: "ct", compare_template_name: "业务模板", collect_metric_ids: ["arp"], collect_metric_ids_effective: ["arp"], available_metric_ids: ["arp", "ip_route"], defaults: { custom_setting: false, threshold: 7, out_of_expect: "ignore" }, sheet_overrides: [{ metric_id: "arp", success: [{ old_groups: [["removed"]], new_groups: [["added"]], name: "客户迁移规则" }], anomaly: [] }] };
|
||||
const dataset = Array.from({ length: 601 }, (_, i) => ({ id: `row-${i}`, metric_id: "arp", sheet_id: i % 2 ? "s2" : "s1", verdict: i === 600 ? "anomaly" : "migrated", color: i === 600 ? "red" : "green", in_expect: i !== 600, old_key: `10.1.${Math.floor(i / 255)}.${i % 255}`, new_key: `10.1.${Math.floor(i / 255)}.${i % 255}`, old_status: "gone", new_status: "up", rule_hit: i === 600 ? "service_content_changed" : "success#1", integrity_changes: i === 600 ? [{ field: "mac", before: "aa:bb", after: "cc:dd" }] : [] }));
|
||||
function run(rid, pid = "p") {
|
||||
return { id: rid, purpose: rid === "hist" ? "manual" : "auto", created_at: now,
|
||||
|
|
@ -32,6 +32,12 @@ await page.route("**/v1/**", async (route) => {
|
|||
let data = { items: [] };
|
||||
if (p === "/v1/auth/me") data = { user: { id: "fixture", username: "fixture", role: "admin", is_active: true } };
|
||||
else if (req.method() === "PATCH" && p.endsWith("/monitor-templates/mt")) { data = { ...monitor, ...req.postDataJSON() }; writes.push(req.postDataJSON()); }
|
||||
else if (req.method() === "PATCH" && p.endsWith("/projects/p")) {
|
||||
writes.push(req.postDataJSON()); Object.assign(project, req.postDataJSON());
|
||||
project.collect_metric_ids_effective = project.collect_metric_ids;
|
||||
data = project;
|
||||
}
|
||||
else if (p.endsWith("/p/ensure-highfreq")) data = { project, hf_status: "running", collect: { old: [], new: [] } };
|
||||
else if (req.method() === "POST" && p.endsWith("/p/batches")) { writes.push(req.postDataJSON()); data = { ...wave, id: "created-wave", ...req.postDataJSON() }; }
|
||||
else if (p.endsWith("/biz-migration/projects")) data = { items: [project, second] };
|
||||
else if (p.endsWith("/monitor-templates")) data = { items: [monitor] };
|
||||
|
|
@ -42,7 +48,7 @@ await page.route("**/v1/**", async (route) => {
|
|||
else if (p.includes("/biz-migration/projects/") && p.endsWith("/batches")) data = { items: [{ ...wave, id: p.includes("/p2/") ? "wave2" : "wave" }] };
|
||||
else if (p.endsWith("/board")) {
|
||||
if (failBoard) { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ detail: "fixture_refresh_error" }) }); return; }
|
||||
data = { project, batch: wave, run: run(url.searchParams.get("run_id") || liveRunId, p.includes("wave2") ? "p2" : "p") };
|
||||
data = { project, batch: wave, sampling: [{ metric_id: "arp", collect_round_id: "paired-round-1", old_collect_status: "success", new_collect_status: uncertain ? "running" : "success", old_duration_sec: 18, new_duration_sec: 20, old_queue_sec: 2, new_queue_sec: 2, start_skew_sec: 0.4 }], run: run(url.searchParams.get("run_id") || liveRunId, p.includes("wave2") ? "p2" : "p") };
|
||||
} else if (p.endsWith("/runs")) data = { items: [run(liveRunId), run("hist")] };
|
||||
else if (p.endsWith("/diffs")) {
|
||||
let rows = dataset;
|
||||
|
|
@ -67,7 +73,9 @@ try {
|
|||
await dialog().getByText("范围外异常", { exact: true }).waitFor();
|
||||
assert.equal(await dialog().locator(".bm-live-kpis .is-bad").last().locator("strong").textContent(), "1");
|
||||
assert.equal(requests.filter((r) => r.p.endsWith("/diffs")).at(-1).query.limit, "100");
|
||||
passed.push("live continuity KPIs", "outside anomaly visible before acceptance", "100 row page bound");
|
||||
await dialog().getByText("双端关联采集 · 及时性", { exact: true }).waitFor();
|
||||
assert.equal(await dialog().locator('.bm-sampling tbody td:last-child').textContent(), '0.4 s');
|
||||
passed.push("measured paired start skew", "paired round and duration visible", "live continuity KPIs", "outside anomaly visible before acceptance", "100 row page bound");
|
||||
await dialog().getByRole("button", { name: "下一页", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelector('.bm-board__table tbody tr code')?.textContent === '10.1.0.100');
|
||||
passed.push("server pagination beyond first page");
|
||||
|
|
@ -97,7 +105,8 @@ try {
|
|||
await dialog().getByRole("button", { name: "刷新看板", exact: true }).click();
|
||||
await dialog().getByText("数据待确认,不能视为验收通过", { exact: true }).waitFor();
|
||||
await dialog().getByText(/两侧采集间隔过大/).first().waitFor();
|
||||
passed.push("uncertain sampling visibly blocks healthy appearance", "duplicate key warning");
|
||||
await dialog().getByText("采集中", { exact: true }).waitFor();
|
||||
passed.push("in-flight paired sample remains uncertain", "uncertain sampling visibly blocks healthy appearance", "duplicate key warning");
|
||||
uncertain = false;
|
||||
await dialog().getByRole("combobox", { name: "判定历史", exact: true }).selectOption("hist");
|
||||
await dialog().getByText("历史快照", { exact: true }).waitFor();
|
||||
|
|
@ -123,6 +132,19 @@ try {
|
|||
assert.ok(await dialog().locator(".bm-board").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||
await page.screenshot({ path: path.join(output, "cutover-monitor-mobile.png"), fullPage: true });
|
||||
passed.push("390px responsive board", "table overflow stays within scroll container");
|
||||
await page.setViewportSize({ width: 1440, height: 1080 });
|
||||
await dialog().getByRole("button", { name: "基线配置", exact: true }).click();
|
||||
await dialog().locator('.bm-metric-chip').filter({ hasText: 'ip_route' }).click();
|
||||
await dialog().getByText("手动收尾", { exact: true }).waitFor();
|
||||
assert.equal(await dialog().getByRole("spinbutton", { name: "ip_route interval", exact: true }).count(), 0);
|
||||
await dialog().getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
await page.waitForTimeout(500);
|
||||
assert.deepEqual(writes.at(-1).collect_metric_ids, ['arp', 'ip_route']);
|
||||
await dialog().locator('.bm-metric-chip').filter({ hasText: 'ip_route' }).click();
|
||||
await dialog().getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
await page.waitForTimeout(500);
|
||||
assert.deepEqual(writes.at(-1).collect_metric_ids, ['arp']);
|
||||
passed.push("route can be selected from comparison items", "route has manual mode instead of periodic interval", "dynamic metric additions saved explicitly", "removed metric no longer selected");
|
||||
await dialog().getByRole("button", { name: "割接批次", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.bm-expect-table tbody tr').length === 100);
|
||||
await dialog().getByRole("checkbox", { name: "ARP · 公网 业务 0", exact: true }).check();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue