From 06a56150956277719b771934303ead1b828dfbfc Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 5 Jun 2026 16:13:31 +0800 Subject: [PATCH] feat(managed-ne): tag stats cards and bulk ops by tag Add tags/remark to import template, stats/ids-by-tag APIs, per-tag overview sub-cards, and bulk proxy/connect-test dialogs filtered by tag. Co-authored-by: Cursor --- netx_api/managed_ne_router.py | 13 +++ netx_api/ne_service.py | 100 ++++++++++++++++- tests/test_managed_ne.py | 25 +++++ web/src/constants/queryKeys.ts | 1 + web/src/i18n/en.ts | 19 ++++ web/src/i18n/zh.ts | 19 ++++ web/src/index.css | 106 ++++++++++++++++++ web/src/pages/NePage.tsx | 191 +++++++++++++++++++++++++++++++++ web/src/services/api.ts | 22 ++++ 9 files changed, 492 insertions(+), 4 deletions(-) diff --git a/netx_api/managed_ne_router.py b/netx_api/managed_ne_router.py index ce27379..13c5901 100644 --- a/netx_api/managed_ne_router.py +++ b/netx_api/managed_ne_router.py @@ -16,7 +16,9 @@ from .ne_service import ( create_managed_ne, batch_delete_managed_ne, delete_managed_ne, + get_ids_by_tag, get_managed_ne, + get_managed_ne_stats, import_managed_ne, list_managed_ne, update_managed_ne, @@ -55,6 +57,17 @@ def api_credentials_configured(): return {"configured": credentials_configured()} +@router.get("/meta/stats") +def api_managed_ne_stats(db: Session = Depends(get_db)): + return get_managed_ne_stats(db) + + +@router.get("/meta/ids-by-tag") +def api_ids_by_tag(tag: str | None = Query(default=None), db: Session = Depends(get_db)): + """Return all NE ids that carry the given tag (or every id when tag is omitted).""" + return {"ids": get_ids_by_tag(db, tag)} + + @router.post("") def api_create_managed_ne(body: ManagedNeCreate, db: Session = Depends(get_db)): return create_managed_ne(db, body).model_dump() diff --git a/netx_api/ne_service.py b/netx_api/ne_service.py index c117ba4..eb29d82 100644 --- a/netx_api/ne_service.py +++ b/netx_api/ne_service.py @@ -6,6 +6,7 @@ from typing import Any import pandas as pd from fastapi import HTTPException +from sqlalchemy import or_ from sqlalchemy.orm import Session from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS @@ -30,6 +31,8 @@ IMPORT_COLUMNS = ( "protocol", "name", "vendor", + "tags", + "remark", ) @@ -176,11 +179,16 @@ def list_managed_ne( stmt = db.query(ManagedNE) kw = str(keyword or "").strip() if kw: + like = f"%{kw}%" stmt = stmt.filter( - ManagedNE.name.contains(kw) - | ManagedNE.ip_address.contains(kw) - | ManagedNE.username.contains(kw) - | ManagedNE.tags.contains(kw) + or_( + ManagedNE.name.ilike(like), + ManagedNE.ip_address.ilike(like), + ManagedNE.username.ilike(like), + ManagedNE.tags.ilike(like), + ManagedNE.vendor.ilike(like), + ManagedNE.device_type.ilike(like), + ) ) v = str(vendor or "").strip() if v: @@ -357,6 +365,80 @@ def delete_managed_ne(db: Session, ne_id: str) -> dict[str, bool]: return {"ok": True} +def get_managed_ne_stats(db: Session) -> dict[str, Any]: + """Return total counts by connect_status, and tag statistics.""" + from sqlalchemy import func + + rows = db.query(ManagedNE.connect_status, func.count(ManagedNE.id)).group_by(ManagedNE.connect_status).all() + by_status: dict[str, int] = {} + total = 0 + for status, cnt in rows: + by_status[str(status or "unknown")] = int(cnt) + total += int(cnt) + + # Tag statistics & per-tag connect_status aggregation (space-separated) + def _bump(bucket: dict[str, int], status: str) -> None: + s = str(status or "unknown") + bucket[s] = int(bucket.get(s, 0)) + 1 + + tag_counts: dict[str, int] = {} + no_tag_count = 0 + per_tag_by_status: dict[str, dict[str, int]] = {} + per_tag_total: dict[str, int] = {} + + for connect_status, tags_str in db.query(ManagedNE.connect_status, ManagedNE.tags).all(): + status = str(connect_status or "unknown") + tags_val = str(tags_str or "").strip() + if not tags_val: + no_tag_count += 1 + per_tag_total["__no_tag__"] = int(per_tag_total.get("__no_tag__", 0)) + 1 + per_tag_by_status.setdefault("__no_tag__", {}) + _bump(per_tag_by_status["__no_tag__"], status) + continue + for t in tags_val.split(): + if not t: + continue + tag_counts[t] = int(tag_counts.get(t, 0)) + 1 + per_tag_total[t] = int(per_tag_total.get(t, 0)) + 1 + per_tag_by_status.setdefault(t, {}) + _bump(per_tag_by_status[t], status) + + return { + "total": total, + "by_status": by_status, + "no_tag_count": int(no_tag_count), + "tag_counts": {k: int(tag_counts[k]) for k in sorted(tag_counts.keys())}, + "tags": sorted(tag_counts.keys()), + "per_tag": { + k: {"total": int(per_tag_total.get(k, 0)), "by_status": per_tag_by_status.get(k, {})} + for k in sorted(per_tag_total.keys(), key=lambda x: ("0" if x == "__no_tag__" else "1") + x) + }, + } + + +def get_ids_by_tag(db: Session, tag: str | None) -> list[str]: + """ + Return NE ids by tag. + + - tag is None: all ids + - tag == "__no_tag__": ids where tags is empty/blank + - otherwise: ids where tag exists in space-separated tags list + """ + result: list[str] = [] + norm = str(tag).strip() if tag is not None else None + for ne_id, tags_str in db.query(ManagedNE.id, ManagedNE.tags).all(): + tags_val = str(tags_str or "").strip() + if norm is None: + result.append(str(ne_id)) + elif norm == "__no_tag__": + if not tags_val: + result.append(str(ne_id)) + else: + if norm in tags_val.split(): + result.append(str(ne_id)) + return result + + def batch_delete_managed_ne(db: Session, ids: list[str]) -> dict[str, Any]: ne_ids = [str(x).strip() for x in ids if str(x).strip()] if not ne_ids: @@ -384,6 +466,8 @@ def build_managed_ne_import_template(fmt: str = "xlsx") -> tuple[str, bytes, str "protocol": "ssh", "name": "Core-SW1", "vendor": "Cisco", + "tags": "core", + "remark": "", }, { "device_type": "zte_zxros", @@ -394,6 +478,8 @@ def build_managed_ne_import_template(fmt: str = "xlsx") -> tuple[str, bytes, str "protocol": "ssh", "name": "PE-01", "vendor": "ZTE", + "tags": "edge bastion", + "remark": "no direct password, use batch proxy", }, ] df = pd.DataFrame(rows, columns=list(IMPORT_COLUMNS)) @@ -478,6 +564,12 @@ def import_managed_ne(db: Session, content: bytes, filename: str) -> ImportResul existing.protocol = protocol existing.username = username existing.password_enc = encrypt_secret(password) if password else "" + tags_val = _import_cell_str(row.get("tags", "")) + remark_val = _import_cell_str(row.get("remark", "")) + if tags_val: + existing.tags = tags_val + if remark_val: + existing.remark = remark_val existing.updated_at = now except CredentialCryptoError as exc: failed.append(ImportFailure(row=row_no, reason=str(exc))) diff --git a/tests/test_managed_ne.py b/tests/test_managed_ne.py index 0684f1f..55bfcc0 100644 --- a/tests/test_managed_ne.py +++ b/tests/test_managed_ne.py @@ -117,9 +117,34 @@ class ManagedNeApiTests(unittest.TestCase): self.assertEqual(r4.status_code, 200) self.assertEqual(r4.json()["total"], 1) + r4b = self.client.get("/v1/managed-ne", params={"keyword": "pe-01"}) + self.assertEqual(r4b.status_code, 200) + self.assertEqual(r4b.json()["total"], 1) + r5 = self.client.delete(f"/v1/managed-ne/{ne_id}") self.assertEqual(r5.status_code, 200) + def test_list_keyword_case_insensitive(self): + r = self.client.post( + "/v1/managed-ne", + json={ + "name": "Core-R1", + "vendor": "Cisco", + "device_type": "cisco_ios", + "ip_address": "192.168.0.11", + "username": "admin", + "password": "pass123", + }, + ) + self.assertEqual(r.status_code, 200, r.text) + ne_id = r.json()["id"] + for kw in ("R1", "r1", "core-r1"): + with self.subTest(keyword=kw): + listed = self.client.get("/v1/managed-ne", params={"keyword": kw}) + self.assertEqual(listed.status_code, 200) + self.assertEqual(listed.json()["total"], 1, listed.text) + self.client.delete(f"/v1/managed-ne/{ne_id}") + def test_create_without_crypto_key(self): settings.credential_secret_key = "" r = self.client.post( diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index ff62286..fb008ab 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -11,6 +11,7 @@ export const queryKeys = { umeSyncStatus: (page: number, pageSize: number) => ["umeSyncStatus", page, pageSize] as const, umeNE: (keyword: string, page: number, pageSize: number) => ["umeNE", keyword, page, pageSize] as const, managedNeMeta: ["managedNeMeta"] as const, + managedNeStats: ["managedNeStats"] as const, managedNeAll: ["managedNe"] as const, managedNe: (keyword: string, vendor: string, connectStatus: string, page: number, pageSize: number) => ["managedNe", keyword, vendor, connectStatus, page, pageSize] as const, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index bc77758..c4344c8 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -144,6 +144,25 @@ const en = { keywordPh: "name / IP / user / tags", allVendors: "All vendors", allConnectStatus: "All connect status", + stats: { + title: "Overview", + total: "{{n}} NEs managed", + pass: "Connected", + fail: "Failed", + unknown: "Untested", + testing: "Testing", + batchProxy: "Batch add proxy", + batchTest: "Batch connect-test", + tagFilter: "Filter by tag", + all: "All", + allTag: "All (no tag filter)", + noTag: "No tags", + tagStats: "Top tags", + confirm: "Apply to {{n}} NE(s). Continue?", + proxyDone: "Proxy applied to {{n}} NE(s)", + testDone: "Connect-test submitted for {{n}} NE(s)", + loadingIds: "Loading…", + }, col: { name: "Name", vendor: "Vendor", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b595a08..1202a77 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -143,6 +143,25 @@ const zh = { keywordPh: "名称 / IP / 用户名 / 标签", allVendors: "全部厂商", allConnectStatus: "全部连通状态", + stats: { + title: "统计", + total: "共 {{n}} 台网元", + pass: "已连通", + fail: "失败", + unknown: "未测试", + testing: "测试中", + batchProxy: "一键添加代理", + batchTest: "一键连通性测试", + tagFilter: "按标签筛选", + all: "全部", + allTag: "全部(无标签筛选)", + noTag: "无标签", + tagStats: "按标签统计(Top)", + confirm: "将对 {{n}} 台网元执行操作,确认继续?", + proxyDone: "已为 {{n}} 台网元提交代理配置", + testDone: "已提交 {{n}} 台网元连通性测试", + loadingIds: "加载中…", + }, col: { name: "名称", vendor: "厂商", diff --git a/web/src/index.css b/web/src/index.css index 63d93a0..27af4c4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -716,6 +716,112 @@ pre { flex-wrap: wrap; } +/* ── NE stats card ── */ +.ne-stats-card { + padding: 12px 14px; +} + +.ne-stats-card__header { + display: flex; + align-items: center; + gap: 12px; + justify-content: space-between; + margin-bottom: 10px; +} + +.ne-stats-card__header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.ne-stats-card__header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ne-stats-card__title { + font-size: 15px; + font-weight: 600; + color: #1e293b; +} + +.ne-stats-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 9px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; +} + +.ne-stats-pill--pass { background: #dcfce7; color: #166534; } +.ne-stats-pill--fail { background: #fee2e2; color: #991b1b; } +.ne-stats-pill--testing{ background: #fef9c3; color: #854d0e; } +.ne-stats-pill--unknown{ background: #f1f5f9; color: #475569; } + +.ne-stats-card__actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ne-stats-card__tag-select { + height: 32px; + font-size: 13px; + padding: 0 8px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + min-width: 150px; +} + +.ne-stats-card__loading { + color: #94a3b8; + font-size: 13px; + margin: 0; +} + +.ne-stats-card__tag-cards { + width: 100%; + display: grid; + grid-template-columns: repeat(4, minmax(190px, 1fr)); + gap: 10px; +} + +.ne-tag-card { + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #f8fafc; + padding: 10px 10px; +} + +.ne-tag-card__title { + font-size: 13px; + font-weight: 600; + color: #0f172a; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ne-tag-card__total { + font-size: 12px; + color: #64748b; + margin-bottom: 8px; +} + +.ne-tag-card__pills { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + .mini-chart__bar-wrap { height: 10px; border-radius: 999px; diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx index 1acfb08..d690fb8 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/pages/NePage.tsx @@ -6,11 +6,14 @@ import { connectTestManagedNe, createManagedNe, deleteManagedNe, + fetchIdsByTag, fetchManagedNe, fetchManagedNeMeta, + fetchManagedNeStats, importManagedNe, managedNeImportTemplateUrl, updateManagedNe, + type ManagedNeStats, } from "../services/api"; import { HelpHint } from "../components/HelpHint"; import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields"; @@ -120,12 +123,23 @@ export function NePage() { const [batchHop, setBatchHop] = useState(emptyHopProxyFields); const [connectDetailRow, setConnectDetailRow] = useState(null); + // --- bulk-by-tag dialog --- + const [bulkTagModalOpen, setBulkTagModalOpen] = useState(false); + const [bulkTagAction, setBulkTagAction] = useState<"proxy" | "test">("proxy"); + const [bulkTagSelected, setBulkTagSelected] = useState(""); // "" = all, "__no_tag__" = no-tag NEs + const metaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta, staleTime: 60_000, }); + const statsQuery = useQuery({ + queryKey: queryKeys.managedNeStats, + queryFn: fetchManagedNeStats, + staleTime: 10_000, + }); + const listQuery = useQuery({ queryKey: queryKeys.managedNe(keyword, vendorFilter, statusFilter, page, pageSize), queryFn: () => @@ -273,6 +287,44 @@ export function NePage() { onError: (err) => showError(String(err)), }); + // Bulk-by-tag: fetch ids then run proxy/test + const [bulkHop, setBulkHop] = useState(() => emptyHopProxyFields()); + const bulkByTagMutation = useMutation({ + mutationFn: async (params: { action: "proxy" | "test"; tag: string }) => { + const apiTag = params.tag === "" ? null : params.tag; + const { ids } = await fetchIdsByTag(apiTag); + if (ids.length === 0) throw new Error(t("managedNe.stats.loadingIds")); + if (!window.confirm(t("managedNe.stats.confirm", { n: ids.length }))) return null; + if (params.action === "test") { + const res = await connectTestManagedNe(ids); + return { type: "test" as const, n: res.submitted }; + } + const res = await batchApplyHopManagedNe(ids, { + hop_vendor: bulkHop.hop_vendor, + hop_host: bulkHop.hop_host.trim(), + hop_port: bulkHop.hop_port, + hop_protocol: bulkHop.hop_protocol, + hop_username: bulkHop.hop_username.trim(), + hop_password: bulkHop.hop_password, + hop_command_template: bulkHop.hop_command_template.trim(), + hop_vrf: bulkHop.hop_vrf.trim(), + hop_target_auth_mode: bulkHop.hop_target_auth_mode, + }); + return { type: "proxy" as const, n: res.updated }; + }, + onSuccess: async (res) => { + if (!res) return; + setBulkTagModalOpen(false); + if (res.type === "test") showOk(t("managedNe.stats.testDone", { n: res.n })); + else showOk(t("managedNe.stats.proxyDone", { n: res.n })); + await Promise.all([ + invalidateList(), + queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }), + ]); + }, + onError: (err) => showError(String(err)), + }); + const vendors = metaQuery.data?.vendors ?? []; const deviceTypes = metaQuery.data?.device_types ?? []; const credsOk = metaQuery.data?.credentials_configured ?? false; @@ -340,6 +392,34 @@ export function NePage() { } }; + const stats: ManagedNeStats | undefined = statsQuery.data; + const statTags: string[] = stats?.tags ?? []; + const perTag = stats?.per_tag ?? {}; + const tagCardItems = useMemo(() => { + if (!stats) return []; + const items: Array<{ key: string; title: string; total: number; by_status: Record }> = []; + items.push({ key: "__all__", title: t("managedNe.stats.all"), total: stats.total, by_status: stats.by_status }); + // no-tag first (if any) + if ((stats.no_tag_count ?? 0) > 0 || perTag["__no_tag__"]) { + items.push({ + key: "__no_tag__", + title: t("managedNe.stats.noTag"), + total: perTag["__no_tag__"]?.total ?? stats.no_tag_count ?? 0, + by_status: perTag["__no_tag__"]?.by_status ?? {}, + }); + } + for (const tag of stats.tags || []) { + const x = perTag[tag]; + items.push({ + key: tag, + title: tag, + total: x?.total ?? 0, + by_status: x?.by_status ?? {}, + }); + } + return items; + }, [stats, perTag, t]); + return (
{!credsOk ? ( @@ -348,6 +428,57 @@ export function NePage() { ) : null} + {/* ── statistics card ── */} +
+
+
+ {t("managedNe.stats.title")} +
+
+ + +
+
+ {stats ? ( +
+ {tagCardItems.map((x) => ( +
+
{x.title}
+
{t("managedNe.stats.total", { n: x.total })}
+
+ {(["pass", "fail", "testing", "unknown"] as const).map((s) => ( + + {t(`managedNe.stats.${s}`)} {x.by_status[s] ?? 0} + + ))} +
+
+ ))} +
+ ) : ( +

{t("managedNe.stats.loadingIds")}

+ )} +
+

{t("managedNe.title")}

@@ -746,6 +877,66 @@ export function NePage() {
) : null} + {bulkTagModalOpen ? ( +
setBulkTagModalOpen(false)}> +
e.stopPropagation()}> +

+ {bulkTagAction === "proxy" ? t("managedNe.stats.batchProxy") : t("managedNe.stats.batchTest")} + {bulkTagSelected && bulkTagSelected !== "__no_tag__" ? ` · ${bulkTagSelected}` : ""} + {bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""} + {bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""} +

+ + {bulkTagAction === "proxy" ? ( + <> +

{t("managedNe.hop.batchHint", { n: "?" })}

+ setBulkHop((prev) => ({ ...prev, ...patch }))} /> + + ) : ( +

{t("managedNe.stats.confirm", { n: "?" })}

+ )} +
+ + +
+
+
+ ) : null} + {connectDetailRow ? (
setConnectDetailRow(null)}>
e.stopPropagation()}> diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 1748448..19f35fc 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -102,6 +102,28 @@ export const fetchManagedNeMeta = () => credentials_configured: creds.configured, })); +export type ManagedNeStats = { + total: number; + by_status: Record; + tags: string[]; + no_tag_count: number; + tag_counts: Record; + per_tag: Record< + string, + { + total: number; + by_status: Record; + } + >; +}; + +export const fetchManagedNeStats = () => apiGet("/v1/managed-ne/meta/stats"); + +export const fetchIdsByTag = (tag: string | null) => { + const url = tag ? `/v1/managed-ne/meta/ids-by-tag?tag=${encodeURIComponent(tag)}` : "/v1/managed-ne/meta/ids-by-tag"; + return apiGet<{ ids: string[] }>(url); +}; + export const fetchManagedNe = (params: { keyword: string; vendor: string;