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

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