mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
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:
parent
305822aa4f
commit
924f9bd2f7
16 changed files with 779 additions and 112 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "辅命令",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
60
web/src/pages/network/cutoverDataCache.ts
Normal file
60
web/src/pages/network/cutoverDataCache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
web/src/pages/network/cutoverPrefetch.ts
Normal file
13
web/src/pages/network/cutoverPrefetch.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue