mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Ship gated eye polish, fabric levels, and collection UI refresh.
Topology MCP adds pull/compact/bundle/suggest-hubs with a no-template skill path; API gains fabric level and NE collection policy; web list pages get paging and denser collect/network workflows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b1aee86701
commit
b81e5869a6
91 changed files with 10395 additions and 1513 deletions
14
web/WEB.md
14
web/WEB.md
|
|
@ -138,14 +138,14 @@ src/
|
|||
- MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web
|
||||
- BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO
|
||||
|
||||
## 分类与切片(清单打标)
|
||||
## 分类(清单打标)
|
||||
|
||||
- Fabric 标签:`topo_fabric_node.role` / `region_folder_id`(`role_source` / `region_source`)
|
||||
- 主流程:网元清单表(Fabric 全量)+ 临时正则查找 → 确认后批量写角色/区域;支持单行编辑
|
||||
- 关联状态:`link_status`=`managed|ume|both|orphaned`;筛选 `link_status=linked|orphaned|…`
|
||||
- API:`GET /fabric/nodes`(keyword/role/region/unmatched/link_status)、`POST /fabric/nodes/match`、`POST /fabric/nodes/tags/bulk`、`PATCH /fabric/nodes/{id}/tags`
|
||||
- 切片:`POST /v1/topology/slices/generate`(`core_only` / `core_agg` / `agg_access`;dry_run;可重叠上图)
|
||||
- 前端:网络管理 →「分类与切片」
|
||||
- Fabric 主键:`topo_fabric_node.level`(major.minor,越小越靠外);`role` 为 `floor(level)` 同步别名
|
||||
- 预设:0 外部 / 1 核心 / 2 汇聚 / 3 接入;子层如 1.1、2.1
|
||||
- 主流程:清单 + 正则 → 批量写 level/区域;支持单行编辑
|
||||
- API:`GET /fabric/nodes`(level / level_major / role / unmatched=level)、`POST …/match`、`POST …/tags/bulk`(`level`)、`PATCH …/tags`
|
||||
- Agent:`classifyTopologyFabricNodes`(netx-topology MCP)
|
||||
- 前端:网络管理 →「分类」
|
||||
|
||||
## WebCRT
|
||||
|
||||
|
|
|
|||
116
web/src/components/ListPager.tsx
Normal file
116
web/src/components/ListPager.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
export type ListPagerProps = {
|
||||
page: number;
|
||||
pages: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
pageSizeOptions?: number[];
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange?: (size: number) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Shared denser pager: meta + prev/next + jump + optional page size. */
|
||||
export function ListPager({
|
||||
page,
|
||||
pages,
|
||||
total,
|
||||
pageSize,
|
||||
pageSizeOptions = [20, 50, 100, 200],
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}: ListPagerProps) {
|
||||
const { t } = useI18n();
|
||||
const safePages = Math.max(1, pages || 1);
|
||||
const [jumpDraft, setJumpDraft] = useState(String(page));
|
||||
|
||||
useEffect(() => {
|
||||
setJumpDraft(String(page));
|
||||
}, [page]);
|
||||
|
||||
const commitJump = () => {
|
||||
const n = Math.floor(Number(jumpDraft));
|
||||
if (!Number.isFinite(n)) {
|
||||
setJumpDraft(String(page));
|
||||
return;
|
||||
}
|
||||
const next = Math.min(safePages, Math.max(1, n));
|
||||
setJumpDraft(String(next));
|
||||
if (next !== page) onPageChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`pager pt-list-pager ${className}`.trim()}>
|
||||
<div className="pager__meta muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(total),
|
||||
page: String(page),
|
||||
pages: String(safePages),
|
||||
})}
|
||||
</div>
|
||||
<div className="pager__controls btn-row">
|
||||
<button
|
||||
className="pager__btn"
|
||||
type="button"
|
||||
disabled={disabled || page <= 1}
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<label className="pager__jump">
|
||||
<span className="visually-hidden">{t("common.jumpPage")}</span>
|
||||
<input
|
||||
className="pager__jump-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={safePages}
|
||||
value={jumpDraft}
|
||||
disabled={disabled}
|
||||
aria-label={t("common.jumpPage")}
|
||||
title={t("common.jumpPage")}
|
||||
onChange={(e) => setJumpDraft(e.target.value)}
|
||||
onBlur={commitJump}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitJump();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="muted">/ {safePages}</span>
|
||||
</label>
|
||||
<button
|
||||
className="pager__btn"
|
||||
type="button"
|
||||
disabled={disabled || page >= safePages}
|
||||
onClick={() => onPageChange(Math.min(safePages, page + 1))}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
{onPageSizeChange ? (
|
||||
<select
|
||||
className="pager__size"
|
||||
value={String(pageSize)}
|
||||
disabled={disabled}
|
||||
aria-label={t("common.pageSize")}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value) || pageSizeOptions[0] || 50;
|
||||
onPageSizeChange(next);
|
||||
}}
|
||||
>
|
||||
{pageSizeOptions.map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{t("common.perPage", { n: String(n) })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,12 +27,14 @@ export const queryKeys = {
|
|||
collectionEligibleNeAll: ["collectionEligibleNe"] as const,
|
||||
collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const,
|
||||
neCollectionsAll: ["neCollections"] as const,
|
||||
neCollections: (page: number) => ["neCollections", page] as const,
|
||||
neCollections: (page: number, pageSize = 20, status = "", keyword = "") =>
|
||||
["neCollections", page, pageSize, status, keyword] as const,
|
||||
neCollectionDashboard: ["neCollectionDashboard"] as const,
|
||||
neCollectionPolicy: ["neCollectionPolicy"] as const,
|
||||
neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const,
|
||||
neCollectionRunsAll: ["neCollectionRuns"] as const,
|
||||
neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) =>
|
||||
["neCollectionRuns", jobId, page, status, keyword] as const,
|
||||
neCollectionRuns: (jobId: string, page: number, pageSize: number, status: string, keyword: string) =>
|
||||
["neCollectionRuns", jobId, page, pageSize, status, keyword] as const,
|
||||
cliMeta: ["cliMeta"] as const,
|
||||
cliProfiles: ["cliProfiles"] as const,
|
||||
cliTargetsAll: ["cliTargets"] as const,
|
||||
|
|
@ -56,7 +58,9 @@ export const queryKeys = {
|
|||
unmatched: string,
|
||||
linkStatus: string,
|
||||
page: number,
|
||||
) => ["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page] as const,
|
||||
levelMajor?: string,
|
||||
) =>
|
||||
["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page, levelMajor || ""] as const,
|
||||
fabricNodeSearch: (q: string, page: number) => ["fabricNodeSearch", q, page] as const,
|
||||
topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
|
||||
fabricSummary: ["fabricSummary"] as const,
|
||||
|
|
@ -66,26 +70,35 @@ export const queryKeys = {
|
|||
["topologyViewGraph", viewId, focusFolderId] as const,
|
||||
lldpCollectDashboard: ["lldpCollectDashboard"] as const,
|
||||
lldpCollectJobsAll: ["lldpCollectJobs"] as const,
|
||||
lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const,
|
||||
lldpCollectJobs: (page: number, pageSize = 10, status = "", keyword = "") =>
|
||||
["lldpCollectJobs", page, pageSize, status, keyword] as const,
|
||||
lldpCollectJobAll: ["lldpCollectJob"] as const,
|
||||
lldpCollectJob: (jobId: string, page = 1) => ["lldpCollectJob", jobId, page] as const,
|
||||
lldpCollectJob: (jobId: string, page = 1, pageSize = 20, status = "", keyword = "") =>
|
||||
["lldpCollectJob", jobId, page, pageSize, status, keyword] as const,
|
||||
fabricEdgesAll: ["fabricEdges"] as const,
|
||||
fabricEdges: (status: string, keyword: string, page: number) =>
|
||||
["fabricEdges", status, keyword, page] as const,
|
||||
fabricEdges: (status: string, keyword: string, page: number, pageSize = 20, source = "") =>
|
||||
["fabricEdges", status, keyword, page, pageSize, source] as const,
|
||||
configSyncDashboard: ["configSyncDashboard"] as const,
|
||||
configSyncPolicy: ["configSyncPolicy"] as const,
|
||||
configSyncCyclesAll: ["configSyncCycles"] as const,
|
||||
configSyncCycles: (page: number) => ["configSyncCycles", page] as const,
|
||||
configSyncCycles: (page: number, pageSize = 20, status = "", keyword = "") =>
|
||||
["configSyncCycles", page, pageSize, status, keyword] as const,
|
||||
configSyncCycleTasksAll: ["configSyncCycleTasks"] as const,
|
||||
configSyncCycleTasks: (cycleId: string, page: number, status: string, keyword: string) =>
|
||||
["configSyncCycleTasks", cycleId, page, status, keyword] as const,
|
||||
configSyncCycleTasks: (
|
||||
cycleId: string,
|
||||
page: number,
|
||||
pageSize = 20,
|
||||
status = "",
|
||||
keyword = "",
|
||||
) => ["configSyncCycleTasks", cycleId, page, pageSize, status, keyword] as const,
|
||||
networkConfigsAll: ["networkConfigs"] as const,
|
||||
networkConfigs: (page: number, keyword: string, source: string, vendor: string) =>
|
||||
["networkConfigs", page, keyword, source, vendor] as const,
|
||||
networkConfigs: (page: number, keyword: string, source: string, vendor: string, pageSize = 20) =>
|
||||
["networkConfigs", page, keyword, source, vendor, pageSize] as const,
|
||||
networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const,
|
||||
portTrafficDashboard: ["portTrafficDashboard"] as const,
|
||||
portTrafficDevicesAll: ["portTrafficDevices"] as const,
|
||||
portTrafficDevices: (page: number) => ["portTrafficDevices", page] as const,
|
||||
portTrafficDevices: (page: number, pageSize = 20, status = "", keyword = "") =>
|
||||
["portTrafficDevices", page, pageSize, status, keyword] as const,
|
||||
portTrafficTargets: (deviceId: string) => ["portTrafficTargets", deviceId] as const,
|
||||
portTrafficEvents: (deviceId: string) => ["portTrafficEvents", deviceId] as const,
|
||||
portTrafficBoards: ["portTrafficBoards"] as const,
|
||||
|
|
|
|||
11
web/src/hooks/useDebouncedValue.ts
Normal file
11
web/src/hooks/useDebouncedValue.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Debounce a rapidly changing value (e.g. keyword input) before querying. */
|
||||
export function useDebouncedValue<T>(value: T, delayMs = 300): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
|
|
@ -9,6 +9,13 @@ const en = {
|
|||
nextPage: "Next",
|
||||
perPage: "{{n}}/page",
|
||||
pagerMeta: "{{total}} items · page {{page}}/{{pages}}",
|
||||
jumpPage: "Go to page",
|
||||
pageSize: "Page size",
|
||||
exportCsv: "Export CSV",
|
||||
exporting: "Exporting…",
|
||||
exportOk: "Exported {{count}} rows",
|
||||
exportTruncated: "Exported first {{count}} of {{total}} rows (truncated)",
|
||||
exportFailed: "Export failed",
|
||||
opFailed: "Operation failed",
|
||||
empty: "-",
|
||||
cancel: "Cancel",
|
||||
|
|
@ -100,7 +107,7 @@ const en = {
|
|||
devices: "Device list",
|
||||
topology: "Topology",
|
||||
lldpLinks: "LLDP",
|
||||
topoClassify: "Classify / slices",
|
||||
topoClassify: "Classify",
|
||||
alarms: "Alarms",
|
||||
configs: "Configurations",
|
||||
webcrt: "WebCRT",
|
||||
|
|
@ -130,10 +137,15 @@ const en = {
|
|||
statusRunning: "Running",
|
||||
statusPaused: "Paused",
|
||||
statusStopped: "Stopped",
|
||||
statusDraft: "Draft",
|
||||
collecting: "Collecting",
|
||||
deleted: "Deleted",
|
||||
confirmDelete: "Delete this device monitoring and its samples?",
|
||||
empty: "No devices monitored yet. Pick a NE and interfaces to start.",
|
||||
keywordPh: "Name / IP / note / ID",
|
||||
allStatus: "All statuses",
|
||||
exportDevicesName: "port-traffic-devices",
|
||||
exportEventsName: "port-traffic-events",
|
||||
wall: "Ops wall",
|
||||
wallTitle: "Port traffic wall",
|
||||
toDevices: "Manage devices",
|
||||
|
|
@ -354,6 +366,14 @@ const en = {
|
|||
edgeStatusAll: "All statuses",
|
||||
edgeStatusActive: "Active",
|
||||
edgeStatusMissing: "Missing",
|
||||
edgeSourceAll: "All sources",
|
||||
exportEdgesName: "lldp-fabric-edges",
|
||||
exportJobsName: "lldp-collect-jobs",
|
||||
exportItemsName: "lldp-job-items",
|
||||
statusAll: "All statuses",
|
||||
jobKeywordPh: "Job ID / error / scope",
|
||||
itemKeywordPh: "NE name / IP / error",
|
||||
itemResultAll: "All results",
|
||||
missCount: "Miss cycles",
|
||||
replacedBy: "Replaced",
|
||||
kpi: {
|
||||
|
|
@ -392,6 +412,7 @@ const en = {
|
|||
done: "Done",
|
||||
failed: "Failed",
|
||||
cancelled: "Cancelled",
|
||||
stopped: "Stopped",
|
||||
},
|
||||
triggerMode: {
|
||||
manual: "Manual",
|
||||
|
|
@ -444,7 +465,11 @@ const en = {
|
|||
cyclesEmpty: "No sync cycles yet",
|
||||
tasksTitle: "Cycle tasks",
|
||||
taskKeywordPh: "Name / IP / message",
|
||||
keywordPh: "ID / trigger / message",
|
||||
statusAll: "All statuses",
|
||||
allStatus: "All statuses",
|
||||
exportCyclesName: "config-sync-cycles",
|
||||
exportTasksName: "config-sync-tasks",
|
||||
expand: "Details",
|
||||
collapse: "Collapse",
|
||||
kpi: {
|
||||
|
|
@ -472,12 +497,14 @@ const en = {
|
|||
keywordPh: "Name / IP / ID",
|
||||
allSource: "All sources",
|
||||
empty: "No network elements.",
|
||||
exportName: "network-devices",
|
||||
col: {
|
||||
source: "Source",
|
||||
name: "Name",
|
||||
vendor: "Vendor",
|
||||
deviceType: "Device type",
|
||||
connect: "Connect",
|
||||
actions: "Actions",
|
||||
},
|
||||
},
|
||||
networkConfigs: {
|
||||
|
|
@ -490,6 +517,7 @@ const en = {
|
|||
export: "Export",
|
||||
exportBoth: "Export all",
|
||||
exportOk: "Config exported",
|
||||
exportListName: "network-configs",
|
||||
close: "Close",
|
||||
tabSet: "Set format",
|
||||
tabHier: "Hierarchical",
|
||||
|
|
@ -626,6 +654,28 @@ const en = {
|
|||
passwordMustChange: "New password must differ from the default/old password",
|
||||
},
|
||||
collect: {
|
||||
statusAll: "All statuses",
|
||||
keywordPh: "Job title / ID",
|
||||
exportJobsName: "collect-jobs",
|
||||
exportRunsName: "collect-runs",
|
||||
policyTitle: "Collect policy",
|
||||
policyHint:
|
||||
"Periodic schedule is off by default (one-shot / manual). Enable it to auto-create and run jobs on an interval. Keeps the last 3 finished jobs by default.",
|
||||
policySaved: "Policy saved",
|
||||
enabled: "Enable scheduled collect",
|
||||
interval: "Collect interval",
|
||||
intervalUnitDays: "days",
|
||||
intervalUnitHours: "hours",
|
||||
historyKeep: "Jobs to keep",
|
||||
scope: "Scope",
|
||||
scopeAll: "All NEs (managed + UME)",
|
||||
scopeSelected: "Selected NEs only",
|
||||
savePolicy: "Save policy",
|
||||
collectNow: "Collect now",
|
||||
selectedCount: "{{count}} selected",
|
||||
colSource: "Source",
|
||||
policyCommandsRequired: "Add commands and save the policy before enabling schedule or collecting now",
|
||||
policyTargetsRequired: "No eligible NEs in scope; adjust scope or select NEs",
|
||||
create: {
|
||||
title: "New collection job",
|
||||
hint: "Configure commands and NEs, then create the job. Click Start in the job list to run collection.",
|
||||
|
|
@ -676,6 +726,7 @@ const en = {
|
|||
confirmDelete: "Delete this collection job and its log files?",
|
||||
jobs: {
|
||||
title: "Collection jobs",
|
||||
listTitle: "Job list",
|
||||
col: {
|
||||
title: "Job",
|
||||
status: "Status",
|
||||
|
|
@ -705,6 +756,7 @@ const en = {
|
|||
idle: "Idle",
|
||||
last: "Last job",
|
||||
active: "Active jobs",
|
||||
nextDue: "Next due",
|
||||
},
|
||||
runs: {
|
||||
status: "Status",
|
||||
|
|
@ -1152,10 +1204,20 @@ const en = {
|
|||
keywordPh: "keyword(alarm key/cause/ne_name/host_name/ip…)",
|
||||
hostNamePh: "host_name (contains)",
|
||||
allSeverity: "All severities",
|
||||
clearedAll: "is_cleared: all",
|
||||
clearedAll: "Cleared: all",
|
||||
clearedYes: "Cleared",
|
||||
clearedNo: "Active",
|
||||
clearTitle: "Clear filters and reset to page 1",
|
||||
filterByHost: "Filter by this host",
|
||||
noHostName: "No host_name (sync inventory first)",
|
||||
col: {
|
||||
time: "Time",
|
||||
severity: "Severity",
|
||||
neId: "NE ID",
|
||||
hostName: "Host",
|
||||
neType: "NE type",
|
||||
cause: "Cause",
|
||||
},
|
||||
},
|
||||
},
|
||||
webcrt: {
|
||||
|
|
@ -1397,6 +1459,7 @@ const en = {
|
|||
treeLoadFailed: "Failed to load topology tree",
|
||||
treeRetry: "Retry",
|
||||
graphLoading: "Loading topology…",
|
||||
graphLoadFailed: "Failed to load topology",
|
||||
graphRefreshing: "Refreshing…",
|
||||
canvasEmpty: "No NEs on this map yet",
|
||||
canvasEmptyHint: "Use Add NE, or pick a region that already has devices.",
|
||||
|
|
@ -1476,8 +1539,8 @@ const en = {
|
|||
"Remove selected link(s) from the canvas? They are deleted from Fabric only when you Save. LLDP may recreate them on the next collect.",
|
||||
edgeDeleted: "Queued {{count}} link delete(s) — Save to apply",
|
||||
discoverCancelled: "Discovery cancelled",
|
||||
truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify/slices or a new map.",
|
||||
truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze in classify/slices or create a new map.",
|
||||
truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify or a new map.",
|
||||
truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze via classify workflow or create a new map.",
|
||||
truncatedNodes: "Too many view nodes; display truncated. Narrow membership or create another map.",
|
||||
truncatedEdges: "Too many edges; display truncated. Filter status or create another map.",
|
||||
truncatedGeneric: "Graph data truncated. Narrow scope or create another map.",
|
||||
|
|
@ -1506,6 +1569,11 @@ const en = {
|
|||
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
|
||||
saving: "Saving…",
|
||||
saved: "Topology saved",
|
||||
saveFailed: "Save failed: {{detail}}",
|
||||
readOnlyBanner: "Read-only (ne:write scope required to edit)",
|
||||
readOnlyHint: "Read-only — no write permission",
|
||||
discoverTimeout: "Discovery timed out; check LLDP tasks for progress",
|
||||
policySyncFailed: "Failed to sync LLDP policy; local toggle still applies to canvas discovery",
|
||||
discover: "Discover links",
|
||||
discovering: "Discovering…",
|
||||
discoverOne: "Discover links",
|
||||
|
|
@ -1697,16 +1765,16 @@ const en = {
|
|||
connectHint: "Connect mode: drag from the NE center anchor to the target (Esc or Select to exit)",
|
||||
},
|
||||
topoClassify: {
|
||||
title: "Classify & slices",
|
||||
title: "Classify",
|
||||
openTopo: "Topology",
|
||||
inventory: "NE inventory",
|
||||
keywordPh: "Filter by name/IP",
|
||||
filterRoleAll: "All roles",
|
||||
filterLevelAll: "All levels",
|
||||
filterRegionAll: "All regions",
|
||||
filterUnmatchedOff: "No unmatched filter",
|
||||
kindAny: "Role or region empty",
|
||||
kindRole: "Role empty",
|
||||
kindRegion: "Region empty",
|
||||
kindAny: "Missing level or region",
|
||||
kindLevel: "Missing level",
|
||||
kindRegion: "Missing region",
|
||||
filterLinkAll: "All link states",
|
||||
filterLinkLinked: "Linked inventory",
|
||||
filterLinkOrphaned: "Detached",
|
||||
|
|
@ -1715,10 +1783,14 @@ const en = {
|
|||
linkUme: "UME",
|
||||
linkBoth: "Managed+UME",
|
||||
linkOrphaned: "Detached",
|
||||
roleCore: "Core",
|
||||
roleAggregation: "Aggregation",
|
||||
roleAccess: "Access",
|
||||
roleUnknown: "Unknown",
|
||||
levelExternal: "L0 external",
|
||||
levelCore: "L1 core",
|
||||
levelCoreSub: "L1.1 core′",
|
||||
levelAgg: "L2 aggregation",
|
||||
levelAggSub: "L2.1 agg′",
|
||||
levelAccess: "L3 access",
|
||||
levelAccessSub: "L3.1 deeper access",
|
||||
levelInvalid: "Invalid level (0–99.9, one decimal)",
|
||||
sourceManual: "Manual",
|
||||
sourceUmeSync: "UME sync",
|
||||
sourceLldp: "LLDP placeholder",
|
||||
|
|
@ -1731,9 +1803,9 @@ const en = {
|
|||
findMatches: "Find matches",
|
||||
matchOk: "Matched {{count}}; selected",
|
||||
matchHint: "{{count}} matched",
|
||||
assignRole: "Role only",
|
||||
assignLevel: "Level only",
|
||||
assignRegion: "Region only",
|
||||
assignBoth: "Role + region",
|
||||
assignBoth: "Level + region",
|
||||
clearRegion: "Clear region",
|
||||
confirmAssign: "Confirm assign",
|
||||
bulkConfirm: "Write tags to {{count}} selected/matched NE(s)?",
|
||||
|
|
@ -1741,7 +1813,7 @@ const en = {
|
|||
selectedCount: "{{count}} selected",
|
||||
selectPage: "Select page",
|
||||
colNe: "NE",
|
||||
colRole: "Role",
|
||||
colLevel: "Level",
|
||||
colRegion: "Region",
|
||||
colActions: "Actions",
|
||||
edit: "Edit",
|
||||
|
|
@ -1758,20 +1830,6 @@ const en = {
|
|||
rowSaved: "Saved",
|
||||
loading: "Loading…",
|
||||
empty: "No NEs.",
|
||||
showSlices: "Show slice maps",
|
||||
hideSlices: "Hide slice maps",
|
||||
slices: "Slice maps",
|
||||
pickRegion: "Pick region",
|
||||
tplCore: "Core only",
|
||||
tplCoreAgg: "Core + neighbor Agg",
|
||||
tplAggAcc: "Agg + neighbor Access",
|
||||
seedPhysical: "Also place Cores on physical overview",
|
||||
slicePreview: "Preview slices",
|
||||
sliceApply: "Generate maps",
|
||||
sliceConfirm: "Create custom maps and freeze placements?",
|
||||
sliceOk: "Created {{count}} slice map(s)",
|
||||
sliceStats: "{{maps}} map(s) planned, {{overlap}} NE(s) on multiple maps",
|
||||
nodeCount: "{{count}} NE(s)",
|
||||
},
|
||||
audit: {
|
||||
title: "Audit & ops",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ const zh = {
|
|||
nextPage: "下一页",
|
||||
perPage: "{{n}}/页",
|
||||
pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页",
|
||||
jumpPage: "跳转到页",
|
||||
pageSize: "每页条数",
|
||||
exportCsv: "导出 CSV",
|
||||
exporting: "导出中…",
|
||||
exportOk: "已导出 {{count}} 条",
|
||||
exportTruncated: "已导出前 {{count}} 条(共 {{total}},已截断)",
|
||||
exportFailed: "导出失败",
|
||||
opFailed: "操作失败",
|
||||
empty: "-",
|
||||
cancel: "取消",
|
||||
|
|
@ -100,7 +107,7 @@ const zh = {
|
|||
devices: "设备列表",
|
||||
topology: "拓扑信息",
|
||||
lldpLinks: "LLDP",
|
||||
topoClassify: "分类切片",
|
||||
topoClassify: "分类",
|
||||
alarms: "告警信息",
|
||||
configs: "配置信息",
|
||||
webcrt: "WebCRT",
|
||||
|
|
@ -130,10 +137,15 @@ const zh = {
|
|||
statusRunning: "运行中",
|
||||
statusPaused: "已暂停",
|
||||
statusStopped: "已停止",
|
||||
statusDraft: "草稿",
|
||||
collecting: "采集中",
|
||||
deleted: "已删除",
|
||||
confirmDelete: "删除该设备监控及其采样数据?",
|
||||
empty: "暂无设备监控。请先选择网元并勾选接口。",
|
||||
keywordPh: "名称 / IP / 备注 / ID",
|
||||
allStatus: "全部状态",
|
||||
exportDevicesName: "port-traffic-devices",
|
||||
exportEventsName: "port-traffic-events",
|
||||
wall: "监控大屏",
|
||||
wallTitle: "端口流量大屏",
|
||||
toDevices: "设备管理",
|
||||
|
|
@ -350,6 +362,14 @@ const zh = {
|
|||
edgeStatusAll: "全部状态",
|
||||
edgeStatusActive: "活跃",
|
||||
edgeStatusMissing: "未发现",
|
||||
edgeSourceAll: "全部来源",
|
||||
exportEdgesName: "lldp-fabric-edges",
|
||||
exportJobsName: "lldp-collect-jobs",
|
||||
exportItemsName: "lldp-job-items",
|
||||
statusAll: "全部状态",
|
||||
jobKeywordPh: "任务 ID / 错误 / 范围",
|
||||
itemKeywordPh: "网元名称 / IP / 错误",
|
||||
itemResultAll: "全部结果",
|
||||
missCount: "未发现周期",
|
||||
replacedBy: "已被替换",
|
||||
kpi: {
|
||||
|
|
@ -388,6 +408,7 @@ const zh = {
|
|||
done: "已完成",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
stopped: "已停止",
|
||||
},
|
||||
triggerMode: {
|
||||
manual: "手动",
|
||||
|
|
@ -440,7 +461,11 @@ const zh = {
|
|||
cyclesEmpty: "暂无同步周期",
|
||||
tasksTitle: "周期任务",
|
||||
taskKeywordPh: "名称 / IP / 消息",
|
||||
keywordPh: "ID / 触发 / 消息",
|
||||
statusAll: "全部状态",
|
||||
allStatus: "全部状态",
|
||||
exportCyclesName: "config-sync-cycles",
|
||||
exportTasksName: "config-sync-tasks",
|
||||
expand: "详情",
|
||||
collapse: "收起",
|
||||
kpi: {
|
||||
|
|
@ -468,12 +493,14 @@ const zh = {
|
|||
keywordPh: "名称 / IP / ID",
|
||||
allSource: "全部来源",
|
||||
empty: "暂无网元。",
|
||||
exportName: "network-devices",
|
||||
col: {
|
||||
source: "来源",
|
||||
name: "名称",
|
||||
vendor: "厂商",
|
||||
deviceType: "设备类型",
|
||||
connect: "连通状态",
|
||||
actions: "操作",
|
||||
},
|
||||
},
|
||||
networkConfigs: {
|
||||
|
|
@ -486,6 +513,7 @@ const zh = {
|
|||
export: "导出",
|
||||
exportBoth: "导出全部",
|
||||
exportOk: "配置已导出",
|
||||
exportListName: "network-configs",
|
||||
close: "关闭",
|
||||
tabSet: "Set 格式",
|
||||
tabHier: "层级格式",
|
||||
|
|
@ -621,6 +649,27 @@ const zh = {
|
|||
passwordMustChange: "新密码不能与默认/旧密码相同",
|
||||
},
|
||||
collect: {
|
||||
statusAll: "全部状态",
|
||||
keywordPh: "任务名称 / ID",
|
||||
exportJobsName: "collect-jobs",
|
||||
exportRunsName: "collect-runs",
|
||||
policyTitle: "采集策略",
|
||||
policyHint: "默认不启用周期(只采一次);勾选后按周期自动创建并执行。默认保留最近 3 次任务数据。",
|
||||
policySaved: "策略已保存",
|
||||
enabled: "启用周期调度",
|
||||
interval: "采集周期",
|
||||
intervalUnitDays: "天",
|
||||
intervalUnitHours: "小时",
|
||||
historyKeep: "任务保留数",
|
||||
scope: "范围",
|
||||
scopeAll: "全部网元(纳管+UME)",
|
||||
scopeSelected: "仅选中网元",
|
||||
savePolicy: "保存策略",
|
||||
collectNow: "立即采集",
|
||||
selectedCount: "已选 {{count}} 台",
|
||||
colSource: "来源",
|
||||
policyCommandsRequired: "启用周期或立即采集前,请先填写采集命令并保存策略",
|
||||
policyTargetsRequired: "选定范围内没有可用网元,请调整范围或勾选网元",
|
||||
create: {
|
||||
title: "新建采集任务",
|
||||
hint: "配置命令与网元后创建任务;创建后需在任务列表中点击「开始」执行采集。",
|
||||
|
|
@ -671,6 +720,7 @@ const zh = {
|
|||
confirmDelete: "确定删除该采集任务?相关日志文件将一并删除。",
|
||||
jobs: {
|
||||
title: "采集任务",
|
||||
listTitle: "任务列表",
|
||||
col: {
|
||||
title: "任务",
|
||||
status: "状态",
|
||||
|
|
@ -700,6 +750,7 @@ const zh = {
|
|||
idle: "空闲",
|
||||
last: "最近任务",
|
||||
active: "活跃任务",
|
||||
nextDue: "下次周期",
|
||||
},
|
||||
runs: {
|
||||
status: "状态",
|
||||
|
|
@ -1145,10 +1196,20 @@ const zh = {
|
|||
keywordPh: "keyword(告警键/原因/ne_name/host_name/ip 等)",
|
||||
hostNamePh: "host_name(含匹配)",
|
||||
allSeverity: "全部级别",
|
||||
clearedAll: "is_cleared: all",
|
||||
clearedAll: "清除状态:全部",
|
||||
clearedYes: "已清除",
|
||||
clearedNo: "未清除",
|
||||
clearTitle: "清空 keyword、host_name、级别、is_cleared,回到第 1 页",
|
||||
filterByHost: "按该主机名筛选",
|
||||
noHostName: "无 host_name(需先同步网元)",
|
||||
col: {
|
||||
time: "告警时间",
|
||||
severity: "级别",
|
||||
neId: "网元 ID",
|
||||
hostName: "主机名",
|
||||
neType: "网元类型",
|
||||
cause: "原因",
|
||||
},
|
||||
},
|
||||
},
|
||||
webcrt: {
|
||||
|
|
@ -1388,6 +1449,7 @@ const zh = {
|
|||
treeLoadFailed: "拓扑树加载失败",
|
||||
treeRetry: "重试",
|
||||
graphLoading: "正在加载拓扑图…",
|
||||
graphLoadFailed: "拓扑图加载失败",
|
||||
graphRefreshing: "刷新中…",
|
||||
canvasEmpty: "这张图上还没有网元",
|
||||
canvasEmptyHint: "可用「添加网元」上图,或从左侧换到有设备的区域。",
|
||||
|
|
@ -1464,8 +1526,8 @@ const zh = {
|
|||
deleteEdgeConfirm: "从画布移除选中链路?仅在点击「保存」后才会从 Fabric 删除;LLDP 下次采集若仍存在可能再次出现。",
|
||||
edgeDeleted: "已标记删除 {{count}} 条链路(保存后生效)",
|
||||
discoverCancelled: "发现已取消",
|
||||
truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类/切片或新建视图。",
|
||||
truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类/切片中解冻或新建视图。",
|
||||
truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类页或新建视图。",
|
||||
truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类流程中处理或新建视图。",
|
||||
truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。",
|
||||
truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。",
|
||||
truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。",
|
||||
|
|
@ -1494,6 +1556,11 @@ const zh = {
|
|||
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
|
||||
saving: "保存中…",
|
||||
saved: "拓扑已保存",
|
||||
saveFailed: "保存失败:{{detail}}",
|
||||
readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)",
|
||||
readOnlyHint: "只读模式,无编辑权限",
|
||||
discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度",
|
||||
policySyncFailed: "同步 LLDP 策略失败,本地开关仍对本页发现生效",
|
||||
discover: "发现链路",
|
||||
discovering: "发现中…",
|
||||
discoverOne: "发现链路",
|
||||
|
|
@ -1680,15 +1747,15 @@ const zh = {
|
|||
connectHint: "连线模式:从网元中心锚点拖到目标网元(Esc 或切回选择退出)",
|
||||
},
|
||||
topoClassify: {
|
||||
title: "分类与切片",
|
||||
title: "分类",
|
||||
openTopo: "拓扑画布",
|
||||
inventory: "网元清单",
|
||||
keywordPh: "关键字筛选名称/IP",
|
||||
filterRoleAll: "全部角色",
|
||||
filterLevelAll: "全部层级",
|
||||
filterRegionAll: "全部区域",
|
||||
filterUnmatchedOff: "不过滤未分类",
|
||||
kindAny: "角色或区域未填",
|
||||
kindRole: "角色未填",
|
||||
kindAny: "层级或区域未填",
|
||||
kindLevel: "层级未填",
|
||||
kindRegion: "区域未填",
|
||||
filterLinkAll: "全部关联",
|
||||
filterLinkLinked: "已关联库存",
|
||||
|
|
@ -1698,10 +1765,14 @@ const zh = {
|
|||
linkUme: "UME",
|
||||
linkBoth: "托管+UME",
|
||||
linkOrphaned: "已解绑",
|
||||
roleCore: "核心",
|
||||
roleAggregation: "汇聚",
|
||||
roleAccess: "接入",
|
||||
roleUnknown: "未知",
|
||||
levelExternal: "L0 外部",
|
||||
levelCore: "L1 核心",
|
||||
levelCoreSub: "L1.1 次核心",
|
||||
levelAgg: "L2 汇聚",
|
||||
levelAggSub: "L2.1 二级汇聚",
|
||||
levelAccess: "L3 接入",
|
||||
levelAccessSub: "L3.1 更深接入",
|
||||
levelInvalid: "层级无效(0–99.9,一位小数)",
|
||||
sourceManual: "手动",
|
||||
sourceUmeSync: "UME 同步",
|
||||
sourceLldp: "LLDP 占位",
|
||||
|
|
@ -1714,9 +1785,9 @@ const zh = {
|
|||
findMatches: "查找匹配",
|
||||
matchOk: "匹配 {{count}} 条,已勾选",
|
||||
matchHint: "当前匹配 {{count}} 条",
|
||||
assignRole: "只改角色",
|
||||
assignLevel: "只改层级",
|
||||
assignRegion: "只改区域",
|
||||
assignBoth: "角色+区域",
|
||||
assignBoth: "层级+区域",
|
||||
clearRegion: "清空区域",
|
||||
confirmAssign: "确认写入",
|
||||
bulkConfirm: "将给已选/匹配的 {{count}} 条网元写入分类,确定?",
|
||||
|
|
@ -1724,7 +1795,7 @@ const zh = {
|
|||
selectedCount: "已选 {{count}}",
|
||||
selectPage: "全选本页",
|
||||
colNe: "网元",
|
||||
colRole: "角色",
|
||||
colLevel: "层级",
|
||||
colRegion: "区域",
|
||||
colActions: "操作",
|
||||
edit: "编辑",
|
||||
|
|
@ -1739,20 +1810,6 @@ const zh = {
|
|||
rowSaved: "已保存",
|
||||
loading: "加载中…",
|
||||
empty: "没有网元。",
|
||||
showSlices: "展开切片建图",
|
||||
hideSlices: "收起切片建图",
|
||||
slices: "切片建图",
|
||||
pickRegion: "选择区域",
|
||||
tplCore: "仅 Core",
|
||||
tplCoreAgg: "Core + 邻接 Agg",
|
||||
tplAggAcc: "Agg + 邻接 Access",
|
||||
seedPhysical: "同时给物理总览上 Core",
|
||||
slicePreview: "预览切片",
|
||||
sliceApply: "生成切片图",
|
||||
sliceConfirm: "将创建自定义拓扑图并上图冻结,确定?",
|
||||
sliceOk: "已创建 {{count}} 张切片图",
|
||||
sliceStats: "计划 {{maps}} 张图,重复上图网元 {{overlap}} 个",
|
||||
nodeCount: "{{count}} 个网元",
|
||||
},
|
||||
audit: {
|
||||
title: "操作审计",
|
||||
|
|
|
|||
|
|
@ -2208,6 +2208,36 @@ pre {
|
|||
min-width: 88px;
|
||||
}
|
||||
|
||||
.pager__jump {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pager__jump-input {
|
||||
width: 3.2rem;
|
||||
min-width: 2.5rem;
|
||||
max-width: 4.5rem;
|
||||
padding: 4px 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
|
|
@ -7591,6 +7621,26 @@ pre {
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topo-readonly-banner {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
z-index: 5;
|
||||
max-width: min(360px, calc(100% - 24px));
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.45);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
color: #bae6fd;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topo-canvas__overlay--error {
|
||||
background: rgba(15, 23, 42, 0.88);
|
||||
}
|
||||
|
||||
.app-main .topo-toolbar__outside-peers,
|
||||
.topo-toolbar__outside-peers {
|
||||
margin-left: auto;
|
||||
|
|
@ -8506,12 +8556,32 @@ html.login-page--paused .login-page__flare {
|
|||
|
||||
|
||||
/* —— Network Management shell (left nav + main) ——
|
||||
Light ops shell + login-aligned accent blues. */
|
||||
Fill the app chrome without a second document scrollbar.
|
||||
Negative margins + 100vh used to fight app-main padding and over-expand the page. */
|
||||
.app--shell:has(.network-shell),
|
||||
.app--shell:has(.audit-shell) {
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main:has(> .network-shell),
|
||||
.app-main:has(> .audit-shell) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.network-shell {
|
||||
display: flex;
|
||||
height: calc(100vh - 48px);
|
||||
min-height: 480px;
|
||||
margin: -20px -28px -32px;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
background: var(--nm-chrome);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../components/ListPager";
|
||||
import {
|
||||
createNeCollection,
|
||||
deleteCollectionJob,
|
||||
|
|
@ -8,31 +10,51 @@ import {
|
|||
fetchCollectionRuns,
|
||||
fetchEligibleNe,
|
||||
fetchNeCollections,
|
||||
formatErr,
|
||||
pauseCollectionJob,
|
||||
startCollectionJob,
|
||||
startCollectionFromPolicy,
|
||||
retryFailedCollectionJob,
|
||||
updateCollectionPolicy,
|
||||
collectionJobDownloadUrl,
|
||||
collectionRunDownloadUrl,
|
||||
} from "../services/api";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import type { CollectionJobItem, EligibleNeItem } from "../types";
|
||||
import type { CollectionJobItem, CollectionRunItem, CollectionTargetRef, EligibleNeItem } from "../types";
|
||||
import { downloadCsv, fetchAllPages } from "../utils/csvExport";
|
||||
import { pageCount } from "../utils/display";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
const POLL_MS = 2000;
|
||||
const ELIGIBLE_PAGE_SIZE = 20;
|
||||
const POLICY_TARGET_PAGE_SIZE = 20;
|
||||
const JOB_STATUS_OPTIONS = ["draft", "pending", "running", "paused", "done", "failed", "cancelled"] as const;
|
||||
const JOB_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
const RUN_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
function eligibleKey(row: Pick<EligibleNeItem, "id" | "source">): string {
|
||||
const src = String(row.source || "managed").trim().toLowerCase() || "managed";
|
||||
return `${src}:${row.id}`;
|
||||
}
|
||||
|
||||
function targetKey(ref: CollectionTargetRef): string {
|
||||
const src = String(ref.source || "managed").trim().toLowerCase() || "managed";
|
||||
return `${src}:${ref.id}`;
|
||||
}
|
||||
|
||||
function collectionErrorMessage(err: unknown, t: (key: string) => string): string {
|
||||
const raw = String(err);
|
||||
if (raw.includes("collection_ne_busy")) return t("collect.neBusy");
|
||||
if (raw.includes("collection_job_running")) return t("collect.jobRunning");
|
||||
if (raw.includes("commands_empty") || raw.includes("commands_required_for_schedule")) {
|
||||
return t("collect.policyCommandsRequired");
|
||||
}
|
||||
if (raw.includes("no_selected_targets") || raw.includes("no_eligible_ne")) {
|
||||
return t("collect.policyTargetsRequired");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +62,7 @@ export function CollectPage() {
|
|||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [commands, setCommands] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
|
|
@ -48,12 +71,46 @@ export function CollectPage() {
|
|||
const [neKeyword, setNeKeyword] = useState("");
|
||||
const [nePage, setNePage] = useState(1);
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [jobPageSize, setJobPageSize] = useState(20);
|
||||
const [jobStatus, setJobStatus] = useState("");
|
||||
const [jobKeyword, setJobKeyword] = useState("");
|
||||
const [exportingJobs, setExportingJobs] = useState(false);
|
||||
const [expandedJobId, setExpandedJobId] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const [policyEnabled, setPolicyEnabled] = useState(false);
|
||||
const [policyIntervalValue, setPolicyIntervalValue] = useState(1);
|
||||
const [policyIntervalUnit, setPolicyIntervalUnit] = useState<"days" | "hours">("days");
|
||||
const [policyHistoryKeep, setPolicyHistoryKeep] = useState(3);
|
||||
const [policyScopeMode, setPolicyScopeMode] = useState<"all" | "selected">("all");
|
||||
const [policyTitle, setPolicyTitle] = useState("");
|
||||
const [policyCommands, setPolicyCommands] = useState("");
|
||||
const [policySelectedMap, setPolicySelectedMap] = useState<Record<string, CollectionTargetRef>>({});
|
||||
const [policyHydrated, setPolicyHydrated] = useState(false);
|
||||
const [policyTargetKeyword, setPolicyTargetKeyword] = useState("");
|
||||
const [policyTargetPage, setPolicyTargetPage] = useState(1);
|
||||
|
||||
const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
|
||||
const debouncedPolicyTargetKeyword = useDebouncedValue(policyTargetKeyword, 300);
|
||||
|
||||
const selectedIds = useMemo(() => Object.keys(selectedMap), [selectedMap]);
|
||||
const selectedList = useMemo(() => Object.values(selectedMap), [selectedMap]);
|
||||
|
||||
// Deep link: /collect?job_id=… → open runs modal, then strip param
|
||||
useEffect(() => {
|
||||
const jobId = String(searchParams.get("job_id") || "").trim();
|
||||
if (!jobId) return;
|
||||
setExpandedJobId(jobId);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (next.get("job_id") === jobId) next.delete("job_id");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const eligibleQuery = useQuery({
|
||||
queryKey: queryKeys.collectionEligibleNe(nePage, neKeyword),
|
||||
queryFn: () =>
|
||||
|
|
@ -63,8 +120,14 @@ export function CollectPage() {
|
|||
});
|
||||
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: queryKeys.neCollections(jobPage),
|
||||
queryFn: () => fetchNeCollections({ page: jobPage, pageSize: 20 }),
|
||||
queryKey: queryKeys.neCollections(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
|
||||
queryFn: () =>
|
||||
fetchNeCollections({
|
||||
page: jobPage,
|
||||
pageSize: jobPageSize,
|
||||
status: jobStatus,
|
||||
keyword: debouncedJobKeyword,
|
||||
}),
|
||||
staleTime: 1000,
|
||||
refetchInterval: (q) => {
|
||||
const items = q.state.data?.items ?? [];
|
||||
|
|
@ -85,6 +148,42 @@ export function CollectPage() {
|
|||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const p = dashQuery.data?.policy;
|
||||
if (!p || policyHydrated) return;
|
||||
setPolicyEnabled(Boolean(p.enabled));
|
||||
const hours = Math.max(1, Number(p.interval_hours || Number(p.interval_days || 1) * 24));
|
||||
if (hours % 24 === 0) {
|
||||
setPolicyIntervalUnit("days");
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24)));
|
||||
} else {
|
||||
setPolicyIntervalUnit("hours");
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(8760, hours)));
|
||||
}
|
||||
setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(p.history_keep ?? 3))));
|
||||
setPolicyScopeMode(p.scope_mode === "selected" ? "selected" : "all");
|
||||
setPolicyTitle(String(p.title || ""));
|
||||
setPolicyCommands(String(p.commands || ""));
|
||||
const map: Record<string, CollectionTargetRef> = {};
|
||||
for (const ref of p.selected_targets || []) {
|
||||
map[targetKey(ref)] = { source: ref.source, id: ref.id };
|
||||
}
|
||||
setPolicySelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
}, [dashQuery.data?.policy, policyHydrated]);
|
||||
|
||||
const policyTargetsQuery = useQuery({
|
||||
queryKey: queryKeys.collectionEligibleNe(policyTargetPage, debouncedPolicyTargetKeyword),
|
||||
queryFn: () =>
|
||||
fetchEligibleNe({
|
||||
page: policyTargetPage,
|
||||
pageSize: POLICY_TARGET_PAGE_SIZE,
|
||||
keyword: debouncedPolicyTargetKeyword,
|
||||
}),
|
||||
staleTime: 5000,
|
||||
enabled: policyScopeMode === "selected",
|
||||
});
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: queryKeys.neCollectionDetail(expandedJobId),
|
||||
queryFn: () => fetchCollectionJob(expandedJobId),
|
||||
|
|
@ -121,6 +220,62 @@ export function CollectPage() {
|
|||
]);
|
||||
};
|
||||
|
||||
const savePolicyMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const hours =
|
||||
policyIntervalUnit === "days"
|
||||
? Math.max(1, Math.min(365, policyIntervalValue)) * 24
|
||||
: Math.max(1, Math.min(8760, policyIntervalValue));
|
||||
return updateCollectionPolicy({
|
||||
enabled: policyEnabled,
|
||||
interval_hours: hours,
|
||||
history_keep: policyHistoryKeep,
|
||||
scope_mode: policyScopeMode,
|
||||
title: policyTitle.trim(),
|
||||
commands: policyCommands,
|
||||
selected_targets: Object.values(policySelectedMap),
|
||||
});
|
||||
},
|
||||
onSuccess: async (saved) => {
|
||||
setPolicyEnabled(Boolean(saved.enabled));
|
||||
const hours = Math.max(1, Number(saved.interval_hours || Number(saved.interval_days || 1) * 24));
|
||||
if (hours % 24 === 0) {
|
||||
setPolicyIntervalUnit("days");
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24)));
|
||||
} else {
|
||||
setPolicyIntervalUnit("hours");
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(8760, hours)));
|
||||
}
|
||||
setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(saved.history_keep ?? 3))));
|
||||
setPolicyScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
|
||||
setPolicyTitle(String(saved.title || ""));
|
||||
setPolicyCommands(String(saved.commands || ""));
|
||||
const map: Record<string, CollectionTargetRef> = {};
|
||||
for (const ref of saved.selected_targets || []) {
|
||||
map[targetKey(ref)] = { source: ref.source, id: ref.id };
|
||||
}
|
||||
setPolicySelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
queryClient.setQueryData(queryKeys.neCollectionDashboard, (prev: unknown) => {
|
||||
if (!prev || typeof prev !== "object") return prev;
|
||||
return { ...(prev as object), policy: saved };
|
||||
});
|
||||
showOk(t("collect.policySaved"));
|
||||
await refreshAll();
|
||||
},
|
||||
onError: (err) => showError(collectionErrorMessage(err, t)),
|
||||
});
|
||||
|
||||
const startFromPolicyMutation = useMutation({
|
||||
mutationFn: startCollectionFromPolicy,
|
||||
onSuccess: async (job) => {
|
||||
showOk(t("collect.started", { id: job.id }));
|
||||
setExpandedJobId(job.id);
|
||||
await refreshAll();
|
||||
},
|
||||
onError: (err) => showError(collectionErrorMessage(err, t)),
|
||||
});
|
||||
|
||||
const invalidateJobs = async (jobId?: string) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard });
|
||||
|
|
@ -231,7 +386,58 @@ export function CollectPage() {
|
|||
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
|
||||
|
||||
const jobTotal = jobsQuery.data?.total ?? 0;
|
||||
const jobPages = pageCount(jobTotal, 20);
|
||||
const jobPages = pageCount(jobTotal, jobPageSize);
|
||||
const hasJobFilters = Boolean(jobKeyword.trim() || jobStatus);
|
||||
|
||||
const exportJobsCsv = async () => {
|
||||
setExportingJobs(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<CollectionJobItem>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (page, pageSize) =>
|
||||
fetchNeCollections({
|
||||
page,
|
||||
pageSize,
|
||||
status: jobStatus,
|
||||
keyword: debouncedJobKeyword,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("collect.exportJobsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "title", header: t("collect.jobs.col.title") },
|
||||
{ key: "status", header: t("collect.jobs.col.status") },
|
||||
{
|
||||
key: "progress",
|
||||
header: t("collect.jobs.col.progress"),
|
||||
value: (r) =>
|
||||
`${r.success_count}/${r.ne_count} ${t("collect.jobs.ok")}, ${r.fail_count} ${t("collect.jobs.fail")}`,
|
||||
},
|
||||
{
|
||||
key: "started_at",
|
||||
header: "started_at",
|
||||
value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
|
||||
},
|
||||
{
|
||||
key: "ended_at",
|
||||
header: "ended_at",
|
||||
value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
|
||||
},
|
||||
],
|
||||
);
|
||||
if (rows.length < jobTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(jobTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingJobs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commandLines = useMemo(
|
||||
() =>
|
||||
|
|
@ -262,10 +468,25 @@ export function CollectPage() {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingJobs || jobTotal === 0}
|
||||
onClick={() => void exportJobsCsv()}
|
||||
>
|
||||
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching || dashQuery.isFetching}>
|
||||
{jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={startFromPolicyMutation.isPending || jobActive}
|
||||
onClick={() => startFromPolicyMutation.mutate()}
|
||||
>
|
||||
{startFromPolicyMutation.isPending ? t("collect.form.starting") : t("collect.collectNow")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setCreateOpen(true)}>
|
||||
{t("collect.create.expand")}
|
||||
</button>
|
||||
{running?.status === "running" || running?.status === "pending" ? (
|
||||
|
|
@ -294,10 +515,6 @@ export function CollectPage() {
|
|||
: t("collect.kpi.idle")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("collect.kpi.active")}</div>
|
||||
<div className="pt-list-kpi__value">{dash?.active_count ?? "—"}</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("collect.kpi.last")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
|
||||
|
|
@ -306,10 +523,225 @@ export function CollectPage() {
|
|||
: t("common.empty")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("collect.kpi.nextDue")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
|
||||
{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ marginBottom: 16 }}>
|
||||
<h3>{t("collect.policyTitle")}</h3>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{t("collect.policyHint")}
|
||||
</p>
|
||||
<div className="config-sync-policy-row">
|
||||
<label className="config-sync-policy-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={policyEnabled}
|
||||
onChange={(e) => setPolicyEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>{t("collect.enabled")}</span>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("collect.interval")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={policyIntervalUnit === "days" ? 365 : 8760}
|
||||
value={policyIntervalValue}
|
||||
onChange={(e) => {
|
||||
const max = policyIntervalUnit === "days" ? 365 : 8760;
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1)));
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={policyIntervalUnit}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value === "hours" ? "hours" : "days";
|
||||
if (next === policyIntervalUnit) return;
|
||||
if (next === "hours") {
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(8760, policyIntervalValue * 24)));
|
||||
} else {
|
||||
setPolicyIntervalValue(
|
||||
Math.max(1, Math.min(365, Math.round(policyIntervalValue / 24) || 1)),
|
||||
);
|
||||
}
|
||||
setPolicyIntervalUnit(next);
|
||||
}}
|
||||
>
|
||||
<option value="days">{t("collect.intervalUnitDays")}</option>
|
||||
<option value="hours">{t("collect.intervalUnitHours")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("collect.historyKeep")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={200}
|
||||
value={policyHistoryKeep}
|
||||
onChange={(e) =>
|
||||
setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("collect.scope")}</span>
|
||||
<select
|
||||
value={policyScopeMode}
|
||||
onChange={(e) => setPolicyScopeMode(e.target.value === "selected" ? "selected" : "all")}
|
||||
>
|
||||
<option value="all">{t("collect.scopeAll")}</option>
|
||||
<option value="selected">{t("collect.scopeSelected")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={savePolicyMutation.isPending}
|
||||
onClick={() => savePolicyMutation.mutate()}
|
||||
>
|
||||
{t("collect.savePolicy")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="filter-inline" style={{ marginTop: 12 }}>
|
||||
<label className="config-sync-policy-field" style={{ flex: 1 }}>
|
||||
<span>{t("collect.form.jobTitle")}</span>
|
||||
<input
|
||||
value={policyTitle}
|
||||
placeholder={t("collect.form.jobTitlePh")}
|
||||
onChange={(e) => setPolicyTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="config-sync-policy-field" style={{ display: "block", marginTop: 12 }}>
|
||||
<span>{t("collect.form.commands")}</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={policyCommands}
|
||||
placeholder={t("collect.form.commandsPh")}
|
||||
onChange={(e) => setPolicyCommands(e.target.value)}
|
||||
style={{ width: "100%", fontFamily: "var(--font-mono, monospace)" }}
|
||||
/>
|
||||
<span className="muted">{t("collect.form.commandsHint")}</span>
|
||||
</label>
|
||||
|
||||
{policyScopeMode === "selected" ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p className="muted">
|
||||
{t("collect.selectedCount", { count: String(Object.keys(policySelectedMap).length) })}
|
||||
</p>
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={policyTargetKeyword}
|
||||
placeholder={t("collect.create.filterKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setPolicyTargetKeyword(e.target.value);
|
||||
setPolicyTargetPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{t("collect.create.pickTitle")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("collect.colSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(policyTargetsQuery.data?.items ?? []).map((row) => {
|
||||
const key = eligibleKey(row);
|
||||
const checked = Boolean(policySelectedMap[key]);
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setPolicySelectedMap((prev) => {
|
||||
const next = { ...prev };
|
||||
if (next[key]) delete next[key];
|
||||
else next[key] = { source: row.source || "managed", id: row.id };
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>{row.name || row.id}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>{row.source}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPager
|
||||
page={policyTargetPage}
|
||||
pages={pageCount(policyTargetsQuery.data?.total ?? 0, POLICY_TARGET_PAGE_SIZE)}
|
||||
total={policyTargetsQuery.data?.total ?? 0}
|
||||
pageSize={POLICY_TARGET_PAGE_SIZE}
|
||||
onPageChange={setPolicyTargetPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<div>
|
||||
<h2>{t("collect.jobs.listTitle")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
type="search"
|
||||
value={jobKeyword}
|
||||
placeholder={t("collect.keywordPh")}
|
||||
onChange={(e) => {
|
||||
setJobKeyword(e.target.value);
|
||||
setJobPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={jobStatus}
|
||||
onChange={(e) => {
|
||||
setJobStatus(e.target.value);
|
||||
setJobPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("collect.statusAll")}</option>
|
||||
{JOB_STATUS_OPTIONS.map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{st}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasJobFilters}
|
||||
onClick={() => {
|
||||
setJobKeyword("");
|
||||
setJobStatus("");
|
||||
setJobPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{jobsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{jobsQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
|
||||
|
||||
{!(jobsQuery.data?.items ?? []).length && !jobsQuery.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
<p>{t("common.empty")}</p>
|
||||
|
|
@ -347,23 +779,19 @@ export function CollectPage() {
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="pager pt-list-pager">
|
||||
<div className="pager__meta muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(jobTotal),
|
||||
page: String(jobPage),
|
||||
pages: String(jobPages),
|
||||
})}
|
||||
</div>
|
||||
<div className="pager__controls btn-row">
|
||||
<button className="pager__btn" disabled={jobPage <= 1} onClick={() => setJobPage(jobPage - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button className="pager__btn" disabled={jobPage >= jobPages} onClick={() => setJobPage(jobPage + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ListPager
|
||||
page={jobPage}
|
||||
pages={jobPages}
|
||||
total={jobTotal}
|
||||
pageSize={jobPageSize}
|
||||
pageSizeOptions={JOB_PAGE_SIZE_OPTIONS}
|
||||
onPageChange={setJobPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setJobPageSize(size);
|
||||
setJobPage(1);
|
||||
}}
|
||||
disabled={jobsQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -764,7 +1192,6 @@ function JobRow({
|
|||
);
|
||||
}
|
||||
|
||||
const RUN_PAGE_SIZE = 20;
|
||||
const RUN_STATUS_OPTIONS = ["pending", "running", "success", "fail", "cancelled"] as const;
|
||||
|
||||
function JobRunsPanel({
|
||||
|
|
@ -783,19 +1210,23 @@ function JobRunsPanel({
|
|||
retryPending: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const [runPage, setRunPage] = useState(1);
|
||||
const [runPageSize, setRunPageSize] = useState(20);
|
||||
const [runStatus, setRunStatus] = useState("");
|
||||
const [runKeyword, setRunKeyword] = useState("");
|
||||
const [exportingRuns, setExportingRuns] = useState(false);
|
||||
const debouncedRunKeyword = useDebouncedValue(runKeyword, 300);
|
||||
|
||||
const runsQuery = useQuery({
|
||||
queryKey: queryKeys.neCollectionRuns(jobId, runPage, runStatus, runKeyword),
|
||||
queryKey: queryKeys.neCollectionRuns(jobId, runPage, runPageSize, runStatus, debouncedRunKeyword),
|
||||
queryFn: () =>
|
||||
fetchCollectionRuns({
|
||||
jobId,
|
||||
page: runPage,
|
||||
pageSize: RUN_PAGE_SIZE,
|
||||
pageSize: runPageSize,
|
||||
status: runStatus,
|
||||
keyword: runKeyword,
|
||||
keyword: debouncedRunKeyword,
|
||||
}),
|
||||
staleTime: 500,
|
||||
refetchInterval: jobStatus === "running" ? 2000 : false,
|
||||
|
|
@ -808,8 +1239,57 @@ function JobRunsPanel({
|
|||
};
|
||||
|
||||
const runTotal = runsQuery.data?.total ?? 0;
|
||||
const runPages = pageCount(runTotal, RUN_PAGE_SIZE);
|
||||
const runPages = pageCount(runTotal, runPageSize);
|
||||
const runs = runsQuery.data?.items ?? [];
|
||||
const hasRunFilters = Boolean(runStatus || runKeyword.trim());
|
||||
|
||||
const exportRunsCsv = async () => {
|
||||
setExportingRuns(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<CollectionRunItem>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (page, pageSize) =>
|
||||
fetchCollectionRuns({
|
||||
jobId,
|
||||
page,
|
||||
pageSize,
|
||||
status: runStatus,
|
||||
keyword: debouncedRunKeyword,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("collect.exportRunsName")}-${jobId.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "ne_source", header: t("managedNe.col.source"), value: (r) => r.ne_source || "managed" },
|
||||
{ key: "ne_name", header: t("managedNe.col.name") },
|
||||
{ key: "ne_ip", header: t("managedNe.col.ip") },
|
||||
{ key: "status", header: t("collect.runs.status") },
|
||||
{ key: "message", header: t("collect.runs.message") },
|
||||
{
|
||||
key: "started_at",
|
||||
header: "started_at",
|
||||
value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
|
||||
},
|
||||
{
|
||||
key: "ended_at",
|
||||
header: "ended_at",
|
||||
value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
|
||||
},
|
||||
],
|
||||
);
|
||||
if (rows.length < runTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(runTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingRuns(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -841,11 +1321,14 @@ function JobRunsPanel({
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
{runStatus || runKeyword ? (
|
||||
{hasRunFilters ? (
|
||||
<button type="button" onClick={clearRunFilters}>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" disabled={exportingRuns || runTotal === 0} onClick={() => void exportRunsCsv()}>
|
||||
{exportingRuns ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
{jobStatus !== "running" && failCount > 0 ? (
|
||||
<button type="button" disabled={retryPending} onClick={onRetryFailed}>
|
||||
{t("collect.jobs.retryFailed")}
|
||||
|
|
@ -907,21 +1390,19 @@ function JobRunsPanel({
|
|||
</div>
|
||||
</div>
|
||||
<div className="ops-detail-modal__foot">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(runTotal),
|
||||
page: String(runPage),
|
||||
pages: String(runPages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={runPage <= 1} onClick={() => setRunPage(runPage - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button type="button" disabled={runPage >= runPages} onClick={() => setRunPage(runPage + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
<ListPager
|
||||
page={runPage}
|
||||
pages={runPages}
|
||||
total={runTotal}
|
||||
pageSize={runPageSize}
|
||||
pageSizeOptions={RUN_PAGE_SIZE_OPTIONS}
|
||||
onPageChange={setRunPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setRunPageSize(size);
|
||||
setRunPage(1);
|
||||
}}
|
||||
disabled={runsQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../components/ListPager";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { useI18n } from "../i18n";
|
||||
import {
|
||||
createConfigSyncCycle,
|
||||
fetchCliTargets,
|
||||
fetchConfigSyncCycleTasks,
|
||||
fetchConfigSyncCycles,
|
||||
fetchConfigSyncDashboard,
|
||||
formatErr,
|
||||
pauseConfigSyncCycle,
|
||||
resumeConfigSyncCycle,
|
||||
stopConfigSyncCycle,
|
||||
updateConfigSyncPolicy,
|
||||
} from "../services/api";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import type { CliTargetItem, ConfigSyncTargetRef } from "../types";
|
||||
import type { CliTargetItem, ConfigSyncCycle, ConfigSyncTargetRef, ConfigSyncTask } from "../types";
|
||||
import { downloadCsv, fetchAllPages } from "../utils/csvExport";
|
||||
import { pageCount } from "../utils/display";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
|
|
@ -25,12 +30,22 @@ export function ConfigSyncPage() {
|
|||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [cyclePage, setCyclePage] = useState(1);
|
||||
const [cyclePageSize, setCyclePageSize] = useState(20);
|
||||
const [cycleStatus, setCycleStatus] = useState("");
|
||||
const [cycleKeyword, setCycleKeyword] = useState("");
|
||||
const [exportingCycles, setExportingCycles] = useState(false);
|
||||
const [expandedCycleId, setExpandedCycleId] = useState("");
|
||||
const [taskPage, setTaskPage] = useState(1);
|
||||
const [taskPageSize, setTaskPageSize] = useState(20);
|
||||
const [taskStatus, setTaskStatus] = useState("");
|
||||
const [taskKeyword, setTaskKeyword] = useState("");
|
||||
const [exportingTasks, setExportingTasks] = useState(false);
|
||||
|
||||
const debouncedCycleKeyword = useDebouncedValue(cycleKeyword, 300);
|
||||
const debouncedTaskKeyword = useDebouncedValue(taskKeyword, 300);
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [intervalDays, setIntervalDays] = useState(3);
|
||||
|
|
@ -73,22 +88,52 @@ export function ConfigSyncPage() {
|
|||
setPolicyHydrated(true);
|
||||
}, [dashQuery.data, policyHydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = String(searchParams.get("cycle_id") || "").trim();
|
||||
if (!id) return;
|
||||
setExpandedCycleId(id);
|
||||
setTaskPage(1);
|
||||
setTaskPageSize(20);
|
||||
setTaskKeyword("");
|
||||
setTaskStatus("");
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (next.get("cycle_id") === id) next.delete("cycle_id");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const cyclesQuery = useQuery({
|
||||
queryKey: queryKeys.configSyncCycles(cyclePage),
|
||||
queryFn: () => fetchConfigSyncCycles({ page: cyclePage, pageSize: 10 }),
|
||||
queryKey: queryKeys.configSyncCycles(cyclePage, cyclePageSize, cycleStatus, debouncedCycleKeyword),
|
||||
queryFn: () =>
|
||||
fetchConfigSyncCycles({
|
||||
page: cyclePage,
|
||||
pageSize: cyclePageSize,
|
||||
status: cycleStatus,
|
||||
keyword: debouncedCycleKeyword,
|
||||
}),
|
||||
staleTime: 1000,
|
||||
refetchInterval: () => (dashQuery.data?.running_cycle ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: queryKeys.configSyncCycleTasks(expandedCycleId, taskPage, taskStatus, taskKeyword),
|
||||
queryKey: queryKeys.configSyncCycleTasks(
|
||||
expandedCycleId,
|
||||
taskPage,
|
||||
taskPageSize,
|
||||
taskStatus,
|
||||
debouncedTaskKeyword,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchConfigSyncCycleTasks({
|
||||
cycleId: expandedCycleId,
|
||||
page: taskPage,
|
||||
pageSize: 20,
|
||||
pageSize: taskPageSize,
|
||||
status: taskStatus,
|
||||
keyword: taskKeyword,
|
||||
keyword: debouncedTaskKeyword,
|
||||
}),
|
||||
enabled: Boolean(expandedCycleId),
|
||||
staleTime: 800,
|
||||
|
|
@ -144,7 +189,7 @@ export function ConfigSyncPage() {
|
|||
showOk(t("configSync.policySaved"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: (err) => showError(formatErr(err)),
|
||||
});
|
||||
|
||||
const startMut = useMutation({
|
||||
|
|
@ -153,7 +198,7 @@ export function ConfigSyncPage() {
|
|||
showOk(t("configSync.started"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: (err) => showError(formatErr(err)),
|
||||
});
|
||||
|
||||
const pauseMut = useMutation({
|
||||
|
|
@ -162,7 +207,7 @@ export function ConfigSyncPage() {
|
|||
showOk(t("configSync.paused"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: (err) => showError(formatErr(err)),
|
||||
});
|
||||
|
||||
const resumeMut = useMutation({
|
||||
|
|
@ -171,7 +216,7 @@ export function ConfigSyncPage() {
|
|||
showOk(t("configSync.resumed"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: (err) => showError(formatErr(err)),
|
||||
});
|
||||
|
||||
const stopMut = useMutation({
|
||||
|
|
@ -180,7 +225,7 @@ export function ConfigSyncPage() {
|
|||
showOk(t("configSync.stopped"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: (err) => showError(formatErr(err)),
|
||||
});
|
||||
|
||||
const dash = dashQuery.data;
|
||||
|
|
@ -188,8 +233,110 @@ export function ConfigSyncPage() {
|
|||
const last = dash?.last_cycle;
|
||||
const cycles = cyclesQuery.data?.items ?? [];
|
||||
const cycleTotal = Number(cyclesQuery.data?.total || 0);
|
||||
const cyclePages = pageCount(cycleTotal, 10);
|
||||
const cyclePages = pageCount(cycleTotal, cyclePageSize);
|
||||
const taskTotal = Number(tasksQuery.data?.total || 0);
|
||||
const taskPages = pageCount(taskTotal, taskPageSize);
|
||||
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
|
||||
const hasCycleFilters = Boolean(cycleKeyword.trim() || cycleStatus);
|
||||
|
||||
const openCycleTasks = (cycleId: string) => {
|
||||
setExpandedCycleId(cycleId);
|
||||
setTaskPage(1);
|
||||
setTaskKeyword("");
|
||||
setTaskStatus("");
|
||||
};
|
||||
|
||||
const exportCyclesCsv = async () => {
|
||||
setExportingCycles(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<ConfigSyncCycle>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchConfigSyncCycles({
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: cycleStatus,
|
||||
keyword: debouncedCycleKeyword,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("configSync.exportCyclesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "id", header: "ID" },
|
||||
{ key: "trigger_mode", header: t("configSync.col.trigger") },
|
||||
{ key: "status", header: t("configSync.col.status") },
|
||||
{
|
||||
key: "progress",
|
||||
header: t("configSync.col.progress"),
|
||||
value: (r) => `${r.success_count}/${r.planned_count} fail ${r.fail_count}`,
|
||||
},
|
||||
{
|
||||
key: "started_at",
|
||||
header: t("configSync.col.started"),
|
||||
value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
|
||||
},
|
||||
{
|
||||
key: "ended_at",
|
||||
header: t("configSync.col.ended"),
|
||||
value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
|
||||
},
|
||||
{ key: "error_message", header: t("configSync.col.message") },
|
||||
],
|
||||
);
|
||||
if (rows.length < cycleTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(cycleTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingCycles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportTasksCsv = async () => {
|
||||
if (!expandedCycleId) return;
|
||||
setExportingTasks(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<ConfigSyncTask>({
|
||||
pageSize: 200,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchConfigSyncCycleTasks({
|
||||
cycleId: expandedCycleId,
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: taskStatus,
|
||||
keyword: debouncedTaskKeyword,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("configSync.exportTasksName")}-${expandedCycleId.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "ne_name", header: t("configSync.col.name"), value: (r) => r.ne_name || r.target_id },
|
||||
{ key: "ne_ip", header: "IP" },
|
||||
{ key: "vendor", header: t("configSync.col.vendor") },
|
||||
{ key: "source", header: t("configSync.col.source") },
|
||||
{ key: "status", header: t("configSync.col.status") },
|
||||
{ key: "message", header: t("configSync.col.message") },
|
||||
{ key: "target_id", header: "ID" },
|
||||
],
|
||||
);
|
||||
if (rows.length < taskTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(taskTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingTasks(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTarget = (row: CliTargetItem) => {
|
||||
const source = row.source === "ume" ? "ume" : "managed";
|
||||
|
|
@ -416,114 +563,155 @@ export function ConfigSyncPage() {
|
|||
</div>
|
||||
|
||||
<h3>{t("configSync.cyclesTitle")}</h3>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>ID</th>
|
||||
<th>{t("configSync.col.trigger")}</th>
|
||||
<th>{t("configSync.col.status")}</th>
|
||||
<th>{t("configSync.col.progress")}</th>
|
||||
<th>{t("configSync.col.started")}</th>
|
||||
<th>{t("configSync.col.ended")}</th>
|
||||
<th>{t("configSync.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cycles.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
setExpandedCycleId(c.id);
|
||||
setTaskPage(1);
|
||||
setTaskKeyword("");
|
||||
setTaskStatus("");
|
||||
}}
|
||||
>
|
||||
{t("configSync.expand")}
|
||||
</button>
|
||||
</td>
|
||||
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
|
||||
<td>{c.trigger_mode}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`pt-list-status ${
|
||||
c.status === "running" || c.status === "pending"
|
||||
? "pt-list-status--running"
|
||||
: c.status === "paused"
|
||||
? "pt-list-status--paused"
|
||||
: c.status === "failed"
|
||||
? "pt-list-status--failed"
|
||||
: c.status === "success" || c.status === "completed"
|
||||
? "pt-list-status--ok"
|
||||
: "pt-list-status--other"
|
||||
}`}
|
||||
>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{c.success_count}/{c.planned_count} · fail {c.fail_count}
|
||||
</td>
|
||||
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
|
||||
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
|
||||
<td>
|
||||
<div className="btn-row">
|
||||
{c.status === "running" || c.status === "pending" ? (
|
||||
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
|
||||
{t("configSync.pause")}
|
||||
</button>
|
||||
) : null}
|
||||
{c.status === "paused" ? (
|
||||
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
|
||||
{t("configSync.resume")}
|
||||
</button>
|
||||
) : null}
|
||||
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={cycleKeyword}
|
||||
placeholder={t("configSync.keywordPh")}
|
||||
onChange={(e) => {
|
||||
setCycleKeyword(e.target.value);
|
||||
setCyclePage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={cycleStatus}
|
||||
onChange={(e) => {
|
||||
setCycleStatus(e.target.value);
|
||||
setCyclePage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("configSync.statusAll")}</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="running">running</option>
|
||||
<option value="paused">paused</option>
|
||||
<option value="success">success</option>
|
||||
<option value="completed">completed</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="stopped">stopped</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasCycleFilters}
|
||||
onClick={() => {
|
||||
setCycleKeyword("");
|
||||
setCycleStatus("");
|
||||
setCyclePage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingCycles || cycleTotal === 0}
|
||||
onClick={() => void exportCyclesCsv()}
|
||||
>
|
||||
{exportingCycles ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cyclesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{cyclesQuery.isError ? <p className="error-text">{formatErr(cyclesQuery.error)}</p> : null}
|
||||
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>ID</th>
|
||||
<th>{t("configSync.col.trigger")}</th>
|
||||
<th>{t("configSync.col.status")}</th>
|
||||
<th>{t("configSync.col.progress")}</th>
|
||||
<th>{t("configSync.col.started")}</th>
|
||||
<th>{t("configSync.col.ended")}</th>
|
||||
<th>{t("configSync.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cycles.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
|
||||
}}
|
||||
disabled={stopMut.isPending}
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => openCycleTasks(c.id)}
|
||||
>
|
||||
{t("configSync.stop")}
|
||||
{t("configSync.expand")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!cycles.length ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="muted">
|
||||
{t("configSync.cyclesEmpty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager pt-list-pager">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(cycleTotal),
|
||||
page: String(cyclePage),
|
||||
pages: String(cyclePages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={cyclePage <= 1} onClick={() => setCyclePage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button type="button" disabled={cyclePage >= cyclePages} onClick={() => setCyclePage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</td>
|
||||
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
|
||||
<td>{c.trigger_mode}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`pt-list-status ${
|
||||
c.status === "running" || c.status === "pending"
|
||||
? "pt-list-status--running"
|
||||
: c.status === "paused"
|
||||
? "pt-list-status--paused"
|
||||
: c.status === "failed"
|
||||
? "pt-list-status--failed"
|
||||
: c.status === "success" || c.status === "completed"
|
||||
? "pt-list-status--ok"
|
||||
: "pt-list-status--other"
|
||||
}`}
|
||||
>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{c.success_count}/{c.planned_count} · fail {c.fail_count}
|
||||
</td>
|
||||
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
|
||||
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
|
||||
<td>
|
||||
<div className="btn-row">
|
||||
{c.status === "running" || c.status === "pending" ? (
|
||||
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
|
||||
{t("configSync.pause")}
|
||||
</button>
|
||||
) : null}
|
||||
{c.status === "paused" ? (
|
||||
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
|
||||
{t("configSync.resume")}
|
||||
</button>
|
||||
) : null}
|
||||
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
|
||||
}}
|
||||
disabled={stopMut.isPending}
|
||||
>
|
||||
{t("configSync.stop")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!cycles.length && !cyclesQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="muted">
|
||||
{t("configSync.cyclesEmpty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ListPager
|
||||
page={cyclePage}
|
||||
pages={cyclePages}
|
||||
total={cycleTotal}
|
||||
pageSize={cyclePageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
onPageChange={setCyclePage}
|
||||
onPageSizeChange={(size) => {
|
||||
setCyclePageSize(size);
|
||||
setCyclePage(1);
|
||||
}}
|
||||
disabled={cyclesQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expandedCycleId ? (
|
||||
|
|
@ -553,6 +741,13 @@ export function ConfigSyncPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="btn-row ops-detail-modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingTasks || taskTotal === 0}
|
||||
onClick={() => void exportTasksCsv()}
|
||||
>
|
||||
{exportingTasks ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setExpandedCycleId("")}>
|
||||
{t("networkConfigs.close")}
|
||||
</button>
|
||||
|
|
@ -575,7 +770,7 @@ export function ConfigSyncPage() {
|
|||
setTaskPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("configSync.allStatus")}</option>
|
||||
<option value="">{t("configSync.statusAll")}</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="running">running</option>
|
||||
<option value="success">success</option>
|
||||
|
|
@ -586,9 +781,7 @@ export function ConfigSyncPage() {
|
|||
|
||||
{tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{tasksQuery.isError ? (
|
||||
<p className="ops-detail-modal__error">
|
||||
{tasksQuery.error instanceof Error ? tasksQuery.error.message : String(tasksQuery.error)}
|
||||
</p>
|
||||
<p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p>
|
||||
) : null}
|
||||
|
||||
<div className="ops-detail-modal__scroll">
|
||||
|
|
@ -628,25 +821,19 @@ export function ConfigSyncPage() {
|
|||
</div>
|
||||
|
||||
<div className="ops-detail-modal__foot">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(tasksQuery.data?.total ?? 0),
|
||||
page: String(taskPage),
|
||||
pages: String(pageCount(Number(tasksQuery.data?.total || 0), 20)),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={taskPage <= 1} onClick={() => setTaskPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={taskPage >= pageCount(Number(tasksQuery.data?.total || 0), 20)}
|
||||
onClick={() => setTaskPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
<ListPager
|
||||
page={taskPage}
|
||||
pages={taskPages}
|
||||
total={taskTotal}
|
||||
pageSize={taskPageSize}
|
||||
pageSizeOptions={[20, 50, 100, 200]}
|
||||
onPageChange={setTaskPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setTaskPageSize(size);
|
||||
setTaskPage(1);
|
||||
}}
|
||||
disabled={tasksQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ import {
|
|||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { formatErr } from "../services/api";
|
||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||
import type {
|
||||
FabricNodeSearchHit,
|
||||
|
|
@ -114,6 +116,8 @@ const TopologyReactFlowView = lazy(() => import("./topology/TopologyReactFlowVie
|
|||
export function TopologyPage() {
|
||||
const { t, locale } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const { hasScope, isAdmin } = useAuth();
|
||||
const canWriteTopology = isAdmin || hasScope("ne:write");
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
|
@ -295,7 +299,13 @@ export function TopologyPage() {
|
|||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, []);
|
||||
|
||||
const toolBehavior = useMemo(() => behaviorForMode(toolMode), [toolMode]);
|
||||
const toolBehavior = useMemo(() => {
|
||||
const base = behaviorForMode(toolMode);
|
||||
if (!canWriteTopology) {
|
||||
return { ...base, nodesDraggable: false, nodesConnectable: false };
|
||||
}
|
||||
return base;
|
||||
}, [toolMode, canWriteTopology]);
|
||||
|
||||
const canvas = useTopologyCanvas({
|
||||
mapId,
|
||||
|
|
@ -354,6 +364,8 @@ export function TopologyPage() {
|
|||
worldScatter,
|
||||
showWorldScatter,
|
||||
canvasGraphLoading,
|
||||
canvasGraphError,
|
||||
canvasGraphErrorMsg,
|
||||
canvasGraphEmpty,
|
||||
worldNeedsApply,
|
||||
canvasGraphRefreshing,
|
||||
|
|
@ -537,7 +549,10 @@ export function TopologyPage() {
|
|||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||
showOk(t("topology.saved"));
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: async (err) => {
|
||||
showError(t("topology.saveFailed").replace("{{detail}}", formatErr(err)));
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
|
||||
},
|
||||
});
|
||||
|
||||
const isValidConnection = useCallback(
|
||||
|
|
@ -545,14 +560,18 @@ export function TopologyPage() {
|
|||
const source = String(connection.source || "");
|
||||
const target = String(connection.target || "");
|
||||
if (!source || !target || source === target) return false;
|
||||
return !edges.some((e) => e.source === source && e.target === target);
|
||||
return !edges.some(
|
||||
(e) =>
|
||||
(e.source === source && e.target === target) ||
|
||||
(e.source === target && e.target === source),
|
||||
);
|
||||
},
|
||||
[edges],
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!mapId || !isValidConnection(connection)) return;
|
||||
if (!canWriteTopology || !mapId || !isValidConnection(connection)) return;
|
||||
const source = String(connection.source || "");
|
||||
const target = String(connection.target || "");
|
||||
if (!source || !target) return;
|
||||
|
|
@ -586,11 +605,12 @@ export function TopologyPage() {
|
|||
setEdges((es) => [...es, edge]);
|
||||
markDirty();
|
||||
},
|
||||
[mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty],
|
||||
[mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty, canWriteTopology],
|
||||
);
|
||||
|
||||
const addPaletteItems = useCallback(
|
||||
async (items: PaletteItem[], origin?: { x: number; y: number }): Promise<boolean> => {
|
||||
if (!canWriteTopology) return false;
|
||||
if (!mapId) {
|
||||
showError(t("topology.selectMap"));
|
||||
return false;
|
||||
|
|
@ -700,6 +720,7 @@ export function TopologyPage() {
|
|||
|
||||
const onCanvasDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canWriteTopology) return;
|
||||
const raw = e.dataTransfer.getData(PALETTE_DND);
|
||||
if (!raw || !rfRef.current) return;
|
||||
try {
|
||||
|
|
@ -1076,7 +1097,7 @@ export function TopologyPage() {
|
|||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
if (mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
|
||||
if (canWriteTopology && mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
|
||||
|
|
@ -1095,6 +1116,7 @@ export function TopologyPage() {
|
|||
return;
|
||||
}
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (!canWriteTopology) return;
|
||||
if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) {
|
||||
e.preventDefault();
|
||||
void removeSelected();
|
||||
|
|
@ -1103,7 +1125,7 @@ export function TopologyPage() {
|
|||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut]);
|
||||
}, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut, canWriteTopology]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctxMenu) return;
|
||||
|
|
@ -1366,6 +1388,10 @@ export function TopologyPage() {
|
|||
|
||||
const discoverOneFor = (node: Node<NeNodeData> | null) => {
|
||||
closeCtxMenu();
|
||||
if (!canWriteTopology) {
|
||||
showError(t("topology.readOnlyHint"));
|
||||
return;
|
||||
}
|
||||
const id = String(node?.data.managed_ne_id || node?.data.ume_ne_id || "").trim();
|
||||
if (!id) {
|
||||
showError(t("topology.discoverOneNeedNe"));
|
||||
|
|
@ -1917,6 +1943,7 @@ export function TopologyPage() {
|
|||
<main className="topo-main">
|
||||
{canvasMode ? (
|
||||
<TopologyToolbar
|
||||
readOnly={!canWriteTopology}
|
||||
breadcrumbFolders={breadcrumbFolders}
|
||||
activeView={activeView}
|
||||
activeRegion={activeRegion}
|
||||
|
|
@ -2051,6 +2078,11 @@ export function TopologyPage() {
|
|||
{truncateBannerText}
|
||||
</div>
|
||||
) : null}
|
||||
{!canWriteTopology && mapId ? (
|
||||
<div className="topo-readonly-banner" role="status">
|
||||
{t("topology.readOnlyBanner")}
|
||||
</div>
|
||||
) : null}
|
||||
<TopologyViewTools
|
||||
fullscreen={fullscreen}
|
||||
toolbarSlot={viewToolsToolbarSlot}
|
||||
|
|
@ -2248,6 +2280,23 @@ export function TopologyPage() {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
{treeRoot && canvasGraphError ? (
|
||||
<div className="topo-canvas__overlay topo-canvas__overlay--error" role="alert">
|
||||
<p className="topo-canvas__overlay-title">{t("topology.graphLoadFailed")}</p>
|
||||
{canvasGraphErrorMsg ? (
|
||||
<p className="topo-canvas__overlay-hint muted">{canvasGraphErrorMsg}</p>
|
||||
) : null}
|
||||
<div className="topo-browser__empty-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => void graphQuery.refetch()}
|
||||
>
|
||||
{t("topology.treeRetry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{treeRoot && canvasGraphLoading ? (
|
||||
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
|
||||
<span className="topo-loading-spinner topo-loading-spinner--lg" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -1,23 +1,33 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { HelpHint } from "../../components/HelpHint";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import {
|
||||
fetchCliTargets,
|
||||
fetchFabricEdges,
|
||||
fetchLldpCollectDashboard,
|
||||
fetchLldpCollectJob,
|
||||
fetchLldpCollectJobs,
|
||||
formatErr,
|
||||
pauseLldpCollectJob,
|
||||
resumeLldpCollectJob,
|
||||
startLldpCollect,
|
||||
stopLldpCollectJob,
|
||||
updateLldpCollectPolicy,
|
||||
} from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { HelpHint } from "../../components/HelpHint";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import type { CliTargetItem, ConfigSyncTargetRef, TopologyDiscoverJobItem } from "../../types";
|
||||
import type {
|
||||
CliTargetItem,
|
||||
ConfigSyncTargetRef,
|
||||
FabricEdge,
|
||||
LldpCollectJobSummary,
|
||||
TopologyDiscoverJobItem,
|
||||
} from "../../types";
|
||||
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
|
|
@ -89,15 +99,27 @@ export function LldpLinksPage() {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [jobPageSize, setJobPageSize] = useState(20);
|
||||
const [jobStatus, setJobStatus] = useState("");
|
||||
const [jobKeyword, setJobKeyword] = useState("");
|
||||
const [exportingJobs, setExportingJobs] = useState(false);
|
||||
const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
|
||||
const [expandedJobId, setExpandedJobId] = useState("");
|
||||
const [itemPage, setItemPage] = useState(1);
|
||||
const [itemPageSize, setItemPageSize] = useState(20);
|
||||
const [itemStatus, setItemStatus] = useState("");
|
||||
const [itemKeyword, setItemKeyword] = useState("");
|
||||
const [exportingItems, setExportingItems] = useState(false);
|
||||
const debouncedItemKeyword = useDebouncedValue(itemKeyword, 300);
|
||||
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
|
||||
const ITEM_PAGE_SIZE = 20;
|
||||
|
||||
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
|
||||
const [edgeSource, setEdgeSource] = useState("");
|
||||
const [edgeKeyword, setEdgeKeyword] = useState("");
|
||||
const [edgePage, setEdgePage] = useState(1);
|
||||
const EDGE_PAGE_SIZE = 20;
|
||||
const [edgePageSize, setEdgePageSize] = useState(50);
|
||||
const [exportingEdges, setExportingEdges] = useState(false);
|
||||
const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300);
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [intervalValue, setIntervalValue] = useState(1);
|
||||
|
|
@ -156,6 +178,8 @@ export function LldpLinksPage() {
|
|||
if (!jobId) return;
|
||||
setItemDetail(null);
|
||||
setItemPage(1);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
setExpandedJobId(jobId);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
|
|
@ -168,15 +192,33 @@ export function LldpLinksPage() {
|
|||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectJobs(jobPage),
|
||||
queryFn: () => fetchLldpCollectJobs({ page: jobPage, pageSize: 10 }),
|
||||
queryKey: queryKeys.lldpCollectJobs(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
|
||||
queryFn: () =>
|
||||
fetchLldpCollectJobs({
|
||||
page: jobPage,
|
||||
pageSize: jobPageSize,
|
||||
status: jobStatus,
|
||||
keyword: debouncedJobKeyword,
|
||||
}),
|
||||
staleTime: 1000,
|
||||
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const jobDetailQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectJob(expandedJobId, itemPage),
|
||||
queryFn: () => fetchLldpCollectJob(expandedJobId, { page: itemPage, pageSize: ITEM_PAGE_SIZE }),
|
||||
queryKey: queryKeys.lldpCollectJob(
|
||||
expandedJobId,
|
||||
itemPage,
|
||||
itemPageSize,
|
||||
itemStatus,
|
||||
debouncedItemKeyword,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchLldpCollectJob(expandedJobId, {
|
||||
page: itemPage,
|
||||
pageSize: itemPageSize,
|
||||
status: itemStatus,
|
||||
keyword: debouncedItemKeyword,
|
||||
}),
|
||||
enabled: Boolean(expandedJobId),
|
||||
staleTime: 800,
|
||||
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
|
||||
|
|
@ -191,13 +233,20 @@ export function LldpLinksPage() {
|
|||
});
|
||||
|
||||
const edgesQuery = useQuery({
|
||||
queryKey: queryKeys.fabricEdges(edgeStatus, edgeKeyword, edgePage),
|
||||
queryKey: queryKeys.fabricEdges(
|
||||
edgeStatus,
|
||||
debouncedEdgeKeyword,
|
||||
edgePage,
|
||||
edgePageSize,
|
||||
edgeSource,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchFabricEdges({
|
||||
status: edgeStatus === "all" ? "" : edgeStatus,
|
||||
keyword: edgeKeyword,
|
||||
keyword: debouncedEdgeKeyword,
|
||||
source: edgeSource || undefined,
|
||||
page: edgePage,
|
||||
pageSize: EDGE_PAGE_SIZE,
|
||||
pageSize: edgePageSize,
|
||||
}),
|
||||
staleTime: 2000,
|
||||
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
|
||||
|
|
@ -302,14 +351,192 @@ export function LldpLinksPage() {
|
|||
const last = dash?.last_job;
|
||||
const jobs = jobsQuery.data?.items ?? [];
|
||||
const jobTotal = Number(jobsQuery.data?.total || 0);
|
||||
const jobPages = pageCount(jobTotal, 10);
|
||||
const jobPages = pageCount(jobTotal, jobPageSize);
|
||||
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
|
||||
const detailItems = jobDetailQuery.data?.items ?? [];
|
||||
const itemTotal = Number(jobDetailQuery.data?.items_total ?? detailItems.length);
|
||||
const itemPages = pageCount(itemTotal, ITEM_PAGE_SIZE);
|
||||
const itemPages = pageCount(itemTotal, itemPageSize);
|
||||
const edgeItems = edgesQuery.data?.items ?? [];
|
||||
const edgeTotal = Number(edgesQuery.data?.total || 0);
|
||||
const edgePages = pageCount(edgeTotal, EDGE_PAGE_SIZE);
|
||||
const edgePages = pageCount(edgeTotal, edgePageSize);
|
||||
|
||||
const exportEdgesCsv = async () => {
|
||||
setExportingEdges(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<FabricEdge>({
|
||||
pageSize: 200,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchFabricEdges({
|
||||
status: edgeStatus === "all" ? "" : edgeStatus,
|
||||
keyword: debouncedEdgeKeyword,
|
||||
source: edgeSource || undefined,
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("lldpLinks.exportEdgesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{
|
||||
key: "status",
|
||||
header: t("lldpLinks.col.status"),
|
||||
value: (e) => lldpEdgeStatusLabel(t, e.status === "stale" ? "missing" : e.status),
|
||||
},
|
||||
{
|
||||
key: "a",
|
||||
header: t("lldpLinks.col.aSide"),
|
||||
value: (e) => e.a_name || e.a_ip || e.a_node_id,
|
||||
},
|
||||
{ key: "a_port", header: t("lldpLinks.col.aPort") },
|
||||
{
|
||||
key: "b",
|
||||
header: t("lldpLinks.col.bSide"),
|
||||
value: (e) => e.b_name || e.b_ip || e.b_node_id,
|
||||
},
|
||||
{ key: "b_port", header: t("lldpLinks.col.bPort") },
|
||||
{
|
||||
key: "source",
|
||||
header: t("lldpLinks.col.source"),
|
||||
value: (e) => lldpEdgeSourceLabel(t, e.source),
|
||||
},
|
||||
{
|
||||
key: "last_seen_at",
|
||||
header: t("lldpLinks.col.lastSeen"),
|
||||
value: (e) => (e.last_seen_at ? formatSystemTime(e.last_seen_at) : ""),
|
||||
},
|
||||
{ key: "id", header: "ID" },
|
||||
],
|
||||
);
|
||||
if (rows.length < edgeTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(edgeTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingEdges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportJobsCsv = async () => {
|
||||
setExportingJobs(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<LldpCollectJobSummary>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchLldpCollectJobs({
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: jobStatus,
|
||||
keyword: debouncedJobKeyword,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("lldpLinks.exportJobsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "id", header: "ID" },
|
||||
{
|
||||
key: "trigger_mode",
|
||||
header: t("lldpLinks.col.trigger"),
|
||||
value: (j) => lldpTriggerLabel(t, j.trigger_mode),
|
||||
},
|
||||
{
|
||||
key: "scope",
|
||||
header: t("lldpLinks.col.scope"),
|
||||
value: (j) => lldpScopeLabel(t, j.scope),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("lldpLinks.col.status"),
|
||||
value: (j) => lldpJobStatusLabel(t, j.status),
|
||||
},
|
||||
{
|
||||
key: "progress",
|
||||
header: t("lldpLinks.col.progress"),
|
||||
value: (j) => `${j.done}/${j.total}`,
|
||||
},
|
||||
{
|
||||
key: "edges",
|
||||
header: t("lldpLinks.col.edges"),
|
||||
value: (j) => `+${j.edges_added}/~${j.edges_updated}`,
|
||||
},
|
||||
{
|
||||
key: "started_at",
|
||||
header: t("lldpLinks.col.started"),
|
||||
value: (j) => (j.started_at ? formatSystemTime(j.started_at) : ""),
|
||||
},
|
||||
{
|
||||
key: "ended_at",
|
||||
header: t("lldpLinks.col.ended"),
|
||||
value: (j) => (j.ended_at ? formatSystemTime(j.ended_at) : ""),
|
||||
},
|
||||
],
|
||||
);
|
||||
if (rows.length < jobTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(jobTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingJobs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportItemsCsv = async () => {
|
||||
if (!expandedJobId) return;
|
||||
setExportingItems(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<TopologyDiscoverJobItem>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: async (p, ps) => {
|
||||
const job = await fetchLldpCollectJob(expandedJobId, {
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: itemStatus,
|
||||
keyword: debouncedItemKeyword,
|
||||
});
|
||||
return { total: job.items_total, items: (job.items || []) as TopologyDiscoverJobItem[] };
|
||||
},
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("lldpLinks.exportItemsName")}-${expandedJobId.slice(0, 8)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "ne_name", header: t("lldpLinks.col.name") },
|
||||
{ key: "ne_ip", header: "IP" },
|
||||
{
|
||||
key: "result",
|
||||
header: t("lldpLinks.col.status"),
|
||||
value: (it) => lldpItemResultLabel(t, it),
|
||||
},
|
||||
{ key: "neighbors", header: t("lldpLinks.col.neighbors") },
|
||||
{
|
||||
key: "edges",
|
||||
header: t("lldpLinks.col.edges"),
|
||||
value: (it) => `+${it.edges_added}/~${it.edges_updated}`,
|
||||
},
|
||||
{ key: "error", header: t("lldpLinks.col.error") },
|
||||
],
|
||||
);
|
||||
if (rows.length < itemTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(itemTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingItems(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTarget = (row: CliTargetItem) => {
|
||||
const source = row.source === "ume" ? "ume" : "managed";
|
||||
|
|
@ -576,6 +803,17 @@ export function LldpLinksPage() {
|
|||
<option value="active">{t("lldpLinks.edgeStatusActive")}</option>
|
||||
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
|
||||
</select>
|
||||
<select
|
||||
value={edgeSource}
|
||||
onChange={(e) => {
|
||||
setEdgeSource(e.target.value);
|
||||
setEdgePage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("lldpLinks.edgeSourceAll")}</option>
|
||||
<option value="lldp">{t("lldpLinks.edgeSource.lldp")}</option>
|
||||
<option value="manual">{t("lldpLinks.edgeSource.manual")}</option>
|
||||
</select>
|
||||
<input
|
||||
value={edgeKeyword}
|
||||
placeholder={t("lldpLinks.edgeKeywordPh")}
|
||||
|
|
@ -584,6 +822,13 @@ export function LldpLinksPage() {
|
|||
setEdgePage(1);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingEdges || edgeTotal === 0}
|
||||
onClick={() => void exportEdgesCsv()}
|
||||
>
|
||||
{exportingEdges ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
|
|
@ -643,27 +888,54 @@ export function LldpLinksPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={edgePage <= 1} onClick={() => setEdgePage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(edgeTotal),
|
||||
page: String(edgePage),
|
||||
pages: String(edgePages),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={edgePage >= edgePages}
|
||||
onClick={() => setEdgePage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
<ListPager
|
||||
page={edgePage}
|
||||
pages={edgePages}
|
||||
total={edgeTotal}
|
||||
pageSize={edgePageSize}
|
||||
pageSizeOptions={[20, 50, 100, 200]}
|
||||
onPageChange={setEdgePage}
|
||||
onPageSizeChange={(size) => {
|
||||
setEdgePageSize(size);
|
||||
setEdgePage(1);
|
||||
}}
|
||||
disabled={edgesQuery.isLoading}
|
||||
/>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3>
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<select
|
||||
value={jobStatus}
|
||||
onChange={(e) => {
|
||||
setJobStatus(e.target.value);
|
||||
setJobPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("lldpLinks.statusAll")}</option>
|
||||
<option value="running">{t("lldpLinks.jobStatus.running")}</option>
|
||||
<option value="pending">{t("lldpLinks.jobStatus.pending")}</option>
|
||||
<option value="paused">{t("lldpLinks.jobStatus.paused")}</option>
|
||||
<option value="done">{t("lldpLinks.jobStatus.done")}</option>
|
||||
<option value="failed">{t("lldpLinks.jobStatus.failed")}</option>
|
||||
<option value="cancelled">{t("lldpLinks.jobStatus.cancelled")}</option>
|
||||
<option value="stopped">{t("lldpLinks.jobStatus.stopped")}</option>
|
||||
</select>
|
||||
<input
|
||||
value={jobKeyword}
|
||||
placeholder={t("lldpLinks.jobKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setJobKeyword(e.target.value);
|
||||
setJobPage(1);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingJobs || jobTotal === 0}
|
||||
onClick={() => void exportJobsCsv()}
|
||||
>
|
||||
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
|
|
@ -691,6 +963,8 @@ export function LldpLinksPage() {
|
|||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
setItemDetail(null);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
setExpandedJobId(job.id);
|
||||
setItemPage(1);
|
||||
}}
|
||||
|
|
@ -774,21 +1048,19 @@ export function LldpLinksPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={jobPage <= 1} onClick={() => setJobPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(jobTotal),
|
||||
page: String(jobPage),
|
||||
pages: String(jobPages),
|
||||
})}
|
||||
</span>
|
||||
<button type="button" disabled={jobPage >= jobPages} onClick={() => setJobPage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
<ListPager
|
||||
page={jobPage}
|
||||
pages={jobPages}
|
||||
total={jobTotal}
|
||||
pageSize={jobPageSize}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onPageChange={setJobPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setJobPageSize(size);
|
||||
setJobPage(1);
|
||||
}}
|
||||
disabled={jobsQuery.isLoading}
|
||||
/>
|
||||
|
||||
{expandedJobId ? (
|
||||
<div
|
||||
|
|
@ -823,11 +1095,21 @@ export function LldpLinksPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="btn-row ops-detail-modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingItems || itemTotal === 0}
|
||||
onClick={() => void exportItemsCsv()}
|
||||
>
|
||||
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedJobId("");
|
||||
setItemDetail(null);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
setItemPage(1);
|
||||
}}
|
||||
>
|
||||
{t("networkConfigs.close")}
|
||||
|
|
@ -835,6 +1117,29 @@ export function LldpLinksPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<select
|
||||
value={itemStatus}
|
||||
onChange={(e) => {
|
||||
setItemStatus(e.target.value);
|
||||
setItemPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("lldpLinks.itemResultAll")}</option>
|
||||
<option value="ok">{t("lldpLinks.itemResult.ok")}</option>
|
||||
<option value="warn">{t("lldpLinks.itemResult.warn")}</option>
|
||||
<option value="fail">{t("lldpLinks.itemResult.fail")}</option>
|
||||
</select>
|
||||
<input
|
||||
value={itemKeyword}
|
||||
placeholder={t("lldpLinks.itemKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setItemKeyword(e.target.value);
|
||||
setItemPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{jobDetailQuery.data?.error ? (
|
||||
<p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
|
||||
|
|
@ -897,25 +1202,19 @@ export function LldpLinksPage() {
|
|||
</div>
|
||||
|
||||
<div className="ops-detail-modal__foot">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(itemTotal),
|
||||
page: String(itemPage),
|
||||
pages: String(itemPages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={itemPage <= 1} onClick={() => setItemPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={itemPage >= itemPages}
|
||||
onClick={() => setItemPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
<ListPager
|
||||
page={itemPage}
|
||||
pages={itemPages}
|
||||
total={itemTotal}
|
||||
pageSize={itemPageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
onPageChange={setItemPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemPageSize(size);
|
||||
setItemPage(1);
|
||||
}}
|
||||
disabled={jobDetailQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchUmeCurrentAlarms } from "../../services/api";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { fetchUmeCurrentAlarms, formatErr } from "../../services/api";
|
||||
import type { UmeAlarmItem } from "../../types";
|
||||
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
|
|
@ -19,19 +24,24 @@ function severityClass(sev: string | null | undefined): string {
|
|||
/** Current UME alarms query view for Network Management. */
|
||||
export function NetworkAlarmsPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const [curSeverity, setCurSeverity] = useState("");
|
||||
const [curCleared, setCurCleared] = useState("");
|
||||
const [curHostName, setCurHostName] = useState("");
|
||||
const [curKeyword, setCurKeyword] = useState("");
|
||||
const [curPage, setCurPage] = useState(1);
|
||||
const [curPageSize, setCurPageSize] = useState(50);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const debouncedKeyword = useDebouncedValue(curKeyword, 300);
|
||||
const debouncedHost = useDebouncedValue(curHostName, 300);
|
||||
|
||||
const currentQuery = useQuery({
|
||||
queryKey: queryKeys.umeCurrentAlarms(
|
||||
curSeverity,
|
||||
curCleared,
|
||||
curHostName,
|
||||
curKeyword,
|
||||
debouncedHost,
|
||||
debouncedKeyword,
|
||||
curPage,
|
||||
curPageSize,
|
||||
),
|
||||
|
|
@ -39,8 +49,8 @@ export function NetworkAlarmsPage() {
|
|||
fetchUmeCurrentAlarms({
|
||||
severity: curSeverity,
|
||||
isCleared: curCleared,
|
||||
hostName: curHostName,
|
||||
keyword: curKeyword,
|
||||
hostName: debouncedHost,
|
||||
keyword: debouncedKeyword,
|
||||
page: curPage,
|
||||
pageSize: curPageSize,
|
||||
}),
|
||||
|
|
@ -50,13 +60,57 @@ export function NetworkAlarmsPage() {
|
|||
const items = currentQuery.data?.items || [];
|
||||
const curTotal = Number(currentQuery.data?.total || 0);
|
||||
const curPages = pageCount(curTotal, curPageSize);
|
||||
const perPage = (n: number) => t("common.perPage", { n: String(n) });
|
||||
const hasFilters = Boolean(curKeyword.trim() || curHostName.trim() || curSeverity || curCleared);
|
||||
|
||||
const exportCsv = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<UmeAlarmItem>({
|
||||
pageSize: 200,
|
||||
maxRows: 2000,
|
||||
fetchPage: (page, pageSize) =>
|
||||
fetchUmeCurrentAlarms({
|
||||
severity: curSeverity,
|
||||
isCleared: curCleared,
|
||||
hostName: debouncedHost,
|
||||
keyword: debouncedKeyword,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
});
|
||||
downloadCsv(`ume-alarms-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
|
||||
{ key: "time_created", header: t("ume.alarms.col.time"), value: (r) => formatSystemTime(r.time_created) },
|
||||
{ key: "perceived_severity", header: t("ume.alarms.col.severity") },
|
||||
{ key: "ne_id", header: t("ume.alarms.col.neId") },
|
||||
{ key: "host_name", header: t("ume.alarms.col.hostName") },
|
||||
{ key: "ne_type", header: t("ume.alarms.col.neType") },
|
||||
{ key: "native_probable_cause", header: t("ume.alarms.col.cause") },
|
||||
{ key: "is_cleared", header: "is_cleared" },
|
||||
{ key: "alarm_key", header: "alarm_key" },
|
||||
]);
|
||||
if (rows.length < curTotal) {
|
||||
showOk(
|
||||
t("common.exportTruncated", { count: String(rows.length), total: String(curTotal) }),
|
||||
);
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("ume.alarms.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={exporting || curTotal === 0} onClick={() => void exportCsv()}>
|
||||
{exporting ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
|
|
@ -99,8 +153,8 @@ export function NetworkAlarmsPage() {
|
|||
}}
|
||||
>
|
||||
<option value="">{t("ume.alarms.clearedAll")}</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
<option value="false">{t("ume.alarms.clearedNo")}</option>
|
||||
<option value="true">{t("ume.alarms.clearedYes")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -130,12 +184,12 @@ export function NetworkAlarmsPage() {
|
|||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>time_created</th>
|
||||
<th>severity</th>
|
||||
<th>ne_id</th>
|
||||
<th>host_name</th>
|
||||
<th>ne_type</th>
|
||||
<th>cause</th>
|
||||
<th>{t("ume.alarms.col.time")}</th>
|
||||
<th>{t("ume.alarms.col.severity")}</th>
|
||||
<th>{t("ume.alarms.col.neId")}</th>
|
||||
<th>{t("ume.alarms.col.hostName")}</th>
|
||||
<th>{t("ume.alarms.col.neType")}</th>
|
||||
<th>{t("ume.alarms.col.cause")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -177,46 +231,19 @@ export function NetworkAlarmsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="pager pt-list-pager">
|
||||
<div className="pager__meta muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(curTotal),
|
||||
page: String(curPage),
|
||||
pages: String(curPages),
|
||||
})}
|
||||
</div>
|
||||
<div className="pager__controls btn-row">
|
||||
<button
|
||||
className="pager__btn"
|
||||
type="button"
|
||||
onClick={() => setCurPage(Math.max(1, curPage - 1))}
|
||||
disabled={curPage <= 1}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button
|
||||
className="pager__btn"
|
||||
type="button"
|
||||
onClick={() => setCurPage(curPage + 1)}
|
||||
disabled={curPage >= curPages}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
<select
|
||||
className="pager__size"
|
||||
value={String(curPageSize)}
|
||||
onChange={(e) => {
|
||||
setCurPageSize(Number(e.target.value) || 50);
|
||||
setCurPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="50">{perPage(50)}</option>
|
||||
<option value="100">{perPage(100)}</option>
|
||||
<option value="200">{perPage(200)}</option>
|
||||
<option value="500">{perPage(500)}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ListPager
|
||||
page={curPage}
|
||||
pages={curPages}
|
||||
total={curTotal}
|
||||
pageSize={curPageSize}
|
||||
pageSizeOptions={[50, 100, 200, 500]}
|
||||
onPageChange={setCurPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setCurPageSize(size);
|
||||
setCurPage(1);
|
||||
}}
|
||||
disabled={currentQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import {
|
||||
downloadNeConfigSnapshot,
|
||||
fetchNeConfigSnapshotDetail,
|
||||
fetchNeConfigSnapshots,
|
||||
formatErr,
|
||||
} from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { openNewModuleWindow } from "../../utils/moduleWindows";
|
||||
import type { NeConfigSnapshotMeta } from "../../types";
|
||||
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { openNewModuleWindow } from "../../utils/moduleWindows";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
|
|
@ -22,18 +28,45 @@ function fmtBytes(n: number): string {
|
|||
export function NetworkConfigsPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || ""));
|
||||
const [source, setSource] = useState("");
|
||||
const [vendor, setVendor] = useState("");
|
||||
const [selected, setSelected] = useState<{ source: string; id: string } | null>(null);
|
||||
const [tab, setTab] = useState<"primary" | "alt">("primary");
|
||||
const [exporting, setExporting] = useState("");
|
||||
const [exportingList, setExportingList] = useState(false);
|
||||
|
||||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||
const debouncedVendor = useDebouncedValue(vendor, 300);
|
||||
|
||||
useEffect(() => {
|
||||
const q = String(searchParams.get("q") || "").trim();
|
||||
if (!q) return;
|
||||
setKeyword(q);
|
||||
setPage(1);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (next.get("q") === q) next.delete("q");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.networkConfigs(page, keyword, source, vendor),
|
||||
queryKey: queryKeys.networkConfigs(page, debouncedKeyword, source, debouncedVendor, pageSize),
|
||||
queryFn: () =>
|
||||
fetchNeConfigSnapshots({ page, pageSize: 20, keyword, source, vendor }),
|
||||
fetchNeConfigSnapshots({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: debouncedKeyword,
|
||||
source,
|
||||
vendor: debouncedVendor,
|
||||
}),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
|
|
@ -46,7 +79,7 @@ export function NetworkConfigsPage() {
|
|||
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const total = Number(listQuery.data?.total || 0);
|
||||
const pages = pageCount(total, 20);
|
||||
const pages = pageCount(total, pageSize);
|
||||
const detail = detailQuery.data;
|
||||
const showAlt = Boolean(detail?.has_alt);
|
||||
const hasFilters = Boolean(keyword || source || vendor);
|
||||
|
|
@ -62,16 +95,69 @@ export function NetworkConfigsPage() {
|
|||
await downloadNeConfigSnapshot(src, id, field);
|
||||
showOk(t("networkConfigs.exportOk"));
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
showError(formatErr(err));
|
||||
} finally {
|
||||
setExporting("");
|
||||
}
|
||||
};
|
||||
|
||||
const exportListCsv = async () => {
|
||||
setExportingList(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<NeConfigSnapshotMeta>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchNeConfigSnapshots({
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
keyword: debouncedKeyword,
|
||||
source,
|
||||
vendor: debouncedVendor,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("networkConfigs.exportListName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "ne_name", header: t("networkConfigs.col.name"), value: (r) => r.ne_name || r.target_id },
|
||||
{ key: "ne_ip", header: "IP" },
|
||||
{ key: "vendor", header: t("networkConfigs.col.vendor") },
|
||||
{ key: "source", header: t("networkConfigs.col.source") },
|
||||
{ key: "plain_size", header: t("networkConfigs.col.size"), value: (r) => fmtBytes(r.plain_size) },
|
||||
{
|
||||
key: "collected_at",
|
||||
header: t("networkConfigs.col.collected"),
|
||||
value: (r) => (r.collected_at ? formatSystemTime(r.collected_at) : ""),
|
||||
},
|
||||
{ key: "target_id", header: "ID" },
|
||||
],
|
||||
);
|
||||
if (rows.length < total) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(total) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingList(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("networkConfigs.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingList || total === 0}
|
||||
onClick={() => void exportListCsv()}
|
||||
>
|
||||
{exportingList ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
|
|
@ -118,6 +204,7 @@ export function NetworkConfigsPage() {
|
|||
</div>
|
||||
|
||||
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
|
||||
|
||||
{!items.length && !listQuery.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
|
|
@ -199,23 +286,19 @@ export function NetworkConfigsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="pager pt-list-pager">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(total),
|
||||
page: String(page),
|
||||
pages: String(pages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ListPager
|
||||
page={page}
|
||||
pages={pages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={listQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchCliTargets } from "../../services/api";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { fetchCliTargets, formatErr } from "../../services/api";
|
||||
import type { CliTargetItem } from "../../types";
|
||||
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
|
||||
import { pageCount } from "../../utils/display";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
import { openNewModuleWindow } from "../../utils/moduleWindows";
|
||||
|
||||
function connectStatusClass(status: string | null | undefined): string {
|
||||
const s = String(status || "").trim().toLowerCase();
|
||||
|
|
@ -17,28 +21,80 @@ function connectStatusClass(status: string | null | undefined): string {
|
|||
return "pt-list-status--other";
|
||||
}
|
||||
|
||||
/** Read-only inventory of managed + UME NEs for Network Management. */
|
||||
function openWebcrt(row: CliTargetItem) {
|
||||
const path =
|
||||
row.source === "ume"
|
||||
? `/webcrt?ne_id=${encodeURIComponent(row.id)}&source=ume`
|
||||
: `/webcrt?ne_id=${encodeURIComponent(row.id)}`;
|
||||
openNewModuleWindow({ moduleId: "webcrt", path });
|
||||
}
|
||||
|
||||
/** Inventory of managed + UME NEs for Network Management. */
|
||||
export function NetworkDevicesPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [source, setSource] = useState<"all" | "managed" | "ume">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.cliTargets(`${source}:${keyword}`, page, PAGE_SIZE),
|
||||
queryFn: () => fetchCliTargets({ source, keyword, page, pageSize: PAGE_SIZE }),
|
||||
queryKey: queryKeys.cliTargets(`${source}:${debouncedKeyword}`, page, pageSize),
|
||||
queryFn: () => fetchCliTargets({ source, keyword: debouncedKeyword, page, pageSize }),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const total = Number(listQuery.data?.total || 0);
|
||||
const pages = pageCount(total, PAGE_SIZE);
|
||||
const pages = pageCount(total, pageSize);
|
||||
const hasFilters = Boolean(keyword.trim() || source !== "all");
|
||||
|
||||
const exportCsv = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<CliTargetItem>({
|
||||
pageSize: 200,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchCliTargets({ source, keyword: debouncedKeyword, page: p, pageSize: ps }),
|
||||
});
|
||||
downloadCsv(`${t("networkDevices.exportName")}-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
|
||||
{ key: "source", header: t("networkDevices.col.source") },
|
||||
{ key: "name", header: t("networkDevices.col.name"), value: (r) => r.name || r.id },
|
||||
{ key: "ip_address", header: "IP" },
|
||||
{ key: "vendor", header: t("networkDevices.col.vendor") },
|
||||
{
|
||||
key: "device_type",
|
||||
header: t("networkDevices.col.deviceType"),
|
||||
value: (r) => r.device_type || r.ne_type || "",
|
||||
},
|
||||
{ key: "connect_status", header: t("networkDevices.col.connect") },
|
||||
{ key: "id", header: "ID" },
|
||||
]);
|
||||
if (rows.length < total) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(total) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("networkDevices.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={exporting || total === 0} onClick={() => void exportCsv()}>
|
||||
{exporting ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
|
|
@ -76,6 +132,7 @@ export function NetworkDevicesPage() {
|
|||
</div>
|
||||
|
||||
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
|
||||
|
||||
{!items.length && !listQuery.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
|
|
@ -92,6 +149,7 @@ export function NetworkDevicesPage() {
|
|||
<th>{t("networkDevices.col.vendor")}</th>
|
||||
<th>{t("networkDevices.col.deviceType")}</th>
|
||||
<th>{t("networkDevices.col.connect")}</th>
|
||||
<th>{t("networkDevices.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -107,6 +165,24 @@ export function NetworkDevicesPage() {
|
|||
{row.connect_status || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-row pt-list-actions table-actions">
|
||||
<button type="button" onClick={() => openWebcrt(row)}>
|
||||
WebCRT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openNewModuleWindow({
|
||||
moduleId: "network",
|
||||
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("network.nav.configs")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -114,23 +190,19 @@ export function NetworkDevicesPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="pager pt-list-pager">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(total),
|
||||
page: String(page),
|
||||
pages: String(pages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ListPager
|
||||
page={page}
|
||||
pages={pages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={[20, 50, 100, 200]}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={listQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import {
|
||||
createPortTrafficDevice,
|
||||
deletePortTrafficDevice,
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
fetchPortTrafficDevices,
|
||||
fetchPortTrafficEvents,
|
||||
fetchPortTrafficTargets,
|
||||
formatErr,
|
||||
pausePortTrafficDevice,
|
||||
putPortTrafficInterfaces,
|
||||
rebindPortTrafficDevice,
|
||||
|
|
@ -19,13 +21,16 @@ import {
|
|||
} from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import type {
|
||||
CliTargetItem,
|
||||
PortTrafficDevice,
|
||||
PortTrafficDiscoverPort,
|
||||
PortTrafficEvent,
|
||||
PortTrafficIfaceIn,
|
||||
} from "../../types";
|
||||
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
|
|
@ -34,10 +39,11 @@ const TARGET_PAGE_SIZE = 20;
|
|||
|
||||
type ViewMode = "list" | "wizard" | "edit";
|
||||
|
||||
function statusTone(status: string): "running" | "paused" | "stopped" | "other" {
|
||||
function statusTone(status: string): "running" | "paused" | "stopped" | "draft" | "other" {
|
||||
if (status === "running") return "running";
|
||||
if (status === "paused") return "paused";
|
||||
if (status === "stopped") return "stopped";
|
||||
if (status === "draft") return "draft";
|
||||
return "other";
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +128,11 @@ export function PortTrafficPage() {
|
|||
|
||||
const [view, setView] = useState<ViewMode>("list");
|
||||
const [listPage, setListPage] = useState(1);
|
||||
const [listPageSize, setListPageSize] = useState(50);
|
||||
const [listKeyword, setListKeyword] = useState("");
|
||||
const [listStatus, setListStatus] = useState("");
|
||||
const [exportingDevices, setExportingDevices] = useState(false);
|
||||
const debouncedListKeyword = useDebouncedValue(listKeyword, 300);
|
||||
|
||||
// Wizard / edit
|
||||
const [editDeviceId, setEditDeviceId] = useState("");
|
||||
|
|
@ -151,6 +162,7 @@ export function PortTrafficPage() {
|
|||
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
|
||||
const [logDeviceId, setLogDeviceId] = useState("");
|
||||
const [logDeviceLabel, setLogDeviceLabel] = useState("");
|
||||
const [logKeyword, setLogKeyword] = useState("");
|
||||
const [rebindDevice, setRebindDevice] = useState<PortTrafficDevice | null>(null);
|
||||
const [rebindKeyword, setRebindKeyword] = useState("");
|
||||
const [rebindPage, setRebindPage] = useState(1);
|
||||
|
|
@ -169,8 +181,14 @@ export function PortTrafficPage() {
|
|||
});
|
||||
|
||||
const devicesQuery = useQuery({
|
||||
queryKey: queryKeys.portTrafficDevices(listPage),
|
||||
queryFn: () => fetchPortTrafficDevices({ page: listPage, pageSize: 10 }),
|
||||
queryKey: queryKeys.portTrafficDevices(listPage, listPageSize, listStatus, debouncedListKeyword),
|
||||
queryFn: () =>
|
||||
fetchPortTrafficDevices({
|
||||
page: listPage,
|
||||
pageSize: listPageSize,
|
||||
status: listStatus || undefined,
|
||||
keyword: debouncedListKeyword || undefined,
|
||||
}),
|
||||
staleTime: 5000,
|
||||
refetchInterval: (q) => {
|
||||
const items = q.state.data?.items || [];
|
||||
|
|
@ -570,17 +588,109 @@ export function PortTrafficPage() {
|
|||
|
||||
const dash = dashQuery.data;
|
||||
const devices = devicesQuery.data?.items || [];
|
||||
const pages = pageCount(devicesQuery.data?.total || 0, 10);
|
||||
const listTotal = Number(devicesQuery.data?.total || 0);
|
||||
const pages = pageCount(listTotal, listPageSize);
|
||||
const editDevice = editDeviceSnap;
|
||||
const hasListFilters = Boolean(listKeyword.trim() || listStatus);
|
||||
const logEvents = logQuery.data?.items || [];
|
||||
const logKw = logKeyword.trim().toLowerCase();
|
||||
const filteredLogEvents = !logKw
|
||||
? logEvents
|
||||
: logEvents.filter((ev) => {
|
||||
const msg = formatPortTrafficLogMessage(ev.message, t).toLowerCase();
|
||||
const hay = `${ev.level || ""} ${ev.ifname || ""} ${ev.message || ""} ${msg}`.toLowerCase();
|
||||
return hay.includes(logKw);
|
||||
});
|
||||
|
||||
const statusText = (status: string) => {
|
||||
const tone = statusTone(status);
|
||||
if (tone === "running") return t("portTraffic.statusRunning");
|
||||
if (tone === "paused") return t("portTraffic.statusPaused");
|
||||
if (tone === "stopped") return t("portTraffic.statusStopped");
|
||||
if (tone === "draft") return t("portTraffic.statusDraft");
|
||||
return status;
|
||||
};
|
||||
|
||||
const closeLog = () => {
|
||||
setLogDeviceId("");
|
||||
setLogDeviceLabel("");
|
||||
setLogKeyword("");
|
||||
};
|
||||
|
||||
const exportDevicesCsv = async () => {
|
||||
setExportingDevices(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<PortTrafficDevice>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchPortTrafficDevices({
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: listStatus || undefined,
|
||||
keyword: debouncedListKeyword || undefined,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("portTraffic.exportDevicesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "ne_name", header: t("portTraffic.col.device"), value: (r) => deviceLabel(r) },
|
||||
{ key: "ne_ip", header: "IP" },
|
||||
{ key: "ne_id", header: "NE ID" },
|
||||
{ key: "vendor", header: t("portTraffic.col.vendor") },
|
||||
{ key: "status", header: t("portTraffic.col.status"), value: (r) => statusText(r.status) },
|
||||
{ key: "target_count", header: t("portTraffic.col.ports") },
|
||||
{ key: "active_target_count", header: "Active ports" },
|
||||
{ key: "interval_sec", header: t("portTraffic.col.interval") },
|
||||
{
|
||||
key: "last_collect_ended_at",
|
||||
header: t("portTraffic.col.last"),
|
||||
value: (r) => formatSystemTime(r.last_collect_ended_at) || "",
|
||||
},
|
||||
{ key: "note", header: t("portTraffic.note") },
|
||||
{ key: "last_error", header: t("portTraffic.logMessage") },
|
||||
{ key: "id", header: "ID" },
|
||||
],
|
||||
);
|
||||
if (rows.length < listTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(listTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingDevices(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportEventsCsv = () => {
|
||||
try {
|
||||
downloadCsv(
|
||||
`${t("portTraffic.exportEventsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
filteredLogEvents,
|
||||
[
|
||||
{
|
||||
key: "created_at",
|
||||
header: t("portTraffic.logTime"),
|
||||
value: (r: PortTrafficEvent) => formatSystemTime(r.created_at) || "",
|
||||
},
|
||||
{ key: "level", header: t("portTraffic.logLevel") },
|
||||
{ key: "ifname", header: t("portTraffic.col.ifname") },
|
||||
{
|
||||
key: "message",
|
||||
header: t("portTraffic.logMessage"),
|
||||
value: (r: PortTrafficEvent) => formatPortTrafficLogMessage(r.message, t),
|
||||
},
|
||||
],
|
||||
);
|
||||
showOk(t("common.exportOk", { count: String(filteredLogEvents.length) }));
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
|
|
@ -601,6 +711,13 @@ export function PortTrafficPage() {
|
|||
<button type="button" onClick={() => navigate("/network/tasks/port-traffic/wall")}>
|
||||
{t("portTraffic.wall")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingDevices || listTotal === 0}
|
||||
onClick={() => void exportDevicesCsv()}
|
||||
>
|
||||
{exportingDevices ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={openWizard}>
|
||||
{t("portTraffic.create")}
|
||||
</button>
|
||||
|
|
@ -634,157 +751,189 @@ export function PortTrafficPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{!devices.length ? (
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={listKeyword}
|
||||
placeholder={t("portTraffic.keywordPh")}
|
||||
onChange={(e) => {
|
||||
setListKeyword(e.target.value);
|
||||
setListPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={listStatus}
|
||||
onChange={(e) => {
|
||||
setListStatus(e.target.value);
|
||||
setListPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("portTraffic.allStatus")}</option>
|
||||
<option value="running">{t("portTraffic.statusRunning")}</option>
|
||||
<option value="paused">{t("portTraffic.statusPaused")}</option>
|
||||
<option value="stopped">{t("portTraffic.statusStopped")}</option>
|
||||
<option value="draft">{t("portTraffic.statusDraft")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasListFilters}
|
||||
onClick={() => {
|
||||
setListKeyword("");
|
||||
setListStatus("");
|
||||
setListPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{devicesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{devicesQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
|
||||
|
||||
{!devices.length && !devicesQuery.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
<p>{t("portTraffic.empty")}</p>
|
||||
<button type="button" className="btn-primary" onClick={openWizard}>
|
||||
{t("portTraffic.create")}
|
||||
</button>
|
||||
{!hasListFilters ? (
|
||||
<button type="button" className="btn-primary" onClick={openWizard}>
|
||||
{t("portTraffic.create")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("portTraffic.col.device")}</th>
|
||||
<th>{t("portTraffic.col.status")}</th>
|
||||
<th>{t("portTraffic.col.ports")}</th>
|
||||
<th>{t("portTraffic.col.interval")}</th>
|
||||
<th>{t("portTraffic.col.last")}</th>
|
||||
<th>{t("portTraffic.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((row) => {
|
||||
const tone = statusTone(row.status);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<div className="pt-list-task-name">{deviceLabel(row)}</div>
|
||||
<div className="muted" style={{ fontSize: 12 }}>
|
||||
{row.ne_ip || row.ne_id}
|
||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||
{row.note ? ` · ${row.note}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`pt-list-status pt-list-status--${tone}`}>
|
||||
{statusText(row.status)}
|
||||
) : devices.length ? (
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("portTraffic.col.device")}</th>
|
||||
<th>{t("portTraffic.col.status")}</th>
|
||||
<th>{t("portTraffic.col.ports")}</th>
|
||||
<th>{t("portTraffic.col.interval")}</th>
|
||||
<th>{t("portTraffic.col.last")}</th>
|
||||
<th>{t("portTraffic.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((row) => {
|
||||
const tone = statusTone(row.status);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<div className="pt-list-task-name">{deviceLabel(row)}</div>
|
||||
<div className="muted" style={{ fontSize: 12 }}>
|
||||
{row.ne_ip || row.ne_id}
|
||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||
{row.note ? ` · ${row.note}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`pt-list-status pt-list-status--${tone}`}>
|
||||
{statusText(row.status)}
|
||||
</span>
|
||||
{row.collect_running ? (
|
||||
<span className="pt-list-status pt-list-status--collect">
|
||||
{t("portTraffic.collecting")}
|
||||
</span>
|
||||
{row.collect_running ? (
|
||||
<span className="pt-list-status pt-list-status--collect">
|
||||
{t("portTraffic.collecting")}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="pt-list-num">
|
||||
{row.active_target_count}/{row.target_count}
|
||||
</td>
|
||||
<td className="pt-list-num">{row.interval_sec}s</td>
|
||||
<td className="pt-list-time">
|
||||
{formatSystemTime(row.last_collect_ended_at) || "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="pt-list-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => openWallList()}
|
||||
>
|
||||
{t("portTraffic.wall")}
|
||||
</button>
|
||||
<button type="button" onClick={() => openEdit(row)}>
|
||||
{t("portTraffic.edit")}
|
||||
</button>
|
||||
{needsNeRebind(row) ? (
|
||||
<button
|
||||
type="button"
|
||||
title={t("portTraffic.rebindHint")}
|
||||
onClick={() => openRebind(row)}
|
||||
>
|
||||
{t("portTraffic.rebind")}
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="pt-list-num">
|
||||
{row.active_target_count}/{row.target_count}
|
||||
</td>
|
||||
<td className="pt-list-num">{row.interval_sec}s</td>
|
||||
<td className="pt-list-time">
|
||||
{formatSystemTime(row.last_collect_ended_at) || "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="pt-list-actions">
|
||||
{row.status !== "running" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => openWallList()}
|
||||
disabled={startMut.isPending}
|
||||
onClick={() => startMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.wall")}
|
||||
{t("portTraffic.start")}
|
||||
</button>
|
||||
<button type="button" onClick={() => openEdit(row)}>
|
||||
{t("portTraffic.edit")}
|
||||
</button>
|
||||
{needsNeRebind(row) ? (
|
||||
<button
|
||||
type="button"
|
||||
title={t("portTraffic.rebindHint")}
|
||||
onClick={() => openRebind(row)}
|
||||
>
|
||||
{t("portTraffic.rebind")}
|
||||
</button>
|
||||
) : null}
|
||||
{row.status !== "running" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={startMut.isPending}
|
||||
onClick={() => startMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.start")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pauseMut.isPending}
|
||||
onClick={() => pauseMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.pause")}
|
||||
</button>
|
||||
)}
|
||||
{row.status !== "stopped" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={stopMut.isPending}
|
||||
onClick={() => stopMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.stop")}
|
||||
</button>
|
||||
) : null}
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={row.last_error ? "pt-list-log-btn--error" : undefined}
|
||||
onClick={() => {
|
||||
setLogDeviceId(row.id);
|
||||
setLogDeviceLabel(deviceLabel(row));
|
||||
}}
|
||||
disabled={pauseMut.isPending}
|
||||
onClick={() => pauseMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.log")}
|
||||
{t("portTraffic.pause")}
|
||||
</button>
|
||||
)}
|
||||
{row.status !== "stopped" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn--danger"
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("portTraffic.confirmDelete"))) {
|
||||
deleteMut.mutate(row.id);
|
||||
}
|
||||
}}
|
||||
disabled={stopMut.isPending}
|
||||
onClick={() => stopMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.delete")}
|
||||
{t("portTraffic.stop")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager pt-list-pager">
|
||||
<button
|
||||
type="button"
|
||||
className="pager__btn"
|
||||
disabled={listPage <= 1}
|
||||
onClick={() => setListPage((p) => p - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="muted">
|
||||
{listPage}/{Math.max(1, pages)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="pager__btn"
|
||||
disabled={listPage >= pages}
|
||||
onClick={() => setListPage((p) => p + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={row.last_error ? "pt-list-log-btn--error" : undefined}
|
||||
onClick={() => {
|
||||
setLogDeviceId(row.id);
|
||||
setLogDeviceLabel(deviceLabel(row));
|
||||
setLogKeyword("");
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.log")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn--danger"
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("portTraffic.confirmDelete"))) {
|
||||
deleteMut.mutate(row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ListPager
|
||||
page={listPage}
|
||||
pages={pages}
|
||||
total={listTotal}
|
||||
pageSize={listPageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
onPageChange={setListPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setListPageSize(size);
|
||||
setListPage(1);
|
||||
}}
|
||||
disabled={devicesQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1179,14 +1328,7 @@ export function PortTrafficPage() {
|
|||
) : null}
|
||||
|
||||
{logDeviceId ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => {
|
||||
setLogDeviceId("");
|
||||
setLogDeviceLabel("");
|
||||
}}
|
||||
>
|
||||
<div className="modal-backdrop" role="presentation" onClick={closeLog}>
|
||||
<div
|
||||
className="modal modal--wide pt-log-modal"
|
||||
role="dialog"
|
||||
|
|
@ -1201,23 +1343,35 @@ export function PortTrafficPage() {
|
|||
{t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogDeviceId("");
|
||||
setLogDeviceLabel("");
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.logClose")}
|
||||
</button>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!filteredLogEvents.length}
|
||||
onClick={exportEventsCsv}
|
||||
>
|
||||
{t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" onClick={closeLog}>
|
||||
{t("portTraffic.logClose")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-inline" style={{ marginBottom: 10 }}>
|
||||
<input
|
||||
value={logKeyword}
|
||||
placeholder={t("portTraffic.keywordPh")}
|
||||
onChange={(e) => setLogKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{logQuery.isLoading ? (
|
||||
<p className="muted">…</p>
|
||||
) : !(logQuery.data?.items || []).length ? (
|
||||
) : !logEvents.length ? (
|
||||
<p className="muted">{t("portTraffic.logEmpty")}</p>
|
||||
) : !filteredLogEvents.length ? (
|
||||
<p className="muted">{t("portTraffic.logEmpty")}</p>
|
||||
) : (
|
||||
<div className="pt-log-modal__list">
|
||||
{(logQuery.data?.items || []).map((ev) => {
|
||||
{filteredLogEvents.map((ev) => {
|
||||
const level = String(ev.level || "error").toLowerCase();
|
||||
const tone =
|
||||
level === "warn" || level === "warning"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
deleteFabricNodes,
|
||||
fetchFabricNodes,
|
||||
fetchTopologyTree,
|
||||
generateTopologySlices,
|
||||
matchFabricNodes,
|
||||
patchFabricNodeTags,
|
||||
} from "../../services/api";
|
||||
|
|
@ -14,7 +13,7 @@ import { queryKeys } from "../../constants/queryKeys";
|
|||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||
import type { FabricNodeSearchHit, SliceGenerateResult, TopologyTreeFolderItem } from "../../types";
|
||||
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
|
|
@ -37,15 +36,42 @@ function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean {
|
|||
return src === "lldp" || src === "topology" || src === "webcrt";
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = ["core", "aggregation", "access", "unknown"] as const;
|
||||
const LEVEL_PRESETS = [
|
||||
{ value: "0", labelKey: "topoClassify.levelExternal" },
|
||||
{ value: "1", labelKey: "topoClassify.levelCore" },
|
||||
{ value: "1.1", labelKey: "topoClassify.levelCoreSub" },
|
||||
{ value: "2", labelKey: "topoClassify.levelAgg" },
|
||||
{ value: "2.1", labelKey: "topoClassify.levelAggSub" },
|
||||
{ value: "3", labelKey: "topoClassify.levelAccess" },
|
||||
{ value: "3.1", labelKey: "topoClassify.levelAccessSub" },
|
||||
] as const;
|
||||
|
||||
function roleLabel(t: (k: string) => string, role: string): string {
|
||||
const s = String(role || "").trim().toLowerCase();
|
||||
if (s === "core") return t("topoClassify.roleCore");
|
||||
if (s === "aggregation") return t("topoClassify.roleAggregation");
|
||||
if (s === "access") return t("topoClassify.roleAccess");
|
||||
if (s === "unknown") return t("topoClassify.roleUnknown");
|
||||
return role || "-";
|
||||
const FILTER_MAJOR = [
|
||||
{ value: "", labelKey: "topoClassify.filterLevelAll" },
|
||||
{ value: "0", labelKey: "topoClassify.levelExternal" },
|
||||
{ value: "1", labelKey: "topoClassify.levelCore" },
|
||||
{ value: "2", labelKey: "topoClassify.levelAgg" },
|
||||
{ value: "3", labelKey: "topoClassify.levelAccess" },
|
||||
] as const;
|
||||
|
||||
function formatLevel(level: number | null | undefined): string {
|
||||
if (level === null || level === undefined || Number.isNaN(Number(level))) return "—";
|
||||
const n = Number(level);
|
||||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
||||
return String(Math.round(n * 10) / 10);
|
||||
}
|
||||
|
||||
function parseLevelInput(raw: string): number | null {
|
||||
const s = String(raw || "").trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 99.9) throw new Error("level_invalid");
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
function levelPresetLabel(t: (k: string) => string, value: string): string {
|
||||
const hit = LEVEL_PRESETS.find((p) => p.value === value);
|
||||
return hit ? t(hit.labelKey) : value;
|
||||
}
|
||||
|
||||
function managedSourceLabel(t: (k: string) => string, source: string): string {
|
||||
|
|
@ -64,7 +90,7 @@ export function TopologyClassifyPage() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [filterRole, setFilterRole] = useState("");
|
||||
const [filterMajor, setFilterMajor] = useState("");
|
||||
const [filterRegion, setFilterRegion] = useState("");
|
||||
const [unmatched, setUnmatched] = useState("");
|
||||
const [linkStatus, setLinkStatus] = useState("");
|
||||
|
|
@ -89,23 +115,15 @@ export function TopologyClassifyPage() {
|
|||
const [matchField, setMatchField] = useState<"name" | "ip" | "name_ip">("name");
|
||||
const [matchedIds, setMatchedIds] = useState<string[]>([]);
|
||||
const [matchTotal, setMatchTotal] = useState(0);
|
||||
const [assignRole, setAssignRole] = useState("core");
|
||||
const [assignLevel, setAssignLevel] = useState("1");
|
||||
const [assignRegion, setAssignRegion] = useState("");
|
||||
const [assignWhat, setAssignWhat] = useState<"role" | "region" | "both">("role");
|
||||
const [assignWhat, setAssignWhat] = useState<"level" | "region" | "both">("level");
|
||||
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
const [editingId, setEditingId] = useState("");
|
||||
const [editRole, setEditRole] = useState("");
|
||||
const [editLevel, setEditLevel] = useState("");
|
||||
const [editRegion, setEditRegion] = useState("");
|
||||
|
||||
const [sliceFolderId, setSliceFolderId] = useState("");
|
||||
const [sliceTemplate, setSliceTemplate] = useState<"core_only" | "core_agg" | "agg_access">(
|
||||
"core_agg",
|
||||
);
|
||||
const [slicePreview, setSlicePreview] = useState<SliceGenerateResult | null>(null);
|
||||
const [seedPhysical, setSeedPhysical] = useState(true);
|
||||
const [showSlices, setShowSlices] = useState(false);
|
||||
|
||||
const treeQuery = useQuery({
|
||||
queryKey: queryKeys.topologyTree,
|
||||
queryFn: fetchTopologyTree,
|
||||
|
|
@ -120,16 +138,17 @@ export function TopologyClassifyPage() {
|
|||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.fabricNodeInventory(
|
||||
keyword,
|
||||
filterRole,
|
||||
"",
|
||||
filterRegion,
|
||||
unmatched,
|
||||
linkStatus,
|
||||
page,
|
||||
filterMajor,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchFabricNodes({
|
||||
keyword,
|
||||
role: filterRole,
|
||||
levelMajor: filterMajor || undefined,
|
||||
regionFolderId: filterRegion,
|
||||
unmatched,
|
||||
linkStatus,
|
||||
|
|
@ -163,7 +182,9 @@ export function TopologyClassifyPage() {
|
|||
fabric_node_ids: ids.length ? ids : matchedIds,
|
||||
dry_run: false,
|
||||
};
|
||||
if (assignWhat === "role" || assignWhat === "both") body.role = assignRole;
|
||||
if (assignWhat === "level" || assignWhat === "both") {
|
||||
body.level = parseLevelInput(assignLevel);
|
||||
}
|
||||
if (assignWhat === "region" || assignWhat === "both") {
|
||||
body.region_folder_id = assignRegion || "";
|
||||
}
|
||||
|
|
@ -180,11 +201,18 @@ export function TopologyClassifyPage() {
|
|||
});
|
||||
|
||||
const patchMut = useMutation({
|
||||
mutationFn: () =>
|
||||
patchFabricNodeTags(editingId, {
|
||||
role: editRole,
|
||||
mutationFn: () => {
|
||||
let level: number | null;
|
||||
try {
|
||||
level = parseLevelInput(editLevel);
|
||||
} catch {
|
||||
throw new Error(t("topoClassify.levelInvalid"));
|
||||
}
|
||||
return patchFabricNodeTags(editingId, {
|
||||
level,
|
||||
region_folder_id: editRegion || "",
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
showOk(t("topoClassify.rowSaved"));
|
||||
setEditingId("");
|
||||
|
|
@ -213,36 +241,6 @@ export function TopologyClassifyPage() {
|
|||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const slicePreviewMut = useMutation({
|
||||
mutationFn: () =>
|
||||
generateTopologySlices({
|
||||
folder_id: sliceFolderId,
|
||||
template: sliceTemplate,
|
||||
dry_run: true,
|
||||
max_nodes: 300,
|
||||
seed_physical_cores: seedPhysical,
|
||||
}),
|
||||
onSuccess: (out) => setSlicePreview(out),
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const sliceApplyMut = useMutation({
|
||||
mutationFn: () =>
|
||||
generateTopologySlices({
|
||||
folder_id: sliceFolderId,
|
||||
template: sliceTemplate,
|
||||
dry_run: false,
|
||||
max_nodes: 300,
|
||||
seed_physical_cores: seedPhysical,
|
||||
}),
|
||||
onSuccess: async (out) => {
|
||||
showOk(t("topoClassify.sliceOk").replace("{{count}}", String(out.created_view_ids.length)));
|
||||
setSlicePreview(out);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items || [];
|
||||
const deletableSelected = items
|
||||
.filter((n) => selected[n.id] && isFabricNodeDeletable(n))
|
||||
|
|
@ -254,7 +252,7 @@ export function TopologyClassifyPage() {
|
|||
|
||||
const startEdit = (n: FabricNodeSearchHit) => {
|
||||
setEditingId(n.id);
|
||||
setEditRole(n.role || "");
|
||||
setEditLevel(n.level == null ? "" : formatLevel(n.level));
|
||||
setEditRegion(n.region_folder_id || "");
|
||||
};
|
||||
|
||||
|
|
@ -278,7 +276,17 @@ export function TopologyClassifyPage() {
|
|||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => openOrFocusModule({ moduleId: "topology", path: "/topology" })}
|
||||
onClick={() => {
|
||||
const regionId = filterRegion || assignRegion || "";
|
||||
const folder = regions.find((r) => r.id === regionId);
|
||||
const views = folder?.views || [];
|
||||
const primary =
|
||||
views.find((v) => String(v.kind || "") === "physical") || views[0];
|
||||
const path = primary?.id
|
||||
? `/topology?view=${encodeURIComponent(primary.id)}`
|
||||
: "/topology";
|
||||
openOrFocusModule({ moduleId: "topology", path });
|
||||
}}
|
||||
>
|
||||
{t("topoClassify.openTopo")}
|
||||
</button>
|
||||
|
|
@ -299,16 +307,15 @@ export function TopologyClassifyPage() {
|
|||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={filterRole}
|
||||
value={filterMajor}
|
||||
onChange={(e) => {
|
||||
setFilterRole(e.target.value);
|
||||
setFilterMajor(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.filterRoleAll")}</option>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{roleLabel(t, r)}
|
||||
{FILTER_MAJOR.map((r) => (
|
||||
<option key={r.value || "all"} value={r.value}>
|
||||
{t(r.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -337,7 +344,7 @@ export function TopologyClassifyPage() {
|
|||
>
|
||||
<option value="">{t("topoClassify.filterUnmatchedOff")}</option>
|
||||
<option value="any">{t("topoClassify.kindAny")}</option>
|
||||
<option value="role">{t("topoClassify.kindRole")}</option>
|
||||
<option value="level">{t("topoClassify.kindLevel")}</option>
|
||||
<option value="region">{t("topoClassify.kindRegion")}</option>
|
||||
</select>
|
||||
<select
|
||||
|
|
@ -390,26 +397,26 @@ export function TopologyClassifyPage() {
|
|||
<select
|
||||
className="input"
|
||||
value={assignWhat}
|
||||
onChange={(e) => setAssignWhat(e.target.value as "role" | "region" | "both")}
|
||||
onChange={(e) => setAssignWhat(e.target.value as "level" | "region" | "both")}
|
||||
>
|
||||
<option value="role">{t("topoClassify.assignRole")}</option>
|
||||
<option value="level">{t("topoClassify.assignLevel")}</option>
|
||||
<option value="region">{t("topoClassify.assignRegion")}</option>
|
||||
<option value="both">{t("topoClassify.assignBoth")}</option>
|
||||
</select>
|
||||
{assignWhat !== "region" ? (
|
||||
<select
|
||||
className="input"
|
||||
value={assignRole}
|
||||
onChange={(e) => setAssignRole(e.target.value)}
|
||||
value={assignLevel}
|
||||
onChange={(e) => setAssignLevel(e.target.value)}
|
||||
>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{roleLabel(t, r)}
|
||||
{LEVEL_PRESETS.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{levelPresetLabel(t, r.value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
{assignWhat !== "role" ? (
|
||||
{assignWhat !== "level" ? (
|
||||
<select
|
||||
className="input"
|
||||
value={assignRegion}
|
||||
|
|
@ -475,7 +482,7 @@ export function TopologyClassifyPage() {
|
|||
<th>{t("topoClassify.colNe")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("topoClassify.colLink")}</th>
|
||||
<th>{t("topoClassify.colRole")}</th>
|
||||
<th>{t("topoClassify.colLevel")}</th>
|
||||
<th>{t("topoClassify.colRegion")}</th>
|
||||
<th>{t("topoClassify.colActions")}</th>
|
||||
</tr>
|
||||
|
|
@ -503,20 +510,14 @@ export function TopologyClassifyPage() {
|
|||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<select
|
||||
<input
|
||||
className="input"
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{roleLabel(t, r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
value={editLevel}
|
||||
placeholder="1 / 1.1 / 2.1"
|
||||
onChange={(e) => setEditLevel(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
roleLabel(t, n.role || "")
|
||||
formatLevel(n.level)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
|
|
@ -627,95 +628,6 @@ export function TopologyClassifyPage() {
|
|||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="topo-classify__section">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setShowSlices((v) => !v)}
|
||||
>
|
||||
{showSlices ? t("topoClassify.hideSlices") : t("topoClassify.showSlices")}
|
||||
</button>
|
||||
{showSlices ? (
|
||||
<>
|
||||
<h3>{t("topoClassify.slices")}</h3>
|
||||
<div className="topo-classify__draft">
|
||||
<select
|
||||
className="input"
|
||||
value={sliceFolderId}
|
||||
onChange={(e) => {
|
||||
setSliceFolderId(e.target.value);
|
||||
setSlicePreview(null);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.pickRegion")}</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
value={sliceTemplate}
|
||||
onChange={(e) =>
|
||||
setSliceTemplate(e.target.value as "core_only" | "core_agg" | "agg_access")
|
||||
}
|
||||
>
|
||||
<option value="core_only">{t("topoClassify.tplCore")}</option>
|
||||
<option value="core_agg">{t("topoClassify.tplCoreAgg")}</option>
|
||||
<option value="agg_access">{t("topoClassify.tplAggAcc")}</option>
|
||||
</select>
|
||||
<label className="topo-classify__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seedPhysical}
|
||||
onChange={(e) => setSeedPhysical(e.target.checked)}
|
||||
/>
|
||||
{t("topoClassify.seedPhysical")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!sliceFolderId || slicePreviewMut.isPending}
|
||||
onClick={() => slicePreviewMut.mutate()}
|
||||
>
|
||||
{t("topoClassify.slicePreview")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={!sliceFolderId || sliceApplyMut.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("topoClassify.sliceConfirm"))) sliceApplyMut.mutate();
|
||||
}}
|
||||
>
|
||||
{t("topoClassify.sliceApply")}
|
||||
</button>
|
||||
</div>
|
||||
{slicePreview ? (
|
||||
<div className="topo-classify__preview">
|
||||
<p className="panel__hint">
|
||||
{t("topoClassify.sliceStats")
|
||||
.replace("{{maps}}", String(slicePreview.map_count))
|
||||
.replace("{{overlap}}", String(slicePreview.overlap_node_count))}
|
||||
</p>
|
||||
<ul className="topo-classify__slice-list">
|
||||
{slicePreview.maps.map((m) => (
|
||||
<li key={m.name}>
|
||||
<strong>{m.name}</strong>
|
||||
{" · "}
|
||||
{roleLabel(t, m.role)}
|
||||
{" · "}
|
||||
{t("topoClassify.nodeCount").replace("{{count}}", String(m.node_count))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,6 +291,7 @@ export type TopologyReactFlowViewProps = {
|
|||
setCanvasZoom: (z: number) => void;
|
||||
scheduleFlatViewportRefresh: () => void;
|
||||
toggleFullscreen: () => void | Promise<void>;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { NeNodeData } from "./TopologyReactFlowView";
|
|||
import type { ToolMode } from "./toolMode";
|
||||
|
||||
export type TopologyToolbarProps = {
|
||||
readOnly?: boolean;
|
||||
breadcrumbFolders: TopologyTreeFolderItem[];
|
||||
activeView: TopologyTreeViewItem | null | undefined;
|
||||
activeRegion: TopologyTreeFolderItem | null | undefined;
|
||||
|
|
@ -51,6 +52,7 @@ export type TopologyToolbarProps = {
|
|||
};
|
||||
|
||||
export function TopologyToolbar({
|
||||
readOnly = false,
|
||||
breadcrumbFolders,
|
||||
activeView,
|
||||
activeRegion,
|
||||
|
|
@ -186,8 +188,14 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={isWorldFlatCanvas}
|
||||
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
|
||||
disabled={readOnly || isWorldFlatCanvas}
|
||||
title={
|
||||
readOnly
|
||||
? t("topology.readOnlyHint")
|
||||
: isWorldFlatCanvas
|
||||
? t("topology.worldMapNoDirectNes")
|
||||
: undefined
|
||||
}
|
||||
onClick={onAddNe}
|
||||
>
|
||||
{t("topology.addNe")}
|
||||
|
|
@ -195,8 +203,14 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={isWorldFlatCanvas}
|
||||
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
|
||||
disabled={readOnly || isWorldFlatCanvas}
|
||||
title={
|
||||
readOnly
|
||||
? t("topology.readOnlyHint")
|
||||
: isWorldFlatCanvas
|
||||
? t("topology.worldMapNoDirectNes")
|
||||
: undefined
|
||||
}
|
||||
onClick={handleCreateNe}
|
||||
>
|
||||
{t("topology.createNe")}
|
||||
|
|
@ -207,7 +221,7 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!canUndo}
|
||||
disabled={readOnly || !canUndo}
|
||||
onClick={onUndo}
|
||||
title="Ctrl+Z"
|
||||
>
|
||||
|
|
@ -216,7 +230,7 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!canRedo}
|
||||
disabled={readOnly || !canRedo}
|
||||
onClick={onRedo}
|
||||
title="Ctrl+Y"
|
||||
>
|
||||
|
|
@ -225,9 +239,9 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className={`btn btn--sm${dirty ? "" : " btn--ghost"}`}
|
||||
disabled={savePending || !dirty}
|
||||
disabled={readOnly || savePending || !dirty}
|
||||
onClick={onSave}
|
||||
title="Ctrl+S"
|
||||
title={readOnly ? t("topology.readOnlyHint") : "Ctrl+S"}
|
||||
>
|
||||
{savePending ? t("topology.saving") : dirty ? t("topology.saveDirty") : t("topology.save")}
|
||||
</button>
|
||||
|
|
@ -265,8 +279,8 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!staleEdgeCount}
|
||||
title={t("topology.removeStaleHint")}
|
||||
disabled={readOnly || !staleEdgeCount}
|
||||
title={readOnly ? t("topology.readOnlyHint") : t("topology.removeStaleHint")}
|
||||
onClick={() => void onRemoveStale()}
|
||||
>
|
||||
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
|
||||
|
|
@ -286,8 +300,9 @@ export function TopologyToolbar({
|
|||
key={mode}
|
||||
type="button"
|
||||
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`}
|
||||
title={`${label} (${key})`}
|
||||
title={`${label} (${key})${readOnly && mode === "connect" ? ` — ${t("topology.readOnlyHint")}` : ""}`}
|
||||
aria-pressed={toolMode === mode}
|
||||
disabled={readOnly && mode === "connect"}
|
||||
onClick={() => {
|
||||
onToolModeChange(mode);
|
||||
onConnectClickReset();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { RefObject } from "react";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { updateLldpCollectPolicy } from "../../services/api";
|
||||
import {
|
||||
DEFAULT_CANVAS_BG,
|
||||
|
|
@ -87,6 +88,7 @@ export type TopologyViewToolsProps = {
|
|||
|
||||
export function TopologyViewTools(props: TopologyViewToolsProps) {
|
||||
const { t } = useI18n();
|
||||
const { showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
fullscreen,
|
||||
|
|
@ -225,7 +227,7 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
|
|||
void queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard });
|
||||
})
|
||||
.catch(() => {
|
||||
/* local toggle still applies to this canvas discover */
|
||||
showError(t("topology.policySyncFailed"));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, type QueryClient } from "@tanstack/react-query";
|
||||
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
|
||||
import { fetchTopologyGraph } from "../../../services/api";
|
||||
import { fetchTopologyGraph, formatErr } from "../../../services/api";
|
||||
import { queryKeys } from "../../../constants/queryKeys";
|
||||
import type { TopologyTreeViewItem, TopologyViewGraph, TopologyWorldTransform } from "../../../types";
|
||||
import {
|
||||
|
|
@ -493,6 +493,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
const showWorldScatter =
|
||||
isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0;
|
||||
const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data);
|
||||
const canvasGraphError = Boolean(mapId) && graphQuery.isError;
|
||||
const canvasGraphErrorMsg = graphQuery.error ? formatErr(graphQuery.error) : "";
|
||||
const canvasGraphEmpty =
|
||||
Boolean(mapId) &&
|
||||
graphQuery.isSuccess &&
|
||||
|
|
@ -551,6 +553,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
worldDockMe,
|
||||
showWorldScatter,
|
||||
canvasGraphLoading,
|
||||
canvasGraphError,
|
||||
canvasGraphErrorMsg,
|
||||
canvasGraphEmpty,
|
||||
worldNeedsApply,
|
||||
canvasGraphRefreshing,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
removeTopologyViewNodes,
|
||||
startLldpDiscover,
|
||||
stopLldpCollectJob,
|
||||
formatErr,
|
||||
} from "../../../services/api";
|
||||
import type {
|
||||
TopologyDiscoverJob,
|
||||
|
|
@ -181,6 +182,11 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
|||
setDiscoverJobId(jobStart.id);
|
||||
let job: TopologyDiscoverJob = jobStart;
|
||||
let cancelled = false;
|
||||
const isTerminalStatus = (status: string) =>
|
||||
status === "done" ||
|
||||
status === "failed" ||
|
||||
status === "cancelled" ||
|
||||
status === "stopped";
|
||||
for (let i = 0; i < 600; i++) {
|
||||
if (discoverAbortRef.current) {
|
||||
cancelled = true;
|
||||
|
|
@ -202,12 +208,7 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
|||
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
|
||||
}));
|
||||
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
|
||||
if (
|
||||
job.status === "done" ||
|
||||
job.status === "failed" ||
|
||||
job.status === "cancelled" ||
|
||||
job.status === "stopped"
|
||||
) {
|
||||
if (isTerminalStatus(job.status)) {
|
||||
if (job.status === "cancelled" || job.status === "stopped") cancelled = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -227,6 +228,9 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (!isTerminalStatus(job.status)) {
|
||||
throw new Error(t("topology.discoverTimeout"));
|
||||
}
|
||||
if (job.status === "failed") {
|
||||
throw new Error(job.error || "discover_failed");
|
||||
}
|
||||
|
|
@ -293,8 +297,9 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
|||
if (discoverAbortRef.current) {
|
||||
setDiscoverError(t("topology.discoverCancelled"));
|
||||
} else {
|
||||
setDiscoverError(String(err));
|
||||
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
|
||||
const detail = formatErr(err);
|
||||
setDiscoverError(detail);
|
||||
showError(t("topology.discoverFail").replace("{{detail}}", detail));
|
||||
}
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
CollectionJobDetail,
|
||||
CollectionJobItem,
|
||||
CollectionDashboard,
|
||||
CollectionPolicy,
|
||||
CollectionRunList,
|
||||
EligibleNeItem,
|
||||
IntegrationStatus,
|
||||
|
|
@ -147,6 +148,13 @@ export class ApiRequestError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/** User-facing message from API or thrown errors (prefers ApiRequestError.message). */
|
||||
export function formatErr(err: unknown): string {
|
||||
if (err instanceof ApiRequestError) return err.message;
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
export function formatApiDetail(detail: unknown): string {
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
|
|
@ -175,16 +183,18 @@ export function formatApiDetail(detail: unknown): string {
|
|||
}
|
||||
|
||||
export const apiGet = async <T,>(path: string): Promise<T> => {
|
||||
let res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
const doFetch = () => fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
let res = await doFetch();
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
res = await doFetch();
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("401 unauthorized");
|
||||
throw new ApiRequestError(401, data.detail || "unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(`${res.status} ${path}`);
|
||||
return (await res.json()) as T;
|
||||
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
|
||||
return data as T;
|
||||
};
|
||||
|
||||
export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
|
|
@ -600,9 +610,12 @@ export const importManagedNe = async (file: File): Promise<ManagedNeImportResult
|
|||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} import`));
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new ApiRequestError(401, data.detail || "unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} import`);
|
||||
return data as ManagedNeImportResult;
|
||||
};
|
||||
|
||||
|
|
@ -624,10 +637,17 @@ export const createNeCollection = (body: {
|
|||
}) =>
|
||||
apiPost<CollectionJobItem>("/v1/ne-collections", body);
|
||||
|
||||
export const fetchNeCollections = (params: { page: number; pageSize: number }) => {
|
||||
export const fetchNeCollections = (params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, params.page)));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, params.pageSize))));
|
||||
if (params.status?.trim()) p.set("status", params.status.trim());
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: CollectionJobItem[] }>(
|
||||
`/v1/ne-collections?${p.toString()}`,
|
||||
);
|
||||
|
|
@ -636,6 +656,14 @@ export const fetchNeCollections = (params: { page: number; pageSize: number }) =
|
|||
export const fetchCollectionDashboard = () =>
|
||||
apiGet<CollectionDashboard>("/v1/ne-collections/dashboard");
|
||||
|
||||
export const fetchCollectionPolicy = () => apiGet<CollectionPolicy>("/v1/ne-collections/policy");
|
||||
|
||||
export const updateCollectionPolicy = (body: Partial<CollectionPolicy>) =>
|
||||
apiPut<CollectionPolicy>("/v1/ne-collections/policy", body);
|
||||
|
||||
export const startCollectionFromPolicy = () =>
|
||||
apiPost<CollectionJobItem>("/v1/ne-collections/start-from-policy", {});
|
||||
|
||||
export const fetchCollectionJob = (jobId: string) => apiGet<CollectionJobDetail>(`/v1/ne-collections/${jobId}`);
|
||||
|
||||
export const fetchCollectionRuns = (params: {
|
||||
|
|
@ -1233,6 +1261,8 @@ export const fetchFabricSummary = () => apiGet<FabricSummary>("/v1/topology/fabr
|
|||
export const fetchFabricNodes = (params?: {
|
||||
keyword?: string;
|
||||
role?: string;
|
||||
level?: string;
|
||||
levelMajor?: string;
|
||||
regionFolderId?: string;
|
||||
unmatched?: string;
|
||||
linkStatus?: string;
|
||||
|
|
@ -1242,6 +1272,8 @@ export const fetchFabricNodes = (params?: {
|
|||
const p = new URLSearchParams();
|
||||
if (params?.keyword) p.set("keyword", params.keyword);
|
||||
if (params?.role) p.set("role", params.role);
|
||||
if (params?.level) p.set("level", params.level);
|
||||
if (params?.levelMajor) p.set("level_major", params.levelMajor);
|
||||
if (params?.regionFolderId) p.set("region_folder_id", params.regionFolderId);
|
||||
if (params?.unmatched) p.set("unmatched", params.unmatched);
|
||||
if (params?.linkStatus) p.set("link_status", params.linkStatus);
|
||||
|
|
@ -1272,6 +1304,7 @@ export const bulkTagFabricNodes = (body: {
|
|||
fabric_node_ids?: string[];
|
||||
pattern?: string;
|
||||
match_field?: string;
|
||||
level?: number | null;
|
||||
role?: string | null;
|
||||
region_folder_id?: string | null;
|
||||
dry_run?: boolean;
|
||||
|
|
@ -1280,6 +1313,7 @@ export const bulkTagFabricNodes = (body: {
|
|||
dry_run: boolean;
|
||||
matched: number;
|
||||
updated: number;
|
||||
level?: number | null;
|
||||
role?: string | null;
|
||||
region_folder_id?: string | null;
|
||||
samples: Array<Record<string, string>>;
|
||||
|
|
@ -1287,7 +1321,7 @@ export const bulkTagFabricNodes = (body: {
|
|||
|
||||
export const patchFabricNodeTags = (
|
||||
fabricNodeId: string,
|
||||
body: { role?: string | null; region_folder_id?: string | null },
|
||||
body: { level?: number | null; role?: string | null; region_folder_id?: string | null },
|
||||
) =>
|
||||
apiPatch<import("../types").FabricNodeSearchHit>(
|
||||
`/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}/tags`,
|
||||
|
|
@ -1317,14 +1351,6 @@ export const purgePlaceholderFabricNodes = (fabricNodeIds: string[]) =>
|
|||
fabric_node_ids: fabricNodeIds,
|
||||
});
|
||||
|
||||
export const generateTopologySlices = (body: {
|
||||
folder_id: string;
|
||||
template: "core_only" | "core_agg" | "agg_access";
|
||||
dry_run?: boolean;
|
||||
max_nodes?: number;
|
||||
seed_physical_cores?: boolean;
|
||||
}) => apiPost<import("../types").SliceGenerateResult>("/v1/topology/slices/generate", body);
|
||||
|
||||
export const searchFabricNodes = (params?: { q?: string; page?: number; pageSize?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (params?.q) p.set("q", params.q);
|
||||
|
|
@ -1399,10 +1425,17 @@ export const resumeLldpCollectJob = (jobId: string) =>
|
|||
export const stopLldpCollectJob = (jobId: string) =>
|
||||
apiPost<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/stop`, {});
|
||||
|
||||
export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number }) => {
|
||||
export const fetchLldpCollectJobs = (params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
|
||||
if (params.status?.trim()) p.set("status", params.status.trim());
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: LldpCollectJobSummary[] }>(
|
||||
`/v1/topology/lldp-collect/jobs?${p.toString()}`,
|
||||
);
|
||||
|
|
@ -1410,11 +1443,13 @@ export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number
|
|||
|
||||
export const fetchLldpCollectJob = (
|
||||
jobId: string,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
params?: { page?: number; pageSize?: number; status?: string; keyword?: string },
|
||||
) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params?.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params?.pageSize || 20)))));
|
||||
if (params?.status?.trim()) p.set("status", params.status.trim());
|
||||
if (params?.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<TopologyDiscoverJob>(
|
||||
`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}?${p.toString()}`,
|
||||
);
|
||||
|
|
@ -1452,10 +1487,17 @@ export const fetchConfigSyncPolicy = () => apiGet<ConfigSyncPolicy>("/v1/config-
|
|||
export const updateConfigSyncPolicy = (body: Partial<ConfigSyncPolicy>) =>
|
||||
apiPut<ConfigSyncPolicy>("/v1/config-sync/policy", body);
|
||||
|
||||
export const fetchConfigSyncCycles = (params: { page?: number; pageSize?: number }) => {
|
||||
export const fetchConfigSyncCycles = (params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
|
||||
if (params.status?.trim()) p.set("status", params.status.trim());
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncCycle[] }>(
|
||||
`/v1/config-sync/cycles?${p.toString()}`,
|
||||
);
|
||||
|
|
@ -1554,10 +1596,17 @@ export const downloadNeConfigSnapshot = async (
|
|||
export const fetchPortTrafficDashboard = () =>
|
||||
apiGet<PortTrafficDashboard>("/v1/port-traffic/dashboard");
|
||||
|
||||
export const fetchPortTrafficDevices = (params: { page?: number; pageSize?: number }) => {
|
||||
export const fetchPortTrafficDevices = (params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
|
||||
if (params.status?.trim()) p.set("status", params.status.trim());
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficDevice[] }>(
|
||||
`/v1/port-traffic/devices?${p.toString()}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ export type CollectionJobItem = {
|
|||
id: string;
|
||||
title: string;
|
||||
commands: string;
|
||||
trigger_mode?: string;
|
||||
status: string;
|
||||
ne_count: number;
|
||||
success_count: number;
|
||||
|
|
@ -336,6 +337,7 @@ export type CollectionJobSummary = {
|
|||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
trigger_mode?: string;
|
||||
ne_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
|
|
@ -345,11 +347,30 @@ export type CollectionJobSummary = {
|
|||
last_run_at?: string | null;
|
||||
};
|
||||
|
||||
export type CollectionTargetRef = {
|
||||
source: "managed" | "ume" | string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type CollectionPolicy = {
|
||||
enabled: boolean;
|
||||
interval_days: number;
|
||||
interval_hours: number;
|
||||
scope_mode: "all" | "selected" | string;
|
||||
selected_targets: CollectionTargetRef[];
|
||||
title: string;
|
||||
commands: string;
|
||||
history_keep: number;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type CollectionDashboard = {
|
||||
job_count: number;
|
||||
active_count: number;
|
||||
running_job: CollectionJobSummary | null;
|
||||
last_job: CollectionJobSummary | null;
|
||||
next_due_at?: string | null;
|
||||
policy?: CollectionPolicy | null;
|
||||
};
|
||||
|
||||
export type CollectionRunItem = {
|
||||
|
|
@ -480,6 +501,8 @@ export type FabricNodeSearchHit = {
|
|||
vendor: string;
|
||||
managed_ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
/** Layout rank major.minor; null/undefined = unclassified */
|
||||
level?: number | null;
|
||||
role?: string;
|
||||
region_folder_id?: string | null;
|
||||
world_x?: number | null;
|
||||
|
|
@ -498,22 +521,6 @@ export type FabricNodeSearchHit = {
|
|||
}>;
|
||||
};
|
||||
|
||||
export type SliceGenerateResult = {
|
||||
folder_id: string;
|
||||
template: string;
|
||||
dry_run: boolean;
|
||||
map_count: number;
|
||||
overlap_node_count: number;
|
||||
created_view_ids: string[];
|
||||
maps: Array<{
|
||||
name: string;
|
||||
role: string;
|
||||
node_count: number;
|
||||
seed_fabric_node_ids: string[];
|
||||
member_fabric_node_ids: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TopologyViewNodeItem = {
|
||||
fabric_node_id: string;
|
||||
managed_ne_id: string;
|
||||
|
|
|
|||
62
web/src/utils/csvExport.ts
Normal file
62
web/src/utils/csvExport.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
export type CsvColumn<T> = {
|
||||
key: string;
|
||||
header: string;
|
||||
value?: (row: T) => string | number | null | undefined;
|
||||
};
|
||||
|
||||
function escapeCsvCell(raw: string): string {
|
||||
if (/[",\r\n]/.test(raw)) return `"${raw.replace(/"/g, '""')}"`;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Build CSV text (UTF-8 BOM for Excel). */
|
||||
export function toCsv<T>(rows: T[], columns: CsvColumn<T>[]): string {
|
||||
const header = columns.map((c) => escapeCsvCell(c.header)).join(",");
|
||||
const lines = rows.map((row) =>
|
||||
columns
|
||||
.map((c) => {
|
||||
const v = c.value
|
||||
? c.value(row)
|
||||
: (row as Record<string, unknown>)[c.key];
|
||||
return escapeCsvCell(v == null ? "" : String(v));
|
||||
})
|
||||
.join(","),
|
||||
);
|
||||
return `\uFEFF${[header, ...lines].join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
export function downloadTextFile(filename: string, content: string, mime = "text/csv;charset=utf-8") {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function downloadCsv<T>(filename: string, rows: T[], columns: CsvColumn<T>[]) {
|
||||
downloadTextFile(filename, toCsv(rows, columns));
|
||||
}
|
||||
|
||||
/** Page through a list API up to maxRows (default 2000). */
|
||||
export async function fetchAllPages<T>(opts: {
|
||||
pageSize?: number;
|
||||
maxRows?: number;
|
||||
fetchPage: (page: number, pageSize: number) => Promise<{ total?: number; items: T[] }>;
|
||||
}): Promise<T[]> {
|
||||
const pageSize = Math.max(1, Math.min(500, opts.pageSize || 200));
|
||||
const maxRows = Math.max(1, opts.maxRows || 2000);
|
||||
const out: T[] = [];
|
||||
let page = 1;
|
||||
while (out.length < maxRows) {
|
||||
const res = await opts.fetchPage(page, pageSize);
|
||||
const batch = res.items || [];
|
||||
if (!batch.length) break;
|
||||
out.push(...batch);
|
||||
if (batch.length < pageSize) break;
|
||||
if (typeof res.total === "number" && out.length >= res.total) break;
|
||||
page += 1;
|
||||
}
|
||||
return out.slice(0, maxRows);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue