mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
feat(web): NetX workbench, UME-only UI, i18n, and header status
- Replace sidebar app with workbench home and module tabs (openOrFocusModule) - Remove legacy pages; add module registry, shared tab channel, WEB.md - Add zh/en i18n, global ToastProvider, restore oclaw bridge pill in header Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
664775927b
commit
282dbe75af
33 changed files with 1631 additions and 1677 deletions
336
web/src/App.tsx
336
web/src/App.tsx
|
|
@ -1,230 +1,27 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Navigate, Route, Routes, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AppLayout } from "./layout/AppLayout";
|
||||
import { AlarmsPage } from "./pages/AlarmsPage";
|
||||
import { DiagnosticsPage } from "./pages/DiagnosticsPage";
|
||||
import { AiAnalysisPage } from "./pages/AiAnalysisPage";
|
||||
import { IngestPage } from "./pages/IngestPage";
|
||||
import { queryKeys } from "./constants/queryKeys";
|
||||
import { WorkbenchPage } from "./pages/WorkbenchPage";
|
||||
import { UmePage } from "./pages/UmePage";
|
||||
import type { Alarm, Batch, Diagnostics, ImportHistoryItem } from "./types";
|
||||
import { apiDelete, apiPost, apiPostForm, fetchAlarms, fetchBatches, fetchDiagnostics, fetchIntegrationStatus } from "./services/api";
|
||||
import { fetchJobs } from "./services/api";
|
||||
import { useToast } from "./hooks/useToast";
|
||||
import { fetchIntegrationStatus } from "./services/api";
|
||||
|
||||
function App() {
|
||||
const capabilities = {
|
||||
alarms: (import.meta.env.VITE_FEATURE_ALARMS ?? "1") !== "0",
|
||||
diagnostics: (import.meta.env.VITE_FEATURE_DIAGNOSTICS ?? "1") !== "0",
|
||||
aiAnalysis: (import.meta.env.VITE_FEATURE_AI_ANALYSIS ?? "1") !== "0",
|
||||
};
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedBatch, setSelectedBatch] = useState(() => searchParams.get("batch_id") || "");
|
||||
const [alarmCode, setAlarmCode] = useState(() => searchParams.get("alarm_code") || "");
|
||||
const [neName, setNeName] = useState(() => searchParams.get("ne_name") || "");
|
||||
const [severity, setSeverity] = useState(() => searchParams.get("severity") || "");
|
||||
/** AI 分析页专用筛选(与告警列表页过滤独立) */
|
||||
const [aiAlarmCode, setAiAlarmCode] = useState(() => searchParams.get("ai_alarm_code") || "");
|
||||
const [aiNeName, setAiNeName] = useState(() => searchParams.get("ai_ne_name") || "");
|
||||
const [aiSeverity, setAiSeverity] = useState(() => searchParams.get("ai_severity") || "");
|
||||
const [page, setPage] = useState(() => Number(searchParams.get("page") || "1") || 1);
|
||||
const [pageSize, setPageSize] = useState(() => Number(searchParams.get("page_size") || "80") || 80);
|
||||
const [question, setQuestion] = useState("请总结当前告警风险并给出行动建议");
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [status, setStatus] = useState("ready");
|
||||
const { toast, showError, showOk } = useToast();
|
||||
|
||||
const batchesQuery = useQuery({
|
||||
queryKey: ["batches"],
|
||||
queryFn: fetchBatches,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const alarmsQuery = useQuery({
|
||||
queryKey: ["alarms", selectedBatch, alarmCode, neName, severity, page, pageSize],
|
||||
queryFn: () => fetchAlarms({ batchId: selectedBatch, alarmCode, neName, severity, page, pageSize }),
|
||||
enabled: Boolean(selectedBatch),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
const diagnosticsQuery = useQuery({
|
||||
queryKey: ["diagnostics", selectedBatch],
|
||||
queryFn: () => fetchDiagnostics(selectedBatch),
|
||||
enabled: Boolean(selectedBatch) && capabilities.diagnostics,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
const integrationsQuery = useQuery({
|
||||
queryKey: ["integrationsStatus"],
|
||||
queryKey: queryKeys.integrationsStatus,
|
||||
queryFn: fetchIntegrationStatus,
|
||||
refetchInterval: 5000,
|
||||
staleTime: 2000,
|
||||
});
|
||||
const analyzeMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
apiPost<any>("/v1/ap/analyze", {
|
||||
analysis_request_id: `vite-${Date.now()}`,
|
||||
batch_id: selectedBatch,
|
||||
question,
|
||||
filters: { alarm_code: aiAlarmCode, ne_name: aiNeName, severity: aiSeverity },
|
||||
constraints: { language: "zh-CN", max_points: 8 },
|
||||
}),
|
||||
onMutate: () => setStatus("analyzing"),
|
||||
onSuccess: async (data) => {
|
||||
const ok = Boolean(data?.ok) && Boolean(data?.oclaw?.ok);
|
||||
const ans = String(data?.oclaw?.answer || "").trim();
|
||||
setAnswer(ans);
|
||||
setStatus("ready");
|
||||
if (!ok) {
|
||||
showError(`AI 分析失败: ${String(data?.error || "oclaw bridge unavailable")}`);
|
||||
} else if (!ans) {
|
||||
showError("AI 分析已执行,但未返回内容");
|
||||
} else {
|
||||
showOk("AI 分析完成");
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ["diagnostics"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["aiAnalyzeHistory"] });
|
||||
},
|
||||
onError: (e) => {
|
||||
setStatus(String(e));
|
||||
showError(`AI 分析失败: ${String(e)}`);
|
||||
},
|
||||
});
|
||||
|
||||
const [alarmImportStatus, setAlarmImportStatus] = useState("");
|
||||
const [logsImportStatus, setLogsImportStatus] = useState("");
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: ["jobs"],
|
||||
queryFn: fetchJobs,
|
||||
refetchInterval: 5000,
|
||||
staleTime: 2000,
|
||||
});
|
||||
const importAlarmMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
return await apiPostForm<any>("/v1/alarms/import", fd);
|
||||
},
|
||||
onMutate: () => setAlarmImportStatus("上传中..."),
|
||||
onSuccess: async (data) => {
|
||||
const bid = String(data?.batch_id || "").trim();
|
||||
setAlarmImportStatus(`导入完成 batch_id=${bid} success=${data?.success_rows ?? ""} failed=${data?.failed_rows ?? ""}`);
|
||||
showOk("告警导入完成");
|
||||
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["batches"] });
|
||||
if (bid) {
|
||||
setSelectedBatch(bid);
|
||||
// Clear filters to ensure the new batch is visible immediately.
|
||||
setAlarmCode("");
|
||||
setNeName("");
|
||||
setSeverity("");
|
||||
// Ensure alarms list refreshes after state changes and navigation.
|
||||
await queryClient.invalidateQueries({ queryKey: ["alarms"] });
|
||||
navigate("/alarms");
|
||||
await queryClient.invalidateQueries({ queryKey: ["alarms"] });
|
||||
}
|
||||
},
|
||||
onError: async (e) => {
|
||||
setAlarmImportStatus(`导入失败: ${String(e)}`);
|
||||
showError(`告警导入失败: ${String(e)}`);
|
||||
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
|
||||
},
|
||||
});
|
||||
|
||||
const importLogsMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
return await apiPostForm<any>("/v1/logs/import", fd);
|
||||
},
|
||||
onMutate: () => setLogsImportStatus("上传中..."),
|
||||
onSuccess: async () => {
|
||||
setLogsImportStatus("导入完成(占位)");
|
||||
showOk("日志导入完成");
|
||||
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
|
||||
},
|
||||
onError: async (e) => {
|
||||
setLogsImportStatus(`导入失败: ${String(e)}`);
|
||||
showError(`日志导入失败: ${String(e)}`);
|
||||
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteBatchMutation = useMutation({
|
||||
mutationFn: async (batchId: string) => apiDelete<any>(`/v1/batches/${encodeURIComponent(batchId)}`),
|
||||
onSuccess: async (_data, batchId) => {
|
||||
showOk(`已删除批次 ${batchId}`);
|
||||
setSelectedBatch("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["batches"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["alarms"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
|
||||
},
|
||||
onError: (e) => {
|
||||
showError(`删除批次失败: ${String(e)}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAllBatchesMutation = useMutation({
|
||||
mutationFn: async () => apiDelete<any>("/v1/batches"),
|
||||
onSuccess: async () => {
|
||||
showOk("已清空全部批次数据");
|
||||
setSelectedBatch("");
|
||||
setAlarmCode("");
|
||||
setNeName("");
|
||||
setSeverity("");
|
||||
setPage(1);
|
||||
await queryClient.invalidateQueries({ queryKey: ["batches"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["alarms"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
|
||||
},
|
||||
onError: (e) => {
|
||||
showError(`清空全部批次失败: ${String(e)}`);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const next = new URLSearchParams();
|
||||
if (selectedBatch) next.set("batch_id", selectedBatch);
|
||||
if (alarmCode) next.set("alarm_code", alarmCode);
|
||||
if (neName) next.set("ne_name", neName);
|
||||
if (severity) next.set("severity", severity);
|
||||
if (aiAlarmCode) next.set("ai_alarm_code", aiAlarmCode);
|
||||
if (aiNeName) next.set("ai_ne_name", aiNeName);
|
||||
if (aiSeverity) next.set("ai_severity", aiSeverity);
|
||||
next.set("page", String(page));
|
||||
next.set("page_size", String(pageSize));
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [selectedBatch, alarmCode, neName, severity, aiAlarmCode, aiNeName, aiSeverity, page, pageSize, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBatch && batchesQuery.data?.items?.length) {
|
||||
setSelectedBatch(batchesQuery.data.items[0].batch_id);
|
||||
}
|
||||
}, [selectedBatch, batchesQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
// Any filter change should reset pagination to first page.
|
||||
setPage(1);
|
||||
}, [selectedBatch, alarmCode, neName, severity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (batchesQuery.error) {
|
||||
setStatus(String(batchesQuery.error));
|
||||
showError(`批次加载失败: ${String(batchesQuery.error)}`);
|
||||
} else if (alarmsQuery.error) {
|
||||
setStatus(String(alarmsQuery.error));
|
||||
showError(`告警加载失败: ${String(alarmsQuery.error)}`);
|
||||
}
|
||||
}, [batchesQuery.error, alarmsQuery.error, batchesQuery.isSuccess, alarmsQuery.isSuccess, showError]);
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
status={status}
|
||||
connections={{
|
||||
netxApi: integrationsQuery.data?.netx_api?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
|
||||
oclawBridge: integrationsQuery.data?.oclaw_bridge?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
|
||||
netxApiLatencyMs: typeof integrationsQuery.data?.db?.latency_ms === "number" ? integrationsQuery.data.db.latency_ms : undefined,
|
||||
netxApiLatencyMs:
|
||||
typeof integrationsQuery.data?.db?.latency_ms === "number" ? integrationsQuery.data.db.latency_ms : undefined,
|
||||
oclawBridge:
|
||||
integrationsQuery.data?.oclaw_bridge?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
|
||||
oclawBridgeLatencyMs:
|
||||
typeof integrationsQuery.data?.oclaw_bridge?.latency_ms === "number"
|
||||
? integrationsQuery.data.oclaw_bridge.latency_ms
|
||||
|
|
@ -233,111 +30,18 @@ function App() {
|
|||
typeof integrationsQuery.data?.oclaw_bridge?.error_kind === "string"
|
||||
? integrationsQuery.data.oclaw_bridge.error_kind
|
||||
: undefined,
|
||||
oclawBridgeError:
|
||||
typeof integrationsQuery.data?.oclaw_bridge?.error === "string"
|
||||
? integrationsQuery.data.oclaw_bridge.error
|
||||
: undefined,
|
||||
}}
|
||||
capabilities={capabilities}
|
||||
onRefreshBatches={() => queryClient.invalidateQueries({ queryKey: ["batches"] })}
|
||||
onRefreshAlarms={() => queryClient.invalidateQueries({ queryKey: ["alarms"] })}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/diagnostics" replace />} />
|
||||
<Route
|
||||
path="/ingest"
|
||||
element={
|
||||
<IngestPage
|
||||
isImportingAlarm={importAlarmMutation.isPending}
|
||||
alarmImportStatus={alarmImportStatus}
|
||||
isImportingLogs={importLogsMutation.isPending}
|
||||
logsImportStatus={logsImportStatus}
|
||||
history={(jobsQuery.data?.items || []) as ImportHistoryItem[]}
|
||||
onImportAlarmExcel={(f) => f && importAlarmMutation.mutate(f)}
|
||||
onImportLogs={(f) => f && importLogsMutation.mutate(f)}
|
||||
onOpenBatch={(bid) => {
|
||||
setSelectedBatch(String(bid || "").trim());
|
||||
setAlarmCode("");
|
||||
setNeName("");
|
||||
setSeverity("");
|
||||
navigate("/alarms");
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/alarms"
|
||||
element={
|
||||
<AlarmsPage
|
||||
batches={(batchesQuery.data?.items || []) as Batch[]}
|
||||
alarms={(alarmsQuery.data?.items || []) as Alarm[]}
|
||||
total={Number(alarmsQuery.data?.total || 0)}
|
||||
isLoading={alarmsQuery.isLoading || alarmsQuery.isFetching}
|
||||
selectedBatch={selectedBatch}
|
||||
alarmCode={alarmCode}
|
||||
neName={neName}
|
||||
severity={severity}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onSelectBatch={setSelectedBatch}
|
||||
onChangeAlarmCode={setAlarmCode}
|
||||
onChangeNeName={setNeName}
|
||||
onChangeSeverity={setSeverity}
|
||||
onChangePage={setPage}
|
||||
onChangePageSize={setPageSize}
|
||||
onQueryNow={() => queryClient.invalidateQueries({ queryKey: ["alarms"] })}
|
||||
onResetFilters={() => {
|
||||
setAlarmCode("");
|
||||
setNeName("");
|
||||
setSeverity("");
|
||||
setPage(1);
|
||||
}}
|
||||
isDeletingBatch={deleteBatchMutation.isPending}
|
||||
onDeleteBatch={() => {
|
||||
const bid = String(selectedBatch || "").trim();
|
||||
if (!bid) {
|
||||
showError("请先选择批次");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认删除批次 ${bid} 吗?将删除该批次告警、错误记录与作业记录。`)) return;
|
||||
deleteBatchMutation.mutate(bid);
|
||||
}}
|
||||
isDeletingAllBatches={deleteAllBatchesMutation.isPending}
|
||||
onDeleteAllBatches={() => {
|
||||
if (!window.confirm("确认清空全部批次吗?这会删除所有告警、错误明细和导入作业记录。")) return;
|
||||
if (!window.confirm("这是高危操作,无法恢复。请再次确认执行清空全部批次。")) return;
|
||||
deleteAllBatchesMutation.mutate();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/diagnostics" element={<DiagnosticsPage diagnostics={(diagnosticsQuery.data || null) as Diagnostics | null} />} />
|
||||
<Route
|
||||
path="/ai-analysis"
|
||||
element={
|
||||
<AiAnalysisPage
|
||||
batches={(batchesQuery.data?.items || []) as Batch[]}
|
||||
selectedBatch={selectedBatch}
|
||||
onSelectBatch={setSelectedBatch}
|
||||
aiAlarmCode={aiAlarmCode}
|
||||
aiNeName={aiNeName}
|
||||
aiSeverity={aiSeverity}
|
||||
onChangeAiAlarmCode={setAiAlarmCode}
|
||||
onChangeAiNeName={setAiNeName}
|
||||
onChangeAiSeverity={setAiSeverity}
|
||||
onResetAiFilters={() => {
|
||||
setAiAlarmCode("");
|
||||
setAiNeName("");
|
||||
setAiSeverity("");
|
||||
}}
|
||||
question={question}
|
||||
answer={answer}
|
||||
isRunning={analyzeMutation.isPending}
|
||||
enabled={capabilities.aiAnalysis && Boolean(selectedBatch)}
|
||||
onChangeQuestion={setQuestion}
|
||||
onRunAnalyze={() => analyzeMutation.mutate()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/ume" element={<UmePage toastOk={showOk} toastError={showError} />} />
|
||||
</Routes>
|
||||
{toast && <div className={`toast toast--${toast.type}`}>{toast.text}</div>}
|
||||
<Routes>
|
||||
<Route path="/" element={<WorkbenchPage />} />
|
||||
<Route path="/workbench" element={<Navigate to="/" replace />} />
|
||||
<Route path="/ume" element={<UmePage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue