+
+
+
+
{t("collect.eligible.title")}
+
{t("collect.eligible.hint")}
+
+
+
+ {eligibleQuery.isLoading ? {t("common.refreshing")}
: null}
+ {!eligibleQuery.isLoading && items.length === 0 ? (
+ {t("collect.eligible.empty")}
+ ) : (
+
+ )}
+ {neTotal > 0 ? (
+
+
{t("common.pagerMeta", { total: neTotal, page: nePage, pages: nePages })}
+
+
+
+
+
+ ) : null}
+
+
+
+ {t("collect.form.title")}
+ {t("collect.form.commandsHint")}
+
+
+
+
+
+
+ {t("collect.form.meta", { ne: selected.length, cmd: commandLines })}
+
+
+
+
+
+
+
+
+
{t("collect.jobs.title")}
+ {autoPolling ? (
+
+ {t("collect.jobs.autoRefresh")}
+ {jobsQuery.isFetching ? ` · ${t("common.refreshing")}` : ""}
+
+ ) : null}
+
+
+
+
+
+
+ | {t("collect.jobs.col.title")} |
+ {t("collect.jobs.col.status")} |
+ {t("collect.jobs.col.progress")} |
+ {t("collect.jobs.col.created")} |
+ {t("collect.jobs.col.lastRun")} |
+ {t("collect.jobs.col.actions")} |
+
+
+
+ {(jobsQuery.data?.items ?? []).map((job) => (
+ 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}
+ />
+ ))}
+
+
+
+
{t("common.pagerMeta", { total: jobTotal, page: jobPage, pages: jobPages })}
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+ <>
+
+ | {job.title} |
+ {job.status} |
+
+ {job.success_count}/{job.ne_count} {t("collect.jobs.ok")}, {job.fail_count} {t("collect.jobs.fail")}
+ |
+ {formatSystemTime(job.created_at)} |
+ {job.last_run_at ? formatSystemTime(job.last_run_at) : t("common.empty")} |
+
+
+ {canPause ? (
+
+ ) : null}
+ {canRestart ? (
+
+ ) : null}
+
+ {canDelete ? (
+
+ ) : null}
+ |
+
+ {expanded ? (
+
+ {jobStatus === "running" ? (
+
{t("collect.jobs.runsInProgress")}
+ ) : null}
+ {commands ?
{commands} : null}
+
+
+
+ {runStatus || runKeyword ? (
+
+ ) : null}
+
+ {runsQuery.isLoading ?
{t("common.refreshing")}
: null}
+ {!runsQuery.isLoading && runs.length === 0 ?
{t("common.empty")}
: null}
+ {runs.length > 0 ? (
+
+
+
+ | {t("managedNe.col.name")} |
+ {t("managedNe.col.ip")} |
+ {t("collect.runs.status")} |
+ {t("collect.runs.message")} |
+ {t("collect.runs.download")} |
+
+
+
+ {runs.map((run) => (
+
+ | {run.ne_name} |
+ {run.ne_ip} |
+ {run.status} |
+ {run.message || t("common.empty")} |
+
+ {run.has_output ? (
+
+ {t("collect.runs.downloadFile")}
+
+ ) : (
+ t("common.empty")
+ )}
+ |
+
+ ))}
+
+
+ ) : null}
+ {runTotal > 0 ? (
+
+
{t("common.pagerMeta", { total: runTotal, page: runPage, pages: runPages })}
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx
new file mode 100644
index 0000000..d936e2a
--- /dev/null
+++ b/web/src/pages/NePage.tsx
@@ -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 (
+