Add biz_state monitoring with shared LLDP parse and batch collect.

Introduce ParseProfile-based business state tasks that reuse LLDP parsing for snapshot batches (separate from topology Fabric writes), with scheduler, export, and a Phase1 UI under network tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-17 16:17:04 +08:00
parent 066158d2f7
commit 4fa1b9b4dc
27 changed files with 2487 additions and 1 deletions

View file

@ -43,6 +43,9 @@ const PortTrafficBoardListPage = lazy(() =>
const PortTrafficWallPage = lazy(() =>
import("./pages/network/PortTrafficWallPage").then((m) => ({ default: m.PortTrafficWallPage })),
);
const BizStatePage = lazy(() =>
import("./pages/network/BizStatePage").then((m) => ({ default: m.BizStatePage })),
);
const UsersPage = lazy(() => import("./pages/UsersPage").then((m) => ({ default: m.UsersPage })));
const AuditLayout = lazy(() =>
import("./pages/audit/AuditLayout").then((m) => ({ default: m.AuditLayout })),
@ -120,6 +123,7 @@ function ProtectedApp() {
<Route path="tasks/config-sync" element={<ConfigSyncPage />} />
<Route path="tasks/port-traffic/wall" element={<LegacyPortTrafficWallRedirect />} />
<Route path="tasks/port-traffic" element={<PortTrafficPage />} />
<Route path="tasks/biz-state" element={<BizStatePage />} />
</Route>
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />
<Route path="/users" element={<UsersPage />} />

View file

@ -74,6 +74,12 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [
labelKey: "network.nav.portTrafficWall",
group: "tasks",
},
{
id: "biz-state",
path: "/network/tasks/biz-state",
labelKey: "network.nav.bizState",
group: "tasks",
},
],
},
] as const;

View file

@ -156,6 +156,7 @@ const en = {
configSync: "Config sync",
portTraffic: "Port traffic",
portTrafficWall: "Traffic wall",
bizState: "Business state",
},
collapseNav: "Collapse sidebar",
expandNav: "Expand sidebar",

View file

@ -156,6 +156,7 @@ const zh = {
configSync: "配置同步",
portTraffic: "流量监控",
portTrafficWall: "流量大屏",
bizState: "业务状态监控",
},
collapseNav: "折叠侧栏",
expandNav: "展开侧栏",

View file

@ -0,0 +1,382 @@
import { useCallback, useEffect, useState } from "react";
import {
bizStateCollectNow,
bizStateCreateTask,
bizStateDeleteTask,
bizStateDownloadExport,
bizStateGetBatch,
bizStateGetTask,
bizStateListBatches,
bizStateListProfiles,
bizStateListTasks,
bizStatePatchTask,
fetchManagedNe,
} from "../../services/api";
import type { ManagedNeItem } from "../../types";
type TaskRow = {
id: string;
ne_name: string;
ne_ip: string;
vendor: string;
status: string;
collect_running: boolean;
last_error: string;
last_collect_ended_at?: string | null;
};
type Profile = {
profile_id: string;
title: string;
command_template: string;
description: string;
metric_id: string;
};
type BatchRow = {
id: string;
status: string;
row_count: number;
command_count: number;
started_at?: string | null;
};
export function BizStatePage() {
const [tasks, setTasks] = useState<TaskRow[]>([]);
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<any>(null);
const [batches, setBatches] = useState<BatchRow[]>([]);
const [batchDetail, setBatchDetail] = useState<any>(null);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [nes, setNes] = useState<ManagedNeItem[]>([]);
const [neId, setNeId] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const refreshTasks = useCallback(async () => {
const res = await bizStateListTasks();
setTasks((res.items || []) as TaskRow[]);
}, []);
useEffect(() => {
void (async () => {
try {
await refreshTasks();
const neRes = await fetchManagedNe({
keyword: "",
vendor: "",
connectStatus: "",
page: 1,
pageSize: 200,
});
setNes(neRes.items || []);
} catch (e: any) {
setErr(String(e?.message || e));
}
})();
}, [refreshTasks]);
const openTask = async (id: string) => {
setSelectedId(id);
setBatchDetail(null);
setErr("");
try {
const t = await bizStateGetTask(id);
setDetail(t);
const b = await bizStateListBatches(id);
setBatches((b.items || []) as BatchRow[]);
const p = await bizStateListProfiles({
vendor: t.vendor || "",
device_type: t.device_type || "",
});
setProfiles((p.items || []) as Profile[]);
} catch (e: any) {
setErr(String(e?.message || e));
}
};
const createTask = async () => {
if (!neId) return;
const ne = nes.find((n) => n.id === neId);
if (!ne) return;
setBusy(true);
setErr("");
try {
const t = await bizStateCreateTask({
source: "managed",
ne_id: ne.id,
ne_name: ne.name,
ne_ip: ne.ip_address,
vendor: ne.vendor,
device_type: ne.device_type,
});
await refreshTasks();
await openTask(t.id);
} catch (e: any) {
setErr(String(e?.message || e));
} finally {
setBusy(false);
}
};
const setStatus = async (status: string) => {
if (!selectedId) return;
setBusy(true);
try {
await bizStatePatchTask(selectedId, { status });
await openTask(selectedId);
await refreshTasks();
} catch (e: any) {
setErr(String(e?.message || e));
} finally {
setBusy(false);
}
};
const collectNow = async () => {
if (!selectedId) return;
setBusy(true);
setErr("");
try {
await bizStateCollectNow(selectedId);
await openTask(selectedId);
await refreshTasks();
// Poll a few times while collect_running
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 1500));
const t = await bizStateGetTask(selectedId);
setDetail(t);
if (!t.collect_running) {
const b = await bizStateListBatches(selectedId);
setBatches((b.items || []) as BatchRow[]);
break;
}
}
await refreshTasks();
} catch (e: any) {
setErr(String(e?.message || e));
} finally {
setBusy(false);
}
};
const openBatch = async (batchId: string) => {
try {
const d = await bizStateGetBatch(batchId);
setBatchDetail(d);
} catch (e: any) {
setErr(String(e?.message || e));
}
};
const removeTask = async () => {
if (!selectedId) return;
if (!window.confirm("删除该业务监控任务及所有批次?")) return;
setBusy(true);
try {
await bizStateDeleteTask(selectedId);
setSelectedId("");
setDetail(null);
setBatches([]);
setBatchDetail(null);
await refreshTasks();
} catch (e: any) {
setErr(String(e?.message || e));
} finally {
setBusy(false);
}
};
return (
<div className="wb-page">
<div className="wb-page__header">
<h1>业务状态监控</h1>
<p className="wb-muted">Phase1:LLDP 邻居快照采集(共享解析,独立批次;比对 Phase2)</p>
</div>
{err ? <div className="wb-alert wb-alert--error">{err}</div> : null}
<div className="wb-card" style={{ marginBottom: 16 }}>
<h3>新建任务</h3>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<select value={neId} onChange={(e) => setNeId(e.target.value)}>
<option value="">选择托管网元…</option>
{nes.map((n) => (
<option key={n.id} value={n.id}>
{n.name || n.ip_address} ({n.vendor})
</option>
))}
</select>
<button type="button" disabled={busy || !neId} onClick={() => void createTask()}>
创建(默认启用 LLDP)
</button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1.4fr", gap: 16 }}>
<div className="wb-card">
<h3>任务列表</h3>
<table className="wb-table">
<thead>
<tr>
<th>网元</th>
<th>状态</th>
<th>最近采集</th>
</tr>
</thead>
<tbody>
{tasks.map((t) => (
<tr
key={t.id}
onClick={() => void openTask(t.id)}
style={{ cursor: "pointer", background: selectedId === t.id ? "var(--wb-row-active, #eef)" : undefined }}
>
<td>
{t.ne_name || t.ne_ip}
<div className="wb-muted">{t.vendor}</div>
</td>
<td>
{t.status}
{t.collect_running ? " · 采集中" : ""}
</td>
<td className="wb-muted">{t.last_collect_ended_at || "-"}</td>
</tr>
))}
{!tasks.length ? (
<tr>
<td colSpan={3} className="wb-muted">
暂无任务
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="wb-card">
{!detail ? (
<p className="wb-muted">选择左侧任务查看详情</p>
) : (
<>
<h3>
{detail.ne_name} · {detail.status}
</h3>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
<button type="button" disabled={busy} onClick={() => void setStatus("running")}>
启动周期
</button>
<button type="button" disabled={busy} onClick={() => void setStatus("paused")}>
暂停
</button>
<button type="button" disabled={busy} onClick={() => void collectNow()}>
立即采集
</button>
<button type="button" disabled={busy} onClick={() => void removeTask()}>
删除
</button>
</div>
{detail.last_error ? <div className="wb-alert wb-alert--error">{detail.last_error}</div> : null}
<h4>启用项(勾选表)</h4>
<table className="wb-table">
<thead>
<tr>
<th>启用</th>
<th>项</th>
<th>命令</th>
<th>类型</th>
</tr>
</thead>
<tbody>
{(detail.items || []).map((it: any) => {
const prof = profiles.find((p) => p.profile_id === it.source_profile_id);
return (
<tr key={it.id}>
<td>{it.enabled ? "✓" : ""}</td>
<td>
{it.title || prof?.title || it.source_profile_id}
{prof?.description ? (
<div className="wb-muted" style={{ fontSize: 12 }}>
{prof.description}
</div>
) : null}
</td>
<td>
<code>{it.command_override || prof?.command_template || "-"}</code>
</td>
<td>{it.kind}</td>
</tr>
);
})}
</tbody>
</table>
<h4 style={{ marginTop: 16 }}>采集批次</h4>
<table className="wb-table">
<thead>
<tr>
<th>时间</th>
<th>状态</th>
<th>行数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{batches.map((b) => (
<tr key={b.id}>
<td>{b.started_at || "-"}</td>
<td>{b.status}</td>
<td>{b.row_count}</td>
<td style={{ display: "flex", gap: 8 }}>
<button type="button" onClick={() => void openBatch(b.id)}>
查看
</button>
<button type="button" onClick={() => void bizStateDownloadExport(b.id)}>
导出
</button>
</td>
</tr>
))}
{!batches.length ? (
<tr>
<td colSpan={4} className="wb-muted">
尚无批次
</td>
</tr>
) : null}
</tbody>
</table>
{batchDetail ? (
<div style={{ marginTop: 16 }}>
<h4>批次详情 {batchDetail.id}</h4>
<p className="wb-muted">
命令 {batchDetail.command_count} · 行 {batchDetail.row_count} · {batchDetail.status}
</p>
<table className="wb-table">
<thead>
<tr>
<th>本端口</th>
<th>对端系统</th>
<th>对端口</th>
<th>管理IP</th>
</tr>
</thead>
<tbody>
{(batchDetail.lldp_neighbors || []).slice(0, 200).map((n: any, i: number) => (
<tr key={i}>
<td>{n.local_if}</td>
<td>{n.remote_sys}</td>
<td>{n.remote_if}</td>
<td>{n.remote_ip}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</>
)}
</div>
</div>
</div>
);
}

View file

@ -1753,3 +1753,59 @@ export const deletePortTrafficBoard = (boardId: string) =>
apiDelete<{ ok: boolean; id: string }>(
`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`,
);
/* ---- biz_state (business state monitoring) ---- */
export const bizStateListProfiles = (params?: { vendor?: string; device_type?: string }) => {
const p = new URLSearchParams();
if (params?.vendor?.trim()) p.set("vendor", params.vendor.trim());
if (params?.device_type?.trim()) p.set("device_type", params.device_type.trim());
const q = p.toString();
return apiGet<{ items: Record<string, unknown>[] }>(`/v1/biz-state/profiles${q ? `?${q}` : ""}`);
};
export const bizStateListTasks = () =>
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-state/tasks");
export const bizStateCreateTask = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-state/tasks", body);
export const bizStateGetTask = (taskId: string) =>
apiGet<Record<string, unknown>>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`);
export const bizStatePatchTask = (taskId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`, body);
export const bizStateDeleteTask = (taskId: string) =>
apiDelete<{ ok: boolean }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`);
export const bizStateCollectNow = (taskId: string) =>
apiPost<{ ok: boolean }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect`, {});
export const bizStateListBatches = (taskId: string, limit = 50) =>
apiGet<{ items: Record<string, unknown>[] }>(
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches?limit=${limit}`,
);
export const bizStateGetBatch = (batchId: string) =>
apiGet<Record<string, unknown>>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`);
export const bizStateDownloadExport = async (batchId: string): Promise<void> => {
const path = `/v1/biz-state/batches/${encodeURIComponent(batchId)}/export`;
const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() });
if (res.status === 401) {
handleUnauthorized(path);
throw new Error("unauthorized");
}
if (!res.ok) throw new Error(`${res.status} export`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = `biz_state_${batchId}.zip`;
a.click();
} finally {
URL.revokeObjectURL(url);
}
};