Add collect stop, cutover nav prefetch/cache, and lane labels.

Let operators abort queued/running biz_state batches, speed cutover page switches, and show light/heavy collect lanes on monitor items.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-22 19:09:18 +08:00
parent 305822aa4f
commit 924f9bd2f7
16 changed files with 779 additions and 112 deletions

View file

@ -195,6 +195,9 @@ const en = {
scheduleOff: "Manual",
scheduleHint: "When checked, collect on interval; otherwise only Collect now.",
collectNow: "Collect now",
stopCollect: "Stop collect",
stopCollectOk: "Stop collect requested",
stopCollectIdle: "No collect in progress",
delete: "Delete",
confirmDelete: "Delete this task and all batches?",
deleted: "Deleted",
@ -235,6 +238,11 @@ const en = {
scheduleSaved: "Schedule saved",
colInterval: "Interval",
profiles: "Monitor items",
colLane: "Collect lane",
laneLight: "Light",
laneHeavy: "Heavy",
laneLightHint: "Shared SSH with most commands; ~10 min lane cap by default",
laneHeavyHint: "Dedicated SSH + longer timeouts; ~40 min lane cap by default",
enable: "On",
command: "Command template",
colAux: "Aux commands",

View file

@ -195,6 +195,9 @@ const zh = {
scheduleOff: "手动",
scheduleHint: "勾选后按周期自动采集;不勾选则仅支持「立即采集」。",
collectNow: "立即采集",
stopCollect: "停止采集",
stopCollectOk: "已请求停止采集",
stopCollectIdle: "当前没有进行中的采集",
delete: "删除",
confirmDelete: "删除该业务监控任务及所有批次?",
deleted: "已删除",
@ -235,6 +238,11 @@ const zh = {
scheduleSaved: "周期配置已保存",
colInterval: "周期",
profiles: "监控项",
colLane: "采集车道",
laneLight: "轻车道",
laneHeavy: "重车道",
laneLightHint: "与多数命令共用 SSH;整轮默认约 10 分钟上限",
laneHeavyHint: "独立 SSH + 更长超时;整轮默认约 40 分钟上限",
enable: "启用",
command: "命令模板",
colAux: "辅命令",

View file

@ -31,6 +31,7 @@ import {
formatErr,
} from "../../services/api";
import { formatSystemTime } from "../../utils/time";
import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache";
import { jobChipColor, NmStatusChip } from "./nmChips";
type PageTab = "templates" | "jobs";
@ -846,19 +847,36 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const [navCollapsed, setNavCollapsed] = useState(false);
const tplImportRef = useRef<HTMLInputElement | null>(null);
const refresh = useCallback(async () => {
const [taskRes, tpl, maps, j, met] = await Promise.all([
bizStateListTasks(),
bizCompareListTemplates(),
bizCompareListMappings(),
bizCompareListJobs(),
bizCompareListMetrics(),
]);
setTasks((taskRes.items || []) as TaskOpt[]);
setTemplates((tpl.items || []) as Template[]);
setMappings((maps.items || []) as Mapping[]);
setJobs((j.items || []) as Job[]);
setMetrics((met.items || []) as MetricSchema[]);
const refresh = useCallback(async (opts?: { force?: boolean }) => {
type Bundle = {
taskRes: Awaited<ReturnType<typeof bizStateListTasks>>;
tpl: Awaited<ReturnType<typeof bizCompareListTemplates>>;
maps: Awaited<ReturnType<typeof bizCompareListMappings>>;
j: Awaited<ReturnType<typeof bizCompareListJobs>>;
met: Awaited<ReturnType<typeof bizCompareListMetrics>>;
};
const fetchBundle = async (): Promise<Bundle> => {
const [taskRes, tpl, maps, j, met] = await Promise.all([
bizStateListTasks(),
bizCompareListTemplates(),
bizCompareListMappings(),
bizCompareListJobs(),
bizCompareListMetrics(),
]);
return { taskRes, tpl, maps, j, met };
};
const apply = (b: Bundle) => {
setTasks((b.taskRes.items || []) as TaskOpt[]);
setTemplates((b.tpl.items || []) as Template[]);
setMappings((b.maps.items || []) as Mapping[]);
setJobs((b.j.items || []) as Job[]);
setMetrics((b.met.items || []) as MetricSchema[]);
};
if (opts?.force) {
apply(await cutoverCachedGet("bizCompare:lists", fetchBundle, { force: true }));
return;
}
apply(await cutoverCachedGetSWR("bizCompare:lists", fetchBundle, apply));
}, []);
useEffect(() => {
@ -1457,7 +1475,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
else await bizCompareCreateTemplate(body);
showOk(t("bizCompare.templateSaved"));
setTplOpen(false);
await refresh();
invalidateCutoverCache("bizMonitor:");
invalidateCutoverCache("bizMigration:");
await refresh({ force: true });
} catch (e) {
showError(formatErr(e));
} finally {
@ -1471,7 +1491,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
try {
await bizCompareDeleteTemplate(id);
showOk(t("bizCompare.templateDeleted"));
await refresh();
invalidateCutoverCache("bizMonitor:");
invalidateCutoverCache("bizMigration:");
await refresh({ force: true });
} catch (e) {
showError(formatErr(e));
} finally {
@ -1519,7 +1541,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
})),
});
showOk(t("bizCompare.templateImported"));
await refresh();
invalidateCutoverCache("bizMonitor:");
invalidateCutoverCache("bizMigration:");
await refresh({ force: true });
if (pageMode !== "jobs") setPageTab("templates");
} catch (e) {
showError(formatErr(e));
@ -1536,7 +1560,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
await bizCompareDeleteJob(id);
if (jobId === id) closeJob();
showOk(t("bizCompare.jobDeleted"));
await refresh();
await refresh({ force: true });
} catch (e) {
showError(formatErr(e));
} finally {
@ -1577,7 +1601,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setMappingId(String(m.id));
}
showOk(t("bizCompare.mappingSaved"));
await refresh();
await refresh({ force: true });
} catch (e) {
showError(formatErr(e));
} finally {
@ -1712,7 +1736,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const j = await bizCompareCreateJob(jobConfigBody());
showOk(t("bizCompare.created"));
closeCreateJob();
await refresh();
await refresh({ force: true });
await openJob(String(j.id));
} catch (e) {
showError(formatErr(e));
@ -1764,7 +1788,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
try {
await bizCompareUpdateJob(jobId, jobConfigBody());
showOk(t("bizCompare.jobSaved"));
await refresh();
await refresh({ force: true });
} catch (e) {
showError(formatErr(e));
} finally {
@ -1787,7 +1811,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
showOk(t("bizCompare.ran"));
const r = await bizCompareListRuns(jobId);
setRuns(r.items || []);
await refresh();
await refresh({ force: true });
} catch (e) {
showError(formatErr(e));
} finally {

View file

@ -40,6 +40,7 @@ import type { CliTargetItem } from "../../types";
import { writeClipboardText } from "../../utils/clipboard";
import { pageCount } from "../../utils/display";
import { formatSystemTime, localDatetimeInputToUtcIso, utcIsoToLocalDatetimeInput } from "../../utils/time";
import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache";
import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips";
type NeSourceFilter = "all" | "managed" | "ume";
@ -520,12 +521,17 @@ export function BizMigrationPage() {
}, [expectSheets, expectMetricId, portFilter]);
const reloadProjects = useCallback(async () => {
const res = await bizMigrationListProjects();
const res = await cutoverCachedGet("bizMigration:projects", () => bizMigrationListProjects(), {
force: true,
});
setProjects((res.items || []) as Project[]);
invalidateCutoverCache("bizMigration:bootstrap");
}, []);
const reloadMappings = useCallback(async () => {
const mp = await bizCompareListMappings();
const mp = await cutoverCachedGet("bizMigration:mappings", () => bizCompareListMappings(), {
force: true,
});
setMappings(
((mp.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
@ -538,6 +544,7 @@ export function BizMigrationPage() {
: [],
})),
);
invalidateCutoverCache("bizMigration:bootstrap");
}, []);
const parseMapRows = () => {
@ -626,45 +633,56 @@ export function BizMigrationPage() {
useEffect(() => {
void (async () => {
try {
const [pr, mp, mt] = await Promise.all([
bizMigrationListProjects(),
bizCompareListMappings(),
bizMonitorListTemplates(),
]);
setProjects((pr.items || []) as Project[]);
setMappings(
((mp.items || []) as Record<string, unknown>[]).map((x) => ({
type Bundle = {
pr: Awaited<ReturnType<typeof bizMigrationListProjects>>;
mp: Awaited<ReturnType<typeof bizCompareListMappings>>;
mt: Awaited<ReturnType<typeof bizMonitorListTemplates>>;
};
const fetchBundle = async (): Promise<Bundle> => {
const [pr, mp, mt] = await Promise.all([
bizMigrationListProjects(),
bizCompareListMappings(),
bizMonitorListTemplates(),
]);
return { pr, mp, mt };
};
const apply = (b: Bundle) => {
setProjects((b.pr.items || []) as Project[]);
setMappings(
((b.mp.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
name: String(x.name || x.id || ""),
rows: Array.isArray(x.rows)
? (x.rows as Array<{ before_if?: string; after_if?: string }>).map((r) => ({
before_if: String(r.before_if || ""),
after_if: String(r.after_if || ""),
}))
: [],
})),
);
const mts = ((b.mt.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
name: String(x.name || x.id || ""),
rows: Array.isArray(x.rows)
? (x.rows as Array<{ before_if?: string; after_if?: string }>).map((r) => ({
before_if: String(r.before_if || ""),
after_if: String(r.after_if || ""),
}))
compare_template_id: String(x.compare_template_id || ""),
compare_template_name: String(x.compare_template_name || ""),
collect_metric_ids: Array.isArray(x.collect_metric_ids)
? (x.collect_metric_ids as string[])
: [],
})),
);
const mts = ((mt.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
name: String(x.name || x.id || ""),
compare_template_id: String(x.compare_template_id || ""),
compare_template_name: String(x.compare_template_name || ""),
collect_metric_ids: Array.isArray(x.collect_metric_ids)
? (x.collect_metric_ids as string[])
: [],
collect_metric_ids_effective: Array.isArray(x.collect_metric_ids_effective)
? (x.collect_metric_ids_effective as string[])
: [],
}));
setMonitorTpls(mts);
if (!createMonitorTplId && mts.length) {
const preferred =
mts.find((x) => x.name === "默认割接监控") ||
mts.find((x) => /默认|default|状态|status/i.test(x.name)) ||
mts[0];
setCreateMonitorTplId(preferred.id);
setCreateCollectMetricIds(monitorTplMetrics(preferred));
}
collect_metric_ids_effective: Array.isArray(x.collect_metric_ids_effective)
? (x.collect_metric_ids_effective as string[])
: [],
}));
setMonitorTpls(mts);
if (!createMonitorTplId && mts.length) {
const preferred =
mts.find((x) => x.name === "默认割接监控") ||
mts.find((x) => /默认|default|状态|status/i.test(x.name)) ||
mts[0];
setCreateMonitorTplId(preferred.id);
setCreateCollectMetricIds(monitorTplMetrics(preferred));
}
};
apply(await cutoverCachedGetSWR("bizMigration:bootstrap", fetchBundle, apply));
} catch (e) {
showError(formatErr(e));
}

View file

@ -15,6 +15,7 @@ import {
bizMonitorUpdateTemplate,
formatErr,
} from "../../services/api";
import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache";
type MetricField = { name: string; display_name?: string };
type MetricSchema = { metric_id: string; fields: MetricField[] };
@ -828,29 +829,44 @@ export function BizMonitorTemplatesPage() {
const [overridesText, setOverridesText] = useState("[]");
const tplImportRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(async () => {
const [mon, cmp, metrics] = await Promise.all([
bizMonitorListTemplates(),
bizCompareListTemplates(),
bizCompareListMetrics(),
]);
setItems((mon.items || []) as MonitorTpl[]);
setCompareTpls(
((cmp.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
name: String(x.name || x.id || ""),
metrics: Array.isArray(x.metrics) ? (x.metrics as CompareSheet[]) : [],
})),
);
setMetricSchemas(
((metrics.items || []) as MetricSchema[]).map((m) => ({
metric_id: m.metric_id,
fields: (m.fields || []).map((f) => ({
name: f.name,
display_name: f.display_name,
const refresh = useCallback(async (opts?: { force?: boolean }) => {
type Bundle = {
mon: Awaited<ReturnType<typeof bizMonitorListTemplates>>;
cmp: Awaited<ReturnType<typeof bizCompareListTemplates>>;
metrics: Awaited<ReturnType<typeof bizCompareListMetrics>>;
};
const fetchBundle = async (): Promise<Bundle> => {
const [mon, cmp, metrics] = await Promise.all([
bizMonitorListTemplates(),
bizCompareListTemplates(),
bizCompareListMetrics(),
]);
return { mon, cmp, metrics };
};
const apply = (b: Bundle) => {
setItems((b.mon.items || []) as MonitorTpl[]);
setCompareTpls(
((b.cmp.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
name: String(x.name || x.id || ""),
metrics: Array.isArray(x.metrics) ? (x.metrics as CompareSheet[]) : [],
})),
})),
);
);
setMetricSchemas(
((b.metrics.items || []) as MetricSchema[]).map((m) => ({
metric_id: m.metric_id,
fields: (m.fields || []).map((f) => ({
name: f.name,
display_name: f.display_name,
})),
})),
);
};
if (opts?.force) {
apply(await cutoverCachedGet("bizMonitor:lists", fetchBundle, { force: true }));
return;
}
apply(await cutoverCachedGetSWR("bizMonitor:lists", fetchBundle, apply));
}, []);
useEffect(() => {
@ -1016,7 +1032,8 @@ export function BizMonitorTemplatesPage() {
showOk(t("bizMonitorTpl.created"));
}
closeEdit();
await refresh();
invalidateCutoverCache("bizMigration:");
await refresh({ force: true });
} catch (e) {
showError(formatErr(e));
} finally {
@ -1030,7 +1047,8 @@ export function BizMonitorTemplatesPage() {
try {
await bizMonitorDeleteTemplate(id);
showOk(t("bizMonitorTpl.deleted"));
await refresh();
invalidateCutoverCache("bizMigration:");
await refresh({ force: true });
} catch (e) {
showError(formatErr(e));
} finally {
@ -1083,7 +1101,8 @@ export function BizMonitorTemplatesPage() {
sheet_overrides: body.sheet_overrides,
});
showOk(t("bizMonitorTpl.templateImported"));
await refresh();
invalidateCutoverCache("bizMigration:");
await refresh({ force: true });
} catch (e) {
showError(formatErr(e));
} finally {

View file

@ -9,6 +9,7 @@ import { useI18n } from "../../i18n";
import {
bizStateBulkDeleteBatches,
bizStateCollectNow,
bizStateCollectStop,
bizStateCreateTask,
bizStateDeleteBatch,
bizStateDeleteTask,
@ -37,6 +38,7 @@ import type { CliTargetItem } from "../../types";
import { pageCount } from "../../utils/display";
import { writeClipboardText } from "../../utils/clipboard";
import { formatSystemTime } from "../../utils/time";
import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache";
import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips";
type TaskRow = {
@ -68,6 +70,8 @@ type Profile = {
description: string;
metric_id: string;
kind?: string;
/** light | heavy — collect dual-lane */
collect_lane?: string;
placeholders?: Placeholder[];
aux_commands?: Array<{
key: string;
@ -305,7 +309,8 @@ export function BizStatePage() {
const refreshTasks = useCallback(async () => {
const purpose =
purposeFilter === "all" ? "" : purposeFilter === "portrait" ? "portrait" : "cutover_hf";
const res = await bizStateListTasks(purpose);
const key = `bizState:tasks:${purpose || "all"}`;
const res = await cutoverCachedGet(key, () => bizStateListTasks(purpose), { force: true });
const items = (res.items || []) as TaskRow[];
setTasks(items);
return items;
@ -333,13 +338,19 @@ export function BizStatePage() {
useEffect(() => {
void (async () => {
try {
await refreshTasks();
const purpose =
purposeFilter === "all" ? "" : purposeFilter === "portrait" ? "portrait" : "cutover_hf";
const key = `bizState:tasks:${purpose || "all"}`;
const apply = (res: Awaited<ReturnType<typeof bizStateListTasks>>) => {
setTasks((res.items || []) as TaskRow[]);
};
apply(await cutoverCachedGetSWR(key, () => bizStateListTasks(purpose), apply));
} catch (e) {
showError(formatErr(e));
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps -- refresh when purpose filter / refreshTasks changes
}, [refreshTasks]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- refresh when purpose filter changes
}, [purposeFilter]);
// Progress poll while any collect is running (list chips and/or open task).
// Does NOT block navigation; cleans up on unmount / when nothing is collecting.
@ -625,6 +636,7 @@ export function BizStatePage() {
});
showOk(t("bizState.created"));
closeCreate();
invalidateCutoverCache("bizCompare:");
await refreshTasks();
await openTask(String(task.id), "profiles");
} catch (e) {
@ -815,6 +827,35 @@ export function BizStatePage() {
await collectNowForTask(taskId, true);
};
const stopCollectForTask = async (id: string) => {
setBusy(true);
try {
const out = await bizStateCollectStop(id);
if (out.stopped) {
showOk(t("bizState.stopCollectOk"));
} else {
showOk(t("bizState.stopCollectIdle"));
}
setTaskCollecting(id, false);
try {
await refreshTasks();
} catch {
/* ignore */
}
if (taskId === id) {
try {
await refreshTaskProgress(id);
} catch {
/* ignore */
}
}
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
};
const exportTaskCommands = async () => {
if (!taskId) return;
setBusy(true);
@ -835,6 +876,7 @@ export function BizStatePage() {
await bizStateDeleteTask(id);
showOk(t("bizState.deleted"));
if (taskId === id) closeTask();
invalidateCutoverCache("bizCompare:");
await refreshTasks();
} catch (e) {
showError(formatErr(e));
@ -1225,6 +1267,16 @@ export function BizStatePage() {
>
{t("bizState.collectNow")}
</Button>
{row.collect_running || collectingIds[row.id] ? (
<Button
size="sm"
variant="danger"
isDisabled={busy}
onPress={() => void stopCollectForTask(row.id)}
>
{t("bizState.stopCollect")}
</Button>
) : null}
<Button
size="sm"
variant="danger"
@ -1502,6 +1554,7 @@ export function BizStatePage() {
<tr>
<th>{t("bizState.enable")}</th>
<th>{t("bizState.profiles")}</th>
<th>{t("bizState.colLane")}</th>
<th>{t("bizState.params")}</th>
<th>{t("bizState.colActions")}</th>
<th>{t("bizState.command")}</th>
@ -1569,6 +1622,17 @@ export function BizStatePage() {
<div className="bs-bind-hint muted">{t("bizState.bindHintRequired")}</div>
) : null}
</td>
<td>
{String(prof.collect_lane || "light").toLowerCase() === "heavy" ? (
<span title={t("bizState.laneHeavyHint")}>
<NmStatusChip color="warning">{t("bizState.laneHeavy")}</NmStatusChip>
</span>
) : (
<span title={t("bizState.laneLightHint")}>
<NmStatusChip color="default">{t("bizState.laneLight")}</NmStatusChip>
</span>
)}
</td>
<td className="bs-params-cell">
{needsBind ? (
<div
@ -1788,6 +1852,16 @@ export function BizStatePage() {
>
{t("bizState.collectNow")}
</Button>
{detail?.collect_running || (taskId && collectingIds[taskId]) ? (
<Button
size="sm"
variant="danger"
isDisabled={busy || !taskId}
onPress={() => void stopCollectForTask(taskId)}
>
{t("bizState.stopCollect")}
</Button>
) : null}
<Button
size="sm"
variant="secondary"

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NETWORK_NAV, type NetworkNavGroupId } from "../../config/networkNav";
import { useI18n } from "../../i18n";
import { prefetchCutoverPages } from "./cutoverPrefetch";
function groupContainsPath(groupId: NetworkNavGroupId, pathname: string): boolean {
const group = NETWORK_NAV.find((g) => g.id === groupId);
@ -36,6 +37,13 @@ export function NetworkLayout() {
});
}, [pathname]);
// Warm cutover page chunks as soon as that nav group is open / in use.
useEffect(() => {
if (pathname.startsWith("/network/cutover") || expandedKeys.has("cutoverCompare")) {
prefetchCutoverPages();
}
}, [pathname, expandedKeys]);
const activeGroupIds = useMemo(() => {
const ids = new Set<NetworkNavGroupId>();
for (const g of NETWORK_NAV) {
@ -54,7 +62,12 @@ export function NetworkLayout() {
hideSeparator
allowsMultipleExpanded
expandedKeys={expandedKeys}
onExpandedChange={setExpandedKeys}
onExpandedChange={(keys) => {
setExpandedKeys(keys as Set<Key>);
if ((keys as Set<Key>).has("cutoverCompare")) {
prefetchCutoverPages();
}
}}
>
{NETWORK_NAV.map((group) => {
const activeGroup = activeGroupIds.has(group.id);
@ -65,7 +78,12 @@ export function NetworkLayout() {
className={`network-nav__group${activeGroup ? " is-active-group" : ""}`}
>
<Accordion.Heading>
<Accordion.Trigger className="network-nav__group-toggle">
<Accordion.Trigger
className="network-nav__group-toggle"
onHoverStart={
group.id === "cutoverCompare" ? () => prefetchCutoverPages() : undefined
}
>
<span className="network-nav__group-label">{t(group.labelKey)}</span>
<Accordion.Indicator className="network-nav__chevron" />
</Accordion.Trigger>
@ -84,6 +102,11 @@ export function NetworkLayout() {
item.path === "/network/devices" ||
item.path === "/network/tasks/port-traffic"
}
onMouseEnter={
group.id === "cutoverCompare"
? () => prefetchCutoverPages()
: undefined
}
>
{t(item.labelKey)}
</NavLink>

View file

@ -0,0 +1,60 @@
/**
* Short-lived cache for cutover list APIs.
* Switching biz-state / compare / migration / templates remounts each page;
* without cache every click re-waits on Promise.all network round-trips.
*/
type Entry = { at: number; data: unknown };
const store = new Map<string, Entry>();
const DEFAULT_TTL_MS = 45_000;
export async function cutoverCachedGet<T>(
key: string,
fetcher: () => Promise<T>,
opts?: { force?: boolean; ttlMs?: number },
): Promise<T> {
const ttl = opts?.ttlMs ?? DEFAULT_TTL_MS;
if (!opts?.force) {
const hit = store.get(key);
if (hit && Date.now() - hit.at < ttl) {
return hit.data as T;
}
}
const data = await fetcher();
store.set(key, { at: Date.now(), data });
return data;
}
/** Return stale immediately (if any), refresh in background and notify. */
export async function cutoverCachedGetSWR<T>(
key: string,
fetcher: () => Promise<T>,
onFresh?: (data: T) => void,
): Promise<T> {
const hit = store.get(key);
if (hit) {
void fetcher()
.then((data) => {
store.set(key, { at: Date.now(), data });
onFresh?.(data);
})
.catch(() => {
/* keep stale */
});
return hit.data as T;
}
const data = await fetcher();
store.set(key, { at: Date.now(), data });
return data;
}
export function invalidateCutoverCache(prefix = ""): void {
if (!prefix) {
store.clear();
return;
}
for (const k of [...store.keys()]) {
if (k.startsWith(prefix)) store.delete(k);
}
}

View file

@ -0,0 +1,13 @@
/** Prefetch cutover compare route chunks so nav switches don't wait on first download. */
let started = false;
export function prefetchCutoverPages(): void {
if (started) return;
started = true;
// Fire-and-forget dynamic imports (same modules as App.tsx lazy()).
void import("./BizStatePage");
void import("./BizComparePage");
void import("./BizMigrationPage");
void import("./BizMonitorTemplatesPage");
}

View file

@ -35,7 +35,9 @@ export function jobChipColor(status: string | null | undefined): NmChipColor {
.toLowerCase();
if (!s) return "default";
if (s === "running" || s === "pending" || s === "collect") return "success";
if (s === "paused" || s === "draft" || s === "skipped") return "warning";
if (s === "paused" || s === "draft" || s === "skipped" || s === "partial" || s === "cancelled" || s === "queued") {
return "warning";
}
if (s === "failed" || s === "fail" || s === "error" || s === "stopped") return "danger";
if (s === "success" || s === "completed" || s === "ok" || s === "done") return "success";
return "default";

View file

@ -1826,6 +1826,15 @@ export const bizStateDeleteTask = (taskId: string) =>
export const bizStateCollectNow = (taskId: string) =>
apiPost<{ ok: boolean }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect`, {});
export const bizStateCollectStop = (taskId: string) =>
apiPost<{
ok: boolean;
stopped?: boolean;
reason?: string;
cancelled_batches?: string[];
signaled_batches?: string[];
}>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect/stop`, {});
export const bizStateListBatches = (taskId: string, limit = 50) =>
apiGet<{ items: Record<string, unknown>[] }>(
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches?limit=${limit}`,