Add LLDP pause/resume/stop with restart recovery, and localize ops task titles.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-04 22:23:44 +08:00
parent ba038a3af6
commit 8ded8eeb7d
14 changed files with 923 additions and 136 deletions

View file

@ -276,6 +276,13 @@ const en = {
title: "LLDP links",
collectNow: "Collect now",
started: "LLDP collect started",
pause: "Pause",
resume: "Resume",
stop: "Stop",
confirmStop: "Stop this job and skip remaining NEs?",
paused: "Paused",
resumed: "Resumed",
stopped: "Collect stopped",
policyTitle: "Collect policy",
policySaved: "Policy saved",
enabled: "Enable scheduled collect",
@ -1555,6 +1562,7 @@ const en = {
hint: "Live view of running or pending background work. Actor prefers recent audit entries; scheduled work shows as scheduler/system.",
empty: "No tasks to show right now.",
open: "Open",
inflight: "running {{n}}",
kpiActive: "Active / in-flight",
kpiTotal: "Total rows",
kpiKinds: "By kind",
@ -1578,6 +1586,13 @@ const en = {
ume_runtime: "UME runtime",
webcrt: "WebCRT",
},
trigger: {
manual: "Manual",
schedule: "Schedule",
topology: "Topology",
system: "System",
retry_failed: "Retry failed",
},
status: {
collecting: "Collecting",
running: "Running",

View file

@ -273,6 +273,13 @@ const zh = {
title: "LLDP 链路",
collectNow: "立即采集",
started: "已启动 LLDP 采集",
pause: "暂停",
resume: "继续",
stop: "停止",
confirmStop: "停止后将取消未扫描的网元,确定停止本轮采集?",
paused: "已暂停",
resumed: "已继续",
stopped: "已停止采集",
policyTitle: "采集策略",
policySaved: "策略已保存",
enabled: "启用周期调度",
@ -1546,6 +1553,7 @@ const zh = {
hint: "汇总当前系统中正在运行或挂起的各类后台任务;发起人优先取近期审计记录,调度类任务显示为 scheduler/system。",
empty: "当前没有可展示的任务。",
open: "打开",
inflight: "进行中 {{n}}",
kpiActive: "活跃/在途",
kpiTotal: "条目总数",
kpiKinds: "按类型",
@ -1569,6 +1577,13 @@ const zh = {
ume_runtime: "UME 后台",
webcrt: "WebCRT",
},
trigger: {
manual: "手动",
schedule: "调度",
topology: "拓扑",
system: "系统",
retry_failed: "重试失败",
},
status: {
collecting: "采集中",
running: "运行中",

View file

@ -2,7 +2,7 @@ import { useMemo } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useI18n } from "../../i18n";
import { fetchOpsTasks } from "../../services/api";
import { fetchOpsTasks, type OpsTaskItem } from "../../services/api";
import { formatSystemTime } from "../../utils/time";
const POLL_MS = 4000;
@ -16,18 +16,47 @@ function statusTone(status: string): string {
return "other";
}
function kindLabel(kind: string, t: (k: string) => string): string {
function kindLabel(kind: string, t: (k: string, vars?: Record<string, string | number>) => string): string {
const key = `audit.tasks.kind.${kind}`;
const tr = t(key);
return tr === key ? kind : tr;
}
function statusLabel(status: string, t: (k: string) => string): string {
function statusLabel(status: string, t: (k: string, vars?: Record<string, string | number>) => string): string {
const key = `audit.tasks.status.${status}`;
const tr = t(key);
return tr === key ? status : tr;
}
function triggerLabel(trigger: string, t: (k: string, vars?: Record<string, string | number>) => string): string {
const raw = String(trigger || "").trim();
if (!raw || raw === "—") return "—";
const key = `audit.tasks.trigger.${raw}`;
const tr = t(key);
return tr === key ? raw : tr;
}
function taskTitle(row: OpsTaskItem, t: (k: string, vars?: Record<string, string | number>) => string): string {
const kind = kindLabel(row.kind, t);
const subject = String(row.title || "").trim();
if (!subject) return kind;
// Avoid "Kind · Kind · x" if an old backend still prefixed the localized kind.
if (subject === kind || subject.startsWith(`${kind} · `) || subject.startsWith(`${kind}·`)) {
return subject;
}
return `${kind} · ${subject}`;
}
function progressLabel(row: OpsTaskItem, t: (k: string, vars?: Record<string, string | number>) => string): string {
const base = String(row.progress || "").trim();
const inflight = Number(row.inflight || 0);
if (inflight > 0) {
const extra = t("audit.tasks.inflight", { n: inflight });
return base ? `${base} · ${extra}` : extra;
}
return base || "—";
}
export function TaskOverviewPage() {
const { t } = useI18n();
const query = useQuery({
@ -111,7 +140,7 @@ export function TaskOverviewPage() {
<tr key={`${row.kind}:${row.id}`} title={row.detail || undefined}>
<td>{kindLabel(row.kind, t)}</td>
<td>
<div className="pt-list-task-name">{row.title}</div>
<div className="pt-list-task-name">{taskTitle(row, t)}</div>
{row.detail ? (
<div className="muted" style={{ fontSize: 12 }}>
{row.detail}
@ -124,8 +153,8 @@ export function TaskOverviewPage() {
</span>
</td>
<td>{row.actor || "—"}</td>
<td>{row.trigger || "—"}</td>
<td className="pt-list-num">{row.progress || "—"}</td>
<td>{triggerLabel(row.trigger, t)}</td>
<td className="pt-list-num">{progressLabel(row, t)}</td>
<td className="pt-list-time">{formatSystemTime(row.started_at) || "—"}</td>
<td className="pt-list-time">{formatSystemTime(row.updated_at) || "—"}</td>
<td>

View file

@ -6,7 +6,10 @@ import {
fetchLldpCollectDashboard,
fetchLldpCollectJob,
fetchLldpCollectJobs,
pauseLldpCollectJob,
resumeLldpCollectJob,
startLldpCollect,
stopLldpCollectJob,
updateLldpCollectPolicy,
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
@ -55,7 +58,10 @@ export function LldpLinksPage() {
staleTime: 1000,
refetchInterval: (q) => {
const running = q.state.data?.running_job;
return running && (running.status === "running" || running.status === "pending") ? POLL_MS : false;
return running &&
(running.status === "running" || running.status === "pending" || running.status === "paused")
? POLL_MS
: false;
},
});
@ -187,6 +193,33 @@ export function LldpLinksPage() {
onError: (err) => showError(String(err)),
});
const pauseMut = useMutation({
mutationFn: (id: string) => pauseLldpCollectJob(id),
onSuccess: async () => {
showOk(t("lldpLinks.paused"));
await refresh();
},
onError: (err) => showError(String(err)),
});
const resumeMut = useMutation({
mutationFn: (id: string) => resumeLldpCollectJob(id),
onSuccess: async () => {
showOk(t("lldpLinks.resumed"));
await refresh();
},
onError: (err) => showError(String(err)),
});
const stopMut = useMutation({
mutationFn: (id: string) => stopLldpCollectJob(id),
onSuccess: async () => {
showOk(t("lldpLinks.stopped"));
await refresh();
},
onError: (err) => showError(String(err)),
});
const dash = dashQuery.data;
const running = dash?.running_job;
const last = dash?.last_job;
@ -228,6 +261,28 @@ export function LldpLinksPage() {
>
{t("lldpLinks.collectNow")}
</button>
{running?.status === "running" || running?.status === "pending" ? (
<button type="button" onClick={() => pauseMut.mutate(running.id)} disabled={pauseMut.isPending}>
{t("lldpLinks.pause")}
</button>
) : null}
{running?.status === "paused" ? (
<button type="button" onClick={() => resumeMut.mutate(running.id)} disabled={resumeMut.isPending}>
{t("lldpLinks.resume")}
</button>
) : null}
{running &&
(running.status === "running" || running.status === "paused" || running.status === "pending") ? (
<button
type="button"
onClick={() => {
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(running.id);
}}
disabled={stopMut.isPending}
>
{t("lldpLinks.stop")}
</button>
) : null}
</div>
</div>
@ -536,6 +591,7 @@ export function LldpLinksPage() {
<th>{t("lldpLinks.col.missingDelta")}</th>
<th>{t("lldpLinks.col.started")}</th>
<th>{t("lldpLinks.col.ended")}</th>
<th />
</tr>
</thead>
<tbody>
@ -559,7 +615,19 @@ export function LldpLinksPage() {
<td className="mono">{job.id.slice(0, 8)}</td>
<td>{job.trigger_mode}</td>
<td>{job.scope}</td>
<td>{job.status}</td>
<td>
<span
className={
job.status === "running" || job.status === "pending"
? "pt-list-status--running"
: job.status === "paused"
? "pt-list-status--paused"
: undefined
}
>
{job.status}
</span>
</td>
<td>
{job.done}/{job.total}
</td>
@ -569,12 +637,45 @@ export function LldpLinksPage() {
<td>{job.edges_missing ?? job.edges_stale ?? 0}</td>
<td>{job.started_at ? formatSystemTime(job.started_at) : "—"}</td>
<td>{job.ended_at ? formatSystemTime(job.ended_at) : "—"}</td>
<td>
<div className="btn-row">
{job.status === "running" || job.status === "pending" ? (
<button
type="button"
onClick={() => pauseMut.mutate(job.id)}
disabled={pauseMut.isPending}
>
{t("lldpLinks.pause")}
</button>
) : null}
{job.status === "paused" ? (
<button
type="button"
onClick={() => resumeMut.mutate(job.id)}
disabled={resumeMut.isPending}
>
{t("lldpLinks.resume")}
</button>
) : null}
{job.status === "running" || job.status === "paused" || job.status === "pending" ? (
<button
type="button"
onClick={() => {
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(job.id);
}}
disabled={stopMut.isPending}
>
{t("lldpLinks.stop")}
</button>
) : null}
</div>
</td>
</tr>
);
})}
{!jobs.length ? (
<tr>
<td colSpan={10} className="muted">
<td colSpan={11} className="muted">
{t("common.empty")}
</td>
</tr>

View file

@ -215,6 +215,7 @@ export type OpsTaskItem = {
started_at: string | null;
updated_at: string | null;
progress: string;
inflight?: number;
detail: string;
href: string;
};
@ -1144,6 +1145,15 @@ export const updateLldpCollectPolicy = (body: Partial<LldpCollectPolicy>) =>
export const startLldpCollect = () =>
apiPost<{ ok: boolean; job: TopologyDiscoverJob }>("/v1/topology/lldp-collect/start", {});
export const pauseLldpCollectJob = (jobId: string) =>
apiPost<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/pause`, {});
export const resumeLldpCollectJob = (jobId: string) =>
apiPost<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/resume`, {});
export const stopLldpCollectJob = (jobId: string) =>
apiPost<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/stop`, {});
export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number }) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params.page || 1))));