mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
ba038a3af6
commit
8ded8eeb7d
14 changed files with 923 additions and 136 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "运行中",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue