mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Add biz_state Phase3 VRF discover-select and parameterized collect.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1e2cc98d49
commit
06331ca763
18 changed files with 1051 additions and 87 deletions
|
|
@ -1,8 +1,9 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
bizStateCollectNow,
|
||||
bizStateCreateTask,
|
||||
bizStateDeleteTask,
|
||||
bizStateDiscover,
|
||||
bizStateDownloadExport,
|
||||
bizStateGetBatch,
|
||||
bizStateGetTask,
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
bizStateListProfiles,
|
||||
bizStateListTasks,
|
||||
bizStatePatchTask,
|
||||
bizStateSetBindings,
|
||||
fetchManagedNe,
|
||||
} from "../../services/api";
|
||||
import type { ManagedNeItem } from "../../types";
|
||||
|
|
@ -25,12 +27,21 @@ type TaskRow = {
|
|||
last_collect_ended_at?: string | null;
|
||||
};
|
||||
|
||||
type Placeholder = {
|
||||
name: string;
|
||||
bind_mode?: string;
|
||||
discover_profile_id?: string;
|
||||
discover_value_field?: string;
|
||||
};
|
||||
|
||||
type Profile = {
|
||||
profile_id: string;
|
||||
title: string;
|
||||
command_template: string;
|
||||
description: string;
|
||||
metric_id: string;
|
||||
kind?: string;
|
||||
placeholders?: Placeholder[];
|
||||
};
|
||||
|
||||
type BatchRow = {
|
||||
|
|
@ -41,6 +52,8 @@ type BatchRow = {
|
|||
started_at?: string | null;
|
||||
};
|
||||
|
||||
type Candidate = { value: string; label: string; rd?: string };
|
||||
|
||||
export function BizStatePage() {
|
||||
const [tasks, setTasks] = useState<TaskRow[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
|
|
@ -52,6 +65,10 @@ export function BizStatePage() {
|
|||
const [neId, setNeId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [bindItemId, setBindItemId] = useState("");
|
||||
const [candidates, setCandidates] = useState<Candidate[]>([]);
|
||||
const [selectedVrfs, setSelectedVrfs] = useState<string[]>([]);
|
||||
const [discoverCmd, setDiscoverCmd] = useState("");
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
const res = await bizStateListTasks();
|
||||
|
|
@ -75,9 +92,18 @@ export function BizStatePage() {
|
|||
}
|
||||
})();
|
||||
}, [refreshTasks]);
|
||||
|
||||
const collectProfiles = useMemo(
|
||||
() => profiles.filter((p) => (p.kind || "collect") === "collect"),
|
||||
[profiles],
|
||||
);
|
||||
|
||||
const openTask = async (id: string) => {
|
||||
setSelectedId(id);
|
||||
setBatchDetail(null);
|
||||
setBindItemId("");
|
||||
setCandidates([]);
|
||||
setSelectedVrfs([]);
|
||||
setErr("");
|
||||
try {
|
||||
const t = await bizStateGetTask(id);
|
||||
|
|
@ -140,7 +166,6 @@ export function BizStatePage() {
|
|||
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);
|
||||
|
|
@ -186,11 +211,101 @@ export function BizStatePage() {
|
|||
}
|
||||
};
|
||||
|
||||
const toggleProfileItem = async (profile: Profile, enable: boolean) => {
|
||||
if (!selectedId || !detail) return;
|
||||
const items = [...(detail.items || [])];
|
||||
const idx = items.findIndex((it: any) => it.source_profile_id === profile.profile_id);
|
||||
if (enable) {
|
||||
if (idx >= 0) {
|
||||
items[idx] = { ...items[idx], enabled: true };
|
||||
} else {
|
||||
items.push({
|
||||
source_profile_id: profile.profile_id,
|
||||
kind: "catalog",
|
||||
enabled: true,
|
||||
title: profile.title,
|
||||
bindings: [],
|
||||
});
|
||||
}
|
||||
} else if (idx >= 0) {
|
||||
items[idx] = { ...items[idx], enabled: false };
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await bizStatePatchTask(selectedId, { items });
|
||||
await openTask(selectedId);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startDiscover = async (item: any) => {
|
||||
if (!selectedId || !detail) return;
|
||||
const prof = profiles.find((p) => p.profile_id === item.source_profile_id);
|
||||
const ph = (prof?.placeholders || [])[0];
|
||||
if (!ph) {
|
||||
setErr("该监控项无需参数关联");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
setBindItemId(item.id);
|
||||
try {
|
||||
const res = await bizStateDiscover({
|
||||
task_id: selectedId,
|
||||
collect_profile_id: item.source_profile_id,
|
||||
placeholder: ph.name,
|
||||
});
|
||||
if (!res.ok) {
|
||||
setErr(res.error || "discover failed");
|
||||
setCandidates([]);
|
||||
return;
|
||||
}
|
||||
setDiscoverCmd(res.command || "");
|
||||
const cand = (res.candidates || []) as Candidate[];
|
||||
setCandidates(cand);
|
||||
const existing = (item.bindings || [])
|
||||
.filter((b: any) => b.placeholder === ph.name)
|
||||
.map((b: any) => String(b.value));
|
||||
setSelectedVrfs(existing.length ? existing : cand.map((c) => c.value));
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveBindings = async () => {
|
||||
if (!selectedId || !bindItemId) return;
|
||||
const item = (detail?.items || []).find((it: any) => it.id === bindItemId);
|
||||
const prof = profiles.find((p) => p.profile_id === item?.source_profile_id);
|
||||
const phName = (prof?.placeholders || [])[0]?.name || "vrf";
|
||||
setBusy(true);
|
||||
try {
|
||||
await bizStateSetBindings(
|
||||
selectedId,
|
||||
bindItemId,
|
||||
selectedVrfs.map((v) => ({ placeholder: phName, value: v })),
|
||||
);
|
||||
await openTask(selectedId);
|
||||
setBindItemId("");
|
||||
setCandidates([]);
|
||||
} 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>
|
||||
<p className="wb-muted">
|
||||
LLDP 快照 + VRF 发现关联(选 VRF 后采路由摘要)+ 割接比对见「业务状态比对」
|
||||
</p>
|
||||
</div>
|
||||
{err ? <div className="wb-alert wb-alert--error">{err}</div> : null}
|
||||
|
||||
|
|
@ -227,7 +342,10 @@ export function BizStatePage() {
|
|||
<tr
|
||||
key={t.id}
|
||||
onClick={() => void openTask(t.id)}
|
||||
style={{ cursor: "pointer", background: selectedId === t.id ? "var(--wb-row-active, #eef)" : undefined }}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: selectedId === t.id ? "var(--wb-row-active, #eef)" : undefined,
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
{t.ne_name || t.ne_ip}
|
||||
|
|
@ -275,40 +393,107 @@ export function BizStatePage() {
|
|||
</div>
|
||||
{detail.last_error ? <div className="wb-alert wb-alert--error">{detail.last_error}</div> : null}
|
||||
|
||||
<h4>启用项(勾选表)</h4>
|
||||
<h4>可用监控项</h4>
|
||||
<table className="wb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>启用</th>
|
||||
<th>项</th>
|
||||
<th>命令</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);
|
||||
{collectProfiles.map((prof) => {
|
||||
const it = (detail.items || []).find(
|
||||
(x: any) => x.source_profile_id === prof.profile_id,
|
||||
);
|
||||
const enabled = Boolean(it?.enabled);
|
||||
const binds = it?.bindings || [];
|
||||
const needsBind = (prof.placeholders || []).length > 0;
|
||||
return (
|
||||
<tr key={it.id}>
|
||||
<td>{it.enabled ? "✓" : ""}</td>
|
||||
<tr key={prof.profile_id}>
|
||||
<td>
|
||||
{it.title || prof?.title || it.source_profile_id}
|
||||
{prof?.description ? (
|
||||
<div className="wb-muted" style={{ fontSize: 12 }}>
|
||||
{prof.description}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => void toggleProfileItem(prof, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{prof.title}
|
||||
<div className="wb-muted" style={{ fontSize: 12 }}>
|
||||
{prof.description}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code>{prof.command_template}</code>
|
||||
</td>
|
||||
<td>
|
||||
{needsBind
|
||||
? binds.length
|
||||
? binds.map((b: any) => b.value).join(", ")
|
||||
: "未关联"
|
||||
: "—"}
|
||||
</td>
|
||||
<td>
|
||||
{needsBind && enabled && it ? (
|
||||
<button type="button" disabled={busy} onClick={() => void startDiscover(it)}>
|
||||
发现 VRF
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<code>{it.command_override || prof?.command_template || "-"}</code>
|
||||
</td>
|
||||
<td>{it.kind}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{bindItemId && candidates.length ? (
|
||||
<div style={{ marginTop: 12, padding: 12, border: "1px solid var(--wb-border, #ddd)" }}>
|
||||
<h4>选择 VRF 绑定</h4>
|
||||
<p className="wb-muted">
|
||||
命令 <code>{discoverCmd}</code> · 已选 {selectedVrfs.length}
|
||||
</p>
|
||||
<div style={{ maxHeight: 220, overflow: "auto", marginBottom: 8 }}>
|
||||
{candidates.map((c) => (
|
||||
<label key={c.value} style={{ display: "block", marginBottom: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedVrfs.includes(c.value)}
|
||||
onChange={(e) => {
|
||||
setSelectedVrfs((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, c.value]
|
||||
: prev.filter((x) => x !== c.value),
|
||||
);
|
||||
}}
|
||||
/>{" "}
|
||||
{c.label}
|
||||
{c.rd ? <span className="wb-muted"> · RD {c.rd}</span> : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" disabled={busy || !selectedVrfs.length} onClick={() => void saveBindings()}>
|
||||
保存关联
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setBindItemId("");
|
||||
setCandidates([]);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h4 style={{ marginTop: 16 }}>采集批次</h4>
|
||||
<table className="wb-table">
|
||||
<thead>
|
||||
|
|
@ -351,26 +536,54 @@ export function BizStatePage() {
|
|||
<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>
|
||||
{(batchDetail.lldp_neighbors || []).length ? (
|
||||
<>
|
||||
<h5>LLDP</h5>
|
||||
<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>
|
||||
</>
|
||||
) : null}
|
||||
{(batchDetail.vrf_route_summary || []).length ? (
|
||||
<>
|
||||
<h5>VRF 路由摘要</h5>
|
||||
<table className="wb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>VRF</th>
|
||||
<th>来源</th>
|
||||
<th>条数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(batchDetail.vrf_route_summary || []).map((r: any, i: number) => (
|
||||
<tr key={i}>
|
||||
<td>{r.vrf}</td>
|
||||
<td>{r.source}</td>
|
||||
<td>{r.networks}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1756,14 +1756,40 @@ export const deletePortTrafficBoard = (boardId: string) =>
|
|||
|
||||
/* ---- 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 bizStateListProfiles = (params?: {
|
||||
vendor?: string;
|
||||
device_type?: string;
|
||||
kind?: string;
|
||||
}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.vendor) q.set("vendor", params.vendor);
|
||||
if (params?.device_type) q.set("device_type", params.device_type);
|
||||
if (params?.kind) q.set("kind", params.kind);
|
||||
const qs = q.toString();
|
||||
return apiGet<{ items: Record<string, unknown>[] }>(
|
||||
`/v1/biz-state/profiles${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const bizStateDiscover = (body: Record<string, unknown>) =>
|
||||
apiPost<{
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
command?: string;
|
||||
candidates?: Array<{ value: string; label: string; rd?: string; protocols?: string }>;
|
||||
raw_preview?: string;
|
||||
}>("/v1/biz-state/discover", body);
|
||||
|
||||
export const bizStateSetBindings = (
|
||||
taskId: string,
|
||||
itemId: string,
|
||||
bindings: Array<{ placeholder: string; value: string }>,
|
||||
) =>
|
||||
apiPut<Record<string, unknown>>(
|
||||
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/items/${encodeURIComponent(itemId)}/bindings`,
|
||||
{ bindings },
|
||||
);
|
||||
|
||||
export const bizStateListTasks = () =>
|
||||
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-state/tasks");
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue