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:
oliver 2026-05-27 23:34:29 +08:00
parent 282dbe75af
commit 042c015045
36 changed files with 3695 additions and 17 deletions

View file

@ -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()`

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
View 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>
);
}

View file

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

View file

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

View file

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