mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
feat(ops): add managed NE management and batch CLI collection
Introduce workbench operations for multi-vendor NE CRUD/connect-test and Netmiko batch collection with job lifecycle controls, log downloads, and paginated run filters. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
282dbe75af
commit
042c015045
36 changed files with 3695 additions and 17 deletions
15
web/WEB.md
15
web/WEB.md
|
|
@ -33,6 +33,8 @@ src/
|
|||
|------|------|----------|
|
||||
| `/` | 工作台 | — |
|
||||
| `/ume` | UME 同步 | `ume` |
|
||||
| `/ne` | 网元管理 | `managed-ne` |
|
||||
| `/collect` | 批量采集 | `collect` |
|
||||
|
||||
**新增模块只需改 `config/modules.ts`:**
|
||||
|
||||
|
|
@ -60,6 +62,19 @@ src/
|
|||
- 失效缓存用 prefix key(如 `queryKeys.umeSyncStatusAll`)
|
||||
- 顶栏连接状态:`App` 轮询 `GET /v1/integrations/status`(5s),展示 **netx api** 与 **oclaw bridge**(含延迟 / 错误类型)
|
||||
|
||||
## 网元管理(独立于 UME)
|
||||
|
||||
- API:`/v1/managed-ne/*`(CRUD、导入、连通性测试)
|
||||
- 环境变量:`NETX_CREDENTIAL_SECRET_KEY`(Fernet,用于加密存储 SSH 密码)
|
||||
- 导入列:`device_type,ip,username,password,port,protocol,name,vendor`
|
||||
- 连通性测试成功后会**始终**用探测到的设备名覆盖「名称」
|
||||
|
||||
## 批量采集
|
||||
|
||||
- API:`/v1/ne-collections/*`(仅 `connect_status=pass` 的网元可参与)
|
||||
- 采集日志目录:`NETX_NE_COLLECTION_DATA_DIR`(默认 `data/ne_collections`)
|
||||
- 命令每行一条,`#` 为注释;输出格式与旧版 NetX 采集 `.txt` 一致
|
||||
|
||||
## Toast
|
||||
|
||||
- 使用 `ToastProvider`(`main.tsx`)+ `useToast()`
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { AppLayout } from "./layout/AppLayout";
|
||||
import { queryKeys } from "./constants/queryKeys";
|
||||
import { WorkbenchPage } from "./pages/WorkbenchPage";
|
||||
import { CollectPage } from "./pages/CollectPage";
|
||||
import { NePage } from "./pages/NePage";
|
||||
import { UmePage } from "./pages/UmePage";
|
||||
import { fetchIntegrationStatus } from "./services/api";
|
||||
|
||||
|
|
@ -40,6 +42,8 @@ function App() {
|
|||
<Route path="/" element={<WorkbenchPage />} />
|
||||
<Route path="/workbench" element={<Navigate to="/" replace />} />
|
||||
<Route path="/ume" element={<UmePage />} />
|
||||
<Route path="/ne" element={<NePage />} />
|
||||
<Route path="/collect" element={<CollectPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
export type ModuleIconTone = "blue" | "green" | "amber";
|
||||
export type WorkbenchSection = "monitoring";
|
||||
export type WorkbenchSection = "monitoring" | "operations";
|
||||
|
||||
export type ModuleDefinition = {
|
||||
moduleId: string;
|
||||
|
|
@ -25,6 +25,24 @@ export const MODULES: readonly ModuleDefinition[] = [
|
|||
iconTone: "blue",
|
||||
titleKey: "layout.titleUme",
|
||||
},
|
||||
{
|
||||
moduleId: "managed-ne",
|
||||
path: "/ne",
|
||||
section: "operations",
|
||||
labelKey: "workbench.cards.managedNe",
|
||||
descKey: "workbench.cards.managedNeDesc",
|
||||
iconTone: "green",
|
||||
titleKey: "layout.titleManagedNe",
|
||||
},
|
||||
{
|
||||
moduleId: "collect",
|
||||
path: "/collect",
|
||||
section: "operations",
|
||||
labelKey: "workbench.cards.collect",
|
||||
descKey: "workbench.cards.collectDesc",
|
||||
iconTone: "amber",
|
||||
titleKey: "layout.titleCollect",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getModuleById(moduleId: string): ModuleDefinition | undefined {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,18 @@ export const queryKeys = {
|
|||
umeCurrentAlarmsAll: ["umeCurrentAlarms"] as const,
|
||||
umeSyncStatus: (page: number, pageSize: number) => ["umeSyncStatus", page, pageSize] as const,
|
||||
umeNE: (keyword: string, page: number, pageSize: number) => ["umeNE", keyword, page, pageSize] as const,
|
||||
managedNeMeta: ["managedNeMeta"] as const,
|
||||
managedNeAll: ["managedNe"] as const,
|
||||
managedNe: (keyword: string, vendor: string, connectStatus: string, page: number, pageSize: number) =>
|
||||
["managedNe", keyword, vendor, connectStatus, page, pageSize] as const,
|
||||
collectionEligibleNeAll: ["collectionEligibleNe"] as const,
|
||||
collectionEligibleNe: (page: number) => ["collectionEligibleNe", page] as const,
|
||||
neCollectionsAll: ["neCollections"] as const,
|
||||
neCollections: (page: number) => ["neCollections", page] 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,
|
||||
umeCurrentAlarms: (
|
||||
severity: string,
|
||||
cleared: string,
|
||||
|
|
|
|||
|
|
@ -18,14 +18,21 @@ const en = {
|
|||
openInNewTab: "Open in new tab",
|
||||
openModule: "Open or switch to this module tab",
|
||||
monitoring: "Monitoring",
|
||||
operations: "Operations",
|
||||
cards: {
|
||||
umeSync: "UME Sync",
|
||||
umeSyncDesc: "UME alarms, subscription & inventory",
|
||||
managedNe: "NE Management",
|
||||
managedNeDesc: "Multi-vendor SSH/Telnet NEs & connectivity test",
|
||||
collect: "Batch Collect",
|
||||
collectDesc: "Run custom CLI commands on connected NEs and save logs",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
title: "UME Integration",
|
||||
titleUme: "UME Sync",
|
||||
titleManagedNe: "NE Management",
|
||||
titleCollect: "Batch Collect",
|
||||
navUme: "UME",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw bridge",
|
||||
|
|
@ -34,6 +41,112 @@ const en = {
|
|||
langZh: "中文",
|
||||
langEn: "English",
|
||||
},
|
||||
collect: {
|
||||
eligible: {
|
||||
title: "Eligible NEs (connectivity passed)",
|
||||
hint: "Only NEs with connect_status=pass. Run connectivity test in NE Management first.",
|
||||
empty: "No NEs with passed connectivity test.",
|
||||
},
|
||||
form: {
|
||||
title: "Collection setup",
|
||||
jobTitle: "Job title",
|
||||
jobTitlePh: "Auto-generated if empty",
|
||||
commands: "Commands (one per line, # for comments)",
|
||||
commandsPh: "display version\ndisplay current-configuration",
|
||||
commandsHint: "Commands run in order on each device; output is saved as a text log (legacy NetX format).",
|
||||
meta: "{{ne}} NE(s) selected · {{cmd}} command(s)",
|
||||
start: "Start collection",
|
||||
starting: "Submitting…",
|
||||
},
|
||||
started: "Collection job started: {{id}}",
|
||||
paused: "Job paused",
|
||||
restarted: "Collection restarted",
|
||||
deleted: "Job deleted",
|
||||
nothingToRetry: "No failed devices to retry",
|
||||
confirmDelete: "Delete this collection job and its log files?",
|
||||
jobs: {
|
||||
title: "Collection jobs",
|
||||
col: {
|
||||
title: "Job",
|
||||
status: "Status",
|
||||
progress: "Progress",
|
||||
created: "Created",
|
||||
lastRun: "Last run",
|
||||
actions: "Actions",
|
||||
},
|
||||
ok: "ok",
|
||||
fail: "fail",
|
||||
expand: "Details",
|
||||
collapse: "Hide",
|
||||
pause: "Pause",
|
||||
restart: "Restart",
|
||||
downloadResults: "Download results",
|
||||
delete: "Delete",
|
||||
autoRefresh: "Auto-refresh every 2s while jobs are running",
|
||||
runsInProgress: "Some devices are still being collected…",
|
||||
},
|
||||
runs: {
|
||||
status: "Status",
|
||||
message: "Message",
|
||||
download: "Log",
|
||||
downloadFile: "Download",
|
||||
filterStatus: "Status filter",
|
||||
filterDevice: "Device filter",
|
||||
deviceKeywordPh: "Name / IP",
|
||||
allStatus: "All statuses",
|
||||
},
|
||||
},
|
||||
managedNe: {
|
||||
title: "Network Elements",
|
||||
credsNotConfigured:
|
||||
"NETX_CREDENTIAL_SECRET_KEY is not set. Configure a Fernet key in .env and restart the API to store passwords.",
|
||||
add: "Add NE",
|
||||
importBtn: "Import",
|
||||
importing: "Importing…",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Delete this network element?",
|
||||
keywordPh: "name / IP / user / tags",
|
||||
allVendors: "All vendors",
|
||||
allConnectStatus: "All connect status",
|
||||
col: {
|
||||
name: "Name",
|
||||
vendor: "Vendor",
|
||||
deviceType: "Device type",
|
||||
ip: "Management IP",
|
||||
port: "Port",
|
||||
protocol: "Protocol",
|
||||
user: "Username",
|
||||
password: "Password",
|
||||
tags: "Tags",
|
||||
remark: "Remark",
|
||||
connect: "Connect",
|
||||
testedAt: "Tested at",
|
||||
actions: "Actions",
|
||||
},
|
||||
connect: {
|
||||
run: "Connectivity test",
|
||||
running: "Testing…",
|
||||
submitted: "Submitted tests for {{n}} device(s)",
|
||||
},
|
||||
importResult: {
|
||||
done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)",
|
||||
},
|
||||
form: {
|
||||
requiredHint: "Fields marked with * are required",
|
||||
nameConnectHint: "After a successful connectivity test, name is replaced with the device hostname",
|
||||
createTitle: "Add network element",
|
||||
editTitle: "Edit network element",
|
||||
save: "Save",
|
||||
saving: "Saving…",
|
||||
cancel: "Cancel",
|
||||
created: "Network element created",
|
||||
updated: "Network element updated",
|
||||
deleted: "Network element deleted",
|
||||
passwordRequired: "Password is required",
|
||||
passwordOptional: "leave blank to keep unchanged",
|
||||
},
|
||||
},
|
||||
ume: {
|
||||
token: {
|
||||
title: "UME Token",
|
||||
|
|
|
|||
|
|
@ -18,14 +18,21 @@ const zh = {
|
|||
openInNewTab: "在新标签页中打开",
|
||||
openModule: "打开或切换到该模块页签",
|
||||
monitoring: "监控",
|
||||
operations: "运维",
|
||||
cards: {
|
||||
umeSync: "UME同步",
|
||||
umeSyncDesc: "UME 告警同步、订阅与清单",
|
||||
managedNe: "网元管理",
|
||||
managedNeDesc: "多厂商 SSH/Telnet 网元与连通性测试",
|
||||
collect: "批量采集",
|
||||
collectDesc: "对已连通网元执行定制 CLI 命令并保存日志",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
title: "UME 对接",
|
||||
titleUme: "UME同步",
|
||||
titleManagedNe: "网元管理",
|
||||
titleCollect: "批量采集",
|
||||
navUme: "UME 对接",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw bridge",
|
||||
|
|
@ -34,6 +41,111 @@ const zh = {
|
|||
langZh: "中文",
|
||||
langEn: "English",
|
||||
},
|
||||
collect: {
|
||||
eligible: {
|
||||
title: "可选网元(连通性已通过)",
|
||||
hint: "仅展示 connect_status=pass 的网元。请先在「网元管理」完成连通性测试。",
|
||||
empty: "暂无已通过连通性测试的网元。",
|
||||
},
|
||||
form: {
|
||||
title: "采集配置",
|
||||
jobTitle: "任务名称",
|
||||
jobTitlePh: "留空则自动生成",
|
||||
commands: "采集命令(每行一条,# 开头为注释)",
|
||||
commandsPh: "display version\ndisplay current-configuration",
|
||||
commandsHint: "命令将按顺序在每台设备上执行,输出合并为一份文本日志(格式与旧版 NetX 采集一致)。",
|
||||
meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令",
|
||||
start: "开始采集",
|
||||
starting: "提交中…",
|
||||
},
|
||||
started: "采集任务已启动:{{id}}",
|
||||
paused: "任务已暂停",
|
||||
restarted: "已重新开始采集",
|
||||
deleted: "任务已删除",
|
||||
nothingToRetry: "没有可重试的失败网元",
|
||||
confirmDelete: "确定删除该采集任务?相关日志文件将一并删除。",
|
||||
jobs: {
|
||||
title: "采集任务",
|
||||
col: {
|
||||
title: "任务",
|
||||
status: "状态",
|
||||
progress: "进度",
|
||||
created: "创建时间",
|
||||
lastRun: "最近执行",
|
||||
actions: "操作",
|
||||
},
|
||||
ok: "成功",
|
||||
fail: "失败",
|
||||
expand: "详情",
|
||||
collapse: "收起",
|
||||
pause: "暂停",
|
||||
restart: "重新开始",
|
||||
downloadResults: "下载结果",
|
||||
delete: "删除",
|
||||
autoRefresh: "任务进行中,每 2 秒自动刷新",
|
||||
runsInProgress: "部分网元仍在采集中,请稍候…",
|
||||
},
|
||||
runs: {
|
||||
status: "状态",
|
||||
message: "说明",
|
||||
download: "日志",
|
||||
downloadFile: "下载",
|
||||
filterStatus: "状态筛选",
|
||||
filterDevice: "设备筛选",
|
||||
deviceKeywordPh: "名称 / IP",
|
||||
allStatus: "全部状态",
|
||||
},
|
||||
},
|
||||
managedNe: {
|
||||
title: "网元管理",
|
||||
credsNotConfigured: "未配置 NETX_CREDENTIAL_SECRET_KEY,无法保存密码。请在 .env 中设置 Fernet 密钥后重启 API。",
|
||||
add: "新增网元",
|
||||
importBtn: "批量导入",
|
||||
importing: "导入中…",
|
||||
edit: "编辑",
|
||||
delete: "删除",
|
||||
confirmDelete: "确定删除该网元?",
|
||||
keywordPh: "名称 / IP / 用户名 / 标签",
|
||||
allVendors: "全部厂商",
|
||||
allConnectStatus: "全部连通状态",
|
||||
col: {
|
||||
name: "名称",
|
||||
vendor: "厂商",
|
||||
deviceType: "设备类型",
|
||||
ip: "管理地址",
|
||||
port: "端口",
|
||||
protocol: "协议",
|
||||
user: "用户名",
|
||||
password: "密码",
|
||||
tags: "标签",
|
||||
remark: "备注",
|
||||
connect: "连通性",
|
||||
testedAt: "测试时间",
|
||||
actions: "操作",
|
||||
},
|
||||
connect: {
|
||||
run: "连通性测试",
|
||||
running: "测试中…",
|
||||
submitted: "已提交 {{n}} 台设备测试",
|
||||
},
|
||||
importResult: {
|
||||
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
|
||||
},
|
||||
form: {
|
||||
requiredHint: "带 * 为必填项",
|
||||
nameConnectHint: "连通性测试成功后将自动更新为设备名称",
|
||||
createTitle: "新增网元",
|
||||
editTitle: "编辑网元",
|
||||
save: "保存",
|
||||
saving: "保存中…",
|
||||
cancel: "取消",
|
||||
created: "网元已创建",
|
||||
updated: "网元已更新",
|
||||
deleted: "网元已删除",
|
||||
passwordRequired: "请填写密码",
|
||||
passwordOptional: "留空则不修改",
|
||||
},
|
||||
},
|
||||
ume: {
|
||||
token: {
|
||||
title: "UME Token 状态",
|
||||
|
|
|
|||
|
|
@ -511,6 +511,22 @@ pre {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-btn--danger {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.link-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 12px;
|
||||
color: #9db2cf;
|
||||
|
|
@ -555,21 +571,28 @@ pre {
|
|||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
padding: 10px 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2c405f;
|
||||
background: #0f1e34;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
z-index: 60;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.toast--ok {
|
||||
border-color: #1f5a3a;
|
||||
border-color: #81c784;
|
||||
background: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.toast--error {
|
||||
border-color: #5f1f2a;
|
||||
border-color: #ef9a9a;
|
||||
background: #ffebee;
|
||||
color: #b71c1c;
|
||||
}
|
||||
|
||||
.inline-spinner {
|
||||
|
|
@ -739,6 +762,201 @@ pre {
|
|||
}
|
||||
}
|
||||
|
||||
.page-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel--warn {
|
||||
border-color: #ffe082;
|
||||
background: #fff8e1;
|
||||
color: #5d4037;
|
||||
}
|
||||
|
||||
.panel__toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel__toolbar h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(720px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.modal__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.form-label__required {
|
||||
color: #c62828;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-label__optional {
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.form-field-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.form-grid input,
|
||||
.form-grid select {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-grid__full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-grid--single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel__hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.panel__hint--live {
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.collect-commands {
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
padding: 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.collect-meta {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.collect-cmd-preview {
|
||||
font-size: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.collect-runs-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.collect-runs-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.collect-runs-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.collect-runs-filter select {
|
||||
min-width: 140px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.collect-runs-filter input {
|
||||
min-width: 180px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -752,4 +970,7 @@ pre {
|
|||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
531
web/src/pages/CollectPage.tsx
Normal file
531
web/src/pages/CollectPage.tsx
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createNeCollection,
|
||||
deleteCollectionJob,
|
||||
fetchCollectionJob,
|
||||
fetchCollectionRuns,
|
||||
fetchEligibleNe,
|
||||
fetchNeCollections,
|
||||
pauseCollectionJob,
|
||||
restartCollectionJob,
|
||||
collectionJobDownloadUrl,
|
||||
collectionRunDownloadUrl,
|
||||
} from "../services/api";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import type { CollectionJobDetail, CollectionJobItem, EligibleNeItem } from "../types";
|
||||
import { pageCount } from "../utils/display";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
export function CollectPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [commands, setCommands] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [nePage, setNePage] = useState(1);
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [expandedJobId, setExpandedJobId] = useState("");
|
||||
|
||||
const POLL_MS = 2000;
|
||||
const ELIGIBLE_PAGE_SIZE = 20;
|
||||
|
||||
const eligibleQuery = useQuery({
|
||||
queryKey: queryKeys.collectionEligibleNe(nePage),
|
||||
queryFn: () => fetchEligibleNe({ page: nePage, pageSize: ELIGIBLE_PAGE_SIZE }),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: queryKeys.neCollections(jobPage),
|
||||
queryFn: () => fetchNeCollections({ page: jobPage, pageSize: 20 }),
|
||||
staleTime: 1000,
|
||||
refetchInterval: (q) => {
|
||||
const items = q.state.data?.items ?? [];
|
||||
return items.some((j) => j.status === "running") ? POLL_MS : false;
|
||||
},
|
||||
});
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: queryKeys.neCollectionDetail(expandedJobId),
|
||||
queryFn: () => fetchCollectionJob(expandedJobId),
|
||||
enabled: Boolean(expandedJobId),
|
||||
staleTime: 500,
|
||||
refetchInterval: (q) => (q.state.data?.job.status === "running" ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const jobs = jobsQuery.data?.items ?? [];
|
||||
const jobActive = jobs.some((j) => j.status === "running");
|
||||
const expandedJobRunning =
|
||||
Boolean(expandedJobId) &&
|
||||
(jobs.find((j) => j.id === expandedJobId)?.status === "running" || detailQuery.data?.job.status === "running");
|
||||
const autoPolling = jobActive || expandedJobRunning;
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll }),
|
||||
expandedJobId
|
||||
? queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(expandedJobId) })
|
||||
: Promise.resolve(),
|
||||
expandedJobId
|
||||
? queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionRunsAll })
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
};
|
||||
|
||||
const invalidateJobs = async (jobId?: string) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
|
||||
if (jobId) {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(jobId) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionRunsAll });
|
||||
}
|
||||
};
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: pauseCollectionJob,
|
||||
onSuccess: async (job) => {
|
||||
showOk(t("collect.paused"));
|
||||
await invalidateJobs(job.id);
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: restartCollectionJob,
|
||||
onSuccess: async (job) => {
|
||||
showOk(t("collect.restarted"));
|
||||
setExpandedJobId(job.id);
|
||||
await invalidateJobs(job.id);
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteCollectionJob,
|
||||
onSuccess: async (_, jobId) => {
|
||||
showOk(t("collect.deleted"));
|
||||
if (expandedJobId === jobId) setExpandedJobId("");
|
||||
await invalidateJobs();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createNeCollection({
|
||||
title: title.trim(),
|
||||
commands,
|
||||
ne_ids: selected,
|
||||
}),
|
||||
onSuccess: async (job) => {
|
||||
showOk(t("collect.started", { id: job.id }));
|
||||
setExpandedJobId(job.id);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(job.id) });
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const items = eligibleQuery.data?.items ?? [];
|
||||
const allSelected = items.length > 0 && items.every((x) => selected.includes(x.id));
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
const ids = new Set(items.map((x) => x.id));
|
||||
setSelected((prev) => prev.filter((id) => !ids.has(id)));
|
||||
} else {
|
||||
setSelected((prev) => [...new Set([...prev, ...items.map((x) => x.id)])]);
|
||||
}
|
||||
};
|
||||
|
||||
const neTotal = eligibleQuery.data?.total ?? 0;
|
||||
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
|
||||
|
||||
const jobTotal = jobsQuery.data?.total ?? 0;
|
||||
const jobPages = pageCount(jobTotal, 20);
|
||||
|
||||
const commandLines = useMemo(
|
||||
() =>
|
||||
commands
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("#")).length,
|
||||
[commands],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<div>
|
||||
<h2>{t("collect.eligible.title")}</h2>
|
||||
<p className="panel__hint">{t("collect.eligible.hint")}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => eligibleQuery.refetch()} disabled={eligibleQuery.isFetching}>
|
||||
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
{eligibleQuery.isLoading ? <p>{t("common.refreshing")}</p> : null}
|
||||
{!eligibleQuery.isLoading && items.length === 0 ? (
|
||||
<p>{t("collect.eligible.empty")}</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="select all" />
|
||||
</th>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.vendor")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
<th>{t("managedNe.col.connect")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row: EligibleNeItem) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={() =>
|
||||
setSelected((prev) =>
|
||||
prev.includes(row.id) ? prev.filter((x) => x !== row.id) : [...prev, row.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>{row.name || row.ip_address}</td>
|
||||
<td>{row.vendor}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>
|
||||
<span className="conn-pill conn-pill--up">{row.connect_status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{neTotal > 0 ? (
|
||||
<div className="pager">
|
||||
<div className="pager__meta">{t("common.pagerMeta", { total: neTotal, page: nePage, pages: nePages })}</div>
|
||||
<div className="pager__controls">
|
||||
<button className="pager__btn" disabled={nePage <= 1} onClick={() => setNePage(nePage - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button className="pager__btn" disabled={nePage >= nePages} onClick={() => setNePage(nePage + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h2>{t("collect.form.title")}</h2>
|
||||
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
|
||||
<div className="form-grid form-grid--single">
|
||||
<label className="form-grid__full">
|
||||
{t("collect.form.jobTitle")}
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t("collect.form.jobTitlePh")} />
|
||||
</label>
|
||||
<label className="form-grid__full">
|
||||
{t("collect.form.commands")}
|
||||
<textarea
|
||||
className="collect-commands"
|
||||
rows={10}
|
||||
value={commands}
|
||||
onChange={(e) => setCommands(e.target.value)}
|
||||
placeholder={t("collect.form.commandsPh")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel__actions">
|
||||
<span className="collect-meta">
|
||||
{t("collect.form.meta", { ne: selected.length, cmd: commandLines })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.length === 0 || commandLines === 0 || startMutation.isPending}
|
||||
onClick={() => startMutation.mutate()}
|
||||
>
|
||||
{startMutation.isPending ? t("collect.form.starting") : t("collect.form.start")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<div>
|
||||
<h2>{t("collect.jobs.title")}</h2>
|
||||
{autoPolling ? (
|
||||
<p className="panel__hint panel__hint--live">
|
||||
{t("collect.jobs.autoRefresh")}
|
||||
{jobsQuery.isFetching ? ` · ${t("common.refreshing")}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching}>
|
||||
{jobsQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("collect.jobs.col.title")}</th>
|
||||
<th>{t("collect.jobs.col.status")}</th>
|
||||
<th>{t("collect.jobs.col.progress")}</th>
|
||||
<th>{t("collect.jobs.col.created")}</th>
|
||||
<th>{t("collect.jobs.col.lastRun")}</th>
|
||||
<th>{t("collect.jobs.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(jobsQuery.data?.items ?? []).map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
expanded={expandedJobId === job.id}
|
||||
detail={expandedJobId === job.id ? detailQuery.data : undefined}
|
||||
onToggle={() => setExpandedJobId(expandedJobId === job.id ? "" : job.id)}
|
||||
onPause={() => pauseMutation.mutate(job.id)}
|
||||
onRestart={() => restartMutation.mutate(job.id)}
|
||||
onDelete={() => {
|
||||
if (window.confirm(t("collect.confirmDelete"))) deleteMutation.mutate(job.id);
|
||||
}}
|
||||
actionPending={pauseMutation.isPending || restartMutation.isPending || deleteMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="pager">
|
||||
<div className="pager__meta">{t("common.pagerMeta", { total: jobTotal, page: jobPage, pages: jobPages })}</div>
|
||||
<div className="pager__controls">
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobRow({
|
||||
job,
|
||||
expanded,
|
||||
detail,
|
||||
onToggle,
|
||||
onPause,
|
||||
onRestart,
|
||||
onDelete,
|
||||
actionPending,
|
||||
}: {
|
||||
job: CollectionJobItem;
|
||||
expanded: boolean;
|
||||
detail?: CollectionJobDetail;
|
||||
onToggle: () => void;
|
||||
onPause: () => void;
|
||||
onRestart: () => void;
|
||||
onDelete: () => void;
|
||||
actionPending: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const canPause = job.status === "running";
|
||||
const canRestart = job.status !== "running";
|
||||
const canDelete = job.status !== "running";
|
||||
const hasResults = (job.output_count ?? 0) > 0;
|
||||
|
||||
const downloadResults = () => {
|
||||
window.location.assign(collectionJobDownloadUrl(job.id));
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>{job.title}</td>
|
||||
<td>{job.status}</td>
|
||||
<td>
|
||||
{job.success_count}/{job.ne_count} {t("collect.jobs.ok")}, {job.fail_count} {t("collect.jobs.fail")}
|
||||
</td>
|
||||
<td>{formatSystemTime(job.created_at)}</td>
|
||||
<td>{job.last_run_at ? formatSystemTime(job.last_run_at) : t("common.empty")}</td>
|
||||
<td className="table-actions">
|
||||
<button type="button" className="link-btn" onClick={onToggle}>
|
||||
{expanded ? t("collect.jobs.collapse") : t("collect.jobs.expand")}
|
||||
</button>
|
||||
{canPause ? (
|
||||
<button type="button" className="link-btn" disabled={actionPending} onClick={onPause}>
|
||||
{t("collect.jobs.pause")}
|
||||
</button>
|
||||
) : null}
|
||||
{canRestart ? (
|
||||
<button type="button" className="link-btn" disabled={actionPending} onClick={onRestart}>
|
||||
{t("collect.jobs.restart")}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
disabled={actionPending || !hasResults}
|
||||
onClick={downloadResults}
|
||||
>
|
||||
{t("collect.jobs.downloadResults")}
|
||||
</button>
|
||||
{canDelete ? (
|
||||
<button type="button" className="link-btn link-btn--danger" disabled={actionPending} onClick={onDelete}>
|
||||
{t("collect.jobs.delete")}
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded ? (
|
||||
<tr>
|
||||
<td colSpan={6}>
|
||||
<JobRunsPanel jobId={job.id} jobStatus={job.status} commands={detail?.job.commands ?? ""} />
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const RUN_PAGE_SIZE = 20;
|
||||
const RUN_STATUS_OPTIONS = ["pending", "running", "success", "fail", "cancelled"] as const;
|
||||
|
||||
function JobRunsPanel({
|
||||
jobId,
|
||||
jobStatus,
|
||||
commands,
|
||||
}: {
|
||||
jobId: string;
|
||||
jobStatus: string;
|
||||
commands: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [runPage, setRunPage] = useState(1);
|
||||
const [runStatus, setRunStatus] = useState("");
|
||||
const [runKeyword, setRunKeyword] = useState("");
|
||||
|
||||
const runsQuery = useQuery({
|
||||
queryKey: queryKeys.neCollectionRuns(jobId, runPage, runStatus, runKeyword),
|
||||
queryFn: () =>
|
||||
fetchCollectionRuns({
|
||||
jobId,
|
||||
page: runPage,
|
||||
pageSize: RUN_PAGE_SIZE,
|
||||
status: runStatus,
|
||||
keyword: runKeyword,
|
||||
}),
|
||||
staleTime: 500,
|
||||
refetchInterval: jobStatus === "running" ? 2000 : false,
|
||||
});
|
||||
|
||||
const clearRunFilters = () => {
|
||||
setRunStatus("");
|
||||
setRunKeyword("");
|
||||
setRunPage(1);
|
||||
};
|
||||
|
||||
const runTotal = runsQuery.data?.total ?? 0;
|
||||
const runPages = pageCount(runTotal, RUN_PAGE_SIZE);
|
||||
const runs = runsQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="collect-runs-panel">
|
||||
{jobStatus === "running" ? (
|
||||
<p className="panel__hint panel__hint--live">{t("collect.jobs.runsInProgress")}</p>
|
||||
) : null}
|
||||
{commands ? <pre className="collect-cmd-preview">{commands}</pre> : null}
|
||||
<div className="collect-runs-toolbar">
|
||||
<label className="collect-runs-filter">
|
||||
{t("collect.runs.filterDevice")}
|
||||
<input
|
||||
type="search"
|
||||
value={runKeyword}
|
||||
placeholder={t("collect.runs.deviceKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setRunKeyword(e.target.value);
|
||||
setRunPage(1);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="collect-runs-filter">
|
||||
{t("collect.runs.filterStatus")}
|
||||
<select
|
||||
value={runStatus}
|
||||
onChange={(e) => {
|
||||
setRunStatus(e.target.value);
|
||||
setRunPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("collect.runs.allStatus")}</option>
|
||||
{RUN_STATUS_OPTIONS.map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{st}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{runStatus || runKeyword ? (
|
||||
<button type="button" className="link-btn" onClick={clearRunFilters}>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{runsQuery.isLoading ? <p>{t("common.refreshing")}</p> : null}
|
||||
{!runsQuery.isLoading && runs.length === 0 ? <p>{t("common.empty")}</p> : null}
|
||||
{runs.length > 0 ? (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
<th>{t("collect.runs.status")}</th>
|
||||
<th>{t("collect.runs.message")}</th>
|
||||
<th>{t("collect.runs.download")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((run) => (
|
||||
<tr key={run.id}>
|
||||
<td>{run.ne_name}</td>
|
||||
<td>{run.ne_ip}</td>
|
||||
<td>{run.status}</td>
|
||||
<td title={run.message}>{run.message || t("common.empty")}</td>
|
||||
<td>
|
||||
{run.has_output ? (
|
||||
<a className="link-btn" href={collectionRunDownloadUrl(run.id)} target="_blank" rel="noreferrer">
|
||||
{t("collect.runs.downloadFile")}
|
||||
</a>
|
||||
) : (
|
||||
t("common.empty")
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
{runTotal > 0 ? (
|
||||
<div className="pager">
|
||||
<div className="pager__meta">{t("common.pagerMeta", { total: runTotal, page: runPage, pages: runPages })}</div>
|
||||
<div className="pager__controls">
|
||||
<button className="pager__btn" disabled={runPage <= 1} onClick={() => setRunPage(runPage - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button className="pager__btn" disabled={runPage >= runPages} onClick={() => setRunPage(runPage + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
493
web/src/pages/NePage.tsx
Normal file
493
web/src/pages/NePage.tsx
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
connectTestManagedNe,
|
||||
createManagedNe,
|
||||
deleteManagedNe,
|
||||
fetchManagedNe,
|
||||
fetchManagedNeMeta,
|
||||
importManagedNe,
|
||||
updateManagedNe,
|
||||
} from "../services/api";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import type { ManagedNeItem } from "../types";
|
||||
import { pageCount } from "../utils/display";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
vendor: string;
|
||||
device_type: string;
|
||||
ip_address: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
username: string;
|
||||
password: string;
|
||||
tags: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: "",
|
||||
vendor: "ZTE",
|
||||
device_type: "zte_zxros",
|
||||
ip_address: "",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
username: "",
|
||||
password: "",
|
||||
tags: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
|
||||
return (
|
||||
<span className="form-label">
|
||||
{children}
|
||||
{required ? (
|
||||
<span className="form-label__required" title="required" aria-hidden="true">
|
||||
{" "}
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" {
|
||||
if (status === "pass") return "up";
|
||||
if (status === "fail") return "down";
|
||||
if (status === "testing") return "warn";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function NePage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const importRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [vendorFilter, setVendorFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ManagedNeItem | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
|
||||
const metaQuery = useQuery({
|
||||
queryKey: queryKeys.managedNeMeta,
|
||||
queryFn: fetchManagedNeMeta,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.managedNe(keyword, vendorFilter, statusFilter, page, pageSize),
|
||||
queryFn: () =>
|
||||
fetchManagedNe({
|
||||
keyword,
|
||||
vendor: vendorFilter,
|
||||
connectStatus: statusFilter,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
refetchInterval: (q) => {
|
||||
const items = q.state.data?.items || [];
|
||||
return items.some((x) => x.connect_status === "testing") ? 2000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const total = listQuery.data?.total ?? 0;
|
||||
const pages = pageCount(total, pageSize);
|
||||
const perPage = (n: number) => t("common.perPage", { n });
|
||||
|
||||
const invalidateList = () => queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = {
|
||||
name: form.name,
|
||||
vendor: form.vendor,
|
||||
device_type: form.device_type,
|
||||
ip_address: form.ip_address,
|
||||
port: form.port,
|
||||
protocol: form.protocol,
|
||||
username: form.username,
|
||||
tags: form.tags,
|
||||
remark: form.remark,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
};
|
||||
if (editing) {
|
||||
if (!form.password) delete (body as { password?: string }).password;
|
||||
return updateManagedNe(editing.id, body);
|
||||
}
|
||||
if (!form.password) throw new Error(t("managedNe.form.passwordRequired"));
|
||||
return createManagedNe({ ...body, password: form.password });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
showOk(editing ? t("managedNe.form.updated") : t("managedNe.form.created"));
|
||||
await invalidateList();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteManagedNe,
|
||||
onSuccess: async () => {
|
||||
showOk(t("managedNe.form.deleted"));
|
||||
await invalidateList();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const connectMutation = useMutation({
|
||||
mutationFn: connectTestManagedNe,
|
||||
onSuccess: async (res) => {
|
||||
showOk(t("managedNe.connect.submitted", { n: res.submitted }));
|
||||
await invalidateList();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: importManagedNe,
|
||||
onSuccess: async (res) => {
|
||||
showOk(
|
||||
t("managedNe.importResult.done", {
|
||||
inserted: res.inserted,
|
||||
updated: res.updated,
|
||||
failed: res.failed.length,
|
||||
}),
|
||||
);
|
||||
await invalidateList();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const vendors = metaQuery.data?.vendors ?? [];
|
||||
const deviceTypes = metaQuery.data?.device_types ?? [];
|
||||
const credsOk = metaQuery.data?.credentials_configured ?? false;
|
||||
|
||||
const allSelected = useMemo(() => {
|
||||
const items = listQuery.data?.items ?? [];
|
||||
return items.length > 0 && items.every((x) => selected.includes(x.id));
|
||||
}, [listQuery.data?.items, selected]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: ManagedNeItem) => {
|
||||
setEditing(row);
|
||||
setForm({
|
||||
name: row.name,
|
||||
vendor: row.vendor,
|
||||
device_type: row.device_type,
|
||||
ip_address: row.ip_address,
|
||||
port: row.port,
|
||||
protocol: row.protocol,
|
||||
username: row.username,
|
||||
password: "",
|
||||
tags: row.tags,
|
||||
remark: row.remark,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const items = listQuery.data?.items ?? [];
|
||||
if (allSelected) {
|
||||
const pageIds = new Set(items.map((x) => x.id));
|
||||
setSelected((prev) => prev.filter((id) => !pageIds.has(id)));
|
||||
} else {
|
||||
const ids = items.map((x) => x.id);
|
||||
setSelected((prev) => [...new Set([...prev, ...ids])]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
{!credsOk ? (
|
||||
<section className="panel panel--warn">
|
||||
<p>{t("managedNe.credsNotConfigured")}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("managedNe.title")}</h2>
|
||||
<div className="panel__actions">
|
||||
<button type="button" onClick={openCreate} disabled={!credsOk}>
|
||||
{t("managedNe.add")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => importRef.current?.click()}
|
||||
disabled={!credsOk || importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending ? t("managedNe.importing") : t("managedNe.importBtn")}
|
||||
</button>
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) importMutation.mutate(file);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.length === 0 || connectMutation.isPending}
|
||||
onClick={() => connectMutation.mutate(selected)}
|
||||
>
|
||||
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")}
|
||||
</button>
|
||||
<button type="button" onClick={() => invalidateList()}>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={keyword}
|
||||
placeholder={t("managedNe.keywordPh")}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<select value={vendorFilter} onChange={(e) => setVendorFilter(e.target.value)}>
|
||||
<option value="">{t("managedNe.allVendors")}</option>
|
||||
{vendors.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">{t("managedNe.allConnectStatus")}</option>
|
||||
<option value="unknown">unknown</option>
|
||||
<option value="testing">testing</option>
|
||||
<option value="pass">pass</option>
|
||||
<option value="fail">fail</option>
|
||||
</select>
|
||||
<button type="button" onClick={() => setPage(1)}>
|
||||
{t("common.query")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setKeyword("");
|
||||
setVendorFilter("");
|
||||
setStatusFilter("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleSelectAll} aria-label="select all" />
|
||||
</th>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.vendor")}</th>
|
||||
<th>{t("managedNe.col.deviceType")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
<th>{t("managedNe.col.user")}</th>
|
||||
<th>{t("managedNe.col.connect")}</th>
|
||||
<th>{t("managedNe.col.testedAt")}</th>
|
||||
<th>{t("managedNe.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(listQuery.data?.items || []).map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={() => toggleSelect(row.id)}
|
||||
/>
|
||||
</td>
|
||||
<td>{row.name || row.ip_address}</td>
|
||||
<td>{row.vendor}</td>
|
||||
<td>{row.device_type}</td>
|
||||
<td>
|
||||
{row.ip_address}:{row.port}/{row.protocol}
|
||||
</td>
|
||||
<td>{row.username}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`conn-pill conn-pill--${connectPillLevel(row.connect_status)}`}
|
||||
title={row.connect_message || undefined}
|
||||
>
|
||||
{row.connect_status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{row.connect_tested_at ? formatSystemTime(row.connect_tested_at) : t("common.empty")}</td>
|
||||
<td className="table-actions">
|
||||
<button type="button" className="link-btn" onClick={() => openEdit(row)}>
|
||||
{t("managedNe.edit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => {
|
||||
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
|
||||
}}
|
||||
>
|
||||
{t("managedNe.delete")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="pager">
|
||||
<div className="pager__meta">{t("common.pagerMeta", { total, page, pages })}</div>
|
||||
<div className="pager__controls">
|
||||
<button className="pager__btn" onClick={() => setPage(Math.max(1, page - 1))} disabled={page <= 1}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button className="pager__btn" onClick={() => setPage(page + 1)} disabled={page >= pages}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
<select
|
||||
className="pager__size"
|
||||
value={String(pageSize)}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value) || 50);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="20">{perPage(20)}</option>
|
||||
<option value="50">{perPage(50)}</option>
|
||||
<option value="100">{perPage(100)}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{modalOpen ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={() => setModalOpen(false)}>
|
||||
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}</h3>
|
||||
<p className="form-hint">{t("managedNe.form.requiredHint")}</p>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<FormLabel>{t("managedNe.col.name")}</FormLabel>
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<span className="form-field-hint">{t("managedNe.form.nameConnectHint")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel required>{t("managedNe.col.vendor")}</FormLabel>
|
||||
<select required value={form.vendor} onChange={(e) => setForm({ ...form, vendor: e.target.value })}>
|
||||
{vendors.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel required>{t("managedNe.col.deviceType")}</FormLabel>
|
||||
<select
|
||||
required
|
||||
value={form.device_type}
|
||||
onChange={(e) => setForm({ ...form, device_type: e.target.value })}
|
||||
>
|
||||
{deviceTypes.map((dt) => (
|
||||
<option key={dt} value={dt}>
|
||||
{dt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel required>{t("managedNe.col.ip")}</FormLabel>
|
||||
<input
|
||||
required
|
||||
value={form.ip_address}
|
||||
onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
|
||||
disabled={Boolean(editing)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel>{t("managedNe.col.port")}</FormLabel>
|
||||
<input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 22 })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel>{t("managedNe.col.protocol")}</FormLabel>
|
||||
<select value={form.protocol} onChange={(e) => setForm({ ...form, protocol: e.target.value })}>
|
||||
<option value="ssh">ssh</option>
|
||||
<option value="telnet">telnet</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel required>{t("managedNe.col.user")}</FormLabel>
|
||||
<input
|
||||
required
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel required={!editing}>
|
||||
{t("managedNe.col.password")}
|
||||
{editing ? (
|
||||
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
|
||||
) : null}
|
||||
</FormLabel>
|
||||
<input
|
||||
type="password"
|
||||
required={!editing}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel>{t("managedNe.col.tags")}</FormLabel>
|
||||
<input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
|
||||
</label>
|
||||
<label className="form-grid__full">
|
||||
<FormLabel>{t("managedNe.col.remark")}</FormLabel>
|
||||
<input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="modal__actions">
|
||||
<button type="button" onClick={() => setModalOpen(false)}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</button>
|
||||
<button type="button" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
||||
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
|
|||
import { modulesInSection, type WorkbenchSection } from "../config/modules";
|
||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||
|
||||
const SECTIONS: WorkbenchSection[] = ["monitoring"];
|
||||
const SECTIONS: WorkbenchSection[] = ["monitoring", "operations"];
|
||||
|
||||
export function WorkbenchPage() {
|
||||
const { t } = useI18n();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import type {
|
||||
CollectionJobDetail,
|
||||
CollectionJobItem,
|
||||
CollectionRunList,
|
||||
EligibleNeItem,
|
||||
IntegrationStatus,
|
||||
ManagedNeImportResult,
|
||||
ManagedNeItem,
|
||||
ManagedNeListResponse,
|
||||
ManagedNeMeta,
|
||||
UmeAlarmItem,
|
||||
UmeAlarmSubscriptionStatus,
|
||||
UmeNeItem,
|
||||
|
|
@ -25,6 +33,26 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
return data as T;
|
||||
};
|
||||
|
||||
export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
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} ${path}`));
|
||||
return data as T;
|
||||
};
|
||||
|
||||
export const apiDelete = async <T,>(path: string): Promise<T> => {
|
||||
const res = await fetch(path, { method: "DELETE", headers: { accept: "application/json" } });
|
||||
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} ${path}`));
|
||||
return data as T;
|
||||
};
|
||||
|
||||
export const fetchIntegrationStatus = () => apiGet<IntegrationStatus>("/v1/integrations/status");
|
||||
|
||||
export const fetchUmeAlarmSubscriptionStatus = () =>
|
||||
|
|
@ -64,6 +92,104 @@ export const fetchUmeNe = (params: { keyword: string; page: number; pageSize: nu
|
|||
);
|
||||
};
|
||||
|
||||
export const fetchManagedNeMeta = () =>
|
||||
Promise.all([
|
||||
apiGet<ManagedNeMeta>("/v1/managed-ne/meta/device-types"),
|
||||
apiGet<{ configured: boolean }>("/v1/managed-ne/meta/credentials-configured"),
|
||||
]).then(([types, creds]) => ({
|
||||
device_types: types.device_types,
|
||||
vendors: types.vendors,
|
||||
credentials_configured: creds.configured,
|
||||
}));
|
||||
|
||||
export const fetchManagedNe = (params: {
|
||||
keyword: string;
|
||||
vendor: string;
|
||||
connectStatus: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (params.keyword) p.set("keyword", params.keyword);
|
||||
if (params.vendor) p.set("vendor", params.vendor);
|
||||
if (params.connectStatus) p.set("connect_status", params.connectStatus);
|
||||
p.set("page", String(Math.max(1, params.page)));
|
||||
p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize))));
|
||||
return apiGet<ManagedNeListResponse>(`/v1/managed-ne?${p.toString()}`);
|
||||
};
|
||||
|
||||
export const createManagedNe = (body: Record<string, unknown>) =>
|
||||
apiPost<ManagedNeItem>("/v1/managed-ne", body);
|
||||
|
||||
export const updateManagedNe = (id: string, body: Record<string, unknown>) =>
|
||||
apiPatch<ManagedNeItem>(`/v1/managed-ne/${id}`, body);
|
||||
|
||||
export const deleteManagedNe = (id: string) => apiDelete<{ ok: boolean }>(`/v1/managed-ne/${id}`);
|
||||
|
||||
export const connectTestManagedNe = (ids: string[]) =>
|
||||
apiPost<{ ok: boolean; submitted: number }>("/v1/managed-ne/connect-test", { ids });
|
||||
|
||||
export const importManagedNe = async (file: File): Promise<ManagedNeImportResult> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch("/v1/managed-ne/import", { method: "POST", body: form });
|
||||
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`));
|
||||
return data as ManagedNeImportResult;
|
||||
};
|
||||
|
||||
export const fetchEligibleNe = (params: { page: number; pageSize: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, params.page)));
|
||||
p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize))));
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: EligibleNeItem[] }>(
|
||||
`/v1/ne-collections/eligible-ne?${p.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const createNeCollection = (body: { title?: string; commands: string; ne_ids: string[] }) =>
|
||||
apiPost<CollectionJobItem>("/v1/ne-collections", body);
|
||||
|
||||
export const fetchNeCollections = (params: { page: number; pageSize: number }) => {
|
||||
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))));
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: CollectionJobItem[] }>(
|
||||
`/v1/ne-collections?${p.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchCollectionJob = (jobId: string) => apiGet<CollectionJobDetail>(`/v1/ne-collections/${jobId}`);
|
||||
|
||||
export const fetchCollectionRuns = (params: {
|
||||
jobId: string;
|
||||
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(200, params.pageSize))));
|
||||
if (params.status) p.set("status", params.status);
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<CollectionRunList>(`/v1/ne-collections/${params.jobId}/runs?${p.toString()}`);
|
||||
};
|
||||
|
||||
export const pauseCollectionJob = (jobId: string) =>
|
||||
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/pause`, {});
|
||||
|
||||
export const restartCollectionJob = (jobId: string) =>
|
||||
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/restart`, {});
|
||||
|
||||
export const deleteCollectionJob = (jobId: string) =>
|
||||
apiDelete<{ ok: boolean }>(`/v1/ne-collections/${jobId}`);
|
||||
|
||||
export const collectionRunDownloadUrl = (runId: string) => `/v1/ne-collections/runs/${runId}/download`;
|
||||
|
||||
export const collectionJobDownloadUrl = (jobId: string) => `/v1/ne-collections/${jobId}/download`;
|
||||
|
||||
export const fetchUmeCurrentAlarms = (params: {
|
||||
severity: string;
|
||||
isCleared: string;
|
||||
|
|
|
|||
|
|
@ -119,6 +119,95 @@ export type UmeAlarmItem = {
|
|||
last_seen_at?: string;
|
||||
};
|
||||
|
||||
export type ConnectStatus = "unknown" | "testing" | "pass" | "fail";
|
||||
|
||||
export type ManagedNeItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
vendor: string;
|
||||
device_type: string;
|
||||
ip_address: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
username: string;
|
||||
connect_status: ConnectStatus;
|
||||
connect_message: string;
|
||||
connect_tested_at: string | null;
|
||||
tags: string;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ManagedNeListResponse = {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: ManagedNeItem[];
|
||||
};
|
||||
|
||||
export type ManagedNeMeta = {
|
||||
device_types: string[];
|
||||
vendors: string[];
|
||||
};
|
||||
|
||||
export type ManagedNeImportResult = {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
failed: Array<{ row: number; reason: string }>;
|
||||
};
|
||||
|
||||
export type EligibleNeItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
vendor: string;
|
||||
device_type: string;
|
||||
ip_address: string;
|
||||
connect_status: string;
|
||||
connect_tested_at: string | null;
|
||||
};
|
||||
|
||||
export type CollectionJobItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
commands: string;
|
||||
status: string;
|
||||
ne_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
output_count: number;
|
||||
error_message: string;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
last_run_at: string | null;
|
||||
};
|
||||
|
||||
export type CollectionRunItem = {
|
||||
id: string;
|
||||
job_id: string;
|
||||
ne_id: string;
|
||||
ne_name: string;
|
||||
ne_ip: string;
|
||||
status: string;
|
||||
message: string;
|
||||
output_rel_path: string;
|
||||
has_output: boolean;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
};
|
||||
|
||||
export type CollectionJobDetail = {
|
||||
job: CollectionJobItem;
|
||||
};
|
||||
|
||||
export type CollectionRunList = {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: CollectionRunItem[];
|
||||
};
|
||||
|
||||
export type UmeTokenStatus = {
|
||||
ok?: boolean;
|
||||
has_token: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue