mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 01:00:53 +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
|
|
@ -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>)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue