From 4811a25ac3834d05638a1bb15b8d2cca8e79d6bc Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 17 Sep 2026 17:21:16 +0800 Subject: [PATCH] Allow biz_state tasks from all CLI targets with searchable NE picker. Co-authored-by: Cursor --- netx_api/biz_state/service.py | 30 ++-- web/src/i18n/en.ts | 11 +- web/src/i18n/zh.ts | 11 +- web/src/pages/network/BizStatePage.tsx | 228 +++++++++++++++++++++---- 4 files changed, 229 insertions(+), 51 deletions(-) diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index 32d9299..b87d737 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -105,25 +105,31 @@ def _ne_meta(db: Session, *, source: str, ne_id: str) -> dict[str, str]: "vendor": str(getattr(row, "vendor", "") or ""), "device_type": str(getattr(row, "device_type", "") or ""), } - # ume: best-effort from inventory if present - try: + if src == "ume": from ..models import UmeInventoryNE row = db.get(UmeInventoryNE, nid) - if row: - return { - "ne_name": str(getattr(row, "name", "") or ""), - "ne_ip": str(getattr(row, "ip", "") or ""), - "vendor": str(getattr(row, "vendor", "") or ""), - "device_type": str(getattr(row, "device_type", "") or ""), - } - except Exception: - pass - return {"ne_name": "", "ne_ip": "", "vendor": "", "device_type": ""} + if not row: + raise HTTPException(status_code=404, detail="ume_ne_not_found") + return { + "ne_name": str( + getattr(row, "user_label", "") + or getattr(row, "ne_name", "") + or getattr(row, "host_name", "") + or getattr(row, "ip_address", "") + or nid + ), + "ne_ip": str(getattr(row, "ip_address", "") or getattr(row, "ip", "") or ""), + "vendor": str(getattr(row, "vendor", "") or ""), + "device_type": str(getattr(row, "device_type", "") or getattr(row, "ne_type", "") or ""), + } + raise HTTPException(status_code=400, detail="invalid_source") def create_task(db: Session, body: dict[str, Any]) -> dict[str, Any]: source = str(body.get("source") or "managed").strip().lower() or "managed" + if source not in ("managed", "ume"): + raise HTTPException(status_code=400, detail="invalid_source") ne_id = str(body.get("ne_id") or "").strip() if not ne_id: raise HTTPException(status_code=400, detail="ne_id_required") diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 0fa406d..bc30056 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -169,11 +169,18 @@ const en = { bizState: { title: "Business state", create: "Create task", - createHint: "Pick a managed NE (LLDP enabled by default)", + createHint: "Pick any valid NE (managed / UME); search supported", created: "Task created", createFailed: "Create failed", empty: "No business-state tasks yet.", - pickNe: "Select managed NE…", + pickNe: "Select NE…", + neKeywordPh: "Name / IP / ID", + allSource: "All sources", + selectedNe: "Selected", + neEmpty: "No matching NEs.", + colSource: "Source", + colVendor: "Vendor", + colConnect: "Connect", start: "Start schedule", pause: "Pause", collectNow: "Collect now", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1c6fba7..1531d6b 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -169,11 +169,18 @@ const zh = { bizState: { title: "业务状态监控", create: "创建任务", - createHint: "选择托管网元后创建(默认启用 LLDP)", + createHint: "从托管 / UME 等有效网元中选择(支持查找)", created: "已创建任务", createFailed: "创建失败", empty: "暂无业务监控任务。", - pickNe: "选择托管网元…", + pickNe: "选择网元…", + neKeywordPh: "名称 / IP / ID", + allSource: "全部来源", + selectedNe: "已选", + neEmpty: "无匹配网元。", + colSource: "来源", + colVendor: "厂商", + colConnect: "连通", start: "启动周期", pause: "暂停", collectNow: "立即采集", diff --git a/web/src/pages/network/BizStatePage.tsx b/web/src/pages/network/BizStatePage.tsx index 49e1c21..b8a61ec 100644 --- a/web/src/pages/network/BizStatePage.tsx +++ b/web/src/pages/network/BizStatePage.tsx @@ -1,5 +1,6 @@ import { Button, Input, Modal } from "@heroui/react"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { ListPager } from "../../components/ListPager"; import { AppModalShell } from "../../components/ui/AppModalShell"; import { FieldSelect } from "../../components/ui/FieldSelect"; import { useDebouncedValue } from "../../hooks/useDebouncedValue"; @@ -22,11 +23,13 @@ import { formatErr, } from "../../services/api"; import type { CliTargetItem } from "../../types"; +import { pageCount } from "../../utils/display"; import { formatSystemTime } from "../../utils/time"; -import { jobChipColor, NmStatusChip } from "./nmChips"; +import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips"; type TaskRow = { id: string; + source?: string; ne_name: string; ne_ip: string; vendor: string; @@ -66,6 +69,9 @@ type SheetDef = { }; type TaskTab = "profiles" | "batches"; +type NeSourceFilter = "all" | "managed" | "ume"; + +const NE_PAGE_SIZE = 10; function fmtTime(v?: string | null) { if (!v) return "—"; @@ -78,6 +84,10 @@ function cellText(v: unknown): string { return String(v); } +function neSourceOf(row: CliTargetItem): "managed" | "ume" { + return row.source === "ume" ? "ume" : "managed"; +} + function buildBatchSheets(batch: any, t: (k: string) => string): SheetDef[] { const sheets: SheetDef[] = []; const cmds = (batch?.commands || []) as any[]; @@ -152,12 +162,21 @@ export function BizStatePage() { const { showOk, showError } = useToast(); const [tasks, setTasks] = useState([]); - const [nes, setNes] = useState([]); - const [neId, setNeId] = useState(""); const [busy, setBusy] = useState(false); const [listKeyword, setListKeyword] = useState(""); const debouncedListKw = useDebouncedValue(listKeyword, 250); + // create-task modal (all valid CLI targets) + const [createOpen, setCreateOpen] = useState(false); + const [neKeyword, setNeKeyword] = useState(""); + const debouncedNeKw = useDebouncedValue(neKeyword, 300); + const [neSource, setNeSource] = useState("all"); + const [nePage, setNePage] = useState(1); + const [neTotal, setNeTotal] = useState(0); + const [neItems, setNeItems] = useState([]); + const [neLoading, setNeLoading] = useState(false); + const [selectedNe, setSelectedNe] = useState(null); + // task modal const [taskId, setTaskId] = useState(""); const [detail, setDetail] = useState(null); @@ -187,13 +206,6 @@ export function BizStatePage() { void (async () => { try { await refreshTasks(); - const neRes = await fetchCliTargets({ - source: "managed", - keyword: "", - page: 1, - pageSize: 200, - }); - setNes(neRes.items || []); } catch (e) { showError(formatErr(e)); } @@ -201,11 +213,40 @@ export function BizStatePage() { // eslint-disable-next-line react-hooks/exhaustive-deps -- mount once }, [refreshTasks]); + useEffect(() => { + if (!createOpen) return; + let cancelled = false; + setNeLoading(true); + void (async () => { + try { + const res = await fetchCliTargets({ + source: neSource, + keyword: debouncedNeKw, + page: nePage, + pageSize: NE_PAGE_SIZE, + }); + if (cancelled) return; + setNeItems(res.items || []); + setNeTotal(Number(res.total || 0)); + } catch (e) { + if (!cancelled) showError(formatErr(e)); + } finally { + if (!cancelled) setNeLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [createOpen, neSource, debouncedNeKw, nePage]); + + const nePages = pageCount(neTotal, NE_PAGE_SIZE); + const filteredTasks = useMemo(() => { const kw = debouncedListKw.trim().toLowerCase(); if (!kw) return tasks; return tasks.filter((row) => { - const blob = `${row.ne_name} ${row.ne_ip} ${row.vendor} ${row.status} ${row.last_error}`.toLowerCase(); + const blob = + `${row.ne_name} ${row.ne_ip} ${row.vendor} ${row.status} ${row.source || ""} ${row.last_error}`.toLowerCase(); return blob.includes(kw); }); }, [tasks, debouncedListKw]); @@ -235,6 +276,19 @@ export function BizStatePage() { ); }, [activeSheet, debouncedSheetKw, sheetColumn]); + const openCreate = () => { + setCreateOpen(true); + setSelectedNe(null); + setNeKeyword(""); + setNeSource("all"); + setNePage(1); + }; + + const closeCreate = () => { + setCreateOpen(false); + setSelectedNe(null); + }; + const loadTask = async (id: string) => { const task = await bizStateGetTask(id); setDetail(task); @@ -269,21 +323,20 @@ export function BizStatePage() { }; const createTask = async () => { - if (!neId) return; - const ne = nes.find((n) => n.id === neId); - if (!ne) return; + if (!selectedNe) return; + const source = neSourceOf(selectedNe); setBusy(true); try { const task = 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, + source, + ne_id: selectedNe.id, + ne_name: selectedNe.name, + ne_ip: selectedNe.ip_address, + vendor: selectedNe.vendor || "", + device_type: selectedNe.device_type || "", }); showOk(t("bizState.created")); - setNeId(""); + closeCreate(); await refreshTasks(); await openTask(String(task.id), "profiles"); } catch (e) { @@ -458,19 +511,7 @@ export function BizStatePage() {

{t("bizState.title")}

- setNeId(e.target.value)} - aria-label={t("bizState.pickNe")} - > - - {nes.map((n) => ( - - ))} - -
@@ -501,6 +542,7 @@ export function BizStatePage() { {t("bizState.colNe")} + {t("bizState.colSource")} {t("bizState.colStatus")} {t("bizState.colLast")} {t("bizState.colActions")} @@ -516,6 +558,11 @@ export function BizStatePage() {
{row.last_error ?
{row.last_error}
: null} + + + {row.source || "managed"} + +
{row.status} @@ -588,7 +635,7 @@ export function BizStatePage() { ))} {!filteredTasks.length ? ( - +
{t("bizState.empty")}
@@ -598,6 +645,117 @@ export function BizStatePage() {
+ {/* Create task: pick any CLI target with search */} + + + {t("bizState.create")} + + + +

{t("bizState.createHint")}

+
+ { + setNeKeyword(e.target.value); + setNePage(1); + }} + /> + { + setNeSource(e.target.value as NeSourceFilter); + setNePage(1); + }} + aria-label={t("bizState.colSource")} + > + + + + + {selectedNe ? ( + + {t("bizState.selectedNe")}: {selectedNe.name} ({selectedNe.ip_address}) · {neSourceOf(selectedNe)} + + ) : null} +
+
+ + + + + + + + + + + + {neItems.map((row) => { + const checked = + selectedNe?.id === row.id && neSourceOf(selectedNe) === neSourceOf(row); + return ( + + + + + + + + + ); + })} + {!neItems.length ? ( + + + + ) : null} + +
+ {t("bizState.colSource")}{t("bizState.colNe")}IP{t("bizState.colVendor")}{t("bizState.colConnect")}
+ setSelectedNe(row)} + /> + + {row.source} + {row.name || "—"}{row.ip_address || "—"}{row.vendor || "—"} + + {row.connect_status || "—"} + +
+
+ {neLoading ? t("common.refreshing") : t("bizState.neEmpty")} +
+
+
+ +
+ + + + +
+ {/* Task detail modal */}