mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 13:00:44 +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
79
web/WEB.md
Normal file
79
web/WEB.md
Normal file
|
|
@ -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` 增加 `<Route path="..." element={...} />`
|
||||||
|
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 配置)
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>web</title>
|
<title>NetX · UME</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
184
web/src/App.css
184
web/src/App.css
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
336
web/src/App.tsx
336
web/src/App.tsx
|
|
@ -1,230 +1,27 @@
|
||||||
import { useEffect, useState } from "react";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Navigate, Route, Routes, useNavigate, useSearchParams } from "react-router-dom";
|
|
||||||
import { AppLayout } from "./layout/AppLayout";
|
import { AppLayout } from "./layout/AppLayout";
|
||||||
import { AlarmsPage } from "./pages/AlarmsPage";
|
import { queryKeys } from "./constants/queryKeys";
|
||||||
import { DiagnosticsPage } from "./pages/DiagnosticsPage";
|
import { WorkbenchPage } from "./pages/WorkbenchPage";
|
||||||
import { AiAnalysisPage } from "./pages/AiAnalysisPage";
|
|
||||||
import { IngestPage } from "./pages/IngestPage";
|
|
||||||
import { UmePage } from "./pages/UmePage";
|
import { UmePage } from "./pages/UmePage";
|
||||||
import type { Alarm, Batch, Diagnostics, ImportHistoryItem } from "./types";
|
import { fetchIntegrationStatus } from "./services/api";
|
||||||
import { apiDelete, apiPost, apiPostForm, fetchAlarms, fetchBatches, fetchDiagnostics, fetchIntegrationStatus } from "./services/api";
|
|
||||||
import { fetchJobs } from "./services/api";
|
|
||||||
import { useToast } from "./hooks/useToast";
|
|
||||||
|
|
||||||
function App() {
|
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({
|
const integrationsQuery = useQuery({
|
||||||
queryKey: ["integrationsStatus"],
|
queryKey: queryKeys.integrationsStatus,
|
||||||
queryFn: fetchIntegrationStatus,
|
queryFn: fetchIntegrationStatus,
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
staleTime: 2000,
|
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 (
|
return (
|
||||||
<AppLayout
|
<AppLayout
|
||||||
status={status}
|
|
||||||
connections={{
|
connections={{
|
||||||
netxApi: integrationsQuery.data?.netx_api?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
|
netxApi: integrationsQuery.data?.netx_api?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
|
||||||
oclawBridge: integrationsQuery.data?.oclaw_bridge?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
|
netxApiLatencyMs:
|
||||||
netxApiLatencyMs: typeof integrationsQuery.data?.db?.latency_ms === "number" ? integrationsQuery.data.db.latency_ms : undefined,
|
typeof integrationsQuery.data?.db?.latency_ms === "number" ? integrationsQuery.data.db.latency_ms : undefined,
|
||||||
|
oclawBridge:
|
||||||
|
integrationsQuery.data?.oclaw_bridge?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
|
||||||
oclawBridgeLatencyMs:
|
oclawBridgeLatencyMs:
|
||||||
typeof integrationsQuery.data?.oclaw_bridge?.latency_ms === "number"
|
typeof integrationsQuery.data?.oclaw_bridge?.latency_ms === "number"
|
||||||
? integrationsQuery.data.oclaw_bridge.latency_ms
|
? integrationsQuery.data.oclaw_bridge.latency_ms
|
||||||
|
|
@ -233,111 +30,18 @@ function App() {
|
||||||
typeof integrationsQuery.data?.oclaw_bridge?.error_kind === "string"
|
typeof integrationsQuery.data?.oclaw_bridge?.error_kind === "string"
|
||||||
? integrationsQuery.data.oclaw_bridge.error_kind
|
? integrationsQuery.data.oclaw_bridge.error_kind
|
||||||
: undefined,
|
: 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>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/diagnostics" replace />} />
|
<Route path="/" element={<WorkbenchPage />} />
|
||||||
<Route
|
<Route path="/workbench" element={<Navigate to="/" replace />} />
|
||||||
path="/ingest"
|
<Route path="/ume" element={<UmePage />} />
|
||||||
element={
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
<IngestPage
|
</Routes>
|
||||||
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>}
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
web/src/components/AppsGridIcon.tsx
Normal file
7
web/src/components/AppsGridIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export function AppsGridIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
web/src/components/HeaderMenu.tsx
Normal file
67
web/src/components/HeaderMenu.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="header-menu header-menu--on-brand" ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="header-menu__trigger header-menu__trigger--on-brand"
|
||||||
|
aria-label={t("layout.moreMenu")}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="header-menu__panel header-menu__panel--light" role="menu">
|
||||||
|
<div className="header-menu__label header-menu__label--light">{t("layout.language")}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
className={`header-menu__item header-menu__item--light${locale === "zh" ? " header-menu__item--active-light" : ""}`}
|
||||||
|
aria-checked={locale === "zh"}
|
||||||
|
onClick={() => pick("zh")}
|
||||||
|
>
|
||||||
|
{t("layout.langZh")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
className={`header-menu__item header-menu__item--light${locale === "en" ? " header-menu__item--active-light" : ""}`}
|
||||||
|
aria-checked={locale === "en"}
|
||||||
|
onClick={() => pick("en")}
|
||||||
|
>
|
||||||
|
{t("layout.langEn")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
web/src/components/HelpHint.tsx
Normal file
46
web/src/components/HelpHint.tsx
Normal file
|
|
@ -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<HTMLSpanElement>(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 (
|
||||||
|
<span className="help-hint" ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="help-hint__trigger"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="help-hint__popover" role="tooltip">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
web/src/components/WorkbenchCardIcon.tsx
Normal file
15
web/src/components/WorkbenchCardIcon.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { ModuleIconTone } from "../config/modules";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tone: ModuleIconTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkbenchCardIcon({ tone }: Props) {
|
||||||
|
return (
|
||||||
|
<span className={`wb-card__icon wb-card__icon--${tone}`} aria-hidden>
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
|
||||||
|
<path d="M12 2a2 2 0 0 1 2 2v1.07A7.002 7.002 0 0 1 19.93 11H21a2 2 0 0 1 0 4h-1.07A7.002 7.002 0 0 1 13 20.93V22a2 2 0 0 1-4 0v-1.07A7.002 7.002 0 0 1 4.07 15H3a2 2 0 0 1 0-4h1.07A7.002 7.002 0 0 1 11 5.07V4a2 2 0 0 1 2-2zm0 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
web/src/config/modules.ts
Normal file
56
web/src/config/modules.ts
Normal file
|
|
@ -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";
|
||||||
|
}
|
||||||
21
web/src/constants/queryKeys.ts
Normal file
21
web/src/constants/queryKeys.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
14
web/src/hooks/useAppWindowRegistration.ts
Normal file
14
web/src/hooks/useAppWindowRegistration.ts
Normal file
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
|
@ -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<ToastState>(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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
44
web/src/hooks/useToast.tsx
Normal file
44
web/src/hooks/useToast.tsx
Normal file
|
|
@ -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<ToastApi | null>(null);
|
||||||
|
|
||||||
|
function useToastState(autoHideMs: number): ToastApi {
|
||||||
|
const [toast, setToast] = useState<ToastState>(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 (
|
||||||
|
<ToastContext.Provider value={api}>
|
||||||
|
{children}
|
||||||
|
{api.toast ? <div className={`toast toast--${api.toast.type}`}>{api.toast.text}</div> : null}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast(): ToastApi {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error("useToast must be used within ToastProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
78
web/src/i18n/context.tsx
Normal file
78
web/src/i18n/context.tsx
Normal file
|
|
@ -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<Locale, typeof zh> = { 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<string, unknown>)[k];
|
||||||
|
}
|
||||||
|
return typeof cur === "string" ? cur : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(text: string, vars?: Record<string, string | number>): 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, string | number>) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(() => {
|
||||||
|
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<string, string | number>) => {
|
||||||
|
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 <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
const ctx = useContext(I18nContext);
|
||||||
|
if (!ctx) throw new Error("useI18n must be used within I18nProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
138
web/src/i18n/en.ts
Normal file
138
web/src/i18n/en.ts
Normal file
|
|
@ -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;
|
||||||
1
web/src/i18n/index.ts
Normal file
1
web/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { I18nProvider, useI18n, type Locale } from "./context";
|
||||||
138
web/src/i18n/zh.ts
Normal file
138
web/src/i18n/zh.ts
Normal file
|
|
@ -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;
|
||||||
|
|
@ -5,88 +5,313 @@
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||||
background: #0a1220;
|
background: #eef2f7;
|
||||||
color: #d7e1f3;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app--shell {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 240px 1fr;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.app-brand {
|
||||||
border-right: 1px solid #203149;
|
display: flex;
|
||||||
background: linear-gradient(180deg, #0f1e34 0%, #0a1220 100%);
|
align-items: center;
|
||||||
padding: 20px 14px;
|
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-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 18px;
|
letter-spacing: 0.4px;
|
||||||
letter-spacing: 0.5px;
|
color: #fff;
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
display: block;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #98abc6;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active,
|
.app-brand__logo--static {
|
||||||
.nav-item:hover {
|
display: inline-block;
|
||||||
background: rgba(61, 130, 247, 0.18);
|
}
|
||||||
|
|
||||||
|
.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;
|
color: #dce8ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.disabled {
|
.header-menu__item--active {
|
||||||
opacity: 0.45;
|
background: rgba(61, 130, 247, 0.22);
|
||||||
|
color: #dce8ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.app-main button,
|
||||||
padding: 18px;
|
.app-main select,
|
||||||
}
|
.app-main input,
|
||||||
|
.app-main textarea {
|
||||||
.topbar {
|
border: 1px solid #cbd5e1;
|
||||||
display: flex;
|
background: #fff;
|
||||||
justify-content: space-between;
|
color: #1f2937;
|
||||||
align-items: flex-start;
|
border-radius: 6px;
|
||||||
margin-bottom: 16px;
|
padding: 8px 10px;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
select,
|
select,
|
||||||
input,
|
input,
|
||||||
textarea {
|
textarea {
|
||||||
border: 1px solid #2a3f5e;
|
border: 1px solid #cbd5e1;
|
||||||
background: #0f1e34;
|
background: #fff;
|
||||||
color: #d7e1f3;
|
color: #1f2937;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,12 +331,22 @@ button {
|
||||||
margin-bottom: 12px;
|
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,
|
.card,
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid #233855;
|
border: 1px solid #e2e8f0;
|
||||||
background: rgba(13, 27, 46, 0.9);
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h3,
|
.card h3,
|
||||||
|
|
@ -156,10 +391,14 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #73b1ff;
|
color: #1565c0;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
@ -167,11 +406,17 @@ table {
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
border-bottom: 1px solid #223651;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.split {
|
.split {
|
||||||
|
|
@ -193,8 +438,8 @@ pre {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #091425;
|
background: #f8fafc;
|
||||||
border: 1px solid #223651;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
|
@ -204,7 +449,7 @@ pre {
|
||||||
|
|
||||||
.ws-log-line {
|
.ws-log-line {
|
||||||
padding: 3px 0;
|
padding: 3px 0;
|
||||||
border-bottom: 1px solid #152238;
|
border-bottom: 1px solid #e8edf3;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,47 +458,55 @@ pre {
|
||||||
}
|
}
|
||||||
|
|
||||||
.ws-log-line--error {
|
.ws-log-line--error {
|
||||||
color: #ff8a9a;
|
color: #c62828;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ws-log-line--warning {
|
.ws-log-line--warning {
|
||||||
color: #f0c060;
|
color: #e65100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ws-log-line--info {
|
.ws-log-line--info {
|
||||||
color: #b8c9e0;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ws-log-ts {
|
.ws-log-ts {
|
||||||
color: #6b8299;
|
color: #94a3b8;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 4px 8px;
|
padding: 6px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 6px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: #29405f;
|
background: #f1f5f9;
|
||||||
|
color: #334155;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill--high {
|
.pill--high {
|
||||||
background: #5f1f2a;
|
background: #ffebee;
|
||||||
|
border-color: #ffcdd2;
|
||||||
|
color: #b71c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill--medium {
|
.pill--medium {
|
||||||
background: #664813;
|
background: #fff8e1;
|
||||||
|
border-color: #ffe082;
|
||||||
|
color: #e65100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill--low {
|
.pill--low {
|
||||||
background: #1f5a3a;
|
background: #e8f5e9;
|
||||||
|
border-color: #c8e6c9;
|
||||||
|
color: #2e7d32;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-btn {
|
.link-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #73b1ff;
|
color: #1565c0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
@ -264,27 +517,38 @@ pre {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conn-pill {
|
.app-main .conn-pill:not(.conn-pill--on-brand) {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border: 1px solid #2a3f5e;
|
border: 1px solid #cbd5e1;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 4px 10px;
|
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 {
|
.conn-pill--up {
|
||||||
border-color: #1f5a3a;
|
border-color: #a5d6a7;
|
||||||
color: #8de3b0;
|
color: #2e7d32;
|
||||||
|
background: #e8f5e9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conn-pill--down {
|
.conn-pill--down {
|
||||||
border-color: #5f1f2a;
|
border-color: #ef9a9a;
|
||||||
color: #f5a9b3;
|
color: #c62828;
|
||||||
|
background: #ffebee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conn-pill--unknown {
|
.conn-pill--unknown {
|
||||||
border-color: #4a5e7e;
|
border-color: #cbd5e1;
|
||||||
color: #c1cde0;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
|
|
@ -327,8 +591,14 @@ pre {
|
||||||
}
|
}
|
||||||
|
|
||||||
.conn-pill--warn {
|
.conn-pill--warn {
|
||||||
border-color: #664813;
|
border-color: #ffe082;
|
||||||
color: #e7c46d;
|
color: #e65100;
|
||||||
|
background: #fff8e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main .inline-spinner {
|
||||||
|
border-color: rgba(21, 101, 192, 0.2);
|
||||||
|
border-top-color: #1565c0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-line {
|
.skeleton-line {
|
||||||
|
|
@ -375,10 +645,10 @@ pre {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid #4a5e7e;
|
border: 1px solid #cbd5e1;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #14253e;
|
background: #f8fafc;
|
||||||
color: #a8bad5;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
@ -387,8 +657,8 @@ pre {
|
||||||
|
|
||||||
.help-hint__trigger:hover,
|
.help-hint__trigger:hover,
|
||||||
.help-hint__trigger:focus-visible {
|
.help-hint__trigger:focus-visible {
|
||||||
border-color: #3d82f7;
|
border-color: #1565c0;
|
||||||
color: #dce8ff;
|
color: #1565c0;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,14 +669,14 @@ pre {
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
width: min(360px, 80vw);
|
width: min(360px, 80vw);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid #2c405f;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #0f1e34;
|
background: #fff;
|
||||||
color: #c1cde0;
|
color: #334155;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.45;
|
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 {
|
.mini-chart__bar-wrap {
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,31 @@
|
||||||
import { NavLink } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import type { ReactNode } from "react";
|
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 = {
|
type Props = {
|
||||||
status: string;
|
|
||||||
connections: {
|
connections: {
|
||||||
netxApi: "up" | "down" | "unknown";
|
netxApi: ConnLevel;
|
||||||
oclawBridge: "up" | "down" | "unknown";
|
|
||||||
netxApiLatencyMs?: number;
|
netxApiLatencyMs?: number;
|
||||||
|
oclawBridge: ConnLevel;
|
||||||
oclawBridgeLatencyMs?: number;
|
oclawBridgeLatencyMs?: number;
|
||||||
oclawBridgeErrorKind?: string;
|
oclawBridgeErrorKind?: string;
|
||||||
};
|
oclawBridgeError?: string;
|
||||||
onRefreshBatches: () => void;
|
|
||||||
onRefreshAlarms: () => void;
|
|
||||||
capabilities: {
|
|
||||||
alarms: boolean;
|
|
||||||
diagnostics: boolean;
|
|
||||||
aiAnalysis: boolean;
|
|
||||||
};
|
};
|
||||||
children: ReactNode;
|
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 =
|
const netxSuffix =
|
||||||
typeof connections.netxApiLatencyMs === "number" ? ` (${connections.netxApiLatencyMs}ms)` : "";
|
typeof connections.netxApiLatencyMs === "number" ? ` (${connections.netxApiLatencyMs}ms)` : "";
|
||||||
const oclawSuffix =
|
const oclawSuffix =
|
||||||
|
|
@ -29,49 +34,57 @@ export function AppLayout({ status, connections, onRefreshBatches, onRefreshAlar
|
||||||
: connections.oclawBridgeErrorKind
|
: connections.oclawBridgeErrorKind
|
||||||
? ` (${connections.oclawBridgeErrorKind})`
|
? ` (${connections.oclawBridgeErrorKind})`
|
||||||
: "";
|
: "";
|
||||||
|
const oclawTitle =
|
||||||
|
connections.oclawBridge === "down" && connections.oclawBridgeError
|
||||||
|
? connections.oclawBridgeError
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
useAppWindowRegistration(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app app--shell">
|
||||||
<aside className="sidebar">
|
<header className="app-brand">
|
||||||
<div className="logo">netx</div>
|
<div className="app-brand__start">
|
||||||
<NavLink
|
{onWorkbench ? (
|
||||||
to="/diagnostics"
|
<Link to="/" className="app-brand__logo">
|
||||||
className={({ isActive }) => `nav-item${isActive ? " active" : ""}${!capabilities.diagnostics ? " disabled" : ""}`}
|
NetX
|
||||||
>
|
</Link>
|
||||||
诊断中心
|
) : (
|
||||||
</NavLink>
|
<span className="app-brand__logo app-brand__logo--static">NetX</span>
|
||||||
<NavLink to="/ingest" className={({ isActive }) => `nav-item${isActive ? " active" : ""}`}>
|
)}
|
||||||
数据接入
|
{!onWorkbench ? (
|
||||||
</NavLink>
|
<>
|
||||||
<NavLink to="/alarms" className={({ isActive }) => `nav-item${isActive ? " active" : ""}`}>
|
<span className="app-brand__sep" aria-hidden />
|
||||||
告警中心
|
<button
|
||||||
</NavLink>
|
type="button"
|
||||||
<NavLink
|
className="app-brand__apps"
|
||||||
to="/ai-analysis"
|
onClick={returnToWorkbench}
|
||||||
className={({ isActive }) => `nav-item${isActive ? " active" : ""}${!capabilities.aiAnalysis ? " disabled" : ""}`}
|
title={t("workbench.backToWorkbench")}
|
||||||
>
|
aria-label={t("workbench.backToWorkbench")}
|
||||||
AI 分析
|
>
|
||||||
</NavLink>
|
<AppsGridIcon />
|
||||||
<NavLink to="/ume" className={({ isActive }) => `nav-item${isActive ? " active" : ""}`}>
|
</button>
|
||||||
UME 对接
|
<span className="app-brand__module-title">{pageTitle}</span>
|
||||||
</NavLink>
|
</>
|
||||||
</aside>
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="app-brand__actions">
|
||||||
|
<span className={`conn-pill conn-pill--on-brand conn-pill--${connections.netxApi}`}>
|
||||||
|
{t("layout.netxApi")}: {connections.netxApi}
|
||||||
|
{netxSuffix}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`conn-pill conn-pill--on-brand conn-pill--${connections.oclawBridge}`}
|
||||||
|
title={oclawTitle}
|
||||||
|
>
|
||||||
|
{t("layout.oclawBridge")}: {connections.oclawBridge}
|
||||||
|
{oclawSuffix}
|
||||||
|
</span>
|
||||||
|
<HeaderMenu />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main className="main">
|
<main className="app-main">{children}</main>
|
||||||
<header className="topbar">
|
|
||||||
<div>
|
|
||||||
<h1>运维中心</h1>
|
|
||||||
</div>
|
|
||||||
<div className="top-actions">
|
|
||||||
<span className={`conn-pill conn-pill--${connections.netxApi}`}>netx api: {connections.netxApi}{netxSuffix}</span>
|
|
||||||
<span className={`conn-pill conn-pill--${connections.oclawBridge}`}>oclaw bridge: {connections.oclawBridge}{oclawSuffix}</span>
|
|
||||||
<button onClick={onRefreshBatches} disabled={!capabilities.alarms}>刷新批次</button>
|
|
||||||
<button onClick={onRefreshAlarms} disabled={!capabilities.alarms}>刷新告警</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
<footer className="status">status: {status}</footer>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,27 @@
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
import App from './App.tsx'
|
import App from "./App.tsx";
|
||||||
import { ErrorBoundary } from './layout/ErrorBoundary'
|
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(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<I18nProvider>
|
||||||
<App />
|
<ToastProvider>
|
||||||
</BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ToastProvider>
|
||||||
|
</I18nProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
|
|
||||||
|
|
@ -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<number | null>(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 (
|
|
||||||
<section className="panel">
|
|
||||||
<article className="card card--full">
|
|
||||||
<h3>分析范围(前提条件)</h3>
|
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
|
||||||
此处筛选仅作用于 AI 分析请求中的 <code>dataset_ref.filters</code>,与告警列表页的过滤相互独立。
|
|
||||||
</p>
|
|
||||||
<div className="filter-inline">
|
|
||||||
<select value={selectedBatch} onChange={(e) => onSelectBatch(e.target.value)}>
|
|
||||||
{batches.length === 0 && <option value="">暂无批次</option>}
|
|
||||||
{batches.map((b) => (
|
|
||||||
<option key={b.batch_id} value={b.batch_id}>
|
|
||||||
{b.batch_id} · success={b.success_rows} · failed={b.failed_rows}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
placeholder="告警码 contains"
|
|
||||||
value={aiAlarmCode}
|
|
||||||
onChange={(e) => onChangeAiAlarmCode(e.target.value)}
|
|
||||||
disabled={!selectedBatch}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
placeholder="网元 contains"
|
|
||||||
value={aiNeName}
|
|
||||||
onChange={(e) => onChangeAiNeName(e.target.value)}
|
|
||||||
disabled={!selectedBatch}
|
|
||||||
/>
|
|
||||||
<select value={aiSeverity} onChange={(e) => onChangeAiSeverity(e.target.value)} disabled={!selectedBatch}>
|
|
||||||
<option value="">全部级别</option>
|
|
||||||
<option value="critical">critical</option>
|
|
||||||
<option value="major">major</option>
|
|
||||||
<option value="minor">minor</option>
|
|
||||||
<option value="warning">warning</option>
|
|
||||||
<option value="info">info</option>
|
|
||||||
<option value="unknown">unknown</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="actions-row actions-row--inline" style={{ marginTop: 10 }}>
|
|
||||||
<button type="button" onClick={onResetAiFilters} disabled={!selectedBatch}>
|
|
||||||
重置 AI 筛选
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<div className="panel split" style={{ marginTop: 16 }}>
|
|
||||||
<article>
|
|
||||||
<h2>AI 分析输入</h2>
|
|
||||||
<textarea value={question} onChange={(e) => onChangeQuestion(e.target.value)} rows={6} disabled={!enabled} />
|
|
||||||
<button type="button" onClick={onRunAnalyze} disabled={!enabled || isRunning}>
|
|
||||||
{isRunning ? "分析中..." : "运行分析"}
|
|
||||||
</button>
|
|
||||||
{!selectedBatch && <p className="muted">请先选择批次。</p>}
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<h2>AI 回答</h2>
|
|
||||||
<pre>{answer || "暂无结果"}</pre>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article className="card card--full" style={{ marginTop: 16 }}>
|
|
||||||
<h3>AI 运维问答历史</h3>
|
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
|
||||||
默认展示当前批次的历史记录(分页)。点击“查看”可在下方展开回答内容。
|
|
||||||
</p>
|
|
||||||
{historyQuery.isLoading && <div className="skeleton-line">历史记录加载中...</div>}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>time</th>
|
|
||||||
<th>status</th>
|
|
||||||
<th>question</th>
|
|
||||||
<th style={{ width: 120 }}>action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{historyItems.map((h) => (
|
|
||||||
<tr key={h.id}>
|
|
||||||
<td>{formatSystemTime(h.created_at)}</td>
|
|
||||||
<td>{h.ok ? "ok" : "error"}</td>
|
|
||||||
<td style={{ maxWidth: 780, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{h.question}</td>
|
|
||||||
<td>
|
|
||||||
<button type="button" className="link-btn" onClick={() => setSelectedHistId(Number(h.id))}>
|
|
||||||
查看
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{!historyQuery.isLoading && historyItems.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4}>暂无历史记录</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="pager">
|
|
||||||
<div className="pager__meta">
|
|
||||||
共 {total} 条 · 第 {histPage}/{totalPages} 页
|
|
||||||
</div>
|
|
||||||
<div className="pager__controls">
|
|
||||||
<button className="pager__btn" onClick={() => setHistPage(Math.max(1, histPage - 1))} disabled={histPage <= 1}>
|
|
||||||
上一页
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="pager__btn"
|
|
||||||
onClick={() => setHistPage(Math.min(totalPages, histPage + 1))}
|
|
||||||
disabled={histPage >= totalPages}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</button>
|
|
||||||
<select value={histPageSize} onChange={(e) => setHistPageSize(Number(e.target.value) || 10)}>
|
|
||||||
<option value={10}>10/页</option>
|
|
||||||
<option value={20}>20/页</option>
|
|
||||||
<option value={50}>50/页</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 10 }}>
|
|
||||||
<h4 style={{ marginBottom: 6 }}>回答预览</h4>
|
|
||||||
{!selectedItem && <div className="muted">请选择一条历史记录查看回答。</div>}
|
|
||||||
{selectedItem && (
|
|
||||||
<>
|
|
||||||
{!selectedItem.ok && <div className="muted">错误:{String(selectedItem.error || "")}</div>}
|
|
||||||
<pre style={{ maxHeight: 420, overflow: "auto" }}>{String(selectedItem.answer || "").trim() || "(空)"}</pre>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
import type { Alarm, Batch } from "../types";
|
|
||||||
import { formatSystemTime } from "../utils/time";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
batches: Batch[];
|
|
||||||
alarms: Alarm[];
|
|
||||||
total: number;
|
|
||||||
isLoading: boolean;
|
|
||||||
selectedBatch: string;
|
|
||||||
alarmCode: string;
|
|
||||||
neName: string;
|
|
||||||
severity: string;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
onSelectBatch: (v: string) => void;
|
|
||||||
onChangeAlarmCode: (v: string) => void;
|
|
||||||
onChangeNeName: (v: string) => void;
|
|
||||||
onChangeSeverity: (v: string) => void;
|
|
||||||
onChangePage: (v: number) => void;
|
|
||||||
onChangePageSize: (v: number) => void;
|
|
||||||
onQueryNow: () => void;
|
|
||||||
onResetFilters: () => void;
|
|
||||||
onDeleteBatch: () => void;
|
|
||||||
isDeletingBatch: boolean;
|
|
||||||
onDeleteAllBatches: () => void;
|
|
||||||
isDeletingAllBatches: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AlarmsPage(props: Props) {
|
|
||||||
const {
|
|
||||||
batches,
|
|
||||||
alarms,
|
|
||||||
total,
|
|
||||||
isLoading,
|
|
||||||
selectedBatch,
|
|
||||||
alarmCode,
|
|
||||||
neName,
|
|
||||||
severity,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
onSelectBatch,
|
|
||||||
onChangeAlarmCode,
|
|
||||||
onChangeNeName,
|
|
||||||
onChangeSeverity,
|
|
||||||
onChangePage,
|
|
||||||
onChangePageSize,
|
|
||||||
onQueryNow,
|
|
||||||
onResetFilters,
|
|
||||||
onDeleteBatch,
|
|
||||||
isDeletingBatch,
|
|
||||||
onDeleteAllBatches,
|
|
||||||
isDeletingAllBatches,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(Math.max(0, total) / Math.max(1, pageSize)));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section className="cards">
|
|
||||||
<article className="card card--full">
|
|
||||||
<h3>批次与过滤</h3>
|
|
||||||
<div className="filter-inline">
|
|
||||||
<select value={selectedBatch} onChange={(e) => onSelectBatch(e.target.value)}>
|
|
||||||
{batches.map((b) => (
|
|
||||||
<option key={b.batch_id} value={b.batch_id}>
|
|
||||||
{b.batch_id} · success={b.success_rows} · failed={b.failed_rows}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input placeholder="告警码 contains" value={alarmCode} onChange={(e) => onChangeAlarmCode(e.target.value)} />
|
|
||||||
<input placeholder="网元 contains" value={neName} onChange={(e) => onChangeNeName(e.target.value)} />
|
|
||||||
<select value={severity} onChange={(e) => onChangeSeverity(e.target.value)}>
|
|
||||||
<option value="">全部级别</option>
|
|
||||||
<option value="critical">critical</option>
|
|
||||||
<option value="major">major</option>
|
|
||||||
<option value="minor">minor</option>
|
|
||||||
<option value="warning">warning</option>
|
|
||||||
<option value="info">info</option>
|
|
||||||
<option value="unknown">unknown</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="actions-row actions-row--inline">
|
|
||||||
<button onClick={onQueryNow}>查询</button>
|
|
||||||
<button onClick={onResetFilters}>重置过滤</button>
|
|
||||||
<button onClick={() => window.open(`/v1/batches/${encodeURIComponent(selectedBatch)}/errors.csv`, "_blank")} disabled={!selectedBatch}>
|
|
||||||
下载导入失败明细(CSV)
|
|
||||||
</button>
|
|
||||||
<button onClick={onDeleteBatch} disabled={!selectedBatch || isDeletingBatch}>
|
|
||||||
{isDeletingBatch ? "删除中..." : "删除当前批次"}
|
|
||||||
</button>
|
|
||||||
<button onClick={onDeleteAllBatches} disabled={isDeletingAllBatches}>
|
|
||||||
{isDeletingAllBatches ? "清空中..." : "清空全部批次"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel">
|
|
||||||
<h2>
|
|
||||||
告警列表 ({alarms.length}/{total})
|
|
||||||
</h2>
|
|
||||||
{isLoading && <div className="skeleton-line">告警数据加载中...</div>}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>time</th>
|
|
||||||
<th>severity</th>
|
|
||||||
<th>ne</th>
|
|
||||||
<th>alarm</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{alarms.map((a) => (
|
|
||||||
<tr key={a.id}>
|
|
||||||
<td>{formatSystemTime(a.alarm_time)}</td>
|
|
||||||
<td>{a.severity_norm}</td>
|
|
||||||
<td>{a.ne_name}</td>
|
|
||||||
<td>
|
|
||||||
<button className="link-btn" onClick={() => onChangeAlarmCode(a.alarm_code)}>
|
|
||||||
{a.alarm_code}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{!isLoading && alarms.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4}>暂无数据</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="pager">
|
|
||||||
<div className="pager__meta">共 {total} 条 · 第 {page}/{totalPages} 页</div>
|
|
||||||
<div className="pager__controls">
|
|
||||||
<button className="pager__btn" onClick={() => onChangePage(Math.max(1, page - 1))} disabled={page <= 1}>
|
|
||||||
上一页
|
|
||||||
</button>
|
|
||||||
{Array.from({ length: Math.min(7, totalPages) }).map((_, i) => {
|
|
||||||
const start = Math.max(1, Math.min(totalPages - 6, page - 3));
|
|
||||||
const p = start + i;
|
|
||||||
if (p > totalPages) return null;
|
|
||||||
return (
|
|
||||||
<button key={p} className={`pager__num${p === page ? " active" : ""}`} onClick={() => onChangePage(p)}>
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
className="pager__btn"
|
|
||||||
onClick={() => onChangePage(Math.min(totalPages, page + 1))}
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</button>
|
|
||||||
<select className="pager__size" value={String(pageSize)} onChange={(e) => onChangePageSize(Number(e.target.value) || 80)}>
|
|
||||||
<option value="50">50/页</option>
|
|
||||||
<option value="80">80/页</option>
|
|
||||||
<option value="100">100/页</option>
|
|
||||||
<option value="200">200/页</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import type { Diagnostics } from "../types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
diagnostics: Diagnostics | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function MiniChart({ title, items }: { title: string; items: Array<{ key: string; count: number }> }) {
|
|
||||||
const total = Math.max(1, Number(items.reduce((n, x) => n + Number(x.count || 0), 0)));
|
|
||||||
return (
|
|
||||||
<article className="card">
|
|
||||||
<h3>{title}</h3>
|
|
||||||
<div className="mini-chart">
|
|
||||||
{items.map((x) => (
|
|
||||||
<div className="mini-chart__row" key={`${title}-${x.key}-${x.count}`}>
|
|
||||||
<span className="mini-chart__label">{x.key || "unknown"}</span>
|
|
||||||
<div className="mini-chart__bar-wrap">
|
|
||||||
<div className="mini-chart__bar" style={{ width: `${Math.max(2, Math.round((x.count / total) * 100))}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="mini-chart__value">{x.count}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{items.length === 0 && <div className="muted">暂无数据</div>}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DiagnosticsPage({ diagnostics }: Props) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section className="panel">
|
|
||||||
<h2>统计看板(最新批次)</h2>
|
|
||||||
<div className="muted">
|
|
||||||
batch_id: {diagnostics?.batch_id || "-"} · total_alarms: {diagnostics?.total_alarms ?? "-"}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="cards">
|
|
||||||
<MiniChart title="级别分布" items={(diagnostics?.severity_summary || []) as any} />
|
|
||||||
<MiniChart title="告警类型 Top" items={(diagnostics?.top_alarm_codes || []) as any} />
|
|
||||||
<MiniChart title="网元 Top" items={(diagnostics?.top_ne || []) as any} />
|
|
||||||
<MiniChart title="协议/领域 Top" items={(diagnostics?.protocol_summary || []) as any} />
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import type { ImportHistoryItem } from "../types";
|
|
||||||
import { formatSystemTime } from "../utils/time";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isImportingAlarm: boolean;
|
|
||||||
alarmImportStatus: string;
|
|
||||||
isImportingLogs: boolean;
|
|
||||||
logsImportStatus: string;
|
|
||||||
history: ImportHistoryItem[];
|
|
||||||
onImportAlarmExcel: (file: File | null) => void;
|
|
||||||
onImportLogs: (file: File | null) => void;
|
|
||||||
onOpenBatch: (batchId: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function IngestPage({
|
|
||||||
isImportingAlarm,
|
|
||||||
alarmImportStatus,
|
|
||||||
isImportingLogs,
|
|
||||||
logsImportStatus,
|
|
||||||
history,
|
|
||||||
onImportAlarmExcel,
|
|
||||||
onImportLogs,
|
|
||||||
onOpenBatch,
|
|
||||||
}: Props) {
|
|
||||||
const [alarmFileName, setAlarmFileName] = useState("");
|
|
||||||
const [logFileName, setLogFileName] = useState("");
|
|
||||||
const items = useMemo(() => history.slice().sort((a, b) => b.ts_ms - a.ts_ms).slice(0, 10), [history]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section className="panel">
|
|
||||||
<h2>数据接入中心</h2>
|
|
||||||
<p className="muted">这里是 netx 的主页入口:导入告警 / 日志 / 配置等数据,后续扩展作业化能力。</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="cards">
|
|
||||||
<article className="card">
|
|
||||||
<h3>告警导入(Excel)</h3>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".xlsx,.xls"
|
|
||||||
disabled={isImportingAlarm}
|
|
||||||
onChange={(e) => {
|
|
||||||
const f = e.target.files?.[0] || null;
|
|
||||||
setAlarmFileName(f?.name || "");
|
|
||||||
onImportAlarmExcel(f);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="muted">{alarmFileName ? `已选择:${alarmFileName}` : "选择 Excel 后自动上传导入"}</div>
|
|
||||||
<div className="muted">{alarmImportStatus}</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="card">
|
|
||||||
<h3>日志导入(预留)</h3>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
disabled={isImportingLogs}
|
|
||||||
onChange={(e) => {
|
|
||||||
const f = e.target.files?.[0] || null;
|
|
||||||
setLogFileName(f?.name || "");
|
|
||||||
onImportLogs(f);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="muted">{logFileName ? `已选择:${logFileName}` : "选择日志文件后上传(当前仅占位接口)"}</div>
|
|
||||||
<div className="muted">{logsImportStatus}</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="card">
|
|
||||||
<h3>配置导入(预留)</h3>
|
|
||||||
<button disabled={true} title="后续支持:配置文件导入、解析、对比与风险评估">
|
|
||||||
配置导入(规划中)
|
|
||||||
</button>
|
|
||||||
<div className="muted">后续接入:配置采集/割接对比/配置诊断。</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel">
|
|
||||||
<h2>最近导入</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>time</th>
|
|
||||||
<th>kind</th>
|
|
||||||
<th>file</th>
|
|
||||||
<th>result</th>
|
|
||||||
<th>batch</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((x) => (
|
|
||||||
<tr key={`${x.kind}-${x.ts_ms}-${x.file_name}`}>
|
|
||||||
<td>{formatSystemTime(x.ts_ms)}</td>
|
|
||||||
<td>{x.kind}</td>
|
|
||||||
<td>{x.file_name}</td>
|
|
||||||
<td>{x.ok ? "ok" : "error"}</td>
|
|
||||||
<td>
|
|
||||||
{x.batch_id ? (
|
|
||||||
<button className="link-btn" onClick={() => onOpenBatch(x.batch_id!)}>
|
|
||||||
{x.batch_id}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{items.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5}>暂无导入记录</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export function JobsPage() {
|
|
||||||
return (
|
|
||||||
<section className="panel">
|
|
||||||
<h2>作业中心(预留)</h2>
|
|
||||||
<p>后续接入:网管同步作业、配置解析作业、日志解析作业、抓包分析作业。</p>
|
|
||||||
<p>当前已具备:批次导入统计、错误 CSV 下载、诊断与 AI 分析闭环。</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Fragment, useEffect, useRef, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
apiPost,
|
apiPost,
|
||||||
|
|
@ -13,69 +13,17 @@ import {
|
||||||
fetchUmeTokenStatus,
|
fetchUmeTokenStatus,
|
||||||
refreshUmeToken,
|
refreshUmeToken,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
|
import { HelpHint } from "../components/HelpHint";
|
||||||
|
import { queryKeys } from "../constants/queryKeys";
|
||||||
|
import { useI18n } from "../i18n";
|
||||||
|
import { useToast } from "../hooks/useToast";
|
||||||
|
import type { UmeAlarmSubscriptionStatus } from "../types";
|
||||||
|
import { pageCount, runtimeIntervalLabel } from "../utils/display";
|
||||||
import { formatSystemTime } from "../utils/time";
|
import { formatSystemTime } from "../utils/time";
|
||||||
|
|
||||||
export type UmePageProps = {
|
export function UmePage() {
|
||||||
/** Optional toasts from App shell (fixed bottom-right). */
|
const { t } = useI18n();
|
||||||
toastOk?: (message: string) => void;
|
const { showOk, showError } = useToast();
|
||||||
toastError?: (message: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const UME_ALARM_SUBSCRIPTION_HELP =
|
|
||||||
"订阅需手动建立/取消;建立后(或重启后若库中仍有有效订阅)后台会自动连接 WSS 接收实时告警。后台任务 alarms_current_ws_consumer 的 pause/resume 会停止或恢复 WSS 连接(不会取消 UME 订阅)。";
|
|
||||||
|
|
||||||
const RUNTIME_INTERVAL_LABEL_EN: Record<string, string> = {
|
|
||||||
未启用: "disabled",
|
|
||||||
实时: "realtime",
|
|
||||||
};
|
|
||||||
|
|
||||||
function runtimeIntervalLabel(label?: string | null): string {
|
|
||||||
const raw = String(label ?? "").trim();
|
|
||||||
if (!raw) return "—";
|
|
||||||
return RUNTIME_INTERVAL_LABEL_EN[raw] ?? raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HelpHint({ text }: { text: string }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const rootRef = useRef<HTMLSpanElement>(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 (
|
|
||||||
<span className="help-hint" ref={rootRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="help-hint__trigger"
|
|
||||||
aria-label="帮助说明"
|
|
||||||
aria-expanded={open}
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
>
|
|
||||||
?
|
|
||||||
</button>
|
|
||||||
{open ? (
|
|
||||||
<div className="help-hint__popover" role="tooltip">
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UmePage({ toastOk, toastError }: UmePageProps) {
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [tokenOpError, setTokenOpError] = useState("");
|
const [tokenOpError, setTokenOpError] = useState("");
|
||||||
const [runtimeTaskError, setRuntimeTaskError] = useState("");
|
const [runtimeTaskError, setRuntimeTaskError] = useState("");
|
||||||
|
|
@ -86,7 +34,6 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
const [nePage, setNePage] = useState(1);
|
const [nePage, setNePage] = useState(1);
|
||||||
const [nePageSize, setNePageSize] = useState(50);
|
const [nePageSize, setNePageSize] = useState(50);
|
||||||
const [expandedNeId, setExpandedNeId] = useState("");
|
const [expandedNeId, setExpandedNeId] = useState("");
|
||||||
|
|
||||||
const [curSeverity, setCurSeverity] = useState("");
|
const [curSeverity, setCurSeverity] = useState("");
|
||||||
const [curCleared, setCurCleared] = useState("");
|
const [curCleared, setCurCleared] = useState("");
|
||||||
const [curHostName, setCurHostName] = useState("");
|
const [curHostName, setCurHostName] = useState("");
|
||||||
|
|
@ -97,81 +44,74 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
const syncMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }),
|
mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeNE"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeCurrentAlarms"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeCurrentAlarmsAll });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const syncStatusQuery = useQuery({
|
const syncStatusQuery = useQuery({
|
||||||
queryKey: ["umeSyncStatus", syncPage, syncPageSize],
|
queryKey: queryKeys.umeSyncStatus(syncPage, syncPageSize),
|
||||||
queryFn: () => fetchUmeSyncStatus({ page: syncPage, pageSize: syncPageSize }),
|
queryFn: () => fetchUmeSyncStatus({ page: syncPage, pageSize: syncPageSize }),
|
||||||
staleTime: 5000,
|
staleTime: 5000,
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
const tokenStatusQuery = useQuery({
|
const tokenStatusQuery = useQuery({
|
||||||
queryKey: ["umeTokenStatus"],
|
queryKey: queryKeys.umeTokenStatus,
|
||||||
queryFn: fetchUmeTokenStatus,
|
queryFn: fetchUmeTokenStatus,
|
||||||
staleTime: 3000,
|
staleTime: 3000,
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
const tokenRefreshMutation = useMutation({
|
const tokenRefreshMutation = useMutation({
|
||||||
mutationFn: refreshUmeToken,
|
mutationFn: refreshUmeToken,
|
||||||
onMutate: () => {
|
onMutate: () => setTokenOpError(""),
|
||||||
setTokenOpError("");
|
|
||||||
},
|
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
if (!res?.ok) {
|
if (!res?.ok) {
|
||||||
const msg = String(res.error || res.error_kind || "token_refresh_failed");
|
const msg = String(res.error || res.error_kind || "token_refresh_failed");
|
||||||
setTokenOpError(msg);
|
setTokenOpError(msg);
|
||||||
toastError?.(msg);
|
showError(msg);
|
||||||
} else {
|
} else {
|
||||||
setTokenOpError("");
|
setTokenOpError("");
|
||||||
toastOk?.(res.changed ? "续期成功,token 已更新" : "续期完成(与此前一致)");
|
showOk(res.changed ? t("ume.token.renewOkChanged") : t("ume.token.renewOkSame"));
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const msg = String(err);
|
const msg = String(err);
|
||||||
setTokenOpError(msg);
|
setTokenOpError(msg);
|
||||||
toastError?.(msg);
|
showError(msg);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const subscriptionStatusQuery = useQuery({
|
const subscriptionStatusQuery = useQuery({
|
||||||
queryKey: ["umeAlarmSubscription"],
|
queryKey: queryKeys.umeAlarmSubscription,
|
||||||
queryFn: fetchUmeAlarmSubscriptionStatus,
|
queryFn: fetchUmeAlarmSubscriptionStatus,
|
||||||
staleTime: 3000,
|
staleTime: 3000,
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmClearLocalSubscription = (hint?: string) =>
|
const confirmClearLocalSubscription = (hint?: string) =>
|
||||||
window.confirm(
|
window.confirm(hint || t("ume.subscription.confirmClearDefault"));
|
||||||
`${hint || "UME 侧告警订阅已不存在或已过期(服务器订阅已丢失)。"}\n\n是否清除本地订阅记录?清除后可点击「建立告警订阅」重新订阅。`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const subscriptionEstablishMutation = useMutation({
|
const subscriptionEstablishMutation = useMutation({
|
||||||
mutationFn: (opts?: { forceReestablish?: boolean }) => establishUmeAlarmSubscription(opts),
|
mutationFn: (opts?: { forceReestablish?: boolean }) => establishUmeAlarmSubscription(opts),
|
||||||
onMutate: () => setSubscriptionOpError(""),
|
onMutate: () => setSubscriptionOpError(""),
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
if (!res?.active) {
|
if (!res?.active) {
|
||||||
const msg = "建立订阅未返回有效 id/uri";
|
const msg = t("ume.subscription.establishInvalid");
|
||||||
setSubscriptionOpError(msg);
|
setSubscriptionOpError(msg);
|
||||||
toastError?.(msg);
|
showError(msg);
|
||||||
} else {
|
} else {
|
||||||
setSubscriptionOpError("");
|
setSubscriptionOpError("");
|
||||||
toastOk?.(
|
showOk(res.already_exists ? t("ume.subscription.establishExists") : t("ume.subscription.establishOk"));
|
||||||
res.already_exists
|
|
||||||
? "订阅已存在,未重复建立(WSS 将保持/恢复连接)"
|
|
||||||
: "告警订阅已建立,WSS 将自动连接",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeAlarmSubscription"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const msg = String(err);
|
const msg = String(err);
|
||||||
setSubscriptionOpError(msg);
|
setSubscriptionOpError(msg);
|
||||||
toastError?.(msg);
|
showError(msg);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const subscriptionClearLocalMutation = useMutation({
|
const subscriptionClearLocalMutation = useMutation({
|
||||||
|
|
@ -179,74 +119,68 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
onMutate: () => setSubscriptionOpError(""),
|
onMutate: () => setSubscriptionOpError(""),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setSubscriptionOpError("");
|
setSubscriptionOpError("");
|
||||||
toastOk?.("已清除本地订阅记录,可重新建立订阅");
|
showOk(t("ume.subscription.clearLocalOk"));
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeAlarmSubscription"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const msg = String(err);
|
const msg = String(err);
|
||||||
setSubscriptionOpError(msg);
|
setSubscriptionOpError(msg);
|
||||||
toastError?.(msg);
|
showError(msg);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscriptionCancelMutation = useMutation({
|
const subscriptionCancelMutation = useMutation({
|
||||||
mutationFn: (opts?: { forceClearLocal?: boolean }) => cancelUmeAlarmSubscription(opts),
|
mutationFn: (opts?: { forceClearLocal?: boolean }) => cancelUmeAlarmSubscription(opts),
|
||||||
onMutate: () => setSubscriptionOpError(""),
|
onMutate: () => setSubscriptionOpError(""),
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
if (res?.needs_local_cleanup) {
|
if (res?.needs_local_cleanup) {
|
||||||
const msg =
|
const msg = res.message || t("ume.subscription.serverLostMsg");
|
||||||
res.message ||
|
|
||||||
"UME 侧告警订阅已丢失。请确认是否清除本地订阅记录后重新订阅。";
|
|
||||||
if (confirmClearLocalSubscription(msg)) {
|
if (confirmClearLocalSubscription(msg)) {
|
||||||
subscriptionCancelMutation.mutate({ forceClearLocal: true });
|
subscriptionCancelMutation.mutate({ forceClearLocal: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubscriptionOpError(msg);
|
setSubscriptionOpError(msg);
|
||||||
toastError?.(msg);
|
showError(msg);
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeAlarmSubscription"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (res?.active) {
|
if (res?.active) {
|
||||||
const msg = "取消订阅失败:UME 侧订阅可能仍存在,请查看错误详情后重试";
|
const msg = t("ume.subscription.cancelFailed");
|
||||||
setSubscriptionOpError(msg);
|
setSubscriptionOpError(msg);
|
||||||
toastError?.(msg);
|
showError(msg);
|
||||||
} else {
|
} else {
|
||||||
setSubscriptionOpError("");
|
setSubscriptionOpError("");
|
||||||
toastOk?.(res?.ume_already_missing ? "本地已清除(UME 侧订阅此前已不存在)" : "告警订阅已取消");
|
showOk(res?.ume_already_missing ? t("ume.subscription.cancelOkMissing") : t("ume.subscription.cancelOk"));
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeAlarmSubscription"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const msg = String(err);
|
const msg = String(err);
|
||||||
setSubscriptionOpError(msg);
|
setSubscriptionOpError(msg);
|
||||||
toastError?.(msg);
|
showError(msg);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokenDisconnectMutation = useMutation({
|
const tokenDisconnectMutation = useMutation({
|
||||||
mutationFn: disconnectUmeToken,
|
mutationFn: disconnectUmeToken,
|
||||||
onMutate: () => {
|
onMutate: () => setTokenOpError(""),
|
||||||
setTokenOpError("");
|
|
||||||
},
|
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
if (!res?.ok) {
|
if (!res?.ok) {
|
||||||
const msg = String(res.error || res.error_kind || "token_disconnect_failed");
|
const msg = String(res.error || res.error_kind || "token_disconnect_failed");
|
||||||
setTokenOpError(msg);
|
setTokenOpError(msg);
|
||||||
toastError?.(msg);
|
showError(msg);
|
||||||
} else {
|
} else {
|
||||||
setTokenOpError("");
|
setTokenOpError("");
|
||||||
toastOk?.("已断开 UME token");
|
showOk(t("ume.token.disconnectOk"));
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const msg = String(err);
|
const msg = String(err);
|
||||||
setTokenOpError(msg);
|
setTokenOpError(msg);
|
||||||
toastError?.(msg);
|
showError(msg);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -262,13 +196,14 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
: tokenExpiresIn < 60
|
: tokenExpiresIn < 60
|
||||||
? "unknown"
|
? "unknown"
|
||||||
: "up";
|
: "up";
|
||||||
|
|
||||||
const neQuery = useQuery({
|
const neQuery = useQuery({
|
||||||
queryKey: ["umeNE", neKeyword, nePage, nePageSize],
|
queryKey: queryKeys.umeNE(neKeyword, nePage, nePageSize),
|
||||||
queryFn: () => fetchUmeNe({ keyword: neKeyword, page: nePage, pageSize: nePageSize }),
|
queryFn: () => fetchUmeNe({ keyword: neKeyword, page: nePage, pageSize: nePageSize }),
|
||||||
staleTime: 5000,
|
staleTime: 5000,
|
||||||
});
|
});
|
||||||
const currentQuery = useQuery({
|
const currentQuery = useQuery({
|
||||||
queryKey: ["umeCurrentAlarms", curSeverity, curCleared, curHostName, curKeyword, curPage, curPageSize],
|
queryKey: queryKeys.umeCurrentAlarms(curSeverity, curCleared, curHostName, curKeyword, curPage, curPageSize),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchUmeCurrentAlarms({
|
fetchUmeCurrentAlarms({
|
||||||
severity: curSeverity,
|
severity: curSeverity,
|
||||||
|
|
@ -280,27 +215,26 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
}),
|
}),
|
||||||
staleTime: 5000,
|
staleTime: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const runningTasks = (syncStatusQuery.data?.items || []).filter((x) => String(x.status || "").toLowerCase() === "running");
|
const runningTasks = (syncStatusQuery.data?.items || []).filter((x) => String(x.status || "").toLowerCase() === "running");
|
||||||
const runtimeTasks = syncStatusQuery.data?.runtime_tasks || [];
|
const runtimeTasks = syncStatusQuery.data?.runtime_tasks || [];
|
||||||
const alarmSub =
|
const alarmSub: UmeAlarmSubscriptionStatus =
|
||||||
subscriptionStatusQuery.data ??
|
subscriptionStatusQuery.data ??
|
||||||
syncStatusQuery.data?.alarm_subscription ??
|
syncStatusQuery.data?.alarm_subscription ??
|
||||||
({ active: false } as const);
|
{ active: false };
|
||||||
const wsConsumer = runtimeTasks.find((t) => t.task === "alarms_current_ws_consumer");
|
const wsConsumer = runtimeTasks.find((x) => x.task === "alarms_current_ws_consumer");
|
||||||
const wsConn = subscriptionStatusQuery.data?.ws_connection;
|
const wsConn = subscriptionStatusQuery.data?.ws_connection;
|
||||||
const wsState = String(wsConn?.state || "");
|
const wsState = String(wsConn?.state || "");
|
||||||
const wsPaused = Boolean(wsConsumer?.paused);
|
const wsPaused = Boolean(wsConsumer?.paused);
|
||||||
const wsLabel =
|
const wsLabel =
|
||||||
wsConn?.label ||
|
wsConn?.label ||
|
||||||
(wsPaused ? "已暂停" : wsConsumer?.last_error || wsConsumer?.status || "-");
|
(wsPaused ? t("ume.subscription.wssPaused") : wsConsumer?.last_error || wsConsumer?.status || t("common.empty"));
|
||||||
const subscriptionActive = Boolean(alarmSub.active);
|
const subscriptionActive = Boolean(alarmSub.active);
|
||||||
const serverSubLost = Boolean(
|
const serverSubLost = Boolean(
|
||||||
subscriptionStatusQuery.data?.server_subscription_lost ?? alarmSub.server_subscription_lost,
|
subscriptionStatusQuery.data?.server_subscription_lost ?? alarmSub.server_subscription_lost,
|
||||||
);
|
);
|
||||||
const serverSubLostReason = String(
|
const serverSubLostReason = String(
|
||||||
subscriptionStatusQuery.data?.server_subscription_lost_reason ??
|
subscriptionStatusQuery.data?.server_subscription_lost_reason ?? alarmSub.server_subscription_lost_reason ?? "",
|
||||||
alarmSub.server_subscription_lost_reason ??
|
|
||||||
"",
|
|
||||||
);
|
);
|
||||||
const currentAlarmsMode =
|
const currentAlarmsMode =
|
||||||
subscriptionStatusQuery.data?.current_alarms_mode ?? alarmSub.current_alarms_mode ?? "rest";
|
subscriptionStatusQuery.data?.current_alarms_mode ?? alarmSub.current_alarms_mode ?? "rest";
|
||||||
|
|
@ -329,59 +263,66 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
|
|
||||||
const runtimeTaskMutation = useMutation({
|
const runtimeTaskMutation = useMutation({
|
||||||
mutationFn: async (vars: { task: string; action: "pause" | "resume" }) =>
|
mutationFn: async (vars: { task: string; action: "pause" | "resume" }) =>
|
||||||
apiPost<{ ok: boolean }>(
|
apiPost<{ ok: boolean }>(`/v1/ume/runtime/tasks/${encodeURIComponent(vars.task)}/${vars.action}`, {}),
|
||||||
`/v1/ume/runtime/tasks/${encodeURIComponent(vars.task)}/${vars.action}`,
|
onMutate: () => setRuntimeTaskError(""),
|
||||||
{},
|
|
||||||
),
|
|
||||||
onMutate: () => {
|
|
||||||
setRuntimeTaskError("");
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll });
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
setRuntimeTaskError(String(err));
|
|
||||||
},
|
},
|
||||||
|
onError: (err) => setRuntimeTaskError(String(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const syncTotal = Number(syncStatusQuery.data?.total || 0);
|
||||||
|
const syncPages = pageCount(syncTotal, syncPageSize);
|
||||||
|
const neTotal = Number(neQuery.data?.total || 0);
|
||||||
|
const nePages = pageCount(neTotal, nePageSize);
|
||||||
|
const curTotal = Number(currentQuery.data?.total || 0);
|
||||||
|
const curPages = pageCount(curTotal, curPageSize);
|
||||||
|
|
||||||
|
const perPage = (n: number) => t("common.perPage", { n: String(n) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="cards">
|
<section className="cards">
|
||||||
<article className="card card--full">
|
<article className="card card--full">
|
||||||
<h3>UME Token 状态</h3>
|
<h3>{t("ume.token.title")}</h3>
|
||||||
<div className="actions-row actions-row--inline">
|
<div className="actions-row actions-row--inline">
|
||||||
<span
|
<span
|
||||||
className={`conn-pill conn-pill--${!hasToken ? "down" : tokenNeedsRenewal ? "warn" : "up"}`}
|
className={`conn-pill conn-pill--${!hasToken ? "down" : tokenNeedsRenewal ? "warn" : "up"}`}
|
||||||
title={
|
title={tokenNeedsRenewal ? t("ume.token.renewHint") : undefined}
|
||||||
tokenNeedsRenewal
|
|
||||||
? "库中 token 缺少有效过期时间或已过期,请点「手动续期/登录」或等待后台自动续期"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
token: {!hasToken ? "disconnected" : tokenNeedsRenewal ? "connected (需续期)" : "connected"}
|
token:{" "}
|
||||||
|
{!hasToken
|
||||||
|
? t("ume.token.disconnected")
|
||||||
|
: tokenNeedsRenewal
|
||||||
|
? t("ume.token.connectedRenew")
|
||||||
|
: t("ume.token.connected")}
|
||||||
</span>
|
</span>
|
||||||
<span className={`conn-pill conn-pill--${tokenLevel}`}>
|
<span className={`conn-pill conn-pill--${tokenLevel}`}>
|
||||||
expires_in:{" "}
|
{t("ume.token.expiresIn")}:{" "}
|
||||||
{typeof tokenStatusQuery.data?.expires_in_s === "number"
|
{typeof tokenStatusQuery.data?.expires_in_s === "number"
|
||||||
? tokenNeedsRenewal
|
? tokenNeedsRenewal
|
||||||
? "需续期 / 0s"
|
? t("ume.token.needsRenew")
|
||||||
: `${tokenStatusQuery.data.expires_in_s}s`
|
: `${tokenStatusQuery.data.expires_in_s}s`
|
||||||
: "-"}
|
: t("common.empty")}
|
||||||
</span>
|
</span>
|
||||||
{tokenStatusQuery.data?.token_preview ? <span className="conn-pill">preview: {tokenStatusQuery.data.token_preview}</span> : null}
|
{tokenStatusQuery.data?.token_preview ? (
|
||||||
|
<span className="conn-pill">
|
||||||
|
{t("ume.token.preview")}: {tokenStatusQuery.data.token_preview}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="actions-row actions-row--inline">
|
<div className="actions-row actions-row--inline">
|
||||||
<button
|
<button
|
||||||
onClick={() => queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] })}
|
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus })}
|
||||||
disabled={tokenStatusQuery.isFetching}
|
disabled={tokenStatusQuery.isFetching}
|
||||||
>
|
>
|
||||||
{tokenStatusQuery.isFetching ? (
|
{tokenStatusQuery.isFetching ? (
|
||||||
<>
|
<>
|
||||||
<span className="inline-spinner" aria-hidden />
|
<span className="inline-spinner" aria-hidden />
|
||||||
刷新中…
|
{t("common.refreshing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"刷新状态"
|
t("ume.token.refreshStatus")
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -392,10 +333,10 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
{tokenRefreshMutation.isPending ? (
|
{tokenRefreshMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<span className="inline-spinner" aria-hidden />
|
<span className="inline-spinner" aria-hidden />
|
||||||
续期中…
|
{t("ume.token.renewing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"手动续期/登录"
|
t("ume.token.renewLogin")
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -406,27 +347,28 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
{tokenDisconnectMutation.isPending ? (
|
{tokenDisconnectMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<span className="inline-spinner" aria-hidden />
|
<span className="inline-spinner" aria-hidden />
|
||||||
断开中…
|
{t("ume.token.disconnecting")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"断开 token"
|
t("ume.token.disconnect")
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{(tokenOpError || tokenRefreshMutation.error || tokenDisconnectMutation.error) && (
|
{(tokenOpError || tokenRefreshMutation.error || tokenDisconnectMutation.error) && (
|
||||||
<div className="pill pill--high">
|
<div className="pill pill--high">
|
||||||
操作失败: {tokenOpError || String(tokenRefreshMutation.error || tokenDisconnectMutation.error)}
|
{t("common.opFailed")}: {tokenOpError || String(tokenRefreshMutation.error || tokenDisconnectMutation.error)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="card card--full">
|
<article className="card card--full">
|
||||||
<h3 className="card-title-with-hint">
|
<h3 className="card-title-with-hint">
|
||||||
UME 告警订阅(WebSocket)
|
{t("ume.subscription.title")}
|
||||||
<HelpHint text={UME_ALARM_SUBSCRIPTION_HELP} />
|
<HelpHint text={t("ume.subscription.help")} ariaLabel={t("common.help")} />
|
||||||
</h3>
|
</h3>
|
||||||
<div className="actions-row actions-row--inline">
|
<div className="actions-row actions-row--inline">
|
||||||
<span className={`conn-pill conn-pill--${subscriptionActive ? "up" : "down"}`}>
|
<span className={`conn-pill conn-pill--${subscriptionActive ? "up" : "down"}`}>
|
||||||
订阅: {subscriptionActive ? "已建立" : "未建立"}
|
{t("ume.subscription.sub")}: {subscriptionActive ? t("ume.subscription.subActive") : t("ume.subscription.subInactive")}
|
||||||
</span>
|
</span>
|
||||||
{subscriptionActive && alarmSub.subscription_id ? (
|
{subscriptionActive && alarmSub.subscription_id ? (
|
||||||
<span className="conn-pill" title={alarmSub.wss_uri || ""}>
|
<span className="conn-pill" title={alarmSub.wss_uri || ""}>
|
||||||
|
|
@ -444,12 +386,12 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
</div>
|
</div>
|
||||||
{scheduledSyncSkipped && !serverSubLost ? (
|
{scheduledSyncSkipped && !serverSubLost ? (
|
||||||
<div className="pill pill--low" style={{ marginTop: 8 }}>
|
<div className="pill pill--low" style={{ marginTop: 8 }}>
|
||||||
当前告警由 WSS 实时维护(模式: {currentAlarmsMode}),定时 REST 全量同步已暂停。需要全量对账时请使用下方「同步当前告警」。
|
{t("ume.subscription.wssModeBanner", { mode: currentAlarmsMode })}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{serverSubLost ? (
|
{serverSubLost ? (
|
||||||
<div className="pill pill--medium" style={{ marginTop: 8 }}>
|
<div className="pill pill--medium" style={{ marginTop: 8 }}>
|
||||||
UME 服务器侧告警订阅已丢失或已过期,本地记录可能仍显示「已建立」。请确认清除本地记录后重新订阅。
|
{t("ume.subscription.serverLostBanner")}
|
||||||
{serverSubLostReason ? (
|
{serverSubLostReason ? (
|
||||||
<div className="muted" style={{ marginTop: 6, fontSize: 12, wordBreak: "break-word" }}>
|
<div className="muted" style={{ marginTop: 6, fontSize: 12, wordBreak: "break-word" }}>
|
||||||
{serverSubLostReason.slice(0, 280)}
|
{serverSubLostReason.slice(0, 280)}
|
||||||
|
|
@ -462,94 +404,90 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (serverSubLost && subscriptionActive) {
|
if (serverSubLost && subscriptionActive) {
|
||||||
if (
|
if (window.confirm(t("ume.subscription.confirmReestablish"))) {
|
||||||
window.confirm(
|
|
||||||
"将清除本地订阅记录并在 UME 上重新建立告警订阅,是否继续?",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
subscriptionEstablishMutation.mutate({ forceReestablish: true });
|
subscriptionEstablishMutation.mutate({ forceReestablish: true });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
subscriptionEstablishMutation.mutate();
|
subscriptionEstablishMutation.mutate(undefined);
|
||||||
}}
|
}}
|
||||||
disabled={subPending || (!serverSubLost && subscriptionActive) || !hasToken}
|
disabled={subPending || (!serverSubLost && subscriptionActive) || !hasToken}
|
||||||
title={
|
title={
|
||||||
!hasToken
|
!hasToken
|
||||||
? "请先登录 UME token"
|
? t("ume.subscription.titleLoginFirst")
|
||||||
: serverSubLost
|
: serverSubLost
|
||||||
? "清除本地失效记录并重新向 UME 建立订阅"
|
? t("ume.subscription.titleReestablish")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{subscriptionEstablishMutation.isPending ? (
|
{subscriptionEstablishMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<span className="inline-spinner" aria-hidden />
|
<span className="inline-spinner" aria-hidden />
|
||||||
{serverSubLost ? "重新订阅…" : "建立订阅…"}
|
{serverSubLost ? t("ume.subscription.reestablishing") : t("ume.subscription.establishing")}
|
||||||
</>
|
</>
|
||||||
) : serverSubLost ? (
|
) : serverSubLost ? (
|
||||||
"清除本地并重新订阅"
|
t("ume.subscription.reestablish")
|
||||||
) : (
|
) : (
|
||||||
"建立告警订阅"
|
t("ume.subscription.establish")
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{serverSubLost && subscriptionActive ? (
|
{serverSubLost && subscriptionActive ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirmClearLocalSubscription()) {
|
if (confirmClearLocalSubscription()) subscriptionClearLocalMutation.mutate();
|
||||||
subscriptionClearLocalMutation.mutate();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={subPending}
|
disabled={subPending}
|
||||||
>
|
>
|
||||||
{subscriptionClearLocalMutation.isPending ? (
|
{subscriptionClearLocalMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<span className="inline-spinner" aria-hidden />
|
<span className="inline-spinner" aria-hidden />
|
||||||
清除中…
|
{t("ume.subscription.clearing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"仅清除本地记录"
|
t("ume.subscription.clearLocalOnly")
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => subscriptionCancelMutation.mutate()}
|
onClick={() => subscriptionCancelMutation.mutate(undefined)}
|
||||||
disabled={subPending || !subscriptionActive}
|
disabled={subPending || !subscriptionActive}
|
||||||
>
|
>
|
||||||
{subscriptionCancelMutation.isPending ? (
|
{subscriptionCancelMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<span className="inline-spinner" aria-hidden />
|
<span className="inline-spinner" aria-hidden />
|
||||||
取消订阅…
|
{t("ume.subscription.cancelling")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"取消告警订阅"
|
t("ume.subscription.cancel")
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => queryClient.invalidateQueries({ queryKey: ["umeAlarmSubscription"] })}
|
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription })}
|
||||||
disabled={subscriptionStatusQuery.isFetching}
|
disabled={subscriptionStatusQuery.isFetching}
|
||||||
>
|
>
|
||||||
刷新订阅状态
|
{t("ume.subscription.refreshStatus")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{subscriptionOpError ? (
|
{subscriptionOpError ? (
|
||||||
<div className="pill pill--high">订阅操作失败: {subscriptionOpError}</div>
|
<div className="pill pill--high">
|
||||||
|
{t("ume.subscription.opFailed")}: {subscriptionOpError}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<div className="actions-row actions-row--inline" style={{ marginTop: 0 }}>
|
<div className="actions-row actions-row--inline" style={{ marginTop: 0 }}>
|
||||||
<strong style={{ fontSize: 13 }}>WSS 运行日志</strong>
|
<strong style={{ fontSize: 13 }}>{t("ume.subscription.wsLogs")}</strong>
|
||||||
<span className="muted" style={{ fontSize: 12 }}>
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
{wsConsumer?.last_run_at
|
{wsConsumer?.last_run_at
|
||||||
? `最近活动 ${formatSystemTime(String(wsConsumer.last_run_at))}`
|
? t("ume.subscription.wsActivity", { time: formatSystemTime(String(wsConsumer.last_run_at)) })
|
||||||
: "自动刷新"}
|
: t("ume.subscription.wsAutoRefresh")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ws-log-panel" role="log" aria-live="polite">
|
<div className="ws-log-panel" role="log" aria-live="polite">
|
||||||
{wsLogs.length === 0 ? (
|
{wsLogs.length === 0 ? (
|
||||||
<div className="ws-log-line ws-log-line--info">暂无日志(建立订阅并连接 WSS 后会出现连接、收包、重连等记录)</div>
|
<div className="ws-log-line ws-log-line--info">{t("ume.subscription.wsLogsEmpty")}</div>
|
||||||
) : (
|
) : (
|
||||||
wsLogs.map((line, idx) => {
|
wsLogs.map((line, idx) => {
|
||||||
const lvl = String(line.level || "info").toLowerCase();
|
const lvl = String(line.level || "info").toLowerCase();
|
||||||
|
|
@ -570,31 +508,36 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="card card--full">
|
<article className="card card--full">
|
||||||
<h3>UME 同步</h3>
|
<h3>{t("ume.sync.title")}</h3>
|
||||||
<div className="actions-row actions-row--inline">
|
<div className="actions-row actions-row--inline">
|
||||||
<button onClick={() => syncMutation.mutate(["inventory"])} disabled={syncMutation.isPending}>
|
<button onClick={() => syncMutation.mutate(["inventory"])} disabled={syncMutation.isPending}>
|
||||||
同步 Inventory
|
{t("ume.sync.inventory")}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => syncMutation.mutate(["alarms_current"])} disabled={syncMutation.isPending}>
|
<button onClick={() => syncMutation.mutate(["alarms_current"])} disabled={syncMutation.isPending}>
|
||||||
同步当前告警
|
{t("ume.sync.alarmsCurrent")}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => syncMutation.mutate(["inventory", "alarms_current"])} disabled={syncMutation.isPending}>
|
<button onClick={() => syncMutation.mutate(["inventory", "alarms_current"])} disabled={syncMutation.isPending}>
|
||||||
全量同步
|
{t("ume.sync.full")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{syncMutation.error && <div className="pill pill--high">同步失败: {String(syncMutation.error)}</div>}
|
{syncMutation.error && (
|
||||||
|
<div className="pill pill--high">
|
||||||
|
{t("ume.sync.failed")}: {String(syncMutation.error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h2>当前任务</h2>
|
<h2>{t("ume.tasks.currentTitle")}</h2>
|
||||||
<div className="actions-row actions-row--inline">
|
<div className="actions-row actions-row--inline">
|
||||||
<span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}>
|
<span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}>
|
||||||
running: {runningTasks.length}
|
{t("ume.tasks.running")}: {runningTasks.length}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] })} disabled={syncStatusQuery.isFetching}>
|
<button onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} disabled={syncStatusQuery.isFetching}>
|
||||||
刷新
|
{t("common.refresh")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
|
|
@ -616,31 +559,30 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
<td>{x.trigger_mode}</td>
|
<td>{x.trigger_mode}</td>
|
||||||
<td>{x.status}</td>
|
<td>{x.status}</td>
|
||||||
<td>{formatSystemTime(x.started_at)}</td>
|
<td>{formatSystemTime(x.started_at)}</td>
|
||||||
<td>{x.error_message || "-"}</td>
|
<td>{x.error_message || t("common.empty")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!syncStatusQuery.isLoading && runningTasks.length === 0 && (
|
{!syncStatusQuery.isLoading && runningTasks.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6}>当前无运行中的任务</td>
|
<td colSpan={6}>{t("ume.tasks.noRunning")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<h3 style={{ marginTop: 12 }}>后台任务</h3>
|
|
||||||
|
<h3 style={{ marginTop: 12 }}>{t("ume.tasks.runtimeTitle")}</h3>
|
||||||
{runtimeTaskError ? (
|
{runtimeTaskError ? (
|
||||||
<div className="pill pill--high" style={{ marginBottom: 8 }}>
|
<div className="pill pill--high" style={{ marginBottom: 8 }}>
|
||||||
后台任务操作失败: {runtimeTaskError}
|
{t("ume.tasks.runtimeOpFailed")}: {runtimeTaskError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>task</th>
|
<th>task</th>
|
||||||
<th title="Configured sleep between loop iterations (clamped at process start)">interval</th>
|
<th title={t("ume.tasks.intervalTitle")}>interval</th>
|
||||||
<th>status</th>
|
<th>status</th>
|
||||||
<th title="Last finished sync for scheduled tasks; refreshed at each loop tick">
|
<th title={t("ume.tasks.lastRunTitle")}>last_run_at</th>
|
||||||
last_run_at
|
|
||||||
</th>
|
|
||||||
<th>last_error</th>
|
<th>last_error</th>
|
||||||
<th>actions</th>
|
<th>actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -653,8 +595,8 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
{runtimeIntervalLabel(x.interval_label)}
|
{runtimeIntervalLabel(x.interval_label)}
|
||||||
</td>
|
</td>
|
||||||
<td>{x.status}</td>
|
<td>{x.status}</td>
|
||||||
<td>{x.last_run_at ? formatSystemTime(x.last_run_at) : "-"}</td>
|
<td>{x.last_run_at ? formatSystemTime(x.last_run_at) : t("common.empty")}</td>
|
||||||
<td>{x.last_error || "-"}</td>
|
<td>{x.last_error || t("common.empty")}</td>
|
||||||
<td>
|
<td>
|
||||||
{Boolean(x.paused) ? (
|
{Boolean(x.paused) ? (
|
||||||
<button
|
<button
|
||||||
|
|
@ -680,7 +622,7 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
))}
|
))}
|
||||||
{!syncStatusQuery.isLoading && runtimeTasks.length === 0 && (
|
{!syncStatusQuery.isLoading && runtimeTasks.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6}>暂无后台任务状态</td>
|
<td colSpan={6}>{t("ume.tasks.noRuntime")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -688,10 +630,10 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h2>同步状态</h2>
|
<h2>{t("ume.syncStatus.title")}</h2>
|
||||||
<div className="actions-row actions-row--inline">
|
<div className="actions-row actions-row--inline">
|
||||||
<button onClick={() => queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] })} disabled={syncStatusQuery.isFetching}>
|
<button onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} disabled={syncStatusQuery.isFetching}>
|
||||||
刷新
|
{t("common.refresh")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
|
|
@ -703,7 +645,7 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
<th>pulled</th>
|
<th>pulled</th>
|
||||||
<th>inserted</th>
|
<th>inserted</th>
|
||||||
<th>updated</th>
|
<th>updated</th>
|
||||||
<th title="全量对账删除条数:inventory 为网元行,alarms_current 为当前告警行,其余域多为 0">deleted</th>
|
<th title={t("ume.tasks.deletedTitle")}>deleted</th>
|
||||||
<th>started_at</th>
|
<th>started_at</th>
|
||||||
<th>ended_at</th>
|
<th>ended_at</th>
|
||||||
<th>error</th>
|
<th>error</th>
|
||||||
|
|
@ -720,32 +662,25 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
<td>{x.updated_count}</td>
|
<td>{x.updated_count}</td>
|
||||||
<td>{Number(x.deleted ?? 0)}</td>
|
<td>{Number(x.deleted ?? 0)}</td>
|
||||||
<td>{formatSystemTime(x.started_at)}</td>
|
<td>{formatSystemTime(x.started_at)}</td>
|
||||||
<td>{x.ended_at ? formatSystemTime(x.ended_at) : "-"}</td>
|
<td>{x.ended_at ? formatSystemTime(x.ended_at) : t("common.empty")}</td>
|
||||||
<td>{x.error_message || "-"}</td>
|
<td>{x.error_message || t("common.empty")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!syncStatusQuery.isLoading && (syncStatusQuery.data?.items || []).length === 0 && (
|
{!syncStatusQuery.isLoading && (syncStatusQuery.data?.items || []).length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={10}>暂无同步记录</td>
|
<td colSpan={10}>{t("ume.syncStatus.empty")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="pager">
|
<div className="pager">
|
||||||
<div className="pager__meta">
|
<div className="pager__meta">{t("common.pagerMeta", { total: syncTotal, page: syncPage, pages: syncPages })}</div>
|
||||||
共 {syncStatusQuery.data?.total || 0} 条 · 第 {syncPage}/
|
|
||||||
{Math.max(1, Math.ceil(Math.max(0, Number(syncStatusQuery.data?.total || 0)) / Math.max(1, syncPageSize)))} 页
|
|
||||||
</div>
|
|
||||||
<div className="pager__controls">
|
<div className="pager__controls">
|
||||||
<button className="pager__btn" onClick={() => setSyncPage(Math.max(1, syncPage - 1))} disabled={syncPage <= 1}>
|
<button className="pager__btn" onClick={() => setSyncPage(Math.max(1, syncPage - 1))} disabled={syncPage <= 1}>
|
||||||
上一页
|
{t("common.prevPage")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="pager__btn" onClick={() => setSyncPage(syncPage + 1)} disabled={syncPage >= syncPages}>
|
||||||
className="pager__btn"
|
{t("common.nextPage")}
|
||||||
onClick={() => setSyncPage(syncPage + 1)}
|
|
||||||
disabled={syncPage >= Math.max(1, Math.ceil(Math.max(0, Number(syncStatusQuery.data?.total || 0)) / Math.max(1, syncPageSize)))}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</button>
|
</button>
|
||||||
<select
|
<select
|
||||||
className="pager__size"
|
className="pager__size"
|
||||||
|
|
@ -755,30 +690,32 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
setSyncPage(1);
|
setSyncPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="20">20/页</option>
|
<option value="20">{perPage(20)}</option>
|
||||||
<option value="50">50/页</option>
|
<option value="50">{perPage(50)}</option>
|
||||||
<option value="100">100/页</option>
|
<option value="100">{perPage(100)}</option>
|
||||||
<option value="200">200/页</option>
|
<option value="200">{perPage(200)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h2>网元清单</h2>
|
<h2>{t("ume.ne.title")}</h2>
|
||||||
<div className="filter-inline">
|
<div className="filter-inline">
|
||||||
<input value={neKeyword} placeholder="keyword(ne_id/ne_name/user_label/ip/host_name)" onChange={(e) => setNeKeyword(e.target.value)} />
|
<input value={neKeyword} placeholder={t("ume.ne.keywordPh")} onChange={(e) => setNeKeyword(e.target.value)} />
|
||||||
<button type="button" onClick={() => queryClient.invalidateQueries({ queryKey: ["umeNE"] })}>查询</button>
|
<button type="button" onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll })}>
|
||||||
|
{t("common.query")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="清空 keyword,回到第 1 页"
|
title={t("ume.ne.clearTitle")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNeKeyword("");
|
setNeKeyword("");
|
||||||
setNePage(1);
|
setNePage(1);
|
||||||
}}
|
}}
|
||||||
disabled={!neKeyword.trim()}
|
disabled={!neKeyword.trim()}
|
||||||
>
|
>
|
||||||
清除筛选
|
{t("common.clearFilters")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
|
|
@ -802,7 +739,7 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
<button
|
<button
|
||||||
className="link-btn"
|
className="link-btn"
|
||||||
onClick={() => setExpandedNeId(expandedNeId === x.ne_id ? "" : x.ne_id)}
|
onClick={() => setExpandedNeId(expandedNeId === x.ne_id ? "" : x.ne_id)}
|
||||||
title={expandedNeId === x.ne_id ? "收起详情" : "展开详情"}
|
title={expandedNeId === x.ne_id ? t("ume.ne.collapse") : t("ume.ne.expand")}
|
||||||
>
|
>
|
||||||
{x.ne_id}
|
{x.ne_id}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -810,27 +747,27 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
<td>{x.user_label}</td>
|
<td>{x.user_label}</td>
|
||||||
<td>{x.ip_address}</td>
|
<td>{x.ip_address}</td>
|
||||||
<td>{x.ne_type}</td>
|
<td>{x.ne_type}</td>
|
||||||
<td>{x.device_level || "-"}</td>
|
<td>{x.device_level || t("common.empty")}</td>
|
||||||
<td>{x.host_name || "-"}</td>
|
<td>{x.host_name || t("common.empty")}</td>
|
||||||
<td>{x.hardware_version || "-"}</td>
|
<td>{x.hardware_version || t("common.empty")}</td>
|
||||||
<td>{x.last_seen_at ? formatSystemTime(x.last_seen_at) : "-"}</td>
|
<td>{x.last_seen_at ? formatSystemTime(x.last_seen_at) : t("common.empty")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expandedNeId === x.ne_id ? (
|
{expandedNeId === x.ne_id ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8}>
|
<td colSpan={8}>
|
||||||
<div style={{ fontSize: 12, display: "grid", gridTemplateColumns: "repeat(3, minmax(180px, 1fr))", gap: 8 }}>
|
<div style={{ fontSize: 12, display: "grid", gridTemplateColumns: "repeat(3, minmax(180px, 1fr))", gap: 8 }}>
|
||||||
<div>consistent_state: {x.consistent_state || "-"}</div>
|
<div>consistent_state: {x.consistent_state || t("common.empty")}</div>
|
||||||
<div>admin_status: {x.admin_status || "-"}</div>
|
<div>admin_status: {x.admin_status || t("common.empty")}</div>
|
||||||
<div>connection_status: {x.connection_status || "-"}</div>
|
<div>connection_status: {x.connection_status || t("common.empty")}</div>
|
||||||
<div>maintain_status: {x.maintain_status || "-"}</div>
|
<div>maintain_status: {x.maintain_status || t("common.empty")}</div>
|
||||||
<div>address_type: {x.address_type || "-"}</div>
|
<div>address_type: {x.address_type || t("common.empty")}</div>
|
||||||
<div>location: {x.location || "-"}</div>
|
<div>location: {x.location || t("common.empty")}</div>
|
||||||
<div>loopback: {x.loopback || "-"}</div>
|
<div>loopback: {x.loopback || t("common.empty")}</div>
|
||||||
<div>net_mask: {x.net_mask || "-"}</div>
|
<div>net_mask: {x.net_mask || t("common.empty")}</div>
|
||||||
<div>mac: {x.mac || "-"}</div>
|
<div>mac: {x.mac || t("common.empty")}</div>
|
||||||
<div>interface_version: {x.interface_version || "-"}</div>
|
<div>interface_version: {x.interface_version || t("common.empty")}</div>
|
||||||
<div>create_time: {x.create_time || "-"}</div>
|
<div>create_time: {x.create_time || t("common.empty")}</div>
|
||||||
<div>creator: {x.creator || "-"}</div>
|
<div>creator: {x.creator || t("common.empty")}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -840,20 +777,13 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="pager">
|
<div className="pager">
|
||||||
<div className="pager__meta">
|
<div className="pager__meta">{t("common.pagerMeta", { total: neTotal, page: nePage, pages: nePages })}</div>
|
||||||
共 {neQuery.data?.total || 0} 条 · 第 {nePage}/
|
|
||||||
{Math.max(1, Math.ceil(Math.max(0, Number(neQuery.data?.total || 0)) / Math.max(1, nePageSize)))} 页
|
|
||||||
</div>
|
|
||||||
<div className="pager__controls">
|
<div className="pager__controls">
|
||||||
<button className="pager__btn" onClick={() => setNePage(Math.max(1, nePage - 1))} disabled={nePage <= 1}>
|
<button className="pager__btn" onClick={() => setNePage(Math.max(1, nePage - 1))} disabled={nePage <= 1}>
|
||||||
上一页
|
{t("common.prevPage")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="pager__btn" onClick={() => setNePage(nePage + 1)} disabled={nePage >= nePages}>
|
||||||
className="pager__btn"
|
{t("common.nextPage")}
|
||||||
onClick={() => setNePage(nePage + 1)}
|
|
||||||
disabled={nePage >= Math.max(1, Math.ceil(Math.max(0, Number(neQuery.data?.total || 0)) / Math.max(1, nePageSize)))}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</button>
|
</button>
|
||||||
<select
|
<select
|
||||||
className="pager__size"
|
className="pager__size"
|
||||||
|
|
@ -863,27 +793,23 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
setNePage(1);
|
setNePage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="20">20/页</option>
|
<option value="20">{perPage(20)}</option>
|
||||||
<option value="50">50/页</option>
|
<option value="50">{perPage(50)}</option>
|
||||||
<option value="100">100/页</option>
|
<option value="100">{perPage(100)}</option>
|
||||||
<option value="200">200/页</option>
|
<option value="200">{perPage(200)}</option>
|
||||||
<option value="500">500/页</option>
|
<option value="500">{perPage(500)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h2>当前告警</h2>
|
<h2>{t("ume.alarms.title")}</h2>
|
||||||
<div className="filter-inline">
|
<div className="filter-inline">
|
||||||
<input
|
<input value={curKeyword} placeholder={t("ume.alarms.keywordPh")} onChange={(e) => setCurKeyword(e.target.value)} />
|
||||||
value={curKeyword}
|
<input value={curHostName} placeholder={t("ume.alarms.hostNamePh")} onChange={(e) => setCurHostName(e.target.value)} />
|
||||||
placeholder="keyword(告警键/原因/ne_name/host_name/ip 等)"
|
|
||||||
onChange={(e) => setCurKeyword(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input value={curHostName} placeholder="host_name(含匹配)" onChange={(e) => setCurHostName(e.target.value)} />
|
|
||||||
<select value={curSeverity} onChange={(e) => setCurSeverity(e.target.value)}>
|
<select value={curSeverity} onChange={(e) => setCurSeverity(e.target.value)}>
|
||||||
<option value="">全部级别</option>
|
<option value="">{t("ume.alarms.allSeverity")}</option>
|
||||||
<option value="critical">critical</option>
|
<option value="critical">critical</option>
|
||||||
<option value="major">major</option>
|
<option value="major">major</option>
|
||||||
<option value="minor">minor</option>
|
<option value="minor">minor</option>
|
||||||
|
|
@ -891,13 +817,13 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
<option value="info">info</option>
|
<option value="info">info</option>
|
||||||
</select>
|
</select>
|
||||||
<select value={curCleared} onChange={(e) => setCurCleared(e.target.value)}>
|
<select value={curCleared} onChange={(e) => setCurCleared(e.target.value)}>
|
||||||
<option value="">is_cleared: all</option>
|
<option value="">{t("ume.alarms.clearedAll")}</option>
|
||||||
<option value="true">true</option>
|
<option value="true">true</option>
|
||||||
<option value="false">false</option>
|
<option value="false">false</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="清空 keyword、host_name、级别、is_cleared,回到第 1 页"
|
title={t("ume.alarms.clearTitle")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurKeyword("");
|
setCurKeyword("");
|
||||||
setCurHostName("");
|
setCurHostName("");
|
||||||
|
|
@ -907,7 +833,7 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
}}
|
}}
|
||||||
disabled={!curKeyword.trim() && !curHostName.trim() && !curSeverity && !curCleared}
|
disabled={!curKeyword.trim() && !curHostName.trim() && !curSeverity && !curCleared}
|
||||||
>
|
>
|
||||||
清除筛选
|
{t("common.clearFilters")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
|
|
@ -937,13 +863,13 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
setCurKeyword("");
|
setCurKeyword("");
|
||||||
setCurPage(1);
|
setCurPage(1);
|
||||||
}}
|
}}
|
||||||
title="按该主机名筛选"
|
title={t("ume.alarms.filterByHost")}
|
||||||
>
|
>
|
||||||
{x.host_name}
|
{x.host_name}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted" title="无 host_name(需先同步网元)">
|
<span className="muted" title={t("ume.alarms.noHostName")}>
|
||||||
-
|
{t("common.empty")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -954,20 +880,13 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="pager">
|
<div className="pager">
|
||||||
<div className="pager__meta">
|
<div className="pager__meta">{t("common.pagerMeta", { total: curTotal, page: curPage, pages: curPages })}</div>
|
||||||
共 {currentQuery.data?.total || 0} 条 · 第 {curPage}/
|
|
||||||
{Math.max(1, Math.ceil(Math.max(0, Number(currentQuery.data?.total || 0)) / Math.max(1, curPageSize)))} 页
|
|
||||||
</div>
|
|
||||||
<div className="pager__controls">
|
<div className="pager__controls">
|
||||||
<button className="pager__btn" onClick={() => setCurPage(Math.max(1, curPage - 1))} disabled={curPage <= 1}>
|
<button className="pager__btn" onClick={() => setCurPage(Math.max(1, curPage - 1))} disabled={curPage <= 1}>
|
||||||
上一页
|
{t("common.prevPage")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="pager__btn" onClick={() => setCurPage(curPage + 1)} disabled={curPage >= curPages}>
|
||||||
className="pager__btn"
|
{t("common.nextPage")}
|
||||||
onClick={() => setCurPage(curPage + 1)}
|
|
||||||
disabled={curPage >= Math.max(1, Math.ceil(Math.max(0, Number(currentQuery.data?.total || 0)) / Math.max(1, curPageSize)))}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</button>
|
</button>
|
||||||
<select
|
<select
|
||||||
className="pager__size"
|
className="pager__size"
|
||||||
|
|
@ -977,10 +896,10 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
setCurPage(1);
|
setCurPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="50">50/页</option>
|
<option value="50">{perPage(50)}</option>
|
||||||
<option value="100">100/页</option>
|
<option value="100">{perPage(100)}</option>
|
||||||
<option value="200">200/页</option>
|
<option value="200">{perPage(200)}</option>
|
||||||
<option value="500">500/页</option>
|
<option value="500">{perPage(500)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
37
web/src/pages/WorkbenchPage.tsx
Normal file
37
web/src/pages/WorkbenchPage.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { useI18n } from "../i18n";
|
||||||
|
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
|
||||||
|
import { modulesInSection, type WorkbenchSection } from "../config/modules";
|
||||||
|
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||||
|
|
||||||
|
const SECTIONS: WorkbenchSection[] = ["monitoring"];
|
||||||
|
|
||||||
|
export function WorkbenchPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="workbench">
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<section key={section} className="wb-section">
|
||||||
|
<h2 className="wb-section__title">{t(`workbench.${section}`)}</h2>
|
||||||
|
<div className="wb-grid">
|
||||||
|
{modulesInSection(section).map((mod) => (
|
||||||
|
<button
|
||||||
|
key={mod.moduleId}
|
||||||
|
type="button"
|
||||||
|
className="wb-card"
|
||||||
|
title={t("workbench.openModule")}
|
||||||
|
onClick={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
|
||||||
|
>
|
||||||
|
<WorkbenchCardIcon tone={mod.iconTone} />
|
||||||
|
<span className="wb-card__text">
|
||||||
|
<span className="wb-card__label">{t(mod.labelKey)}</span>
|
||||||
|
{mod.descKey ? <span className="wb-card__desc">{t(mod.descKey)}</span> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
import type {
|
import type {
|
||||||
AiAnalyzeHistoryResponse,
|
|
||||||
AlarmQueryResponse,
|
|
||||||
Batch,
|
|
||||||
Diagnostics,
|
|
||||||
ImportHistoryItem,
|
|
||||||
IntegrationStatus,
|
IntegrationStatus,
|
||||||
UmeAlarmItem,
|
UmeAlarmItem,
|
||||||
UmeAlarmSubscriptionStatus,
|
UmeAlarmSubscriptionStatus,
|
||||||
|
|
@ -30,77 +25,8 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||||
return data as T;
|
return data as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiPostForm = async <T,>(path: string, form: FormData): Promise<T> => {
|
|
||||||
const res = await fetch(path, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { accept: "application/json" },
|
|
||||||
body: form,
|
|
||||||
});
|
|
||||||
const text = await res.text();
|
|
||||||
const data = text ? JSON.parse(text) : {};
|
|
||||||
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
|
|
||||||
return data as T;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const apiDelete = async <T,>(path: string): Promise<T> => {
|
|
||||||
const res = await fetch(path, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { accept: "application/json" },
|
|
||||||
});
|
|
||||||
const text = await res.text();
|
|
||||||
const data = text ? JSON.parse(text) : {};
|
|
||||||
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
|
|
||||||
return data as T;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchBatches = () => apiGet<{ items: Batch[] }>("/v1/batches?limit=30");
|
|
||||||
|
|
||||||
export const fetchIntegrationStatus = () => apiGet<IntegrationStatus>("/v1/integrations/status");
|
export const fetchIntegrationStatus = () => apiGet<IntegrationStatus>("/v1/integrations/status");
|
||||||
|
|
||||||
export const fetchJobs = () =>
|
|
||||||
apiGet<{ items: Array<{ id: number; kind: string; file_name: string; batch_id?: string | null; ok: boolean; summary: string; created_at: string }> }>(
|
|
||||||
"/v1/jobs?limit=20",
|
|
||||||
).then((x) => {
|
|
||||||
const items: ImportHistoryItem[] = (x.items || []).map((j) => ({
|
|
||||||
kind: (j.kind as ImportHistoryItem["kind"]) || "alarms",
|
|
||||||
ts_ms: Date.parse(j.created_at) || Date.now(),
|
|
||||||
file_name: j.file_name || "",
|
|
||||||
batch_id: j.batch_id || undefined,
|
|
||||||
ok: Boolean(j.ok),
|
|
||||||
summary: j.summary || "",
|
|
||||||
}));
|
|
||||||
return { items };
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchDiagnostics = (batchId: string) =>
|
|
||||||
apiGet<Diagnostics>(`/v1/diagnostics?batch_id=${encodeURIComponent(batchId)}`);
|
|
||||||
|
|
||||||
export const fetchAiAnalyzeHistory = (params: { batchId?: string; page: number; pageSize: number }) => {
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
if (params.batchId) p.set("batch_id", params.batchId);
|
|
||||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
|
||||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
|
|
||||||
return apiGet<AiAnalyzeHistoryResponse>(`/v1/ap/history?${p.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchAlarms = (params: {
|
|
||||||
batchId: string;
|
|
||||||
alarmCode: string;
|
|
||||||
neName: string;
|
|
||||||
severity: string;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
}) => {
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
if (params.batchId) p.set("batch_id", params.batchId);
|
|
||||||
if (params.alarmCode) p.set("alarm_code", params.alarmCode);
|
|
||||||
if (params.neName) p.set("ne_name", params.neName);
|
|
||||||
if (params.severity) p.set("severity", params.severity);
|
|
||||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
|
||||||
p.set("page_size", String(Math.max(1, Math.min(200, Number(params.pageSize || 80)))));
|
|
||||||
return apiGet<AlarmQueryResponse>(`/v1/alarms?${p.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchUmeAlarmSubscriptionStatus = () =>
|
export const fetchUmeAlarmSubscriptionStatus = () =>
|
||||||
apiGet<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/status");
|
apiGet<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/status");
|
||||||
|
|
||||||
|
|
@ -123,6 +49,7 @@ export const fetchUmeSyncStatus = (params: { page: number; pageSize: number }) =
|
||||||
p.set("page_size", String(Math.max(1, Math.min(200, Number(params.pageSize || 20)))));
|
p.set("page_size", String(Math.max(1, Math.min(200, Number(params.pageSize || 20)))));
|
||||||
return apiGet<UmeSyncStatusResponse>(`/v1/ume/sync/status?${p.toString()}`);
|
return apiGet<UmeSyncStatusResponse>(`/v1/ume/sync/status?${p.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchUmeTokenStatus = () => apiGet<UmeTokenStatus>("/v1/ume/token/status");
|
export const fetchUmeTokenStatus = () => apiGet<UmeTokenStatus>("/v1/ume/token/status");
|
||||||
export const refreshUmeToken = () => apiPost<UmeTokenStatus>("/v1/ume/token/refresh", {});
|
export const refreshUmeToken = () => apiPost<UmeTokenStatus>("/v1/ume/token/refresh", {});
|
||||||
export const disconnectUmeToken = () => apiPost<UmeTokenStatus>("/v1/ume/token/disconnect", {});
|
export const disconnectUmeToken = () => apiPost<UmeTokenStatus>("/v1/ume/token/disconnect", {});
|
||||||
|
|
@ -132,7 +59,9 @@ export const fetchUmeNe = (params: { keyword: string; page: number; pageSize: nu
|
||||||
if (params.keyword) p.set("keyword", params.keyword);
|
if (params.keyword) p.set("keyword", params.keyword);
|
||||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||||
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50)))));
|
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50)))));
|
||||||
return apiGet<{ total: number; page: number; page_size: number; items: UmeNeItem[] }>(`/v1/ume/inventory/ne?${p.toString()}`);
|
return apiGet<{ total: number; page: number; page_size: number; items: UmeNeItem[] }>(
|
||||||
|
`/v1/ume/inventory/ne?${p.toString()}`,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchUmeCurrentAlarms = (params: {
|
export const fetchUmeCurrentAlarms = (params: {
|
||||||
|
|
@ -150,28 +79,7 @@ export const fetchUmeCurrentAlarms = (params: {
|
||||||
if (params.keyword) p.set("keyword", params.keyword);
|
if (params.keyword) p.set("keyword", params.keyword);
|
||||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||||
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50)))));
|
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50)))));
|
||||||
return apiGet<{ total: number; page: number; page_size: number; items: UmeAlarmItem[] }>(`/v1/ume/alarms?${p.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchUmeHistoryAlarms = (params: {
|
|
||||||
severity: string;
|
|
||||||
neId: string;
|
|
||||||
keyword: string;
|
|
||||||
timeFrom: string;
|
|
||||||
timeTo: string;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
}) => {
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
if (params.severity) p.set("severity", params.severity);
|
|
||||||
if (params.neId) p.set("ne_id", params.neId);
|
|
||||||
if (params.keyword) p.set("keyword", params.keyword);
|
|
||||||
if (params.timeFrom) p.set("time_from", params.timeFrom);
|
|
||||||
if (params.timeTo) p.set("time_to", params.timeTo);
|
|
||||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
|
||||||
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50)))));
|
|
||||||
return apiGet<{ total: number; page: number; page_size: number; items: UmeAlarmItem[] }>(
|
return apiGet<{ total: number; page: number; page_size: number; items: UmeAlarmItem[] }>(
|
||||||
`/v1/ume/alarms/history?${p.toString()}`,
|
`/v1/ume/alarms?${p.toString()}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,14 @@
|
||||||
export type Batch = {
|
|
||||||
batch_id: string;
|
|
||||||
source_file: string;
|
|
||||||
status: string;
|
|
||||||
total_rows: number;
|
|
||||||
success_rows: number;
|
|
||||||
failed_rows: number;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Alarm = {
|
|
||||||
id: number;
|
|
||||||
alarm_time: string;
|
|
||||||
severity_norm: string;
|
|
||||||
ne_name: string;
|
|
||||||
alarm_code: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AlarmQueryResponse = {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
page_size: number;
|
|
||||||
items: Alarm[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Diagnostics = {
|
|
||||||
batch_id: string;
|
|
||||||
total_alarms: number;
|
|
||||||
severity_summary?: Array<{ key: string; count: number }>;
|
|
||||||
top_alarm_codes?: Array<{ key: string; count: number }>;
|
|
||||||
top_ne?: Array<{ key: string; count: number }>;
|
|
||||||
protocol_summary?: Array<{ key: string; count: number }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IntegrationStatus = {
|
export type IntegrationStatus = {
|
||||||
netx_api: { status: "up" | "down" | "unknown"; [k: string]: unknown };
|
netx_api: { status: "up" | "down" | "unknown"; [k: string]: unknown };
|
||||||
db: { status: "up" | "down" | "unknown"; latency_ms?: number; error?: string; [k: string]: unknown };
|
db: { status: "up" | "down" | "unknown"; latency_ms?: number; error?: string; [k: string]: unknown };
|
||||||
oclaw_bridge: {
|
oclaw_bridge?: {
|
||||||
status: "up" | "down" | "unknown";
|
status: "up" | "down" | "unknown";
|
||||||
latency_ms?: number;
|
latency_ms?: number;
|
||||||
http_status?: number | null;
|
|
||||||
error_kind?: string;
|
error_kind?: string;
|
||||||
error?: string;
|
|
||||||
detail?: unknown;
|
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ImportHistoryItem = {
|
|
||||||
kind: "alarms" | "logs" | "config";
|
|
||||||
ts_ms: number;
|
|
||||||
file_name: string;
|
|
||||||
batch_id?: string;
|
|
||||||
ok: boolean;
|
|
||||||
summary: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AiAnalyzeHistoryItem = {
|
|
||||||
id: number;
|
|
||||||
analysis_request_id?: string;
|
|
||||||
batch_id: string;
|
|
||||||
question: string;
|
|
||||||
filters?: Record<string, unknown>;
|
|
||||||
ok: boolean;
|
|
||||||
answer?: string;
|
|
||||||
error?: string;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AiAnalyzeHistoryResponse = {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
page_size: number;
|
|
||||||
items: AiAnalyzeHistoryItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UmeSyncJobItem = {
|
export type UmeSyncJobItem = {
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
|
@ -82,7 +17,6 @@ export type UmeSyncJobItem = {
|
||||||
pulled_count: number;
|
pulled_count: number;
|
||||||
inserted_count: number;
|
inserted_count: number;
|
||||||
updated_count: number;
|
updated_count: number;
|
||||||
/** Full-snapshot reconcile deletes (inventory: NE rows; alarms_current: current alarm rows). */
|
|
||||||
deleted?: number;
|
deleted?: number;
|
||||||
error_message?: string;
|
error_message?: string;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
|
|
|
||||||
16
web/src/utils/display.ts
Normal file
16
web/src/utils/display.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/** Map legacy API labels to English for UI display. */
|
||||||
|
|
||||||
|
const LEGACY_INTERVAL_LABEL_EN: Record<string, string> = {
|
||||||
|
未启用: "disabled",
|
||||||
|
实时: "realtime",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function runtimeIntervalLabel(label?: string | null): string {
|
||||||
|
const raw = String(label ?? "").trim();
|
||||||
|
if (!raw) return "—";
|
||||||
|
return LEGACY_INTERVAL_LABEL_EN[raw] ?? raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageCount(total: number, pageSize: number): number {
|
||||||
|
return Math.max(1, Math.ceil(Math.max(0, total) / Math.max(1, pageSize)));
|
||||||
|
}
|
||||||
68
web/src/utils/moduleWindows.ts
Normal file
68
web/src/utils/moduleWindows.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { getModuleById } from "../config/modules";
|
||||||
|
import {
|
||||||
|
broadcastMessage,
|
||||||
|
createFocusRequestId,
|
||||||
|
focusWindowSafe,
|
||||||
|
registerBroadcastListener,
|
||||||
|
waitForFocusAck,
|
||||||
|
} from "./tabChannel";
|
||||||
|
|
||||||
|
export type ModuleWindowSpec = {
|
||||||
|
moduleId: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODULE_CHANNEL = "netx-module-nav";
|
||||||
|
|
||||||
|
type FocusModuleMessage = {
|
||||||
|
type: "focus-module";
|
||||||
|
moduleId: string;
|
||||||
|
path: string;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function moduleWindowName(moduleId: string): string {
|
||||||
|
return `netx-module-${moduleId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleAckKey(moduleId: string): string {
|
||||||
|
return `netx-mod-focus-ack-${moduleId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerModuleWindow(moduleId: string): () => void {
|
||||||
|
try {
|
||||||
|
window.name = moduleWindowName(moduleId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
return registerBroadcastListener<FocusModuleMessage>(MODULE_CHANNEL, moduleAckKey(moduleId), (data) => {
|
||||||
|
const targetPath = String(data.path || "").trim();
|
||||||
|
if (targetPath && window.location.pathname !== targetPath) {
|
||||||
|
window.location.assign(targetPath);
|
||||||
|
}
|
||||||
|
window.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openOrFocusModule({ moduleId, path }: ModuleWindowSpec): void {
|
||||||
|
const mod = getModuleById(moduleId);
|
||||||
|
const targetPath = mod?.path ?? path;
|
||||||
|
const requestId = createFocusRequestId(`mod-${moduleId}`);
|
||||||
|
const name = moduleWindowName(moduleId);
|
||||||
|
|
||||||
|
broadcastMessage<FocusModuleMessage>(MODULE_CHANNEL, {
|
||||||
|
type: "focus-module",
|
||||||
|
moduleId,
|
||||||
|
path: targetPath,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
focusWindowSafe(window.open(targetPath, name));
|
||||||
|
|
||||||
|
waitForFocusAck(moduleAckKey(moduleId), requestId, () => {
|
||||||
|
focusWindowSafe(window.open(targetPath, name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { moduleIdFromPath } from "../config/modules";
|
||||||
64
web/src/utils/tabChannel.ts
Normal file
64
web/src/utils/tabChannel.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/** Shared cross-tab focus helpers (BroadcastChannel + sessionStorage ack). */
|
||||||
|
|
||||||
|
export const TAB_FOCUS_DELAY_MS = 200;
|
||||||
|
|
||||||
|
export function createFocusRequestId(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerBroadcastListener<T extends { requestId: string }>(
|
||||||
|
channelName: string,
|
||||||
|
ackStorageKey: string,
|
||||||
|
onMessage: (data: T) => void,
|
||||||
|
): () => void {
|
||||||
|
if (typeof BroadcastChannel === "undefined") {
|
||||||
|
return () => undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = new BroadcastChannel(channelName);
|
||||||
|
channel.onmessage = (ev: MessageEvent<T>) => {
|
||||||
|
const data = ev.data;
|
||||||
|
if (!data?.requestId) return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(ackStorageKey, data.requestId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
onMessage(data);
|
||||||
|
};
|
||||||
|
return () => channel.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastMessage<T>(channelName: string, message: T): void {
|
||||||
|
if (typeof BroadcastChannel === "undefined") return;
|
||||||
|
const channel = new BroadcastChannel(channelName);
|
||||||
|
channel.postMessage(message);
|
||||||
|
channel.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function waitForFocusAck(
|
||||||
|
ackStorageKey: string,
|
||||||
|
requestId: string,
|
||||||
|
fallback: () => void,
|
||||||
|
delayMs: number = TAB_FOCUS_DELAY_MS,
|
||||||
|
): void {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(ackStorageKey) === requestId) {
|
||||||
|
sessionStorage.removeItem(ackStorageKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
fallback();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusWindowSafe(win: Window | null): void {
|
||||||
|
try {
|
||||||
|
win?.focus();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
50
web/src/utils/workbench.ts
Normal file
50
web/src/utils/workbench.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import {
|
||||||
|
broadcastMessage,
|
||||||
|
createFocusRequestId,
|
||||||
|
focusWindowSafe,
|
||||||
|
registerBroadcastListener,
|
||||||
|
waitForFocusAck,
|
||||||
|
} from "./tabChannel";
|
||||||
|
|
||||||
|
export const WORKBENCH_WINDOW_NAME = "netx-workbench";
|
||||||
|
|
||||||
|
const CHANNEL_NAME = "netx-workbench-nav";
|
||||||
|
const FOCUS_ACK_KEY = "netx-wb-focus-ack";
|
||||||
|
|
||||||
|
type FocusWorkbenchMessage = {
|
||||||
|
type: "focus-workbench";
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerWorkbenchWindow(): () => void {
|
||||||
|
try {
|
||||||
|
window.name = WORKBENCH_WINDOW_NAME;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
return registerBroadcastListener<FocusWorkbenchMessage>(CHANNEL_NAME, FOCUS_ACK_KEY, () => {
|
||||||
|
window.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function returnToWorkbench(): void {
|
||||||
|
const requestId = createFocusRequestId("wb");
|
||||||
|
|
||||||
|
broadcastMessage<FocusWorkbenchMessage>(CHANNEL_NAME, {
|
||||||
|
type: "focus-workbench",
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (window.opener && !window.opener.closed) {
|
||||||
|
window.opener.focus();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForFocusAck(FOCUS_ACK_KEY, requestId, () => {
|
||||||
|
focusWindowSafe(window.open("/", WORKBENCH_WINDOW_NAME));
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue