diff --git a/web/WEB.md b/web/WEB.md new file mode 100644 index 0000000..5029605 --- /dev/null +++ b/web/WEB.md @@ -0,0 +1,79 @@ +# NetX Web 前端规范 + +## 技术栈 + +- React 19 + TypeScript + Vite +- React Router(工作台 + 模块页) +- TanStack Query(服务端状态) +- 自研 i18n(`src/i18n/`,无额外依赖) + +## 目录结构 + +``` +src/ + config/modules.ts # 模块注册表(工作台卡片、路由、标题 i18n) + constants/queryKeys.ts # React Query key + components/ # 可复用 UI(无业务 API 调用) + hooks/ # 通用 hooks(Toast、窗口注册) + i18n/ # 文案 zh / en + layout/ # AppLayout、ErrorBoundary + pages/ # 页面(按路由) + services/api.ts # HTTP 封装与 API 函数 + types.ts # 与后端契约类型 + utils/ + tabChannel.ts # 跨标签页聚焦(BroadcastChannel) + workbench.ts # 返回工作台 + moduleWindows.ts # 打开/聚焦模块页签 + display.ts # 纯展示辅助 +``` + +## 路由与模块 + +| 路径 | 页面 | moduleId | +|------|------|----------| +| `/` | 工作台 | — | +| `/ume` | UME 同步 | `ume` | + +**新增模块只需改 `config/modules.ts`:** + +1. 在 `MODULES` 增加一项(`moduleId`、`path`、`section`、i18n key、`iconTone`) +2. 在 `App.tsx` 增加 `` +3. 在 `i18n/zh.ts` 与 `en.ts` 补充文案 +4. 工作台卡片由 `modulesInSection` 自动生成,点击走 `openOrFocusModule` + +## 跨标签页导航 + +- **工作台 → 模块**:`openOrFocusModule`(同 `moduleId` 仅一个标签,已打开则聚焦) +- **模块 → 工作台**:顶栏四格图标,`returnToWorkbench`(聚焦原工作台标签;已关闭则新开) +- 实现:`utils/tabChannel.ts` + 命名窗口 `netx-module-{id}` / `netx-workbench` + +## i18n + +- 所有面向用户的文案走 `useI18n().t("dotted.key")` +- 表头/字段名等技术列可保持英文 +- 语言存在 `localStorage`(`netx-locale`),顶栏 ⋯ 切换 + +## 数据请求 + +- API 仅写在 `services/api.ts` +- Query key 集中在 `constants/queryKeys.ts` +- 失效缓存用 prefix key(如 `queryKeys.umeSyncStatusAll`) +- 顶栏连接状态:`App` 轮询 `GET /v1/integrations/status`(5s),展示 **netx api** 与 **oclaw bridge**(含延迟 / 错误类型) + +## Toast + +- 使用 `ToastProvider`(`main.tsx`)+ `useToast()` +- 禁止页面内单独维护一套 toast 状态 + +## 样式 + +- 全局样式:`index.css`(工作台浅色主题) +- 不使用 CSS Modules;类名 BEM 风格:`app-brand__logo`、`wb-card` + +## 构建 + +```bash +cd web && npm run build +``` + +开发:`npm run dev`(需后端 API 或 Vite proxy 配置) diff --git a/web/index.html b/web/index.html index 5e3836a..ff8be2a 100644 --- a/web/index.html +++ b/web/index.html @@ -1,10 +1,10 @@ - + - web + NetX · UME
diff --git a/web/src/App.css b/web/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/web/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/web/src/App.tsx b/web/src/App.tsx index ce343b9..64fe603 100644 --- a/web/src/App.tsx +++ b/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("/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("/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("/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(`/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("/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 ( queryClient.invalidateQueries({ queryKey: ["batches"] })} - onRefreshAlarms={() => queryClient.invalidateQueries({ queryKey: ["alarms"] })} > - - } /> - f && importAlarmMutation.mutate(f)} - onImportLogs={(f) => f && importLogsMutation.mutate(f)} - onOpenBatch={(bid) => { - setSelectedBatch(String(bid || "").trim()); - setAlarmCode(""); - setNeName(""); - setSeverity(""); - navigate("/alarms"); - }} - /> - } - /> - 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(); - }} - /> - } - /> - } /> - { - setAiAlarmCode(""); - setAiNeName(""); - setAiSeverity(""); - }} - question={question} - answer={answer} - isRunning={analyzeMutation.isPending} - enabled={capabilities.aiAnalysis && Boolean(selectedBatch)} - onChangeQuestion={setQuestion} - onRunAnalyze={() => analyzeMutation.mutate()} - /> - } - /> - } /> - - {toast &&
{toast.text}
} + + } /> + } /> + } /> + } /> +
); } diff --git a/web/src/components/AppsGridIcon.tsx b/web/src/components/AppsGridIcon.tsx new file mode 100644 index 0000000..c890b51 --- /dev/null +++ b/web/src/components/AppsGridIcon.tsx @@ -0,0 +1,7 @@ +export function AppsGridIcon() { + return ( + + + + ); +} diff --git a/web/src/components/HeaderMenu.tsx b/web/src/components/HeaderMenu.tsx new file mode 100644 index 0000000..321423d --- /dev/null +++ b/web/src/components/HeaderMenu.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from "react"; +import { useI18n, type Locale } from "../i18n"; + +export function HeaderMenu() { + const { t, locale, setLocale } = useI18n(); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDocClick = (ev: MouseEvent) => { + if (!rootRef.current?.contains(ev.target as Node)) setOpen(false); + }; + const onKey = (ev: KeyboardEvent) => { + if (ev.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const pick = (next: Locale) => { + setLocale(next); + setOpen(false); + }; + + return ( +
+ + {open ? ( +
+
{t("layout.language")}
+ + +
+ ) : null} +
+ ); +} diff --git a/web/src/components/HelpHint.tsx b/web/src/components/HelpHint.tsx new file mode 100644 index 0000000..b7f0c3b --- /dev/null +++ b/web/src/components/HelpHint.tsx @@ -0,0 +1,46 @@ +import { useEffect, useRef, useState } from "react"; + +type Props = { + text: string; + ariaLabel: string; +}; + +export function HelpHint({ text, ariaLabel }: Props) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDocClick = (ev: MouseEvent) => { + if (!rootRef.current?.contains(ev.target as Node)) setOpen(false); + }; + const onKey = (ev: KeyboardEvent) => { + if (ev.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( + + + {open ? ( +
+ {text} +
+ ) : null} +
+ ); +} diff --git a/web/src/components/WorkbenchCardIcon.tsx b/web/src/components/WorkbenchCardIcon.tsx new file mode 100644 index 0000000..09eeb7a --- /dev/null +++ b/web/src/components/WorkbenchCardIcon.tsx @@ -0,0 +1,15 @@ +import type { ModuleIconTone } from "../config/modules"; + +type Props = { + tone: ModuleIconTone; +}; + +export function WorkbenchCardIcon({ tone }: Props) { + return ( + + + + + + ); +} diff --git a/web/src/config/modules.ts b/web/src/config/modules.ts new file mode 100644 index 0000000..174399b --- /dev/null +++ b/web/src/config/modules.ts @@ -0,0 +1,56 @@ +/** + * Single registry for workbench modules. Add new modules here only. + */ + +export type ModuleIconTone = "blue" | "green" | "amber"; +export type WorkbenchSection = "monitoring"; + +export type ModuleDefinition = { + moduleId: string; + path: string; + section: WorkbenchSection; + labelKey: string; + descKey?: string; + iconTone: ModuleIconTone; + titleKey: string; +}; + +export const MODULES: readonly ModuleDefinition[] = [ + { + moduleId: "ume", + path: "/ume", + section: "monitoring", + labelKey: "workbench.cards.umeSync", + descKey: "workbench.cards.umeSyncDesc", + iconTone: "blue", + titleKey: "layout.titleUme", + }, +] as const; + +export function getModuleById(moduleId: string): ModuleDefinition | undefined { + return MODULES.find((m) => m.moduleId === moduleId); +} + +export function moduleIdFromPath(pathname: string): string | null { + for (const m of MODULES) { + if (pathname === m.path || pathname.startsWith(`${m.path}/`)) return m.moduleId; + } + return null; +} + +export function getPageTitleKey(pathname: string): string { + const moduleId = moduleIdFromPath(pathname); + if (moduleId) { + const mod = getModuleById(moduleId); + if (mod) return mod.titleKey; + } + return "workbench.title"; +} + +export function modulesInSection(section: WorkbenchSection): ModuleDefinition[] { + return MODULES.filter((m) => m.section === section); +} + +export function isWorkbenchPath(pathname: string): boolean { + return pathname === "/" || pathname === "/workbench"; +} diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts new file mode 100644 index 0000000..f1b0732 --- /dev/null +++ b/web/src/constants/queryKeys.ts @@ -0,0 +1,21 @@ +/** React Query keys — keep stable references for invalidation. */ + +export const queryKeys = { + integrationsStatus: ["integrationsStatus"] as const, + umeTokenStatus: ["umeTokenStatus"] as const, + umeAlarmSubscription: ["umeAlarmSubscription"] as const, + /** Prefix keys for invalidating all pages/variants of a domain. */ + umeSyncStatusAll: ["umeSyncStatus"] as const, + umeNEAll: ["umeNE"] as const, + 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, + umeCurrentAlarms: ( + severity: string, + cleared: string, + hostName: string, + keyword: string, + page: number, + pageSize: number, + ) => ["umeCurrentAlarms", severity, cleared, hostName, keyword, page, pageSize] as const, +}; diff --git a/web/src/hooks/useAppWindowRegistration.ts b/web/src/hooks/useAppWindowRegistration.ts new file mode 100644 index 0000000..d4d6e61 --- /dev/null +++ b/web/src/hooks/useAppWindowRegistration.ts @@ -0,0 +1,14 @@ +import { useEffect } from "react"; +import { isWorkbenchPath, moduleIdFromPath } from "../config/modules"; +import { registerModuleWindow } from "../utils/moduleWindows"; +import { registerWorkbenchWindow } from "../utils/workbench"; + +/** Register workbench or module tab for cross-tab focus (see utils/tabChannel). */ +export function useAppWindowRegistration(pathname: string): void { + useEffect(() => { + if (isWorkbenchPath(pathname)) return registerWorkbenchWindow(); + const moduleId = moduleIdFromPath(pathname); + if (moduleId) return registerModuleWindow(moduleId); + return undefined; + }, [pathname]); +} diff --git a/web/src/hooks/useToast.ts b/web/src/hooks/useToast.ts deleted file mode 100644 index 66382cc..0000000 --- a/web/src/hooks/useToast.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; - -export type ToastState = { type: "ok" | "error"; text: string } | null; - -export function useToast(autoHideMs = 2600) { - const [toast, setToast] = useState(null); - - const showOk = useCallback((text: string) => setToast({ type: "ok", text }), []); - const showError = useCallback((text: string) => setToast({ type: "error", text }), []); - const clear = useCallback(() => setToast(null), []); - - useEffect(() => { - if (!toast) return; - const t = setTimeout(() => setToast(null), autoHideMs); - return () => clearTimeout(t); - }, [toast, autoHideMs]); - - return { toast, showOk, showError, clear }; -} - diff --git a/web/src/hooks/useToast.tsx b/web/src/hooks/useToast.tsx new file mode 100644 index 0000000..b5d374c --- /dev/null +++ b/web/src/hooks/useToast.tsx @@ -0,0 +1,44 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; + +export type ToastState = { type: "ok" | "error"; text: string } | null; + +type ToastApi = { + toast: ToastState; + showOk: (text: string) => void; + showError: (text: string) => void; + clear: () => void; +}; + +const ToastContext = createContext(null); + +function useToastState(autoHideMs: number): ToastApi { + const [toast, setToast] = useState(null); + + const showOk = useCallback((text: string) => setToast({ type: "ok", text }), []); + const showError = useCallback((text: string) => setToast({ type: "error", text }), []); + const clear = useCallback(() => setToast(null), []); + + useEffect(() => { + if (!toast) return; + const timer = window.setTimeout(() => setToast(null), autoHideMs); + return () => window.clearTimeout(timer); + }, [toast, autoHideMs]); + + return useMemo(() => ({ toast, showOk, showError, clear }), [toast, showOk, showError, clear]); +} + +export function ToastProvider({ children, autoHideMs = 2600 }: { children: ReactNode; autoHideMs?: number }) { + const api = useToastState(autoHideMs); + return ( + + {children} + {api.toast ?
{api.toast.text}
: null} +
+ ); +} + +export function useToast(): ToastApi { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error("useToast must be used within ToastProvider"); + return ctx; +} diff --git a/web/src/i18n/context.tsx b/web/src/i18n/context.tsx new file mode 100644 index 0000000..e22ea22 --- /dev/null +++ b/web/src/i18n/context.tsx @@ -0,0 +1,78 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import en from "./en"; +import zh from "./zh"; + +export type Locale = "zh" | "en"; + +const STORAGE_KEY = "netx-locale"; + +const MESSAGES: Record = { zh, en }; + +function resolveMessage(obj: unknown, path: string): string { + const keys = path.split("."); + let cur: unknown = obj; + for (const k of keys) { + if (cur == null || typeof cur !== "object") return path; + cur = (cur as Record)[k]; + } + return typeof cur === "string" ? cur : path; +} + +function interpolate(text: string, vars?: Record): string { + if (!vars) return text; + let out = text; + for (const [k, v] of Object.entries(vars)) { + out = out.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), String(v)); + } + return out; +} + +type I18nContextValue = { + locale: Locale; + setLocale: (locale: Locale) => void; + t: (key: string, vars?: Record) => string; +}; + +const I18nContext = createContext(null); + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + return saved === "en" ? "en" : "zh"; + } catch { + return "zh"; + } + }); + + const setLocale = useCallback((next: Locale) => { + setLocaleState(next); + try { + localStorage.setItem(STORAGE_KEY, next); + } catch { + /* ignore */ + } + }, []); + + const t = useCallback( + (key: string, vars?: Record) => { + const raw = resolveMessage(MESSAGES[locale], key); + return interpolate(raw, vars); + }, + [locale], + ); + + useEffect(() => { + document.documentElement.lang = locale === "en" ? "en" : "zh-CN"; + }, [locale]); + + const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]); + + return {children}; +} + +export function useI18n() { + const ctx = useContext(I18nContext); + if (!ctx) throw new Error("useI18n must be used within I18nProvider"); + return ctx; +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts new file mode 100644 index 0000000..6fb5f86 --- /dev/null +++ b/web/src/i18n/en.ts @@ -0,0 +1,138 @@ +const en = { + common: { + help: "Help", + refresh: "Refresh", + refreshing: "Refreshing…", + query: "Search", + clearFilters: "Clear filters", + prevPage: "Previous", + nextPage: "Next", + perPage: "{{n}}/page", + pagerMeta: "{{total}} items · page {{page}}/{{pages}}", + opFailed: "Operation failed", + empty: "-", + }, + workbench: { + title: "Workbench", + backToWorkbench: "Back to Workbench", + openInNewTab: "Open in new tab", + openModule: "Open or switch to this module tab", + monitoring: "Monitoring", + cards: { + umeSync: "UME Sync", + umeSyncDesc: "UME alarms, subscription & inventory", + }, + }, + layout: { + title: "UME Integration", + titleUme: "UME Sync", + navUme: "UME", + netxApi: "netx api", + oclawBridge: "oclaw bridge", + language: "Language", + moreMenu: "More", + langZh: "中文", + langEn: "English", + }, + ume: { + token: { + title: "UME Token", + disconnected: "disconnected", + connected: "connected", + connectedRenew: "connected (renew required)", + expiresIn: "expires_in", + needsRenew: "renew / 0s", + preview: "preview", + refreshStatus: "Refresh status", + renewLogin: "Renew / login", + renewing: "Renewing…", + disconnect: "Disconnect token", + disconnecting: "Disconnecting…", + renewOkChanged: "Token renewed and updated", + renewOkSame: "Renew completed (unchanged)", + disconnectOk: "UME token disconnected", + renewHint: "Token missing expiry or expired. Use Renew / login or wait for background keepalive.", + }, + subscription: { + title: "UME alarm subscription (WebSocket)", + help: + "Subscriptions must be created/cancelled manually. After establish (or on restart if a valid subscription exists in DB), the backend auto-connects WSS for live alarms. Pause/resume on runtime task alarms_current_ws_consumer stops/resumes WSS only (does not cancel the UME subscription).", + sub: "subscription", + subActive: "active", + subInactive: "inactive", + wssPaused: "paused", + wssModeBanner: + "Current alarms are maintained via WSS (mode: {{mode}}); scheduled REST full sync is paused. Use Sync current alarms below for full reconcile.", + serverLostBanner: + "UME server subscription is missing or expired; local state may still show active. Clear local record and re-subscribe.", + establish: "Establish subscription", + establishing: "Establishing…", + reestablish: "Clear local & re-subscribe", + reestablishing: "Re-subscribing…", + cancel: "Cancel subscription", + cancelling: "Cancelling…", + refreshStatus: "Refresh subscription", + clearLocalOnly: "Clear local only", + clearing: "Clearing…", + opFailed: "Subscription operation failed", + confirmClearDefault: + "UME alarm subscription no longer exists or has expired.\n\nClear local subscription record? You can establish again afterwards.", + confirmReestablish: "Clear local record and re-establish subscription on UME. Continue?", + titleLoginFirst: "Log in to UME token first", + titleReestablish: "Clear stale local record and re-establish on UME", + establishInvalid: "Establish did not return a valid id/uri", + establishExists: "Subscription already exists (WSS will stay/connect)", + establishOk: "Subscription established; WSS will connect", + clearLocalOk: "Local subscription cleared; you can establish again", + serverLostMsg: "UME subscription lost. Clear local record and re-subscribe?", + cancelFailed: "Cancel failed: subscription may still exist on UME", + cancelOkMissing: "Local cleared (UME subscription was already missing)", + cancelOk: "Subscription cancelled", + wsLogs: "WSS runtime log", + wsActivity: "Last activity {{time}}", + wsAutoRefresh: "Auto refresh", + wsLogsEmpty: "No logs yet (connect WSS after establishing subscription)", + }, + sync: { + title: "UME sync", + inventory: "Sync inventory", + alarmsCurrent: "Sync current alarms", + full: "Full sync", + failed: "Sync failed", + }, + tasks: { + currentTitle: "Running jobs", + running: "running", + noRunning: "No running jobs", + runtimeTitle: "Background tasks", + runtimeOpFailed: "Background task operation failed", + noRuntime: "No background task status", + intervalTitle: "Configured sleep between loop iterations (clamped at process start)", + lastRunTitle: "Last finished sync for scheduled tasks; refreshed at each loop tick", + deletedTitle: "Reconcile deletes: inventory = NE rows; alarms_current = current alarm rows", + }, + syncStatus: { + title: "Sync history", + empty: "No sync records", + }, + ne: { + title: "Network elements", + keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)", + clearTitle: "Clear keyword and reset to page 1", + expand: "Expand details", + collapse: "Collapse details", + }, + alarms: { + title: "Current alarms", + keywordPh: "keyword(alarm key/cause/ne_name/host_name/ip…)", + hostNamePh: "host_name (contains)", + allSeverity: "All severities", + clearedAll: "is_cleared: all", + clearTitle: "Clear filters and reset to page 1", + filterByHost: "Filter by this host", + noHostName: "No host_name (sync inventory first)", + }, + }, +}; + +export default en; diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts new file mode 100644 index 0000000..ace7ff1 --- /dev/null +++ b/web/src/i18n/index.ts @@ -0,0 +1 @@ +export { I18nProvider, useI18n, type Locale } from "./context"; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts new file mode 100644 index 0000000..a83950b --- /dev/null +++ b/web/src/i18n/zh.ts @@ -0,0 +1,138 @@ +const zh = { + common: { + help: "帮助说明", + refresh: "刷新", + refreshing: "刷新中…", + query: "查询", + clearFilters: "清除筛选", + prevPage: "上一页", + nextPage: "下一页", + perPage: "{{n}}/页", + pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页", + opFailed: "操作失败", + empty: "-", + }, + workbench: { + title: "工作台", + backToWorkbench: "返回工作台", + openInNewTab: "在新标签页中打开", + openModule: "打开或切换到该模块页签", + monitoring: "监控", + cards: { + umeSync: "UME同步", + umeSyncDesc: "UME 告警同步、订阅与清单", + }, + }, + layout: { + title: "UME 对接", + titleUme: "UME同步", + navUme: "UME 对接", + netxApi: "netx api", + oclawBridge: "oclaw bridge", + language: "语言", + moreMenu: "更多", + langZh: "中文", + langEn: "English", + }, + ume: { + token: { + title: "UME Token 状态", + disconnected: "disconnected", + connected: "connected", + connectedRenew: "connected (需续期)", + expiresIn: "expires_in", + needsRenew: "需续期 / 0s", + preview: "preview", + refreshStatus: "刷新状态", + renewLogin: "手动续期/登录", + renewing: "续期中…", + disconnect: "断开 token", + disconnecting: "断开中…", + renewOkChanged: "续期成功,token 已更新", + renewOkSame: "续期完成(与此前一致)", + disconnectOk: "已断开 UME token", + renewHint: "库中 token 缺少有效过期时间或已过期,请点「手动续期/登录」或等待后台自动续期", + }, + subscription: { + title: "UME 告警订阅(WebSocket)", + help: + "订阅需手动建立/取消;建立后(或重启后若库中仍有有效订阅)后台会自动连接 WSS 接收实时告警。后台任务 alarms_current_ws_consumer 的 pause/resume 会停止或恢复 WSS 连接(不会取消 UME 订阅)。", + sub: "订阅", + subActive: "已建立", + subInactive: "未建立", + wssPaused: "已暂停", + wssModeBanner: + "当前告警由 WSS 实时维护(模式: {{mode}}),定时 REST 全量同步已暂停。需要全量对账时请使用下方「同步当前告警」。", + serverLostBanner: + "UME 服务器侧告警订阅已丢失或已过期,本地记录可能仍显示「已建立」。请确认清除本地记录后重新订阅。", + establish: "建立告警订阅", + establishing: "建立订阅…", + reestablish: "清除本地并重新订阅", + reestablishing: "重新订阅…", + cancel: "取消告警订阅", + cancelling: "取消订阅…", + refreshStatus: "刷新订阅状态", + clearLocalOnly: "仅清除本地记录", + clearing: "清除中…", + opFailed: "订阅操作失败", + confirmClearDefault: + "UME 侧告警订阅已不存在或已过期(服务器订阅已丢失)。\n\n是否清除本地订阅记录?清除后可点击「建立告警订阅」重新订阅。", + confirmReestablish: "将清除本地订阅记录并在 UME 上重新建立告警订阅,是否继续?", + titleLoginFirst: "请先登录 UME token", + titleReestablish: "清除本地失效记录并重新向 UME 建立订阅", + establishInvalid: "建立订阅未返回有效 id/uri", + establishExists: "订阅已存在,未重复建立(WSS 将保持/恢复连接)", + establishOk: "告警订阅已建立,WSS 将自动连接", + clearLocalOk: "已清除本地订阅记录,可重新建立订阅", + serverLostMsg: "UME 侧告警订阅已丢失。请确认是否清除本地订阅记录后重新订阅。", + cancelFailed: "取消订阅失败:UME 侧订阅可能仍存在,请查看错误详情后重试", + cancelOkMissing: "本地已清除(UME 侧订阅此前已不存在)", + cancelOk: "告警订阅已取消", + wsLogs: "WSS 运行日志", + wsActivity: "最近活动 {{time}}", + wsAutoRefresh: "自动刷新", + wsLogsEmpty: "暂无日志(建立订阅并连接 WSS 后会出现连接、收包、重连等记录)", + }, + sync: { + title: "UME 同步", + inventory: "同步 Inventory", + alarmsCurrent: "同步当前告警", + full: "全量同步", + failed: "同步失败", + }, + tasks: { + currentTitle: "当前任务", + running: "running", + noRunning: "当前无运行中的任务", + runtimeTitle: "后台任务", + runtimeOpFailed: "后台任务操作失败", + noRuntime: "暂无后台任务状态", + intervalTitle: "Configured sleep between loop iterations (clamped at process start)", + lastRunTitle: "Last finished sync for scheduled tasks; refreshed at each loop tick", + deletedTitle: "全量对账删除条数:inventory 为网元行,alarms_current 为当前告警行,其余域多为 0", + }, + syncStatus: { + title: "同步状态", + empty: "暂无同步记录", + }, + ne: { + title: "网元清单", + keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)", + clearTitle: "清空 keyword,回到第 1 页", + expand: "展开详情", + collapse: "收起详情", + }, + alarms: { + title: "当前告警", + keywordPh: "keyword(告警键/原因/ne_name/host_name/ip 等)", + hostNamePh: "host_name(含匹配)", + allSeverity: "全部级别", + clearedAll: "is_cleared: all", + clearTitle: "清空 keyword、host_name、级别、is_cleared,回到第 1 页", + filterByHost: "按该主机名筛选", + noHostName: "无 host_name(需先同步网元)", + }, + }, +}; + +export default zh; diff --git a/web/src/index.css b/web/src/index.css index 6b5e5bc..c31cadf 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -5,88 +5,313 @@ body { margin: 0; font-family: Inter, "Segoe UI", Arial, sans-serif; - background: #0a1220; - color: #d7e1f3; + background: #eef2f7; + color: #1f2937; } #root { min-height: 100vh; } -.app { - display: grid; - grid-template-columns: 240px 1fr; +.app--shell { + display: flex; + flex-direction: column; min-height: 100vh; } -.sidebar { - border-right: 1px solid #203149; - background: linear-gradient(180deg, #0f1e34 0%, #0a1220 100%); - padding: 20px 14px; +.app-brand { + display: flex; + align-items: center; + justify-content: space-between; + height: 52px; + padding: 0 24px; + background: linear-gradient(90deg, #1565c0 0%, #1e88e5 55%, #2196f3 100%); + box-shadow: 0 2px 8px rgba(21, 101, 192, 0.25); } -.logo { +.app-brand__logo { font-size: 20px; font-weight: 700; - margin-bottom: 18px; - letter-spacing: 0.5px; -} - -.nav-item { - display: block; - padding: 10px 12px; - border-radius: 8px; - color: #98abc6; - margin-bottom: 6px; + letter-spacing: 0.4px; + color: #fff; text-decoration: none; + flex-shrink: 0; } -.nav-item.active, -.nav-item:hover { - background: rgba(61, 130, 247, 0.18); +.app-brand__logo--static { + display: inline-block; +} + +.app-brand__sep { + width: 1px; + height: 22px; + background: rgba(255, 255, 255, 0.35); + flex-shrink: 0; +} + +.app-brand__start { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.app-brand__apps { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: #fff; + cursor: pointer; +} + +.app-brand__apps:hover, +.app-brand__apps:focus-visible { + background: rgba(255, 255, 255, 0.16); + outline: none; +} + +.app-brand__module-title { + font-size: 16px; + font-weight: 600; + color: #fff; + white-space: nowrap; +} + +.app-brand__actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.app-main { + flex: 1; + padding: 20px 28px 32px; + background: #eef2f7; + color: #1f2937; +} + +.workbench { + max-width: 1280px; +} + +.wb-section { + margin-bottom: 28px; +} + +.wb-section__title { + margin: 0 0 14px; + padding-bottom: 10px; + font-size: 15px; + font-weight: 600; + color: #334155; + border-bottom: 1px solid #d8dee8; +} + +.wb-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 14px; +} + +.wb-card { + display: flex; + align-items: center; + gap: 14px; + min-height: 72px; + padding: 14px 16px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #fff; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); + cursor: pointer; + text-align: left; + text-decoration: none; + color: inherit; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; +} + +.wb-card:hover { + border-color: #90caf9; + box-shadow: 0 4px 14px rgba(33, 150, 243, 0.15); + transform: translateY(-1px); +} + +.wb-card__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 4px; + flex-shrink: 0; +} + +.wb-card__icon--blue { + background: #e3f2fd; + color: #1565c0; +} + +.wb-card__icon--green { + background: #e8f5e9; + color: #2e7d32; +} + +.wb-card__icon--amber { + background: #fff8e1; + color: #f57f17; +} + +.wb-card__text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.wb-card__label { + font-size: 14px; + font-weight: 600; + color: #1e293b; +} + +.wb-card__desc { + font-size: 12px; + color: #64748b; + line-height: 1.35; +} + +.conn-pill--on-brand { + border-color: rgba(255, 255, 255, 0.35); + background: rgba(255, 255, 255, 0.12); + color: #fff; + font-size: 12px; +} + +.header-menu__trigger--on-brand { + border-color: rgba(255, 255, 255, 0.45); + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.header-menu__trigger--on-brand:hover, +.header-menu__trigger--on-brand:focus-visible { + background: rgba(255, 255, 255, 0.22); + border-color: #fff; +} + +.header-menu__panel--light { + background: #fff; + border-color: #e2e8f0; + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12); +} + +.header-menu__label--light { + color: #64748b; +} + +.header-menu__item--light { + color: #334155; +} + +.header-menu__item--light:hover { + background: #f1f5f9; + color: #0f172a; +} + +.header-menu__item--active-light { + background: #e3f2fd; + color: #1565c0; +} + +.header-menu { + position: relative; +} + +.header-menu__trigger { + min-width: 36px; + height: 36px; + padding: 0 10px; + font-size: 20px; + line-height: 1; + letter-spacing: 0.12em; +} + +.header-menu__trigger:hover, +.header-menu__trigger:focus-visible { + border-color: #3d82f7; + outline: none; +} + +.header-menu__panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 50; + min-width: 148px; + padding: 6px; + border: 1px solid #2c405f; + border-radius: 10px; + background: #0f1e34; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35); +} + +.header-menu__label { + padding: 6px 10px 4px; + font-size: 11px; + color: #6b7f9c; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.header-menu__item { + display: block; + width: 100%; + text-align: left; + padding: 8px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: #c1cde0; + font-size: 13px; + cursor: pointer; +} + +.header-menu__item:hover { + background: rgba(61, 130, 247, 0.14); color: #dce8ff; } -.nav-item.disabled { - opacity: 0.45; +.header-menu__item--active { + background: rgba(61, 130, 247, 0.22); + color: #dce8ff; } -.main { - padding: 18px; -} - -.topbar { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 16px; -} - -.topbar h1 { - margin: 0; - font-size: 26px; -} - -.topbar p { - margin: 6px 0 0; - color: #8ca2c2; -} - -.top-actions { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; +.app-main button, +.app-main select, +.app-main input, +.app-main textarea { + border: 1px solid #cbd5e1; + background: #fff; + color: #1f2937; + border-radius: 6px; + padding: 8px 10px; } button, select, input, textarea { - border: 1px solid #2a3f5e; - background: #0f1e34; - color: #d7e1f3; - border-radius: 8px; + border: 1px solid #cbd5e1; + background: #fff; + color: #1f2937; + border-radius: 6px; padding: 8px 10px; } @@ -106,12 +331,22 @@ button { margin-bottom: 12px; } +.app-main .card, +.app-main .panel { + border: 1px solid #e2e8f0; + background: #fff; + border-radius: 8px; + padding: 14px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); +} + .card, .panel { - border: 1px solid #233855; - background: rgba(13, 27, 46, 0.9); - border-radius: 12px; - padding: 12px; + border: 1px solid #e2e8f0; + background: #fff; + border-radius: 8px; + padding: 14px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); } .card h3, @@ -156,10 +391,14 @@ button { } a { - color: #73b1ff; + color: #1565c0; text-decoration: none; } +.muted { + color: #64748b; +} + table { width: 100%; border-collapse: collapse; @@ -167,11 +406,17 @@ table { th, td { - border-bottom: 1px solid #223651; + border-bottom: 1px solid #e2e8f0; padding: 8px; text-align: left; font-size: 13px; vertical-align: top; + color: #334155; +} + +th { + color: #64748b; + font-weight: 600; } .split { @@ -193,8 +438,8 @@ pre { margin-top: 12px; max-height: 240px; overflow-y: auto; - background: #091425; - border: 1px solid #223651; + background: #f8fafc; + border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; @@ -204,7 +449,7 @@ pre { .ws-log-line { padding: 3px 0; - border-bottom: 1px solid #152238; + border-bottom: 1px solid #e8edf3; word-break: break-word; } @@ -213,47 +458,55 @@ pre { } .ws-log-line--error { - color: #ff8a9a; + color: #c62828; } .ws-log-line--warning { - color: #f0c060; + color: #e65100; } .ws-log-line--info { - color: #b8c9e0; + color: #475569; } .ws-log-ts { - color: #6b8299; + color: #94a3b8; margin-right: 8px; } .pill { margin-top: 8px; - padding: 4px 8px; - border-radius: 999px; + padding: 6px 10px; + border-radius: 6px; width: fit-content; font-size: 12px; - background: #29405f; + background: #f1f5f9; + color: #334155; + border: 1px solid #e2e8f0; } .pill--high { - background: #5f1f2a; + background: #ffebee; + border-color: #ffcdd2; + color: #b71c1c; } .pill--medium { - background: #664813; + background: #fff8e1; + border-color: #ffe082; + color: #e65100; } .pill--low { - background: #1f5a3a; + background: #e8f5e9; + border-color: #c8e6c9; + color: #2e7d32; } .link-btn { border: none; background: transparent; - color: #73b1ff; + color: #1565c0; padding: 0; cursor: pointer; } @@ -264,27 +517,38 @@ pre { font-size: 12px; } -.conn-pill { +.app-main .conn-pill:not(.conn-pill--on-brand) { font-size: 12px; - border: 1px solid #2a3f5e; + border: 1px solid #cbd5e1; border-radius: 999px; padding: 4px 10px; - background: #0f1e34; + background: #f8fafc; + color: #475569; +} + +.conn-pill { + font-size: 12px; + border: 1px solid #cbd5e1; + border-radius: 999px; + padding: 4px 10px; + background: #f8fafc; } .conn-pill--up { - border-color: #1f5a3a; - color: #8de3b0; + border-color: #a5d6a7; + color: #2e7d32; + background: #e8f5e9; } .conn-pill--down { - border-color: #5f1f2a; - color: #f5a9b3; + border-color: #ef9a9a; + color: #c62828; + background: #ffebee; } .conn-pill--unknown { - border-color: #4a5e7e; - color: #c1cde0; + border-color: #cbd5e1; + color: #64748b; } .toast { @@ -327,8 +591,14 @@ pre { } .conn-pill--warn { - border-color: #664813; - color: #e7c46d; + border-color: #ffe082; + color: #e65100; + background: #fff8e1; +} + +.app-main .inline-spinner { + border-color: rgba(21, 101, 192, 0.2); + border-top-color: #1565c0; } .skeleton-line { @@ -375,10 +645,10 @@ pre { width: 18px; height: 18px; padding: 0; - border: 1px solid #4a5e7e; + border: 1px solid #cbd5e1; border-radius: 50%; - background: #14253e; - color: #a8bad5; + background: #f8fafc; + color: #64748b; font-size: 12px; font-weight: 700; line-height: 1; @@ -387,8 +657,8 @@ pre { .help-hint__trigger:hover, .help-hint__trigger:focus-visible { - border-color: #3d82f7; - color: #dce8ff; + border-color: #1565c0; + color: #1565c0; outline: none; } @@ -399,14 +669,14 @@ pre { z-index: 40; width: min(360px, 80vw); padding: 10px 12px; - border: 1px solid #2c405f; + border: 1px solid #e2e8f0; border-radius: 8px; - background: #0f1e34; - color: #c1cde0; + background: #fff; + color: #334155; font-size: 13px; font-weight: 400; line-height: 1.45; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); } .mini-chart__bar-wrap { diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx index f68e50a..247d025 100644 --- a/web/src/layout/AppLayout.tsx +++ b/web/src/layout/AppLayout.tsx @@ -1,26 +1,31 @@ -import { NavLink } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; import type { ReactNode } from "react"; +import { AppsGridIcon } from "../components/AppsGridIcon"; +import { HeaderMenu } from "../components/HeaderMenu"; +import { getPageTitleKey, isWorkbenchPath } from "../config/modules"; +import { useAppWindowRegistration } from "../hooks/useAppWindowRegistration"; +import { useI18n } from "../i18n"; +import { returnToWorkbench } from "../utils/workbench"; + +type ConnLevel = "up" | "down" | "unknown"; type Props = { - status: string; connections: { - netxApi: "up" | "down" | "unknown"; - oclawBridge: "up" | "down" | "unknown"; + netxApi: ConnLevel; netxApiLatencyMs?: number; + oclawBridge: ConnLevel; oclawBridgeLatencyMs?: number; oclawBridgeErrorKind?: string; - }; - onRefreshBatches: () => void; - onRefreshAlarms: () => void; - capabilities: { - alarms: boolean; - diagnostics: boolean; - aiAnalysis: boolean; + oclawBridgeError?: string; }; children: ReactNode; }; -export function AppLayout({ status, connections, onRefreshBatches, onRefreshAlarms, capabilities, children }: Props) { +export function AppLayout({ connections, children }: Props) { + const { t } = useI18n(); + const { pathname } = useLocation(); + const onWorkbench = isWorkbenchPath(pathname); + const pageTitle = t(getPageTitleKey(pathname)); const netxSuffix = typeof connections.netxApiLatencyMs === "number" ? ` (${connections.netxApiLatencyMs}ms)` : ""; const oclawSuffix = @@ -29,49 +34,57 @@ export function AppLayout({ status, connections, onRefreshBatches, onRefreshAlar : connections.oclawBridgeErrorKind ? ` (${connections.oclawBridgeErrorKind})` : ""; + const oclawTitle = + connections.oclawBridge === "down" && connections.oclawBridgeError + ? connections.oclawBridgeError + : undefined; + + useAppWindowRegistration(pathname); + return ( -
- +
+
+
+ {onWorkbench ? ( + + NetX + + ) : ( + NetX + )} + {!onWorkbench ? ( + <> + + + {pageTitle} + + ) : null} +
+
+ + {t("layout.netxApi")}: {connections.netxApi} + {netxSuffix} + + + {t("layout.oclawBridge")}: {connections.oclawBridge} + {oclawSuffix} + + +
+
-
-
-
-

运维中心

-
-
- netx api: {connections.netxApi}{netxSuffix} - oclaw bridge: {connections.oclawBridge}{oclawSuffix} - - -
-
- - {children} -
status: {status}
-
+
{children}
); } diff --git a/web/src/main.tsx b/web/src/main.tsx index 9096385..86a256e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,21 +1,27 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import './index.css' -import App from './App.tsx' -import { ErrorBoundary } from './layout/ErrorBoundary' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "./index.css"; +import App from "./App.tsx"; +import { ErrorBoundary } from "./layout/ErrorBoundary"; +import { I18nProvider } from "./i18n"; +import { ToastProvider } from "./hooks/useToast"; -const queryClient = new QueryClient() +const queryClient = new QueryClient(); -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( - - - + + + + + + + , -) +); diff --git a/web/src/pages/AiAnalysisPage.tsx b/web/src/pages/AiAnalysisPage.tsx deleted file mode 100644 index 57ed3fe..0000000 --- a/web/src/pages/AiAnalysisPage.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import type { AiAnalyzeHistoryItem, Batch } from "../types"; -import { fetchAiAnalyzeHistory } from "../services/api"; -import { formatSystemTime } from "../utils/time"; - -type Props = { - batches: Batch[]; - selectedBatch: string; - onSelectBatch: (v: string) => void; - aiAlarmCode: string; - aiNeName: string; - aiSeverity: string; - onChangeAiAlarmCode: (v: string) => void; - onChangeAiNeName: (v: string) => void; - onChangeAiSeverity: (v: string) => void; - onResetAiFilters: () => void; - question: string; - answer: string; - isRunning: boolean; - enabled: boolean; - onChangeQuestion: (v: string) => void; - onRunAnalyze: () => void; -}; - -export function AiAnalysisPage({ - batches, - selectedBatch, - onSelectBatch, - aiAlarmCode, - aiNeName, - aiSeverity, - onChangeAiAlarmCode, - onChangeAiNeName, - onChangeAiSeverity, - onResetAiFilters, - question, - answer, - isRunning, - enabled, - onChangeQuestion, - onRunAnalyze, -}: Props) { - const [histPage, setHistPage] = useState(1); - const [histPageSize, setHistPageSize] = useState(10); - const [selectedHistId, setSelectedHistId] = useState(null); - - const historyQuery = useQuery({ - queryKey: ["aiAnalyzeHistory", selectedBatch, histPage, histPageSize], - queryFn: () => fetchAiAnalyzeHistory({ batchId: selectedBatch, page: histPage, pageSize: histPageSize }), - enabled: Boolean(selectedBatch), - staleTime: 3000, - }); - - const historyItems: AiAnalyzeHistoryItem[] = useMemo(() => (historyQuery.data?.items || []) as AiAnalyzeHistoryItem[], [historyQuery.data]); - const total = Number(historyQuery.data?.total || 0); - const totalPages = Math.max(1, Math.ceil(Math.max(0, total) / Math.max(1, histPageSize))); - const selectedItem = useMemo( - () => historyItems.find((x) => Number(x.id) === Number(selectedHistId)), - [historyItems, selectedHistId], - ); - - return ( -
-
-

分析范围(前提条件)

-

- 此处筛选仅作用于 AI 分析请求中的 dataset_ref.filters,与告警列表页的过滤相互独立。 -

-
- - onChangeAiAlarmCode(e.target.value)} - disabled={!selectedBatch} - /> - onChangeAiNeName(e.target.value)} - disabled={!selectedBatch} - /> - -
-
- -
-
- -
-
-

AI 分析输入

-