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:
oliver 2026-06-05 16:13:31 +08:00
parent d23b5b7cb0
commit 06a5615095
9 changed files with 492 additions and 4 deletions

View file

@ -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,

View file

@ -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",

View file

@ -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: "厂商",

View file

@ -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;

View file

@ -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<HopProxyFieldsState>(emptyHopProxyFields);
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({
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<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 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<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 (
<div className="page-stack">
{!credsOk ? (
@ -348,6 +428,57 @@ export function NePage() {
</section>
) : 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">
<div className="panel__toolbar">
<h2>{t("managedNe.title")}</h2>
@ -746,6 +877,66 @@ export function NePage() {
</div>
) : 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 ? (
<div className="modal-backdrop" role="presentation" onClick={() => setConnectDetailRow(null)}>
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}>

View file

@ -102,6 +102,28 @@ export const fetchManagedNeMeta = () =>
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: {
keyword: string;
vendor: string;