mirror of
https://github.com/hansjone/netx.git
synced 2026-10-16 15:23:21 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
d23b5b7cb0
commit
06a5615095
9 changed files with 492 additions and 4 deletions
|
|
@ -16,7 +16,9 @@ from .ne_service import (
|
||||||
create_managed_ne,
|
create_managed_ne,
|
||||||
batch_delete_managed_ne,
|
batch_delete_managed_ne,
|
||||||
delete_managed_ne,
|
delete_managed_ne,
|
||||||
|
get_ids_by_tag,
|
||||||
get_managed_ne,
|
get_managed_ne,
|
||||||
|
get_managed_ne_stats,
|
||||||
import_managed_ne,
|
import_managed_ne,
|
||||||
list_managed_ne,
|
list_managed_ne,
|
||||||
update_managed_ne,
|
update_managed_ne,
|
||||||
|
|
@ -55,6 +57,17 @@ def api_credentials_configured():
|
||||||
return {"configured": 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("")
|
@router.post("")
|
||||||
def api_create_managed_ne(body: ManagedNeCreate, db: Session = Depends(get_db)):
|
def api_create_managed_ne(body: ManagedNeCreate, db: Session = Depends(get_db)):
|
||||||
return create_managed_ne(db, body).model_dump()
|
return create_managed_ne(db, body).model_dump()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from typing import Any
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS
|
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS
|
||||||
|
|
@ -30,6 +31,8 @@ IMPORT_COLUMNS = (
|
||||||
"protocol",
|
"protocol",
|
||||||
"name",
|
"name",
|
||||||
"vendor",
|
"vendor",
|
||||||
|
"tags",
|
||||||
|
"remark",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -176,11 +179,16 @@ def list_managed_ne(
|
||||||
stmt = db.query(ManagedNE)
|
stmt = db.query(ManagedNE)
|
||||||
kw = str(keyword or "").strip()
|
kw = str(keyword or "").strip()
|
||||||
if kw:
|
if kw:
|
||||||
|
like = f"%{kw}%"
|
||||||
stmt = stmt.filter(
|
stmt = stmt.filter(
|
||||||
ManagedNE.name.contains(kw)
|
or_(
|
||||||
| ManagedNE.ip_address.contains(kw)
|
ManagedNE.name.ilike(like),
|
||||||
| ManagedNE.username.contains(kw)
|
ManagedNE.ip_address.ilike(like),
|
||||||
| ManagedNE.tags.contains(kw)
|
ManagedNE.username.ilike(like),
|
||||||
|
ManagedNE.tags.ilike(like),
|
||||||
|
ManagedNE.vendor.ilike(like),
|
||||||
|
ManagedNE.device_type.ilike(like),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
v = str(vendor or "").strip()
|
v = str(vendor or "").strip()
|
||||||
if v:
|
if v:
|
||||||
|
|
@ -357,6 +365,80 @@ def delete_managed_ne(db: Session, ne_id: str) -> dict[str, bool]:
|
||||||
return {"ok": True}
|
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]:
|
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()]
|
ne_ids = [str(x).strip() for x in ids if str(x).strip()]
|
||||||
if not ne_ids:
|
if not ne_ids:
|
||||||
|
|
@ -384,6 +466,8 @@ def build_managed_ne_import_template(fmt: str = "xlsx") -> tuple[str, bytes, str
|
||||||
"protocol": "ssh",
|
"protocol": "ssh",
|
||||||
"name": "Core-SW1",
|
"name": "Core-SW1",
|
||||||
"vendor": "Cisco",
|
"vendor": "Cisco",
|
||||||
|
"tags": "core",
|
||||||
|
"remark": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"device_type": "zte_zxros",
|
"device_type": "zte_zxros",
|
||||||
|
|
@ -394,6 +478,8 @@ def build_managed_ne_import_template(fmt: str = "xlsx") -> tuple[str, bytes, str
|
||||||
"protocol": "ssh",
|
"protocol": "ssh",
|
||||||
"name": "PE-01",
|
"name": "PE-01",
|
||||||
"vendor": "ZTE",
|
"vendor": "ZTE",
|
||||||
|
"tags": "edge bastion",
|
||||||
|
"remark": "no direct password, use batch proxy",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
df = pd.DataFrame(rows, columns=list(IMPORT_COLUMNS))
|
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.protocol = protocol
|
||||||
existing.username = username
|
existing.username = username
|
||||||
existing.password_enc = encrypt_secret(password) if password else ""
|
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
|
existing.updated_at = now
|
||||||
except CredentialCryptoError as exc:
|
except CredentialCryptoError as exc:
|
||||||
failed.append(ImportFailure(row=row_no, reason=str(exc)))
|
failed.append(ImportFailure(row=row_no, reason=str(exc)))
|
||||||
|
|
|
||||||
|
|
@ -117,9 +117,34 @@ class ManagedNeApiTests(unittest.TestCase):
|
||||||
self.assertEqual(r4.status_code, 200)
|
self.assertEqual(r4.status_code, 200)
|
||||||
self.assertEqual(r4.json()["total"], 1)
|
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}")
|
r5 = self.client.delete(f"/v1/managed-ne/{ne_id}")
|
||||||
self.assertEqual(r5.status_code, 200)
|
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):
|
def test_create_without_crypto_key(self):
|
||||||
settings.credential_secret_key = ""
|
settings.credential_secret_key = ""
|
||||||
r = self.client.post(
|
r = self.client.post(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export const queryKeys = {
|
||||||
umeSyncStatus: (page: number, pageSize: number) => ["umeSyncStatus", page, pageSize] as const,
|
umeSyncStatus: (page: number, pageSize: number) => ["umeSyncStatus", page, pageSize] as const,
|
||||||
umeNE: (keyword: string, page: number, pageSize: number) => ["umeNE", keyword, page, pageSize] as const,
|
umeNE: (keyword: string, page: number, pageSize: number) => ["umeNE", keyword, page, pageSize] as const,
|
||||||
managedNeMeta: ["managedNeMeta"] as const,
|
managedNeMeta: ["managedNeMeta"] as const,
|
||||||
|
managedNeStats: ["managedNeStats"] as const,
|
||||||
managedNeAll: ["managedNe"] as const,
|
managedNeAll: ["managedNe"] as const,
|
||||||
managedNe: (keyword: string, vendor: string, connectStatus: string, page: number, pageSize: number) =>
|
managedNe: (keyword: string, vendor: string, connectStatus: string, page: number, pageSize: number) =>
|
||||||
["managedNe", keyword, vendor, connectStatus, page, pageSize] as const,
|
["managedNe", keyword, vendor, connectStatus, page, pageSize] as const,
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,25 @@ const en = {
|
||||||
keywordPh: "name / IP / user / tags",
|
keywordPh: "name / IP / user / tags",
|
||||||
allVendors: "All vendors",
|
allVendors: "All vendors",
|
||||||
allConnectStatus: "All connect status",
|
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: {
|
col: {
|
||||||
name: "Name",
|
name: "Name",
|
||||||
vendor: "Vendor",
|
vendor: "Vendor",
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,25 @@ const zh = {
|
||||||
keywordPh: "名称 / IP / 用户名 / 标签",
|
keywordPh: "名称 / IP / 用户名 / 标签",
|
||||||
allVendors: "全部厂商",
|
allVendors: "全部厂商",
|
||||||
allConnectStatus: "全部连通状态",
|
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: {
|
col: {
|
||||||
name: "名称",
|
name: "名称",
|
||||||
vendor: "厂商",
|
vendor: "厂商",
|
||||||
|
|
|
||||||
|
|
@ -716,6 +716,112 @@ pre {
|
||||||
flex-wrap: wrap;
|
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 {
|
.mini-chart__bar-wrap {
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@ import {
|
||||||
connectTestManagedNe,
|
connectTestManagedNe,
|
||||||
createManagedNe,
|
createManagedNe,
|
||||||
deleteManagedNe,
|
deleteManagedNe,
|
||||||
|
fetchIdsByTag,
|
||||||
fetchManagedNe,
|
fetchManagedNe,
|
||||||
fetchManagedNeMeta,
|
fetchManagedNeMeta,
|
||||||
|
fetchManagedNeStats,
|
||||||
importManagedNe,
|
importManagedNe,
|
||||||
managedNeImportTemplateUrl,
|
managedNeImportTemplateUrl,
|
||||||
updateManagedNe,
|
updateManagedNe,
|
||||||
|
type ManagedNeStats,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
import { HelpHint } from "../components/HelpHint";
|
import { HelpHint } from "../components/HelpHint";
|
||||||
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields";
|
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields";
|
||||||
|
|
@ -120,12 +123,23 @@ export function NePage() {
|
||||||
const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields);
|
const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields);
|
||||||
const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(null);
|
const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(null);
|
||||||
|
|
||||||
|
// --- bulk-by-tag dialog ---
|
||||||
|
const [bulkTagModalOpen, setBulkTagModalOpen] = useState(false);
|
||||||
|
const [bulkTagAction, setBulkTagAction] = useState<"proxy" | "test">("proxy");
|
||||||
|
const [bulkTagSelected, setBulkTagSelected] = useState<string>(""); // "" = all, "__no_tag__" = no-tag NEs
|
||||||
|
|
||||||
const metaQuery = useQuery({
|
const metaQuery = useQuery({
|
||||||
queryKey: queryKeys.managedNeMeta,
|
queryKey: queryKeys.managedNeMeta,
|
||||||
queryFn: fetchManagedNeMeta,
|
queryFn: fetchManagedNeMeta,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.managedNeStats,
|
||||||
|
queryFn: fetchManagedNeStats,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: queryKeys.managedNe(keyword, vendorFilter, statusFilter, page, pageSize),
|
queryKey: queryKeys.managedNe(keyword, vendorFilter, statusFilter, page, pageSize),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
|
|
@ -273,6 +287,44 @@ export function NePage() {
|
||||||
onError: (err) => showError(String(err)),
|
onError: (err) => showError(String(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bulk-by-tag: fetch ids then run proxy/test
|
||||||
|
const [bulkHop, setBulkHop] = useState<HopProxyFieldsState>(() => 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 vendors = metaQuery.data?.vendors ?? [];
|
||||||
const deviceTypes = metaQuery.data?.device_types ?? [];
|
const deviceTypes = metaQuery.data?.device_types ?? [];
|
||||||
const credsOk = metaQuery.data?.credentials_configured ?? false;
|
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<string, number> }> = [];
|
||||||
|
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 (
|
return (
|
||||||
<div className="page-stack">
|
<div className="page-stack">
|
||||||
{!credsOk ? (
|
{!credsOk ? (
|
||||||
|
|
@ -348,6 +428,57 @@ export function NePage() {
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* ── statistics card ── */}
|
||||||
|
<section className="card ne-stats-card">
|
||||||
|
<div className="ne-stats-card__header">
|
||||||
|
<div className="ne-stats-card__header-left">
|
||||||
|
<span className="ne-stats-card__title">{t("managedNe.stats.title")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ne-stats-card__header-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!credsOk || bulkByTagMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setBulkTagAction("proxy");
|
||||||
|
setBulkHop(emptyHopProxyFields());
|
||||||
|
setBulkTagModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("managedNe.stats.batchProxy")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={bulkByTagMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setBulkTagAction("test");
|
||||||
|
setBulkTagModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("managedNe.stats.batchTest")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stats ? (
|
||||||
|
<div className="ne-stats-card__tag-cards">
|
||||||
|
{tagCardItems.map((x) => (
|
||||||
|
<div key={x.key} className="ne-tag-card">
|
||||||
|
<div className="ne-tag-card__title">{x.title}</div>
|
||||||
|
<div className="ne-tag-card__total">{t("managedNe.stats.total", { n: x.total })}</div>
|
||||||
|
<div className="ne-tag-card__pills">
|
||||||
|
{(["pass", "fail", "testing", "unknown"] as const).map((s) => (
|
||||||
|
<span key={s} className={`ne-stats-pill ne-stats-pill--${s}`}>
|
||||||
|
{t(`managedNe.stats.${s}`)} {x.by_status[s] ?? 0}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="ne-stats-card__loading">{t("managedNe.stats.loadingIds")}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel__toolbar">
|
<div className="panel__toolbar">
|
||||||
<h2>{t("managedNe.title")}</h2>
|
<h2>{t("managedNe.title")}</h2>
|
||||||
|
|
@ -746,6 +877,66 @@ export function NePage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{bulkTagModalOpen ? (
|
||||||
|
<div className="modal-backdrop" role="presentation" onClick={() => setBulkTagModalOpen(false)}>
|
||||||
|
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>
|
||||||
|
{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")}` : ""}
|
||||||
|
</h3>
|
||||||
|
<label>
|
||||||
|
<FormLabel>{t("managedNe.stats.tagFilter")}</FormLabel>
|
||||||
|
<select
|
||||||
|
className="ne-stats-card__tag-select"
|
||||||
|
value={bulkTagSelected}
|
||||||
|
onChange={(e) => setBulkTagSelected(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t("managedNe.stats.allTag")}</option>
|
||||||
|
<option value="__no_tag__">{t("managedNe.stats.noTag")}</option>
|
||||||
|
{statTags.map((tag) => (
|
||||||
|
<option key={tag} value={tag}>
|
||||||
|
{tag}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{bulkTagAction === "proxy" ? (
|
||||||
|
<>
|
||||||
|
<p className="form-hint">{t("managedNe.hop.batchHint", { n: "?" })}</p>
|
||||||
|
<HopProxyFields value={bulkHop} onChange={(patch) => setBulkHop((prev) => ({ ...prev, ...patch }))} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="form-hint">{t("managedNe.stats.confirm", { n: "?" })}</p>
|
||||||
|
)}
|
||||||
|
<div className="modal__actions">
|
||||||
|
<button type="button" onClick={() => setBulkTagModalOpen(false)}>
|
||||||
|
{t("managedNe.form.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={bulkByTagMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (bulkTagAction === "proxy") {
|
||||||
|
if (!bulkHop.hop_host.trim()) { showError(t("managedNe.hop.hostRequired")); return; }
|
||||||
|
if (!bulkHop.hop_username.trim()) { showError(t("managedNe.hop.userRequired")); return; }
|
||||||
|
if (!bulkHop.hop_password) { showError(t("managedNe.hop.passwordRequired")); return; }
|
||||||
|
}
|
||||||
|
bulkByTagMutation.mutate({ action: bulkTagAction, tag: bulkTagSelected });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bulkByTagMutation.isPending
|
||||||
|
? t("managedNe.stats.loadingIds")
|
||||||
|
: bulkTagAction === "proxy"
|
||||||
|
? t("managedNe.hop.apply")
|
||||||
|
: t("managedNe.connect.run")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{connectDetailRow ? (
|
{connectDetailRow ? (
|
||||||
<div className="modal-backdrop" role="presentation" onClick={() => setConnectDetailRow(null)}>
|
<div className="modal-backdrop" role="presentation" onClick={() => setConnectDetailRow(null)}>
|
||||||
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}>
|
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,28 @@ export const fetchManagedNeMeta = () =>
|
||||||
credentials_configured: creds.configured,
|
credentials_configured: creds.configured,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export type ManagedNeStats = {
|
||||||
|
total: number;
|
||||||
|
by_status: Record<string, number>;
|
||||||
|
tags: string[];
|
||||||
|
no_tag_count: number;
|
||||||
|
tag_counts: Record<string, number>;
|
||||||
|
per_tag: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
total: number;
|
||||||
|
by_status: Record<string, number>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchManagedNeStats = () => apiGet<ManagedNeStats>("/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: {
|
export const fetchManagedNe = (params: {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue