import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { ListPager } from "../components/ListPager"; import { createNeCollection, deleteCollectionJob, fetchCollectionDashboard, fetchCollectionJob, fetchCollectionRuns, fetchEligibleNe, fetchNeCollections, formatErr, pauseCollectionJob, startCollectionJob, startCollectionFromPolicy, retryFailedCollectionJob, updateCollectionPolicy, collectionJobDownloadUrl, collectionRunDownloadUrl, } from "../services/api"; import { queryKeys } from "../constants/queryKeys"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import type { CollectionJobItem, CollectionRunItem, CollectionTargetRef, EligibleNeItem } from "../types"; import { downloadCsv, fetchAllPages } from "../utils/csvExport"; import { pageCount } from "../utils/display"; import { formatSystemTime } from "../utils/time"; const POLL_MS = 2000; const ELIGIBLE_PAGE_SIZE = 20; const POLICY_TARGET_PAGE_SIZE = 20; const JOB_STATUS_OPTIONS = ["draft", "pending", "running", "paused", "done", "failed", "cancelled"] as const; const JOB_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; const RUN_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; function eligibleKey(row: Pick): string { const src = String(row.source || "managed").trim().toLowerCase() || "managed"; return `${src}:${row.id}`; } function targetKey(ref: CollectionTargetRef): string { const src = String(ref.source || "managed").trim().toLowerCase() || "managed"; return `${src}:${ref.id}`; } function collectionErrorMessage(err: unknown, t: (key: string) => string): string { const raw = String(err); if (raw.includes("collection_ne_busy")) return t("collect.neBusy"); if (raw.includes("collection_job_running")) return t("collect.jobRunning"); if (raw.includes("commands_empty") || raw.includes("commands_required_for_schedule")) { return t("collect.policyCommandsRequired"); } if (raw.includes("no_selected_targets") || raw.includes("no_eligible_ne")) { return t("collect.policyTargetsRequired"); } return raw; } export function CollectPage() { const { t } = useI18n(); const { showOk, showError } = useToast(); const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const [commands, setCommands] = useState(""); const [title, setTitle] = useState(""); const [selectedMap, setSelectedMap] = useState>({}); const [pickSelectedIds, setPickSelectedIds] = useState([]); const [neKeyword, setNeKeyword] = useState(""); const [nePage, setNePage] = useState(1); const [jobPage, setJobPage] = useState(1); const [jobPageSize, setJobPageSize] = useState(20); const [jobStatus, setJobStatus] = useState(""); const [jobKeyword, setJobKeyword] = useState(""); const [exportingJobs, setExportingJobs] = useState(false); const [expandedJobId, setExpandedJobId] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [policyEnabled, setPolicyEnabled] = useState(false); const [policyIntervalValue, setPolicyIntervalValue] = useState(1); const [policyIntervalUnit, setPolicyIntervalUnit] = useState<"days" | "hours">("days"); const [policyHistoryKeep, setPolicyHistoryKeep] = useState(3); const [policyScopeMode, setPolicyScopeMode] = useState<"all" | "selected">("all"); const [policyTitle, setPolicyTitle] = useState(""); const [policyCommands, setPolicyCommands] = useState(""); const [policySelectedMap, setPolicySelectedMap] = useState>({}); const [policyHydrated, setPolicyHydrated] = useState(false); const [policyTargetKeyword, setPolicyTargetKeyword] = useState(""); const [policyTargetPage, setPolicyTargetPage] = useState(1); const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300); const debouncedPolicyTargetKeyword = useDebouncedValue(policyTargetKeyword, 300); const selectedIds = useMemo(() => Object.keys(selectedMap), [selectedMap]); const selectedList = useMemo(() => Object.values(selectedMap), [selectedMap]); // Deep link: /collect?job_id=… → open runs modal, then strip param useEffect(() => { const jobId = String(searchParams.get("job_id") || "").trim(); if (!jobId) return; setExpandedJobId(jobId); setSearchParams( (prev) => { const next = new URLSearchParams(prev); if (next.get("job_id") === jobId) next.delete("job_id"); return next; }, { replace: true }, ); }, [searchParams, setSearchParams]); const eligibleQuery = useQuery({ queryKey: queryKeys.collectionEligibleNe(nePage, neKeyword), queryFn: () => fetchEligibleNe({ page: nePage, pageSize: ELIGIBLE_PAGE_SIZE, keyword: neKeyword }), staleTime: 5000, enabled: createOpen, }); const jobsQuery = useQuery({ queryKey: queryKeys.neCollections(jobPage, jobPageSize, jobStatus, debouncedJobKeyword), queryFn: () => fetchNeCollections({ page: jobPage, pageSize: jobPageSize, status: jobStatus, keyword: debouncedJobKeyword, }), staleTime: 1000, refetchInterval: (q) => { const items = q.state.data?.items ?? []; return items.some((j) => j.status === "running" || j.status === "pending") ? POLL_MS : false; }, }); const dashQuery = useQuery({ queryKey: queryKeys.neCollectionDashboard, queryFn: fetchCollectionDashboard, staleTime: 1000, refetchInterval: (q) => { const running = q.state.data?.running_job; return running && (running.status === "running" || running.status === "pending" || running.status === "paused") ? POLL_MS : false; }, }); useEffect(() => { const p = dashQuery.data?.policy; if (!p || policyHydrated) return; setPolicyEnabled(Boolean(p.enabled)); const hours = Math.max(1, Number(p.interval_hours || Number(p.interval_days || 1) * 24)); if (hours % 24 === 0) { setPolicyIntervalUnit("days"); setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24))); } else { setPolicyIntervalUnit("hours"); setPolicyIntervalValue(Math.max(1, Math.min(8760, hours))); } setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(p.history_keep ?? 3)))); setPolicyScopeMode(p.scope_mode === "selected" ? "selected" : "all"); setPolicyTitle(String(p.title || "")); setPolicyCommands(String(p.commands || "")); const map: Record = {}; for (const ref of p.selected_targets || []) { map[targetKey(ref)] = { source: ref.source, id: ref.id }; } setPolicySelectedMap(map); setPolicyHydrated(true); }, [dashQuery.data?.policy, policyHydrated]); const policyTargetsQuery = useQuery({ queryKey: queryKeys.collectionEligibleNe(policyTargetPage, debouncedPolicyTargetKeyword), queryFn: () => fetchEligibleNe({ page: policyTargetPage, pageSize: POLICY_TARGET_PAGE_SIZE, keyword: debouncedPolicyTargetKeyword, }), staleTime: 5000, enabled: policyScopeMode === "selected", }); const detailQuery = useQuery({ queryKey: queryKeys.neCollectionDetail(expandedJobId), queryFn: () => fetchCollectionJob(expandedJobId), enabled: Boolean(expandedJobId), staleTime: 500, refetchInterval: (q) => (q.state.data?.job.status === "running" ? POLL_MS : false), }); const jobs = jobsQuery.data?.items ?? []; const dash = dashQuery.data; const running = dash?.running_job; const last = dash?.last_job; const jobActive = Boolean( running && (running.status === "running" || running.status === "pending" || running.status === "paused"), ); const expandedJobRunning = Boolean(expandedJobId) && (jobs.find((j) => j.id === expandedJobId)?.status === "running" || detailQuery.data?.job.status === "running"); const autoPolling = jobActive || expandedJobRunning; const invalidateOpsTasks = () => queryClient.invalidateQueries({ queryKey: ["opsTasks"] }); const refreshAll = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll }), queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard }), invalidateOpsTasks(), expandedJobId ? queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(expandedJobId) }) : Promise.resolve(), expandedJobId ? queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionRunsAll }) : Promise.resolve(), ]); }; const savePolicyMutation = useMutation({ mutationFn: () => { const hours = policyIntervalUnit === "days" ? Math.max(1, Math.min(365, policyIntervalValue)) * 24 : Math.max(1, Math.min(8760, policyIntervalValue)); return updateCollectionPolicy({ enabled: policyEnabled, interval_hours: hours, history_keep: policyHistoryKeep, scope_mode: policyScopeMode, title: policyTitle.trim(), commands: policyCommands, selected_targets: Object.values(policySelectedMap), }); }, onSuccess: async (saved) => { setPolicyEnabled(Boolean(saved.enabled)); const hours = Math.max(1, Number(saved.interval_hours || Number(saved.interval_days || 1) * 24)); if (hours % 24 === 0) { setPolicyIntervalUnit("days"); setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24))); } else { setPolicyIntervalUnit("hours"); setPolicyIntervalValue(Math.max(1, Math.min(8760, hours))); } setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(saved.history_keep ?? 3)))); setPolicyScopeMode(saved.scope_mode === "selected" ? "selected" : "all"); setPolicyTitle(String(saved.title || "")); setPolicyCommands(String(saved.commands || "")); const map: Record = {}; for (const ref of saved.selected_targets || []) { map[targetKey(ref)] = { source: ref.source, id: ref.id }; } setPolicySelectedMap(map); setPolicyHydrated(true); queryClient.setQueryData(queryKeys.neCollectionDashboard, (prev: unknown) => { if (!prev || typeof prev !== "object") return prev; return { ...(prev as object), policy: saved }; }); showOk(t("collect.policySaved")); await refreshAll(); }, onError: (err) => showError(collectionErrorMessage(err, t)), }); const startFromPolicyMutation = useMutation({ mutationFn: startCollectionFromPolicy, onSuccess: async (job) => { showOk(t("collect.started", { id: job.id })); setExpandedJobId(job.id); await refreshAll(); }, onError: (err) => showError(collectionErrorMessage(err, t)), }); const invalidateJobs = async (jobId?: string) => { await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll }); await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard }); await invalidateOpsTasks(); 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(collectionErrorMessage(err, t)), }); const startJobMutation = useMutation({ mutationFn: startCollectionJob, onSuccess: async (job) => { showOk(t("collect.started", { id: job.id })); setExpandedJobId(job.id); await invalidateJobs(job.id); }, onError: (err) => showError(collectionErrorMessage(err, t)), }); const retryFailedMutation = useMutation({ mutationFn: retryFailedCollectionJob, onSuccess: async (job) => { showOk(t("collect.retryFailedDone")); 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 createMutation = useMutation({ mutationFn: () => { const ne_ids: string[] = []; const ume_ne_ids: string[] = []; for (const row of selectedList) { if (String(row.source || "managed").toLowerCase() === "ume") ume_ne_ids.push(row.id); else ne_ids.push(row.id); } return createNeCollection({ title: title.trim(), commands, ne_ids, ume_ne_ids, }); }, onSuccess: async (job) => { showOk(t("collect.created", { id: job.id })); setCreateOpen(false); setExpandedJobId(job.id); await invalidateJobs(job.id); }, onError: (err) => showError(String(err)), }); const addNe = (row: EligibleNeItem) => { const key = eligibleKey(row); setSelectedMap((prev) => (prev[key] ? prev : { ...prev, [key]: row })); }; const addBatchNe = (rows: EligibleNeItem[]) => { if (rows.length === 0) return; setSelectedMap((prev) => { const next = { ...prev }; for (const row of rows) { const key = eligibleKey(row); if (!next[key]) next[key] = row; } return next; }); }; const removeNe = (key: string) => { setSelectedMap((prev) => { const next = { ...prev }; delete next[key]; return next; }); }; const clearSelected = () => setSelectedMap({}); const eligibleItems = eligibleQuery.data?.items ?? []; const selectablePickItems = eligibleItems.filter((row) => !selectedMap[eligibleKey(row)]); const allPickSelected = selectablePickItems.length > 0 && selectablePickItems.every((row) => pickSelectedIds.includes(eligibleKey(row))); const batchPickCount = pickSelectedIds.length; const neTotal = eligibleQuery.data?.total ?? 0; const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE); const jobTotal = jobsQuery.data?.total ?? 0; const jobPages = pageCount(jobTotal, jobPageSize); const hasJobFilters = Boolean(jobKeyword.trim() || jobStatus); const exportJobsCsv = async () => { setExportingJobs(true); try { const rows = await fetchAllPages({ pageSize: 100, maxRows: 2000, fetchPage: (page, pageSize) => fetchNeCollections({ page, pageSize, status: jobStatus, keyword: debouncedJobKeyword, }), }); downloadCsv( `${t("collect.exportJobsName")}-${new Date().toISOString().slice(0, 10)}.csv`, rows, [ { key: "title", header: t("collect.jobs.col.title") }, { key: "status", header: t("collect.jobs.col.status") }, { key: "progress", header: t("collect.jobs.col.progress"), value: (r) => `${r.success_count}/${r.ne_count} ${t("collect.jobs.ok")}, ${r.fail_count} ${t("collect.jobs.fail")}`, }, { key: "started_at", header: "started_at", value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""), }, { key: "ended_at", header: "ended_at", value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""), }, ], ); if (rows.length < jobTotal) { showOk(t("common.exportTruncated", { count: String(rows.length), total: String(jobTotal) })); } else { showOk(t("common.exportOk", { count: String(rows.length) })); } } catch (err) { showError(t("common.exportFailed") + ": " + formatErr(err)); } finally { setExportingJobs(false); } }; const commandLines = useMemo( () => commands .split("\n") .map((l) => l.trim()) .filter((l) => l && !l.startsWith("#")).length, [commands], ); const actionPending = pauseMutation.isPending || startJobMutation.isPending || retryFailedMutation.isPending || deleteMutation.isPending; return (

{t("collect.jobs.title")}

{autoPolling ? (

{t("collect.jobs.autoRefresh")} {jobsQuery.isFetching || dashQuery.isFetching ? ` · ${t("common.refreshing")}` : ""}

) : null}
{running?.status === "running" || running?.status === "pending" ? ( ) : null}
{t("collect.kpi.jobs")}
{dash?.job_count ?? "—"}
{t("collect.kpi.running")}
{running ? `${running.status} · ${running.success_count}/${running.ne_count}` : t("collect.kpi.idle")}
{t("collect.kpi.last")}
{last ? `${last.title} · ${last.status} · ok ${last.success_count} / fail ${last.fail_count}` : t("common.empty")}
{t("collect.kpi.nextDue")}
{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}

{t("collect.policyTitle")}

{t("collect.policyHint")}