diff --git a/web/WEB.md b/web/WEB.md index 691e194..5c9610e 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -7,26 +7,68 @@ - TanStack Query(服务端状态) - 自研 i18n(`src/i18n/`,无额外依赖) -## 目录结构 +## 代码结构 + +**按领域(feature)组织,依赖方向 `features → 共享层`,反向禁止**(ESLint `no-restricted-imports` 强制)。 ``` 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 # 纯展示辅助 + main.tsx App.tsx # 入口、路由表(lazy 引入各 feature 页面) + features/<领域>/ # 业务模块,kebab-case 目录名 + XxxPage.tsx # 路由入口(XxxLayout.tsx 为带子路由的壳) + components/ # 该领域私有组件(含 modals/) + hooks/ # 该领域私有 hooks(useXxx.ts) + lib/ # 纯逻辑(无 React),可单测 + layout/ # 应用壳:AppLayout、顶栏、ErrorBoundary + components/ (+ ui/) # 跨领域复用 UI(无业务 API 调用) + hooks/ utils/ # 跨领域 hooks / 纯工具 + services/api/ # 后端 API,按领域分文件,index.ts 汇总导出 + types/ # 后端契约类型,按领域分文件,index.ts 汇总导出 + i18n/locales/{zh,en}/.ts # 文案,一个命名空间一个文件 + config/ # 导航与模块注册表(modules / networkNav / auditNav) + constants/queryKeys.ts # React Query key + styles/ # 见「样式规范」 ``` +现有领域:`auth` `workbench` `managed-ne` `ume` `collect` `config-sync` `webcrt` `topology` +`network` `port-traffic` `cutover`(业务状态/对比/割接) `audit` `users` `api-tokens` `sessions`。 + +约定: + +- 只被**一个**领域用到的代码放进该领域的 `components/ hooks/ lib/`;被**两个以上**领域用到才上移到共享层。 +- 跨领域只引用对方的页面级导出或 `components/ lib/` 下的文件;现有合理依赖:`* → auth`、`topology/ume → managed-ne`。 +- 共享层(`components hooks utils services i18n types constants`)**不得** import `features/`。 +- 新增 API:写进 `services/api/<领域>.ts`;新增类型:写进 `types/<领域>.ts`;调用方仍 `from "…/services/api"` / `"…/types"`。 +- 单个组件函数超过 ~500 行视为需要拆分:状态/副作用抽成 `hooks/`,纯计算抽成 `lib/`,区块抽成 `components/`。 + (`BizComparePage` `WebcrtPage` `BizStatePage` `BizMigrationPage` `TopologyPage` 等仍是待拆分的单体组件。) + +## 样式规范 + +`src/index.css` 只是入口:按顺序 `@import` `styles/` 下的文件,**导入顺序即层叠顺序,不要调换**。 + +``` +styles/ + theme/tokens.css # 唯一的色板/圆角/阴影来源(--nx-*),并把旧 --nm-* 映射到它 + base/ # 全局基础(滚动条、body) + layout/ # 应用壳、网络/审计壳(左侧导航) + components/ # 共享原语(按钮、输入、表格、卡片、pill…) + pages/<领域>.css # 页面样式,类名带领域前缀(wb-* topo-* webcrt-* bs-* pt-*…) + legacy/ # 历史遗留的覆盖层,逐步并入 components/,不要往里加新规则 + theme/theme.css # 设计语言层,必须最后加载 +``` + +设计方向:**现代蓝**——沿用 NetX 原有深海军蓝色板、柔和渐变面板、8/12px 圆角、蓝色强调色(主按钮用渐变), +胶囊状态标签;hover 有轻微抬升和蓝色光晕,focus 用柔和焦点环。 + +规则: + +- 颜色一律用 `var(--nx-*)`;半透明用 `rgb(var(--nx-ink-rgb) / .6)`,不要写 `#0f172a` / `rgba(15,23,42,.6)`。 +- 状态色用语义变量 `--nx-ok / --nx-warn / --nx-bad`。 +- 次要文字至少用 `--nx-text-faint`(在 `--nx-surface-1` 上对比度 ≥ 4.5:1)。 +- 顶栏高度固定 48px(`--nx-header-h`),多个页面用 `calc(100vh - 48px)` 布局。 +- 新样式放进所属 `pages/` 或 `components/` 文件;只有跨页面的视觉语言才改 `theme/theme.css`。 +- 避免 `!important` 和"覆盖全局 button"式规则;需要时先在 `components/` 里改原语。 + ## 路由与模块 | 路径 | 页面 | moduleId | @@ -61,7 +103,7 @@ src/ 1. 在 `MODULES` 增加一项(`moduleId`、`path`、`section`、i18n key、`iconTone`) 2. 在 `App.tsx` 增加 `` -3. 在 `i18n/zh.ts` 与 `en.ts` 补充文案 +3. 在 `i18n/locales/zh/.ts` 与 `en/.ts` 补充文案(新命名空间还需在各自 `index.ts` 注册) 4. 工作台卡片由 `modulesInSection` 自动生成,点击走 `openOrFocusModule` ## 跨标签页导航 @@ -79,7 +121,7 @@ src/ ## 数据请求 -- API 仅写在 `services/api.ts` +- API 仅写在 `services/api/<领域>.ts` - Query key 集中在 `constants/queryKeys.ts` - 失效缓存用 prefix key(如 `queryKeys.umeSyncStatusAll`) - 顶栏连接状态:`App` 轮询 `GET /v1/integrations/status`(5s),展示 **netx api** 与 **DSH alarm hub** @@ -184,7 +226,7 @@ src/ ## 样式 -- 全局样式:`index.css`(工作台浅色主题) +- 全局样式:见「样式规范」(`index.css` 为入口,令牌在 `styles/theme/tokens.css`) - 不使用 CSS Modules;类名 BEM 风格:`app-brand__logo`、`wb-card` ## 构建 diff --git a/web/eslint.config.js b/web/eslint.config.js index ef614d2..f872ad9 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -19,4 +19,21 @@ export default defineConfig([ globals: globals.browser, }, }, + { + // Dependency direction: features -> shared, never the reverse (see WEB.md "代码结构"). + files: ['src/{components,hooks,utils,services,i18n,types,constants}/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/features/**'], + message: '共享层不得依赖 features/(依赖方向:features → 共享层)。把共用部分下沉到共享层。', + }, + ], + }, + ], + }, + }, ]) diff --git a/web/src/App.tsx b/web/src/App.tsx index 8c99625..159076a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,75 +1,75 @@ import { lazy, Suspense } from "react"; import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { AppLayout } from "./layout/AppLayout"; -import { WorkbenchPage } from "./pages/WorkbenchPage"; -import { LoginPage } from "./pages/LoginPage"; -import { ForceChangePasswordPage } from "./pages/ForceChangePasswordPage"; -import { useAuth } from "./auth/AuthContext"; +import { WorkbenchPage } from "./features/workbench/WorkbenchPage"; +import { LoginPage } from "./features/auth/LoginPage"; +import { ForceChangePasswordPage } from "./features/auth/ForceChangePasswordPage"; +import { useAuth } from "./features/auth/AuthContext"; -const CollectPage = lazy(() => import("./pages/CollectPage").then((m) => ({ default: m.CollectPage }))); +const CollectPage = lazy(() => import("./features/collect/CollectPage").then((m) => ({ default: m.CollectPage }))); const ConfigSyncPage = lazy(() => - import("./pages/ConfigSyncPage").then((m) => ({ default: m.ConfigSyncPage })), + import("./features/config-sync/ConfigSyncPage").then((m) => ({ default: m.ConfigSyncPage })), ); -const NePage = lazy(() => import("./pages/NePage").then((m) => ({ default: m.NePage }))); -const UmePage = lazy(() => import("./pages/UmePage").then((m) => ({ default: m.UmePage }))); -const WebcrtPage = lazy(() => import("./pages/WebcrtPage").then((m) => ({ default: m.WebcrtPage }))); -const TopologyPage = lazy(() => import("./pages/TopologyPage").then((m) => ({ default: m.TopologyPage }))); +const NePage = lazy(() => import("./features/managed-ne/NePage").then((m) => ({ default: m.NePage }))); +const UmePage = lazy(() => import("./features/ume/UmePage").then((m) => ({ default: m.UmePage }))); +const WebcrtPage = lazy(() => import("./features/webcrt/WebcrtPage").then((m) => ({ default: m.WebcrtPage }))); +const TopologyPage = lazy(() => import("./features/topology/TopologyPage").then((m) => ({ default: m.TopologyPage }))); const NetworkLayout = lazy(() => - import("./pages/network/NetworkLayout").then((m) => ({ default: m.NetworkLayout })), + import("./features/network/NetworkLayout").then((m) => ({ default: m.NetworkLayout })), ); const NetworkDevicesPage = lazy(() => - import("./pages/network/NetworkDevicesPage").then((m) => ({ default: m.NetworkDevicesPage })), + import("./features/network/NetworkDevicesPage").then((m) => ({ default: m.NetworkDevicesPage })), ); const NetworkAlarmsPage = lazy(() => - import("./pages/network/NetworkAlarmsPage").then((m) => ({ default: m.NetworkAlarmsPage })), + import("./features/network/NetworkAlarmsPage").then((m) => ({ default: m.NetworkAlarmsPage })), ); const NetworkConfigsPage = lazy(() => - import("./pages/network/NetworkConfigsPage").then((m) => ({ default: m.NetworkConfigsPage })), + import("./features/network/NetworkConfigsPage").then((m) => ({ default: m.NetworkConfigsPage })), ); const LldpLinksPage = lazy(() => - import("./pages/network/LldpLinksPage").then((m) => ({ default: m.LldpLinksPage })), + import("./features/network/LldpLinksPage").then((m) => ({ default: m.LldpLinksPage })), ); const TopologyClassifyPage = lazy(() => - import("./pages/network/TopologyClassifyPage").then((m) => ({ default: m.TopologyClassifyPage })), + import("./features/network/TopologyClassifyPage").then((m) => ({ default: m.TopologyClassifyPage })), ); const PortTrafficPage = lazy(() => - import("./pages/network/PortTrafficPage").then((m) => ({ default: m.PortTrafficPage })), + import("./features/port-traffic/PortTrafficPage").then((m) => ({ default: m.PortTrafficPage })), ); const PortTrafficBoardListPage = lazy(() => - import("./pages/network/PortTrafficBoardListPage").then((m) => ({ + import("./features/port-traffic/PortTrafficBoardListPage").then((m) => ({ default: m.PortTrafficBoardListPage, })), ); const PortTrafficWallPage = lazy(() => - import("./pages/network/PortTrafficWallPage").then((m) => ({ default: m.PortTrafficWallPage })), + import("./features/port-traffic/PortTrafficWallPage").then((m) => ({ default: m.PortTrafficWallPage })), ); const BizStatePage = lazy(() => - import("./pages/network/BizStatePage").then((m) => ({ default: m.BizStatePage })), + import("./features/cutover/BizStatePage").then((m) => ({ default: m.BizStatePage })), ); const BizComparePage = lazy(() => - import("./pages/network/BizComparePage").then((m) => ({ default: m.BizComparePage })), + import("./features/cutover/BizComparePage").then((m) => ({ default: m.BizComparePage })), ); const BizMigrationPage = lazy(() => - import("./pages/network/BizMigrationPage").then((m) => ({ default: m.BizMigrationPage })), + import("./features/cutover/BizMigrationPage").then((m) => ({ default: m.BizMigrationPage })), ); const BizMonitorTemplatesPage = lazy(() => - import("./pages/network/BizMonitorTemplatesPage").then((m) => ({ + import("./features/cutover/BizMonitorTemplatesPage").then((m) => ({ default: m.BizMonitorTemplatesPage, })), ); -const UsersPage = lazy(() => import("./pages/UsersPage").then((m) => ({ default: m.UsersPage }))); +const UsersPage = lazy(() => import("./features/users/UsersPage").then((m) => ({ default: m.UsersPage }))); const AuditLayout = lazy(() => - import("./pages/audit/AuditLayout").then((m) => ({ default: m.AuditLayout })), + import("./features/audit/AuditLayout").then((m) => ({ default: m.AuditLayout })), ); const TaskOverviewPage = lazy(() => - import("./pages/audit/TaskOverviewPage").then((m) => ({ default: m.TaskOverviewPage })), + import("./features/audit/TaskOverviewPage").then((m) => ({ default: m.TaskOverviewPage })), ); -const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default: m.AuditPage }))); +const AuditPage = lazy(() => import("./features/audit/AuditPage").then((m) => ({ default: m.AuditPage }))); const ApiTokensPage = lazy(() => - import("./pages/ApiTokensPage").then((m) => ({ default: m.ApiTokensPage })), + import("./features/api-tokens/ApiTokensPage").then((m) => ({ default: m.ApiTokensPage })), ); const SessionsPage = lazy(() => - import("./pages/SessionsPage").then((m) => ({ default: m.SessionsPage })), + import("./features/sessions/SessionsPage").then((m) => ({ default: m.SessionsPage })), ); /** Preserve query when redirecting legacy /network/webcrt → /webcrt. */ diff --git a/web/src/pages/network/nmChips.tsx b/web/src/components/NmChips.tsx similarity index 100% rename from web/src/pages/network/nmChips.tsx rename to web/src/components/NmChips.tsx diff --git a/web/src/components/UmeCliConnectPanel.tsx b/web/src/components/UmeCliConnectPanel.tsx deleted file mode 100644 index 8ecdc51..0000000 --- a/web/src/components/UmeCliConnectPanel.tsx +++ /dev/null @@ -1,520 +0,0 @@ -import { Button, Checkbox, Input, Label, TextField } from "@heroui/react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - apiDelete, - apiPatch, - apiPost, - fetchCliMeta, - fetchCliProfiles, - fetchManagedNeMeta, - fetchUmeCliOverride, - fetchUmeNe, - postUmeConnectTest, -} from "../services/api"; -import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields"; -import { HelpHint } from "./HelpHint"; -import { FieldSelect } from "./ui/FieldSelect"; -import { queryKeys } from "../constants/queryKeys"; -import { useI18n } from "../i18n"; -import { useToast } from "../hooks/useToast"; -import type { CliConnectProfileItem, UmeCliOverrideItem, UmeNeItem } from "../types"; -import { defaultHopTemplate, isAutoHopTemplate, patchHopVendorChange } from "../utils/hopProxy"; -import { formatSystemTime } from "../utils/time"; - -function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" { - const s = String(status || "").toLowerCase(); - if (s === "pass" || s === "ok") return "up"; - if (s === "fail" || s === "error") return "down"; - if (s === "testing") return "warn"; - return "unknown"; -} - -function formatUmeNePickLabel(row: UmeNeItem): string { - const name = String(row.user_label || row.ne_name || row.host_name || "").trim(); - const ip = String(row.ip_address || "").trim(); - const id = String(row.ne_id || "").trim(); - const head = [name, ip].filter(Boolean).join(" · "); - return head ? `${head} · ${id}` : id; -} - -type ProfileForm = { - id: string; - name: string; - username: string; - password: string; - port: number; - protocol: string; - device_type_default: string; - vendor_default: string; - is_default: boolean; - hop_enabled: boolean; - hop: HopProxyFieldsState; -}; - -function applyHopTemplate(hop: HopProxyFieldsState, protocol: string, vrf: string, force = false): Partial { - if (!force && !isAutoHopTemplate(hop.hop_command_template, hop.hop_vendor, hop.hop_protocol, hop.hop_vrf)) { - return {}; - } - return { hop_command_template: defaultHopTemplate(hop.hop_vendor, protocol, vrf) }; -} - -const emptyForm = (): ProfileForm => ({ - id: "", - name: "default", - username: "", - password: "", - port: 22, - protocol: "ssh", - device_type_default: "zte_zxros", - vendor_default: "ZTE", - is_default: true, - hop_enabled: false, - hop: emptyHopProxyFields(), -}); - -function profileToForm(row: CliConnectProfileItem): ProfileForm { - return { - id: row.id, - name: row.name, - username: row.username, - password: "", - port: row.port, - protocol: row.protocol, - device_type_default: row.device_type_default, - vendor_default: row.vendor_default, - is_default: row.is_default, - hop_enabled: row.hop_enabled, - hop: { - hop_vendor: (row.hop_vendor || "bastion") as HopProxyFieldsState["hop_vendor"], - hop_host: row.hop_host ?? "", - hop_port: row.hop_port ?? 22, - hop_protocol: row.hop_protocol ?? "ssh", - hop_username: row.hop_username ?? "", - hop_password: "", - hop_command_template: row.hop_command_template ?? "", - hop_vrf: row.hop_vrf ?? "", - hop_target_auth_mode: (row.hop_target_auth_mode || "bastion_managed") as HopProxyFieldsState["hop_target_auth_mode"], - hop_enter_system_view: Boolean(row.hop_enter_system_view), - }, - }; -} - -export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabled?: boolean; embedded?: boolean }) { - const { t } = useI18n(); - const { showOk, showError } = useToast(); - const queryClient = useQueryClient(); - const [form, setForm] = useState(emptyForm); - const [sampleUmeNeId, setSampleUmeNeId] = useState(""); - const [sampleFilter, setSampleFilter] = useState(""); - const [sampleOpen, setSampleOpen] = useState(false); - const [debouncedFilter, setDebouncedFilter] = useState(""); - const [testPending, setTestPending] = useState(false); - const sampleSuggestRef = useRef(null); - - const testNeId = sampleUmeNeId.trim(); - - useEffect(() => { - const timer = window.setTimeout(() => setDebouncedFilter(sampleFilter.trim()), 220); - return () => window.clearTimeout(timer); - }, [sampleFilter]); - - useEffect(() => { - setTestPending(false); - }, [testNeId]); - - const metaQuery = useQuery({ queryKey: queryKeys.cliMeta, queryFn: fetchCliMeta, enabled }); - const neMetaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta, enabled }); - const profilesQuery = useQuery({ queryKey: queryKeys.cliProfiles, queryFn: fetchCliProfiles, enabled }); - const sampleNeQuery = useQuery({ - queryKey: queryKeys.umeNE(debouncedFilter, 1, 5), - queryFn: () => fetchUmeNe({ keyword: debouncedFilter, page: 1, pageSize: 5 }), - enabled: enabled && sampleOpen, - staleTime: 5000, - }); - const sampleHits = useMemo( - () => (sampleNeQuery.data?.items || []).slice(0, 5), - [sampleNeQuery.data], - ); - - useEffect(() => { - if (!sampleOpen) return; - const el = sampleSuggestRef.current; - if (!el) return; - const timer = window.setTimeout(() => { - const body = el.closest('[data-slot="modal-body"], .modal__body'); - if (body) { - const elRect = el.getBoundingClientRect(); - const bodyRect = body.getBoundingClientRect(); - const overflow = elRect.bottom - bodyRect.bottom + 16; - if (overflow > 0) { - body.scrollBy({ top: overflow, behavior: "smooth" }); - return; - } - } - el.scrollIntoView({ block: "nearest", behavior: "smooth" }); - }, 50); - return () => window.clearTimeout(timer); - }, [sampleOpen, sampleHits.length, sampleNeQuery.isFetching]); - const overrideQuery = useQuery({ - queryKey: queryKeys.umeCliOverride(testNeId), - queryFn: () => fetchUmeCliOverride(testNeId), - enabled: Boolean(testNeId), - refetchInterval: (q) => { - const status = String(q.state.data?.connect_status || "").toLowerCase(); - return status === "testing" || testPending ? 2000 : false; - }, - }); - const overrideResult: UmeCliOverrideItem | null = overrideQuery.data ?? null; - const overrideStatus = String(overrideResult?.connect_status || "").toLowerCase(); - const overrideTesting = overrideStatus === "testing" || (testPending && !overrideResult?.connect_tested_at); - - useEffect(() => { - const items = profilesQuery.data?.items || []; - const current = items.find((x) => x.id === form.id); - const picked = current || items.find((x) => x.is_default) || items[0]; - if (picked && (!form.id || !current)) { - setForm(profileToForm(picked)); - } - }, [profilesQuery.data, form.id]); - - const saveMutation = useMutation({ - mutationFn: async () => { - if (form.hop_enabled) { - if (!form.hop.hop_host.trim()) throw new Error(t("managedNe.hop.hostRequired")); - if (!form.hop.hop_username.trim()) throw new Error(t("managedNe.hop.userRequired")); - if (!form.id && !form.hop.hop_password) throw new Error(t("managedNe.hop.passwordRequired")); - } - const body = { - name: form.name.trim(), - username: form.username.trim(), - password: form.password || undefined, - port: form.port, - protocol: form.protocol, - device_type_default: form.device_type_default, - vendor_default: form.vendor_default, - is_default: form.is_default, - hop_enabled: form.hop_enabled, - hop_vendor: form.hop.hop_vendor, - hop_host: form.hop.hop_host, - hop_port: form.hop.hop_port, - hop_protocol: form.hop.hop_protocol, - hop_username: form.hop.hop_username, - hop_password: form.hop_enabled ? form.hop.hop_password || undefined : undefined, - hop_command_template: form.hop.hop_command_template, - hop_vrf: form.hop.hop_vrf, - hop_target_auth_mode: form.hop.hop_target_auth_mode, - hop_enter_system_view: form.hop.hop_enter_system_view, - }; - if (form.id) { - return apiPatch(`/v1/cli/profiles/${form.id}`, body); - } - return apiPost("/v1/cli/profiles", { ...body, password: form.password }); - }, - onSuccess: async (row) => { - setForm(profileToForm(row)); - await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles }); - await queryClient.invalidateQueries({ queryKey: queryKeys.cliMeta }); - showOk(t("ume.cli.saved")); - }, - onError: (e: Error) => showError(e.message), - }); - - const connectTestMutation = useMutation({ - mutationFn: async () => { - const id = sampleUmeNeId.trim(); - if (!id) throw new Error(t("ume.cli.sampleNeRequired")); - return postUmeConnectTest([id]); - }, - onSuccess: async () => { - const id = sampleUmeNeId.trim(); - setTestPending(true); - showOk(t("ume.cli.connectTestSubmitted")); - if (id) { - await queryClient.invalidateQueries({ queryKey: queryKeys.umeCliOverride(id) }); - } - }, - onError: (e: Error) => { - setTestPending(false); - showError(e.message); - }, - }); - - useEffect(() => { - if (!testPending) return; - if (!overrideResult) return; - if (overrideStatus === "testing") return; - setTestPending(false); - void queryClient.invalidateQueries({ queryKey: queryKeys.cliTargetsAll }); - }, [testPending, overrideResult, overrideStatus, queryClient]); - - const deviceTypes = neMetaQuery.data?.device_types ?? []; - const vendors = neMetaQuery.data?.vendors ?? []; - const cliReady = Boolean(metaQuery.data?.cli_profile_ready); - - const body = ( -
- {!embedded ? ( -
-

{t("ume.cli.title")}

- -
- ) : null} -

- {cliReady ? t("ume.cli.statusReady") : t("ume.cli.statusNotReady")} -

-
- setForm({ ...form, name })}> - - - - setForm({ ...form, username })} - > - - - - setForm({ ...form, password })} - > - - - - setForm({ ...form, device_type_default: e.target.value })} - > - {deviceTypes.map((dt) => ( - - ))} - - setForm({ ...form, vendor_default: e.target.value })} - > - {vendors.map((v) => ( - - ))} - -
-
-
{t("managedNe.hop.sectionTitle")}
- { - setForm((prev) => ({ - ...prev, - hop_enabled, - ...(hop_enabled - ? { - hop: { - ...prev.hop, - ...patchHopVendorChange(prev.hop.hop_vendor, prev.hop), - ...applyHopTemplate(prev.hop, prev.hop.hop_protocol, prev.hop.hop_vrf, true), - }, - } - : {}), - })); - }} - > - - - - {t("managedNe.hop.enable")} - - {form.hop_enabled ? ( -
- setForm((prev) => ({ ...prev, hop: { ...prev.hop, ...patch } }))} - hopPasswordRequired={!form.id} - hopPasswordOptional={Boolean(form.id)} - /> -
- ) : null} -
-
- - - {form.id ? ( - - ) : null} -
-
-

{t("ume.cli.connectTestTitle")}

-
-
-
- { - setSampleFilter(e.target.value); - setSampleUmeNeId(""); - setSampleOpen(true); - }} - onFocus={() => setSampleOpen(true)} - onBlur={() => { - window.setTimeout(() => setSampleOpen(false), 160); - }} - onKeyDown={(e) => { - if (e.key === "Escape") { - setSampleOpen(false); - (e.target as HTMLInputElement).blur(); - } - if (e.key === "Enter" && sampleHits[0]) { - e.preventDefault(); - const row = sampleHits[0]; - setSampleUmeNeId(row.ne_id); - setSampleFilter(formatUmeNePickLabel(row)); - setSampleOpen(false); - } - }} - placeholder={t("ume.cli.sampleNePh")} - aria-label={t("ume.cli.connectTestTitle")} - aria-autocomplete="list" - aria-expanded={sampleOpen} - /> -
- -
- {sampleOpen ? ( -
- {sampleNeQuery.isLoading ? ( -
{t("ume.cli.sampleNeLoading")}
- ) : sampleHits.length === 0 ? ( -
{t("ume.cli.sampleNeEmpty")}
- ) : ( - sampleHits.map((row) => { - const title = - String(row.user_label || row.ne_name || row.ne_id).trim() || row.ne_id; - const meta = [row.ip_address, row.ne_type].filter(Boolean).join(" · "); - return ( - - ); - }) - )} -
- ) : null} -
- {testNeId ? ( -
-
{t("ume.cli.connectTestResult")}
- {overrideQuery.isLoading && !overrideResult ? ( -

{t("ume.cli.connectTestWaiting")}

- ) : overrideTesting ? ( -

- {t("ume.cli.connectTestRunning")} -

- ) : overrideResult ? ( - <> -

- - {overrideResult.connect_status} - - {overrideResult.connect_message ? ( - — {overrideResult.connect_message} - ) : null} - {overrideResult.connect_tested_at ? ( - - {formatSystemTime(overrideResult.connect_tested_at, { assumeUtcNaive: true })} - - ) : null} -

-
-                  {overrideResult.connect_detail?.trim() ||
-                    overrideResult.connect_message?.trim() ||
-                    t("managedNe.connectDetailEmpty")}
-                
- - ) : testPending ? ( -

{t("ume.cli.connectTestWaiting")}

- ) : ( -

{t("managedNe.connectDetailEmpty")}

- )} -
- ) : null} -
- {(profilesQuery.data?.items || []).length > 1 ? ( -
- {t("ume.cli.existingProfiles")} -
- {(profilesQuery.data?.items || []).map((p) => ( - - ))} -
-
- ) : null} -
- ); - - if (embedded) return body; - return
{body}
; -} diff --git a/web/src/pages/ApiTokensPage.tsx b/web/src/features/api-tokens/ApiTokensPage.tsx similarity index 97% rename from web/src/pages/ApiTokensPage.tsx rename to web/src/features/api-tokens/ApiTokensPage.tsx index 94e5472..9cb2170 100644 --- a/web/src/pages/ApiTokensPage.tsx +++ b/web/src/features/api-tokens/ApiTokensPage.tsx @@ -2,13 +2,13 @@ import { Button, Checkbox, Input, Modal } from "@heroui/react"; import { useMemo, useState, type FormEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "../auth/AuthContext"; -import { HelpHint } from "../components/HelpHint"; -import { AppModalShell } from "../components/ui/AppModalShell"; -import { FieldSelect } from "../components/ui/FieldSelect"; -import { useI18n } from "../i18n"; -import { useToast } from "../hooks/useToast"; -import { apiDelete, apiGet, apiPatch, apiPost } from "../services/api"; -import { formatSystemTime } from "../utils/time"; +import { HelpHint } from "../../components/HelpHint"; +import { AppModalShell } from "../../components/ui/AppModalShell"; +import { FieldSelect } from "../../components/ui/FieldSelect"; +import { useI18n } from "../../i18n"; +import { useToast } from "../../hooks/useToast"; +import { apiDelete, apiGet, apiPatch, apiPost } from "../../services/api"; +import { formatSystemTime } from "../../utils/time"; type TokenRow = { id: string; diff --git a/web/src/pages/audit/AuditLayout.tsx b/web/src/features/audit/AuditLayout.tsx similarity index 100% rename from web/src/pages/audit/AuditLayout.tsx rename to web/src/features/audit/AuditLayout.tsx diff --git a/web/src/pages/AuditPage.tsx b/web/src/features/audit/AuditPage.tsx similarity index 98% rename from web/src/pages/AuditPage.tsx rename to web/src/features/audit/AuditPage.tsx index da96f09..95339ec 100644 --- a/web/src/pages/AuditPage.tsx +++ b/web/src/features/audit/AuditPage.tsx @@ -2,9 +2,9 @@ import { Button, Input } from "@heroui/react"; import { Fragment, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "../auth/AuthContext"; -import { useI18n } from "../i18n"; -import { apiGet } from "../services/api"; -import { formatSystemTime } from "../utils/time"; +import { useI18n } from "../../i18n"; +import { apiGet } from "../../services/api"; +import { formatSystemTime } from "../../utils/time"; type AuditItem = { id: string; diff --git a/web/src/pages/audit/TaskOverviewPage.tsx b/web/src/features/audit/TaskOverviewPage.tsx similarity index 100% rename from web/src/pages/audit/TaskOverviewPage.tsx rename to web/src/features/audit/TaskOverviewPage.tsx diff --git a/web/src/auth/AuthContext.tsx b/web/src/features/auth/AuthContext.tsx similarity index 97% rename from web/src/auth/AuthContext.tsx rename to web/src/features/auth/AuthContext.tsx index 84af750..cf8b1f8 100644 --- a/web/src/auth/AuthContext.tsx +++ b/web/src/features/auth/AuthContext.tsx @@ -7,7 +7,7 @@ import { useState, type ReactNode, } from "react"; -import { apiGet, apiPost, clearAuthToken } from "../services/api"; +import { apiGet, apiPost, clearAuthToken } from "../../services/api"; export type AuthUser = { id: string; diff --git a/web/src/pages/ForceChangePasswordPage.tsx b/web/src/features/auth/ForceChangePasswordPage.tsx similarity index 96% rename from web/src/pages/ForceChangePasswordPage.tsx rename to web/src/features/auth/ForceChangePasswordPage.tsx index 7470c08..7b3c914 100644 --- a/web/src/pages/ForceChangePasswordPage.tsx +++ b/web/src/features/auth/ForceChangePasswordPage.tsx @@ -1,8 +1,8 @@ import { Alert, Button, Card, Form, Input, Label, TextField } from "@heroui/react"; import { useState, type FormEvent } from "react"; -import { useAuth } from "../auth/AuthContext"; -import { useI18n } from "../i18n"; -import { apiPost } from "../services/api"; +import { useAuth } from "./AuthContext"; +import { useI18n } from "../../i18n"; +import { apiPost } from "../../services/api"; import { LoginShell } from "./LoginShell"; export function ForceChangePasswordPage() { diff --git a/web/src/pages/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx similarity index 98% rename from web/src/pages/LoginPage.tsx rename to web/src/features/auth/LoginPage.tsx index acd79f9..85c9003 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/features/auth/LoginPage.tsx @@ -1,8 +1,8 @@ import { Alert, Button, Card, Form, Input, Label, TextField } from "@heroui/react"; import { useState, type FormEvent } from "react"; import { Navigate, useSearchParams } from "react-router-dom"; -import { useAuth } from "../auth/AuthContext"; -import { useI18n } from "../i18n"; +import { useAuth } from "./AuthContext"; +import { useI18n } from "../../i18n"; import { LoginShell } from "./LoginShell"; export function LoginPage() { diff --git a/web/src/pages/LoginShell.tsx b/web/src/features/auth/LoginShell.tsx similarity index 100% rename from web/src/pages/LoginShell.tsx rename to web/src/features/auth/LoginShell.tsx diff --git a/web/src/pages/CollectPage.tsx b/web/src/features/collect/CollectPage.tsx similarity index 98% rename from web/src/pages/CollectPage.tsx rename to web/src/features/collect/CollectPage.tsx index fdd03e1..7ffc08d 100644 --- a/web/src/pages/CollectPage.tsx +++ b/web/src/features/collect/CollectPage.tsx @@ -2,8 +2,8 @@ import { Button, Input, Modal } from "@heroui/react"; import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; -import { ListPager } from "../components/ListPager"; -import { AppModalShell } from "../components/ui/AppModalShell"; +import { ListPager } from "../../components/ListPager"; +import { AppModalShell } from "../../components/ui/AppModalShell"; import { createNeCollection, deleteCollectionJob, @@ -20,16 +20,16 @@ import { updateCollectionPolicy, collectionJobDownloadUrl, collectionRunDownloadUrl, -} from "../services/api"; -import { queryKeys } from "../constants/queryKeys"; -import { useDebouncedValue } from "../hooks/useDebouncedValue"; -import { useI18n } from "../i18n"; -import { useToast } from "../hooks/useToast"; -import type { CollectionJobItem, CollectionRunItem, CollectionTargetRef, EligibleNeItem } from "../types"; -import { downloadCsv, fetchAllPages } from "../utils/csvExport"; -import { pageCount } from "../utils/display"; -import { jobChipColor, NmStatusChip, sourceChipColor } from "./network/nmChips"; -import { formatSystemTime } from "../utils/time"; +} from "../../services/api"; +import { queryKeys } from "../../constants/queryKeys"; +import { useDebouncedValue } from "../../hooks/useDebouncedValue"; +import { useI18n } from "../../i18n"; +import { useToast } from "../../hooks/useToast"; +import type { CollectionJobItem, CollectionRunItem, CollectionTargetRef, EligibleNeItem } from "../../types"; +import { downloadCsv, fetchAllPages } from "../../utils/csvExport"; +import { pageCount } from "../../utils/display"; +import { jobChipColor, NmStatusChip, sourceChipColor } from "../../components/NmChips"; +import { formatSystemTime } from "../../utils/time"; const POLL_MS = 2000; const ELIGIBLE_PAGE_SIZE = 10; diff --git a/web/src/pages/ConfigSyncPage.tsx b/web/src/features/config-sync/ConfigSyncPage.tsx similarity index 97% rename from web/src/pages/ConfigSyncPage.tsx rename to web/src/features/config-sync/ConfigSyncPage.tsx index 2b013c5..8655aa1 100644 --- a/web/src/pages/ConfigSyncPage.tsx +++ b/web/src/features/config-sync/ConfigSyncPage.tsx @@ -2,13 +2,13 @@ import { Button, Input, Modal } from "@heroui/react"; import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; -import { ListPager } from "../components/ListPager"; -import { AppModalShell } from "../components/ui/AppModalShell"; -import { FieldSelect } from "../components/ui/FieldSelect"; -import { queryKeys } from "../constants/queryKeys"; -import { useDebouncedValue } from "../hooks/useDebouncedValue"; -import { useToast } from "../hooks/useToast"; -import { useI18n } from "../i18n"; +import { ListPager } from "../../components/ListPager"; +import { AppModalShell } from "../../components/ui/AppModalShell"; +import { FieldSelect } from "../../components/ui/FieldSelect"; +import { queryKeys } from "../../constants/queryKeys"; +import { useDebouncedValue } from "../../hooks/useDebouncedValue"; +import { useToast } from "../../hooks/useToast"; +import { useI18n } from "../../i18n"; import { createConfigSyncCycle, fetchCliTargets, @@ -20,12 +20,12 @@ import { resumeConfigSyncCycle, stopConfigSyncCycle, updateConfigSyncPolicy, -} from "../services/api"; -import type { CliTargetItem, ConfigSyncCycle, ConfigSyncTargetRef, ConfigSyncTask } from "../types"; -import { downloadCsv, fetchAllPages } from "../utils/csvExport"; -import { pageCount } from "../utils/display"; -import { formatSystemTime } from "../utils/time"; -import { jobChipColor, NmStatusChip, sourceChipColor } from "./network/nmChips"; +} from "../../services/api"; +import type { CliTargetItem, ConfigSyncCycle, ConfigSyncTargetRef, ConfigSyncTask } from "../../types"; +import { downloadCsv, fetchAllPages } from "../../utils/csvExport"; +import { pageCount } from "../../utils/display"; +import { formatSystemTime } from "../../utils/time"; +import { jobChipColor, NmStatusChip, sourceChipColor } from "../../components/NmChips"; const POLL_MS = 2500; const TARGET_PAGE_SIZE = 10; diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/features/cutover/BizComparePage.tsx similarity index 99% rename from web/src/pages/network/BizComparePage.tsx rename to web/src/features/cutover/BizComparePage.tsx index b2c62bc..8163a98 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/features/cutover/BizComparePage.tsx @@ -1,8 +1,8 @@ import { Button, Input, Modal } from "@heroui/react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; -import { parseTemplateImport, parseIfaceNormText, toOrGroups, fromOrGroups, validateTemplateDraft } from "./compareTemplateModel"; -import type { MetricSheet, FieldRule, RowFilter } from "./compareTemplateModel"; +import { parseTemplateImport, parseIfaceNormText, toOrGroups, fromOrGroups, validateTemplateDraft } from "./lib/compareTemplateModel"; +import type { MetricSheet, FieldRule, RowFilter } from "./lib/compareTemplateModel"; import { ListPager } from "../../components/ListPager"; import { AppModalShell } from "../../components/ui/AppModalShell"; import { FieldSelect } from "../../components/ui/FieldSelect"; @@ -35,8 +35,8 @@ import { formatErr, } from "../../services/api"; import { formatSystemTime } from "../../utils/time"; -import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache"; -import { jobChipColor, NmStatusChip } from "./nmChips"; +import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./lib/cutoverDataCache"; +import { jobChipColor, NmStatusChip } from "../../components/NmChips"; type PageTab = "templates" | "jobs"; type JobDetailTab = "config" | "runs" | "result"; diff --git a/web/src/pages/network/BizMigrationPage.tsx b/web/src/features/cutover/BizMigrationPage.tsx similarity index 99% rename from web/src/pages/network/BizMigrationPage.tsx rename to web/src/features/cutover/BizMigrationPage.tsx index d0fddc8..11742f6 100644 --- a/web/src/pages/network/BizMigrationPage.tsx +++ b/web/src/features/cutover/BizMigrationPage.tsx @@ -40,8 +40,8 @@ import type { CliTargetItem } from "../../types"; import { writeClipboardText } from "../../utils/clipboard"; import { pageCount } from "../../utils/display"; import { formatSystemTime, localDatetimeInputToUtcIso, utcIsoToLocalDatetimeInput } from "../../utils/time"; -import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache"; -import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips"; +import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./lib/cutoverDataCache"; +import { jobChipColor, NmStatusChip, sourceChipColor } from "../../components/NmChips"; type NeSourceFilter = "all" | "managed" | "ume"; type CreatePickSide = "old" | "new"; diff --git a/web/src/pages/network/BizMonitorTemplatesPage.tsx b/web/src/features/cutover/BizMonitorTemplatesPage.tsx similarity index 99% rename from web/src/pages/network/BizMonitorTemplatesPage.tsx rename to web/src/features/cutover/BizMonitorTemplatesPage.tsx index 7ac5bd5..a3b4aa2 100644 --- a/web/src/pages/network/BizMonitorTemplatesPage.tsx +++ b/web/src/features/cutover/BizMonitorTemplatesPage.tsx @@ -16,7 +16,7 @@ import { bizMonitorUpdateTemplate, formatErr, } from "../../services/api"; -import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache"; +import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./lib/cutoverDataCache"; type MetricField = { name: string; display_name?: string }; type MetricSchema = { metric_id: string; fields: MetricField[] }; diff --git a/web/src/pages/network/BizStatePage.tsx b/web/src/features/cutover/BizStatePage.tsx similarity index 99% rename from web/src/pages/network/BizStatePage.tsx rename to web/src/features/cutover/BizStatePage.tsx index 0be8378..c8a8dfe 100644 --- a/web/src/pages/network/BizStatePage.tsx +++ b/web/src/features/cutover/BizStatePage.tsx @@ -43,8 +43,8 @@ import type { CliTargetItem } from "../../types"; import { pageCount } from "../../utils/display"; import { writeClipboardText } from "../../utils/clipboard"; import { formatSystemTime } from "../../utils/time"; -import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache"; -import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips"; +import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./lib/cutoverDataCache"; +import { jobChipColor, NmStatusChip, sourceChipColor } from "../../components/NmChips"; type TaskRow = { id: string; diff --git a/web/src/pages/network/compareTemplateModel.ts b/web/src/features/cutover/lib/compareTemplateModel.ts similarity index 100% rename from web/src/pages/network/compareTemplateModel.ts rename to web/src/features/cutover/lib/compareTemplateModel.ts diff --git a/web/src/pages/network/cutoverDataCache.ts b/web/src/features/cutover/lib/cutoverDataCache.ts similarity index 100% rename from web/src/pages/network/cutoverDataCache.ts rename to web/src/features/cutover/lib/cutoverDataCache.ts diff --git a/web/src/pages/network/cutoverPrefetch.ts b/web/src/features/cutover/lib/cutoverPrefetch.ts similarity index 64% rename from web/src/pages/network/cutoverPrefetch.ts rename to web/src/features/cutover/lib/cutoverPrefetch.ts index c935aae..2fd1912 100644 --- a/web/src/pages/network/cutoverPrefetch.ts +++ b/web/src/features/cutover/lib/cutoverPrefetch.ts @@ -6,8 +6,8 @@ export function prefetchCutoverPages(): void { if (started) return; started = true; // Fire-and-forget dynamic imports (same modules as App.tsx lazy()). - void import("./BizStatePage"); - void import("./BizComparePage"); - void import("./BizMigrationPage"); - void import("./BizMonitorTemplatesPage"); + void import("../BizStatePage"); + void import("../BizComparePage"); + void import("../BizMigrationPage"); + void import("../BizMonitorTemplatesPage"); } diff --git a/web/src/pages/NePage.tsx b/web/src/features/managed-ne/NePage.tsx similarity index 97% rename from web/src/pages/NePage.tsx rename to web/src/features/managed-ne/NePage.tsx index 7eddf4a..62a9b8e 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/features/managed-ne/NePage.tsx @@ -18,28 +18,28 @@ import { downloadManagedNeImportTemplate, syncUmeManagedNe, type ManagedNeStats, -} from "../services/api"; -import { HelpHint } from "../components/HelpHint"; -import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields"; -import { AppModalShell } from "../components/ui/AppModalShell"; -import { FieldSelect } from "../components/ui/FieldSelect"; -import { queryKeys } from "../constants/queryKeys"; -import { useI18n } from "../i18n"; -import { useToast } from "../hooks/useToast"; +} from "../../services/api"; +import { HelpHint } from "../../components/HelpHint"; +import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./components/HopProxyFields"; +import { AppModalShell } from "../../components/ui/AppModalShell"; +import { FieldSelect } from "../../components/ui/FieldSelect"; +import { queryKeys } from "../../constants/queryKeys"; +import { useI18n } from "../../i18n"; +import { useToast } from "../../hooks/useToast"; import { useAuth } from "../auth/AuthContext"; -import type { ManagedNeItem } from "../types"; -import { pageCount } from "../utils/display"; -import { formatSystemTime } from "../utils/time"; -import { openOrFocusModule } from "../utils/moduleWindows"; -import { ManagedNeFormDialog } from "./managedNe/ManagedNeFormDialog"; -import { ManagedNeConnectDetailDialog } from "./managedNe/ManagedNeConnectDetailDialog"; -import { connectStatusClass } from "./managedNe/connectStatus"; +import type { ManagedNeItem } from "../../types"; +import { pageCount } from "../../utils/display"; +import { formatSystemTime } from "../../utils/time"; +import { openOrFocusModule } from "../../utils/moduleWindows"; +import { ManagedNeFormDialog } from "./components/ManagedNeFormDialog"; +import { ManagedNeConnectDetailDialog } from "./components/ManagedNeConnectDetailDialog"; +import { connectStatusClass } from "./lib/connectStatus"; import { deviceTypeForVendor, emptyManagedNeForm, managedSourceKey, type ManagedNeFormState, -} from "./managedNe/formState"; +} from "./lib/formState"; /** Hide UME→managed sync/delete controls until needed again. APIs remain available. */ const SHOW_UME_MANAGED_SYNC = false; diff --git a/web/src/components/HopProxyFields.tsx b/web/src/features/managed-ne/components/HopProxyFields.tsx similarity index 98% rename from web/src/components/HopProxyFields.tsx rename to web/src/features/managed-ne/components/HopProxyFields.tsx index 257eba2..3621bcb 100644 --- a/web/src/components/HopProxyFields.tsx +++ b/web/src/features/managed-ne/components/HopProxyFields.tsx @@ -1,6 +1,6 @@ import { Input, Label, TextField } from "@heroui/react"; -import { useI18n } from "../i18n"; -import { FieldSelect } from "./ui/FieldSelect"; +import { useI18n } from "../../../i18n"; +import { FieldSelect } from "../../../components/ui/FieldSelect"; import { HOP_VENDORS, defaultHopTemplate, @@ -11,7 +11,7 @@ import { patchHopVendorChange, type HopTargetAuthMode, type HopVendor, -} from "../utils/hopProxy"; +} from "../lib/hopProxy"; export type HopProxyFieldsState = { hop_vendor: HopVendor; diff --git a/web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx b/web/src/features/managed-ne/components/ManagedNeConnectDetailDialog.tsx similarity index 89% rename from web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx rename to web/src/features/managed-ne/components/ManagedNeConnectDetailDialog.tsx index 39d24bd..ba2f467 100644 --- a/web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx +++ b/web/src/features/managed-ne/components/ManagedNeConnectDetailDialog.tsx @@ -1,11 +1,11 @@ import { Alert, Button, Chip, Modal } from "@heroui/react"; import { useMutation } from "@tanstack/react-query"; -import { connectTestManagedNe } from "../../services/api"; -import { AppModalShell } from "../../components/ui/AppModalShell"; -import { useI18n } from "../../i18n"; -import { formatSystemTime } from "../../utils/time"; -import type { ManagedNeItem } from "../../types"; -import { connectPillLevel } from "./connectStatus"; +import { connectTestManagedNe } from "../../../services/api"; +import { AppModalShell } from "../../../components/ui/AppModalShell"; +import { useI18n } from "../../../i18n"; +import { formatSystemTime } from "../../../utils/time"; +import type { ManagedNeItem } from "../../../types"; +import { connectPillLevel } from "../lib/connectStatus"; export type ManagedNeConnectDetailDialogProps = { row: ManagedNeItem | null; diff --git a/web/src/pages/managedNe/ManagedNeFormDialog.tsx b/web/src/features/managed-ne/components/ManagedNeFormDialog.tsx similarity index 95% rename from web/src/pages/managedNe/ManagedNeFormDialog.tsx rename to web/src/features/managed-ne/components/ManagedNeFormDialog.tsx index 0bb5b78..da22db8 100644 --- a/web/src/pages/managedNe/ManagedNeFormDialog.tsx +++ b/web/src/features/managed-ne/components/ManagedNeFormDialog.tsx @@ -1,14 +1,14 @@ import { Alert, Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react"; import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../services/api"; -import { HopProxyFields } from "../../components/HopProxyFields"; -import { AppModalShell } from "../../components/ui/AppModalShell"; -import { FieldSelect } from "../../components/ui/FieldSelect"; -import { queryKeys } from "../../constants/queryKeys"; -import { useI18n } from "../../i18n"; -import { patchHopVendorChange } from "../../utils/hopProxy"; -import type { ManagedNeItem } from "../../types"; +import { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../../services/api"; +import { HopProxyFields } from "./HopProxyFields"; +import { AppModalShell } from "../../../components/ui/AppModalShell"; +import { FieldSelect } from "../../../components/ui/FieldSelect"; +import { queryKeys } from "../../../constants/queryKeys"; +import { useI18n } from "../../../i18n"; +import { patchHopVendorChange } from "../lib/hopProxy"; +import type { ManagedNeItem } from "../../../types"; import { applyHopTemplate, buildManagedNeSaveBody, @@ -16,7 +16,7 @@ import { formFromManagedNe, allowsOpenExecPolicy, type ManagedNeFormState, -} from "./formState"; +} from "../lib/formState"; export type ManagedNeFormDialogProps = { open: boolean; diff --git a/web/src/pages/managedNe/connectStatus.ts b/web/src/features/managed-ne/lib/connectStatus.ts similarity index 100% rename from web/src/pages/managedNe/connectStatus.ts rename to web/src/features/managed-ne/lib/connectStatus.ts diff --git a/web/src/pages/managedNe/formState.ts b/web/src/features/managed-ne/lib/formState.ts similarity index 98% rename from web/src/pages/managedNe/formState.ts rename to web/src/features/managed-ne/lib/formState.ts index dfbf42d..a8bca61 100644 --- a/web/src/pages/managedNe/formState.ts +++ b/web/src/features/managed-ne/lib/formState.ts @@ -2,8 +2,8 @@ import { defaultHopTemplate, isAutoHopTemplate, type HopVendor, -} from "../../utils/hopProxy"; -import type { ManagedNeItem } from "../../types"; +} from "./hopProxy"; +import type { ManagedNeItem } from "../../../types"; export type ManagedNeFormState = { name: string; diff --git a/web/src/utils/hopProxy.ts b/web/src/features/managed-ne/lib/hopProxy.ts similarity index 100% rename from web/src/utils/hopProxy.ts rename to web/src/features/managed-ne/lib/hopProxy.ts diff --git a/web/src/utils/zteHop.ts b/web/src/features/managed-ne/lib/zteHop.ts similarity index 100% rename from web/src/utils/zteHop.ts rename to web/src/features/managed-ne/lib/zteHop.ts diff --git a/web/src/pages/network/LldpLinksPage.tsx b/web/src/features/network/LldpLinksPage.tsx similarity index 99% rename from web/src/pages/network/LldpLinksPage.tsx rename to web/src/features/network/LldpLinksPage.tsx index 8221e54..5435f9a 100644 --- a/web/src/pages/network/LldpLinksPage.tsx +++ b/web/src/features/network/LldpLinksPage.tsx @@ -38,7 +38,7 @@ import { jobChipColor, NmStatusChip, sourceChipColor, -} from "./nmChips"; +} from "../../components/NmChips"; const POLL_MS = 2500; const TARGET_PAGE_SIZE = 10; diff --git a/web/src/pages/network/NetworkAlarmsPage.tsx b/web/src/features/network/NetworkAlarmsPage.tsx similarity index 99% rename from web/src/pages/network/NetworkAlarmsPage.tsx rename to web/src/features/network/NetworkAlarmsPage.tsx index e2a1767..c59da7e 100644 --- a/web/src/pages/network/NetworkAlarmsPage.tsx +++ b/web/src/features/network/NetworkAlarmsPage.tsx @@ -13,7 +13,7 @@ import type { UmeAlarmItem } from "../../types"; import { downloadCsv, fetchAllPages } from "../../utils/csvExport"; import { pageCount } from "../../utils/display"; import { formatSystemTime } from "../../utils/time"; -import { NmStatusChip, severityChipColor } from "./nmChips"; +import { NmStatusChip, severityChipColor } from "../../components/NmChips"; /** Current UME alarms query view for Network Management. */ export function NetworkAlarmsPage() { diff --git a/web/src/pages/network/NetworkConfigsPage.tsx b/web/src/features/network/NetworkConfigsPage.tsx similarity index 99% rename from web/src/pages/network/NetworkConfigsPage.tsx rename to web/src/features/network/NetworkConfigsPage.tsx index 39ea554..41a0fa7 100644 --- a/web/src/pages/network/NetworkConfigsPage.tsx +++ b/web/src/features/network/NetworkConfigsPage.tsx @@ -20,7 +20,7 @@ import { downloadCsv, fetchAllPages } from "../../utils/csvExport"; import { pageCount } from "../../utils/display"; import { openNewModuleWindow } from "../../utils/moduleWindows"; import { formatSystemTime } from "../../utils/time"; -import { NmStatusChip, sourceChipColor } from "./nmChips"; +import { NmStatusChip, sourceChipColor } from "../../components/NmChips"; function fmtBytes(n: number): string { if (!n) return "0 B"; diff --git a/web/src/pages/network/NetworkDevicesPage.tsx b/web/src/features/network/NetworkDevicesPage.tsx similarity index 99% rename from web/src/pages/network/NetworkDevicesPage.tsx rename to web/src/features/network/NetworkDevicesPage.tsx index 1ddad86..08869ec 100644 --- a/web/src/pages/network/NetworkDevicesPage.tsx +++ b/web/src/features/network/NetworkDevicesPage.tsx @@ -12,7 +12,7 @@ import type { CliTargetItem } from "../../types"; import { downloadCsv, fetchAllPages } from "../../utils/csvExport"; import { pageCount } from "../../utils/display"; import { openNewModuleWindow } from "../../utils/moduleWindows"; -import { connectChipColor, NmStatusChip, sourceChipColor } from "./nmChips"; +import { connectChipColor, NmStatusChip, sourceChipColor } from "../../components/NmChips"; function openWebcrt(row: CliTargetItem) { const path = diff --git a/web/src/pages/network/NetworkLayout.tsx b/web/src/features/network/NetworkLayout.tsx similarity index 98% rename from web/src/pages/network/NetworkLayout.tsx rename to web/src/features/network/NetworkLayout.tsx index c0c71a5..f13fbae 100644 --- a/web/src/pages/network/NetworkLayout.tsx +++ b/web/src/features/network/NetworkLayout.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { NavLink, Outlet, useLocation } from "react-router-dom"; import { NETWORK_NAV, type NetworkNavGroupId } from "../../config/networkNav"; import { useI18n } from "../../i18n"; -import { prefetchCutoverPages } from "./cutoverPrefetch"; +import { prefetchCutoverPages } from "../cutover/lib/cutoverPrefetch"; function groupContainsPath(groupId: NetworkNavGroupId, pathname: string): boolean { const group = NETWORK_NAV.find((g) => g.id === groupId); diff --git a/web/src/pages/network/NetworkPlaceholderPage.tsx b/web/src/features/network/NetworkPlaceholderPage.tsx similarity index 100% rename from web/src/pages/network/NetworkPlaceholderPage.tsx rename to web/src/features/network/NetworkPlaceholderPage.tsx diff --git a/web/src/pages/network/TopologyClassifyPage.tsx b/web/src/features/network/TopologyClassifyPage.tsx similarity index 100% rename from web/src/pages/network/TopologyClassifyPage.tsx rename to web/src/features/network/TopologyClassifyPage.tsx diff --git a/web/src/pages/network/PortTrafficBoardListPage.tsx b/web/src/features/port-traffic/PortTrafficBoardListPage.tsx similarity index 98% rename from web/src/pages/network/PortTrafficBoardListPage.tsx rename to web/src/features/port-traffic/PortTrafficBoardListPage.tsx index 83fc144..ab13b16 100644 --- a/web/src/pages/network/PortTrafficBoardListPage.tsx +++ b/web/src/features/port-traffic/PortTrafficBoardListPage.tsx @@ -8,7 +8,7 @@ import { import { queryKeys } from "../../constants/queryKeys"; import { useI18n } from "../../i18n"; import { useToast } from "../../hooks/useToast"; -import { openPortTrafficBoardWindow } from "../../utils/portTrafficBoardWindow"; +import { openPortTrafficBoardWindow } from "./lib/portTrafficBoardWindow"; import { formatSystemTime } from "../../utils/time"; export function PortTrafficBoardListPage() { diff --git a/web/src/pages/network/PortTrafficPage.tsx b/web/src/features/port-traffic/PortTrafficPage.tsx similarity index 99% rename from web/src/pages/network/PortTrafficPage.tsx rename to web/src/features/port-traffic/PortTrafficPage.tsx index cc19e70..26b5d94 100644 --- a/web/src/pages/network/PortTrafficPage.tsx +++ b/web/src/features/port-traffic/PortTrafficPage.tsx @@ -36,7 +36,7 @@ import type { import { downloadCsv, fetchAllPages } from "../../utils/csvExport"; import { pageCount } from "../../utils/display"; import { formatSystemTime } from "../../utils/time"; -import { jobChipColor, NmStatusChip } from "./nmChips"; +import { jobChipColor, NmStatusChip } from "../../components/NmChips"; const POLL_MS = 5000; const TARGET_PAGE_SIZE = 10; diff --git a/web/src/pages/network/PortTrafficWallPage.tsx b/web/src/features/port-traffic/PortTrafficWallPage.tsx similarity index 99% rename from web/src/pages/network/PortTrafficWallPage.tsx rename to web/src/features/port-traffic/PortTrafficWallPage.tsx index bc4f595..061a599 100644 --- a/web/src/pages/network/PortTrafficWallPage.tsx +++ b/web/src/features/port-traffic/PortTrafficWallPage.tsx @@ -16,8 +16,8 @@ import { queryKeys } from "../../constants/queryKeys"; import { useI18n } from "../../i18n"; import { useToast } from "../../hooks/useToast"; import type { PortTrafficBoardPanel, PortTrafficBoardPanelIn } from "../../types"; -import { PortTrafficBoardPanelCell } from "./PortTrafficBoardPanelCell"; -import type { WallYMode } from "./PortTrafficWall"; +import { PortTrafficBoardPanelCell } from "./components/PortTrafficBoardPanelCell"; +import type { WallYMode } from "./components/PortTrafficWall"; type BaselineMode = "off" | "shift" | "day" | "week" | "custom"; diff --git a/web/src/pages/network/PortTrafficBoardPanelCell.tsx b/web/src/features/port-traffic/components/PortTrafficBoardPanelCell.tsx similarity index 95% rename from web/src/pages/network/PortTrafficBoardPanelCell.tsx rename to web/src/features/port-traffic/components/PortTrafficBoardPanelCell.tsx index f0f6ce8..0337576 100644 --- a/web/src/pages/network/PortTrafficBoardPanelCell.tsx +++ b/web/src/features/port-traffic/components/PortTrafficBoardPanelCell.tsx @@ -1,9 +1,9 @@ import { Button } from "@heroui/react"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { fetchPortTrafficCompare } from "../../services/api"; -import { queryKeys } from "../../constants/queryKeys"; -import { useI18n } from "../../i18n"; -import type { PortTrafficBoardPanel } from "../../types"; +import { fetchPortTrafficCompare } from "../../../services/api"; +import { queryKeys } from "../../../constants/queryKeys"; +import { useI18n } from "../../../i18n"; +import type { PortTrafficBoardPanel } from "../../../types"; import { PortTrafficWall, type WallYMode } from "./PortTrafficWall"; const POLL_MS = 5000; diff --git a/web/src/pages/network/PortTrafficWall.tsx b/web/src/features/port-traffic/components/PortTrafficWall.tsx similarity index 99% rename from web/src/pages/network/PortTrafficWall.tsx rename to web/src/features/port-traffic/components/PortTrafficWall.tsx index b7af4d4..671fb97 100644 --- a/web/src/pages/network/PortTrafficWall.tsx +++ b/web/src/features/port-traffic/components/PortTrafficWall.tsx @@ -1,9 +1,9 @@ import { useEffect, useMemo, useRef } from "react"; import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; -import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types"; -import { formatSystemTime, parseApiTime } from "../../utils/time"; -import { useI18n } from "../../i18n"; +import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../../types"; +import { formatSystemTime, parseApiTime } from "../../../utils/time"; +import { useI18n } from "../../../i18n"; export type WallYMode = "auto" | "current" | "util"; diff --git a/web/src/utils/portTrafficBoardWindow.ts b/web/src/features/port-traffic/lib/portTrafficBoardWindow.ts similarity index 88% rename from web/src/utils/portTrafficBoardWindow.ts rename to web/src/features/port-traffic/lib/portTrafficBoardWindow.ts index 17414d6..856be33 100644 --- a/web/src/utils/portTrafficBoardWindow.ts +++ b/web/src/features/port-traffic/lib/portTrafficBoardWindow.ts @@ -1,4 +1,4 @@ -import { openNewModuleWindow } from "./moduleWindows"; +import { openNewModuleWindow } from "../../../utils/moduleWindows"; export const PORT_TRAFFIC_WALL_MODULE_ID = "port-traffic-wall"; diff --git a/web/src/pages/SessionsPage.tsx b/web/src/features/sessions/SessionsPage.tsx similarity index 95% rename from web/src/pages/SessionsPage.tsx rename to web/src/features/sessions/SessionsPage.tsx index 9d3197f..8def190 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/features/sessions/SessionsPage.tsx @@ -1,10 +1,10 @@ import { Button } from "@heroui/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { HelpHint } from "../components/HelpHint"; -import { useI18n } from "../i18n"; -import { useToast } from "../hooks/useToast"; -import { apiDelete, apiGet, apiPost } from "../services/api"; -import { formatSystemTime } from "../utils/time"; +import { HelpHint } from "../../components/HelpHint"; +import { useI18n } from "../../i18n"; +import { useToast } from "../../hooks/useToast"; +import { apiDelete, apiGet, apiPost } from "../../services/api"; +import { formatSystemTime } from "../../utils/time"; type SessionRow = { id: string; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/features/topology/TopologyPage.tsx similarity index 97% rename from web/src/pages/TopologyPage.tsx rename to web/src/features/topology/TopologyPage.tsx index aca84fe..69c219b 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/features/topology/TopologyPage.tsx @@ -10,16 +10,16 @@ import { SHOW_ALARM_OVERLAY_KEY, SHOW_LEVEL_COLORS_KEY, WORLD_MAP_ENABLED, -} from "./topology/constants"; -import type { CtxMenu, PaletteItem, PaletteSource } from "./topology/pageTypes"; -import { clampSidebarWidth, loadSidebarWidth, saveSidebarWidth } from "./topology/sidebarLayout"; +} from "./lib/constants"; +import type { CtxMenu, PaletteItem, PaletteSource } from "./lib/pageTypes"; +import { clampSidebarWidth, loadSidebarWidth, saveSidebarWidth } from "./lib/sidebarLayout"; import { canvasBgLuminance, canvasFloatChromeVars, findViewInRegion, isWorldFlatViewName, regionDisplayName, -} from "./topology/treeUtils"; +} from "./lib/treeUtils"; import { loadAutoLayoutAfterDiscover, loadBoolFlag, @@ -29,7 +29,7 @@ import { persistBoolFlag, type LabelColors, type VendorColors, -} from "./topology/displayPrefs"; +} from "./lib/displayPrefs"; import { BUILTIN_EDGE_DEFAULTS, type EdgeDefaultStyle, @@ -40,24 +40,24 @@ import { persistEdgeDefaults, resolveEdgeStyle, withEdgeVisual, -} from "./topology/edgeStyle"; -import { applyViewGraph, flowToPositions } from "./topology/graphFlow"; -import { isLocalPendingEdgeId, newLocalEdgeId } from "./topology/idUtils"; -import { fuzzyIncludes, isPlaceholderSource, nodeMatchesQuery } from "./topology/searchUtils"; -import { TopologyDiscoverPanel } from "./topology/TopologyDiscoverPanel"; -import { TopologyHexBrowser } from "./topology/TopologyHexBrowser"; -import { TopologyToolbar } from "./topology/TopologyToolbar"; -import { TopologyViewTools } from "./topology/TopologyViewTools"; -import { TopologySidebar } from "./topology/TopologySidebar"; -import { TopologyModals } from "./topology/TopologyModals"; -import { TopologyCtxMenu } from "./topology/TopologyCtxMenu"; -import { useTopologyHistory } from "./topology/hooks/useTopologyHistory"; -import { useTopologyDiscover } from "./topology/hooks/useTopologyDiscover"; -import { useTopologyTreeNav } from "./topology/hooks/useTopologyTreeNav"; -import { useTopologyCanvas } from "./topology/hooks/useTopologyCanvas"; -import { useTopologyCreateNe } from "./topology/hooks/useTopologyCreateNe"; -import { useTopologyConnectTest } from "./topology/hooks/useTopologyConnectTest"; -import { useTopologyAlarmOverlay } from "./topology/hooks/useTopologyAlarmOverlay"; +} from "./lib/edgeStyle"; +import { applyViewGraph, flowToPositions } from "./lib/graphFlow"; +import { isLocalPendingEdgeId, newLocalEdgeId } from "./lib/idUtils"; +import { fuzzyIncludes, isPlaceholderSource, nodeMatchesQuery } from "./lib/searchUtils"; +import { TopologyDiscoverPanel } from "./components/TopologyDiscoverPanel"; +import { TopologyHexBrowser } from "./components/TopologyHexBrowser"; +import { TopologyToolbar } from "./components/TopologyToolbar"; +import { TopologyViewTools } from "./components/TopologyViewTools"; +import { TopologySidebar } from "./components/TopologySidebar"; +import { TopologyModals } from "./components/TopologyModals"; +import { TopologyCtxMenu } from "./components/TopologyCtxMenu"; +import { useTopologyHistory } from "./hooks/useTopologyHistory"; +import { useTopologyDiscover } from "./hooks/useTopologyDiscover"; +import { useTopologyTreeNav } from "./hooks/useTopologyTreeNav"; +import { useTopologyCanvas } from "./hooks/useTopologyCanvas"; +import { useTopologyCreateNe } from "./hooks/useTopologyCreateNe"; +import { useTopologyConnectTest } from "./hooks/useTopologyConnectTest"; +import { useTopologyAlarmOverlay } from "./hooks/useTopologyAlarmOverlay"; import { lazy, Suspense, @@ -91,33 +91,33 @@ import { patchTopologyPositions, removeTopologyViewNodes, applyUmeTopologyToFabric, -} from "../services/api"; -import { queryKeys } from "../constants/queryKeys"; -import { useI18n } from "../i18n"; -import { useToast } from "../hooks/useToast"; +} from "../../services/api"; +import { queryKeys } from "../../constants/queryKeys"; +import { useI18n } from "../../i18n"; +import { useToast } from "../../hooks/useToast"; import { useAuth } from "../auth/AuthContext"; -import { formatErr } from "../services/api"; -import { openOrFocusModule } from "../utils/moduleWindows"; +import { formatErr } from "../../services/api"; +import { openOrFocusModule } from "../../utils/moduleWindows"; import type { FabricNodeSearchHit, TopologyViewItem, -} from "../types"; -import { alignNodes } from "./topology/layoutGraph"; +} from "../../types"; +import { alignNodes } from "./lib/layoutGraph"; import { formatPortPairLabel, isAggregateEdgeId, physicalIdsForDisplayEdge, -} from "./topology/linkDisplay"; -import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode"; -import type { NeNodeData } from "./topology/TopologyReactFlowView"; +} from "./lib/linkDisplay"; +import { behaviorForMode, toolModeFromKey, type ToolMode } from "./lib/toolMode"; +import type { NeNodeData } from "./components/TopologyReactFlowView"; import { buildExportGraph, exportTopologySvg, exportTopologyXml, -} from "./topology/exportTopology"; +} from "./lib/exportTopology"; /** xyflow + canvas nodes — only loaded when a map canvas is open. */ -const TopologyReactFlowView = lazy(() => import("./topology/TopologyReactFlowView")); +const TopologyReactFlowView = lazy(() => import("./components/TopologyReactFlowView")); export function TopologyPage() { const { t, locale } = useI18n(); diff --git a/web/src/pages/topology/ParallelEdge.tsx b/web/src/features/topology/components/ParallelEdge.tsx similarity index 100% rename from web/src/pages/topology/ParallelEdge.tsx rename to web/src/features/topology/components/ParallelEdge.tsx diff --git a/web/src/components/ui/TopoModalShell.tsx b/web/src/features/topology/components/TopoModalShell.tsx similarity index 57% rename from web/src/components/ui/TopoModalShell.tsx rename to web/src/features/topology/components/TopoModalShell.tsx index 932d769..f199cb4 100644 --- a/web/src/components/ui/TopoModalShell.tsx +++ b/web/src/features/topology/components/TopoModalShell.tsx @@ -1 +1 @@ -export { AppModalShell, TopoModalShell, type AppModalShellProps } from "./AppModalShell"; +export { AppModalShell, TopoModalShell, type AppModalShellProps } from "../../../components/ui/AppModalShell"; diff --git a/web/src/pages/topology/TopologyCtxMenu.tsx b/web/src/features/topology/components/TopologyCtxMenu.tsx similarity index 98% rename from web/src/pages/topology/TopologyCtxMenu.tsx rename to web/src/features/topology/components/TopologyCtxMenu.tsx index 8f25612..ce971ce 100644 --- a/web/src/pages/topology/TopologyCtxMenu.tsx +++ b/web/src/features/topology/components/TopologyCtxMenu.tsx @@ -2,11 +2,11 @@ import type { RefObject } from "react"; import { createPortal } from "react-dom"; import { Button } from "@heroui/react"; import type { Edge, Node } from "@xyflow/react"; -import { useI18n } from "../../i18n"; -import type { CtxMenu } from "./pageTypes"; -import type { EdgeStyleData } from "./edgeStyle"; -import { formatPortPairLabel } from "./linkDisplay"; -import { isPlaceholderSource } from "./searchUtils"; +import { useI18n } from "../../../i18n"; +import type { CtxMenu } from "../lib/pageTypes"; +import type { EdgeStyleData } from "../lib/edgeStyle"; +import { formatPortPairLabel } from "../lib/linkDisplay"; +import { isPlaceholderSource } from "../lib/searchUtils"; import type { NeNodeData } from "./TopologyReactFlowView"; export type ResolvedEdgeStyle = { diff --git a/web/src/pages/topology/TopologyDiscoverPanel.tsx b/web/src/features/topology/components/TopologyDiscoverPanel.tsx similarity index 95% rename from web/src/pages/topology/TopologyDiscoverPanel.tsx rename to web/src/features/topology/components/TopologyDiscoverPanel.tsx index 5376b05..5acd576 100644 --- a/web/src/pages/topology/TopologyDiscoverPanel.tsx +++ b/web/src/features/topology/components/TopologyDiscoverPanel.tsx @@ -1,7 +1,7 @@ import { Button } from "@heroui/react"; -import type { TopologyDiscoverOut } from "../../types"; -import { useI18n } from "../../i18n"; -import { openOrFocusModule } from "../../utils/moduleWindows"; +import type { TopologyDiscoverOut } from "../../../types"; +import { useI18n } from "../../../i18n"; +import { openOrFocusModule } from "../../../utils/moduleWindows"; export type DiscoverProgress = { index: number; diff --git a/web/src/pages/topology/TopologyHexBrowser.tsx b/web/src/features/topology/components/TopologyHexBrowser.tsx similarity index 99% rename from web/src/pages/topology/TopologyHexBrowser.tsx rename to web/src/features/topology/components/TopologyHexBrowser.tsx index f91fe8c..e6716b4 100644 --- a/web/src/pages/topology/TopologyHexBrowser.tsx +++ b/web/src/features/topology/components/TopologyHexBrowser.tsx @@ -1,14 +1,14 @@ import { Button } from "@heroui/react"; import { Fragment } from "react"; -import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types"; -import { useI18n } from "../../i18n"; +import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../../types"; +import { useI18n } from "../../../i18n"; import { displayViewName, folderNeCount, formatNeCount, isUmeWorldContainer, regionDisplayName, -} from "./treeUtils"; +} from "../lib/treeUtils"; import { LayerGlyph, RegionGlyph } from "./topologyIcons"; export type UmeWorldHexModules = { diff --git a/web/src/pages/topology/TopologyModals.tsx b/web/src/features/topology/components/TopologyModals.tsx similarity index 93% rename from web/src/pages/topology/TopologyModals.tsx rename to web/src/features/topology/components/TopologyModals.tsx index bdbc422..96c6bec 100644 --- a/web/src/pages/topology/TopologyModals.tsx +++ b/web/src/features/topology/components/TopologyModals.tsx @@ -1,6 +1,6 @@ import type { DragEvent } from "react"; -import type { TopologyOutsidePeer } from "../../types"; -import type { PaletteItem, PaletteSource } from "./pageTypes"; +import type { TopologyOutsidePeer } from "../../../types"; +import type { PaletteItem, PaletteSource } from "../lib/pageTypes"; import { AddNePaletteDialog } from "./modals/AddNePaletteDialog"; import { CreateNeModeDialog } from "./modals/CreateNeModeDialog"; import { NewRootDialog } from "./modals/NewRootDialog"; @@ -9,10 +9,10 @@ import { type PlaceholderCreateDialogState, } from "./modals/PlaceholderCreateDialog"; import { OutsidePeersDialog } from "./modals/OutsidePeersDialog"; -import { ManagedNeFormDialog } from "../managedNe/ManagedNeFormDialog"; -import { ManagedNeConnectDetailDialog } from "../managedNe/ManagedNeConnectDetailDialog"; -import type { ManagedNeItem } from "../../types"; -import type { ManagedNeFormState } from "../managedNe/formState"; +import { ManagedNeFormDialog } from "../../managed-ne/components/ManagedNeFormDialog"; +import { ManagedNeConnectDetailDialog } from "../../managed-ne/components/ManagedNeConnectDetailDialog"; +import type { ManagedNeItem } from "../../../types"; +import type { ManagedNeFormState } from "../../managed-ne/lib/formState"; export type { PlaceholderCreateDialogState as CreateNeDialogState }; diff --git a/web/src/pages/topology/TopologyReactFlowView.tsx b/web/src/features/topology/components/TopologyReactFlowView.tsx similarity index 98% rename from web/src/pages/topology/TopologyReactFlowView.tsx rename to web/src/features/topology/components/TopologyReactFlowView.tsx index 5c09ea3..e9c1198 100644 --- a/web/src/pages/topology/TopologyReactFlowView.tsx +++ b/web/src/features/topology/components/TopologyReactFlowView.tsx @@ -24,13 +24,13 @@ import { import "@xyflow/react/dist/style.css"; import { WorldScatterLayer, type WorldScatterPoint } from "./WorldScatterLayer"; import { ParallelEdge } from "./ParallelEdge"; -import { isAggregateEdgeId } from "./linkDisplay"; -import type { ToolMode, ToolModeBehavior } from "./toolMode"; -import type { TopologyViewRole, TopologyWorldTransform } from "../../types"; -import { isPlaceholderSource } from "./searchUtils"; -import { nodeIconTone } from "./vendorTone"; -import { levelToneKey } from "./levelTone"; -import type { NodeAlarmSummary } from "./alarmOverlay"; +import { isAggregateEdgeId } from "../lib/linkDisplay"; +import type { ToolMode, ToolModeBehavior } from "../lib/toolMode"; +import type { TopologyViewRole, TopologyWorldTransform } from "../../../types"; +import { isPlaceholderSource } from "../lib/searchUtils"; +import { nodeIconTone } from "../lib/vendorTone"; +import { levelToneKey } from "../lib/levelTone"; +import type { NodeAlarmSummary } from "../lib/alarmOverlay"; export { isPlaceholderSource }; diff --git a/web/src/pages/topology/TopologySidebar.tsx b/web/src/features/topology/components/TopologySidebar.tsx similarity index 98% rename from web/src/pages/topology/TopologySidebar.tsx rename to web/src/features/topology/components/TopologySidebar.tsx index 7a7b005..28fe2d1 100644 --- a/web/src/pages/topology/TopologySidebar.tsx +++ b/web/src/features/topology/components/TopologySidebar.tsx @@ -1,9 +1,9 @@ import type { MouseEvent as ReactMouseEvent, RefObject } from "react"; import { Button, Input } from "@heroui/react"; -import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types"; -import { useI18n } from "../../i18n"; -import { WORLD_MAP_ENABLED } from "./constants"; -import { isWorldFlatViewName } from "./treeUtils"; +import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../../types"; +import { useI18n } from "../../../i18n"; +import { WORLD_MAP_ENABLED } from "../lib/constants"; +import { isWorldFlatViewName } from "../lib/treeUtils"; import { PlusIcon, SidebarFoldIcon } from "./topologyIcons"; import { TopologyTreeNavFolder, diff --git a/web/src/pages/topology/TopologyToolbar.tsx b/web/src/features/topology/components/TopologyToolbar.tsx similarity index 98% rename from web/src/pages/topology/TopologyToolbar.tsx rename to web/src/features/topology/components/TopologyToolbar.tsx index 01e0a0b..1679476 100644 --- a/web/src/pages/topology/TopologyToolbar.tsx +++ b/web/src/features/topology/components/TopologyToolbar.tsx @@ -1,12 +1,12 @@ import { Button, Dropdown } from "@heroui/react"; import { Fragment, type Key, type RefObject, useMemo } from "react"; import type { Edge, Node, ReactFlowInstance } from "@xyflow/react"; -import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types"; -import { HelpHint } from "../../components/HelpHint"; -import { useI18n } from "../../i18n"; -import { displayViewName, isRegionCanvasFolder, regionDisplayName } from "./treeUtils"; +import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../../types"; +import { HelpHint } from "../../../components/HelpHint"; +import { useI18n } from "../../../i18n"; +import { displayViewName, isRegionCanvasFolder, regionDisplayName } from "../lib/treeUtils"; import type { NeNodeData } from "./TopologyReactFlowView"; -import type { ToolMode } from "./toolMode"; +import type { ToolMode } from "../lib/toolMode"; export type TopologyToolbarProps = { readOnly?: boolean; diff --git a/web/src/pages/topology/TopologyTreeNavFolder.tsx b/web/src/features/topology/components/TopologyTreeNavFolder.tsx similarity index 98% rename from web/src/pages/topology/TopologyTreeNavFolder.tsx rename to web/src/features/topology/components/TopologyTreeNavFolder.tsx index f3a0d14..4a5bda0 100644 --- a/web/src/pages/topology/TopologyTreeNavFolder.tsx +++ b/web/src/features/topology/components/TopologyTreeNavFolder.tsx @@ -1,6 +1,6 @@ -import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types"; -import { useI18n } from "../../i18n"; -import { WORLD_MAP_ENABLED } from "./constants"; +import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../../types"; +import { useI18n } from "../../../i18n"; +import { WORLD_MAP_ENABLED } from "../lib/constants"; import { displayViewName, folderNeCount, @@ -13,7 +13,7 @@ import { isWorldDrillFolder, isWorldFlatViewName, regionDisplayName, -} from "./treeUtils"; +} from "../lib/treeUtils"; import { ChevronIcon, CloseIcon, diff --git a/web/src/pages/topology/TopologyViewTools.tsx b/web/src/features/topology/components/TopologyViewTools.tsx similarity index 98% rename from web/src/pages/topology/TopologyViewTools.tsx rename to web/src/features/topology/components/TopologyViewTools.tsx index dd5ef0d..45419d3 100644 --- a/web/src/pages/topology/TopologyViewTools.tsx +++ b/web/src/features/topology/components/TopologyViewTools.tsx @@ -3,10 +3,10 @@ import { useQueryClient } from "@tanstack/react-query"; import type { RefObject } from "react"; import type { Node } from "@xyflow/react"; import { createPortal } from "react-dom"; -import { queryKeys } from "../../constants/queryKeys"; -import { useI18n } from "../../i18n"; -import { useToast } from "../../hooks/useToast"; -import { updateLldpCollectPolicy } from "../../services/api"; +import { queryKeys } from "../../../constants/queryKeys"; +import { useI18n } from "../../../i18n"; +import { useToast } from "../../../hooks/useToast"; +import { updateLldpCollectPolicy } from "../../../services/api"; import { DEFAULT_CANVAS_BG, DISCOVER_AUTO_ADD_KEY, @@ -17,7 +17,7 @@ import { SHOW_CONNECT_STATUS_KEY, SHOW_ALARM_OVERLAY_KEY, SHOW_LEVEL_COLORS_KEY, -} from "./constants"; +} from "../lib/constants"; import { DEFAULT_LABEL_COLORS, DEFAULT_VENDOR_COLORS, @@ -33,9 +33,9 @@ import { persistVendorColors, type LabelColors, type VendorColors, -} from "./displayPrefs"; -import type { EdgeDefaultStyle, EdgeDefaults, EdgeLineStyle, EdgeSourceKind } from "./edgeStyle"; -import type { alignNodes } from "./layoutGraph"; +} from "../lib/displayPrefs"; +import type { EdgeDefaultStyle, EdgeDefaults, EdgeLineStyle, EdgeSourceKind } from "../lib/edgeStyle"; +import type { alignNodes } from "../lib/layoutGraph"; import type { NeNodeData } from "./TopologyReactFlowView"; export type AlignKind = Parameters[2]; diff --git a/web/src/pages/topology/WorldScatterLayer.tsx b/web/src/features/topology/components/WorldScatterLayer.tsx similarity index 100% rename from web/src/pages/topology/WorldScatterLayer.tsx rename to web/src/features/topology/components/WorldScatterLayer.tsx diff --git a/web/src/pages/topology/modals/AddNePaletteDialog.tsx b/web/src/features/topology/components/modals/AddNePaletteDialog.tsx similarity index 97% rename from web/src/pages/topology/modals/AddNePaletteDialog.tsx rename to web/src/features/topology/components/modals/AddNePaletteDialog.tsx index 4ed069b..49ba584 100644 --- a/web/src/pages/topology/modals/AddNePaletteDialog.tsx +++ b/web/src/features/topology/components/modals/AddNePaletteDialog.tsx @@ -1,8 +1,8 @@ import type { DragEvent } from "react"; import { Button, Chip, Input, Modal } from "@heroui/react"; -import { TopoModalShell } from "../../../components/ui/TopoModalShell"; -import { useI18n } from "../../../i18n"; -import type { PaletteItem, PaletteSource } from "../pageTypes"; +import { TopoModalShell } from "../TopoModalShell"; +import { useI18n } from "../../../../i18n"; +import type { PaletteItem, PaletteSource } from "../../lib/pageTypes"; export type AddNePaletteDialogProps = { open: boolean; diff --git a/web/src/pages/topology/modals/CreateNeModeDialog.tsx b/web/src/features/topology/components/modals/CreateNeModeDialog.tsx similarity index 93% rename from web/src/pages/topology/modals/CreateNeModeDialog.tsx rename to web/src/features/topology/components/modals/CreateNeModeDialog.tsx index b630516..59be53b 100644 --- a/web/src/pages/topology/modals/CreateNeModeDialog.tsx +++ b/web/src/features/topology/components/modals/CreateNeModeDialog.tsx @@ -1,6 +1,6 @@ import { Button, Modal } from "@heroui/react"; -import { TopoModalShell } from "../../../components/ui/TopoModalShell"; -import { useI18n } from "../../../i18n"; +import { TopoModalShell } from "../TopoModalShell"; +import { useI18n } from "../../../../i18n"; export type CreateNeModeDialogProps = { open: boolean; diff --git a/web/src/pages/topology/modals/NewRootDialog.tsx b/web/src/features/topology/components/modals/NewRootDialog.tsx similarity index 93% rename from web/src/pages/topology/modals/NewRootDialog.tsx rename to web/src/features/topology/components/modals/NewRootDialog.tsx index 61f714d..6047db7 100644 --- a/web/src/pages/topology/modals/NewRootDialog.tsx +++ b/web/src/features/topology/components/modals/NewRootDialog.tsx @@ -1,6 +1,6 @@ import { Button, Input, Label, Modal, TextField } from "@heroui/react"; -import { TopoModalShell } from "../../../components/ui/TopoModalShell"; -import { useI18n } from "../../../i18n"; +import { TopoModalShell } from "../TopoModalShell"; +import { useI18n } from "../../../../i18n"; export type NewRootDialogProps = { dialog: { name: string } | null; diff --git a/web/src/pages/topology/modals/OutsidePeersDialog.tsx b/web/src/features/topology/components/modals/OutsidePeersDialog.tsx similarity index 97% rename from web/src/pages/topology/modals/OutsidePeersDialog.tsx rename to web/src/features/topology/components/modals/OutsidePeersDialog.tsx index dea14d8..a43fd8e 100644 --- a/web/src/pages/topology/modals/OutsidePeersDialog.tsx +++ b/web/src/features/topology/components/modals/OutsidePeersDialog.tsx @@ -1,7 +1,7 @@ import { Button, Chip, Input, Modal } from "@heroui/react"; -import { TopoModalShell } from "../../../components/ui/TopoModalShell"; -import type { TopologyOutsidePeer } from "../../../types"; -import { useI18n } from "../../../i18n"; +import { TopoModalShell } from "../TopoModalShell"; +import type { TopologyOutsidePeer } from "../../../../types"; +import { useI18n } from "../../../../i18n"; export type OutsidePeersDialogProps = { open: boolean; diff --git a/web/src/pages/topology/modals/PlaceholderCreateDialog.tsx b/web/src/features/topology/components/modals/PlaceholderCreateDialog.tsx similarity index 95% rename from web/src/pages/topology/modals/PlaceholderCreateDialog.tsx rename to web/src/features/topology/components/modals/PlaceholderCreateDialog.tsx index a845f26..82573ac 100644 --- a/web/src/pages/topology/modals/PlaceholderCreateDialog.tsx +++ b/web/src/features/topology/components/modals/PlaceholderCreateDialog.tsx @@ -1,6 +1,6 @@ import { Button, Input, Label, Modal, TextField } from "@heroui/react"; -import { TopoModalShell } from "../../../components/ui/TopoModalShell"; -import { useI18n } from "../../../i18n"; +import { TopoModalShell } from "../TopoModalShell"; +import { useI18n } from "../../../../i18n"; export type PlaceholderCreateDialogState = { flowX: number; diff --git a/web/src/pages/topology/topologyIcons.tsx b/web/src/features/topology/components/topologyIcons.tsx similarity index 100% rename from web/src/pages/topology/topologyIcons.tsx rename to web/src/features/topology/components/topologyIcons.tsx diff --git a/web/src/pages/topology/hooks/useTopologyAlarmOverlay.ts b/web/src/features/topology/hooks/useTopologyAlarmOverlay.ts similarity index 97% rename from web/src/pages/topology/hooks/useTopologyAlarmOverlay.ts rename to web/src/features/topology/hooks/useTopologyAlarmOverlay.ts index 51f0be5..559e225 100644 --- a/web/src/pages/topology/hooks/useTopologyAlarmOverlay.ts +++ b/web/src/features/topology/hooks/useTopologyAlarmOverlay.ts @@ -12,8 +12,8 @@ import { normalizeHostKey, worseSeverity, type NodeAlarmSummary, -} from "../alarmOverlay"; -import type { NeNodeData } from "../TopologyReactFlowView"; +} from "../lib/alarmOverlay"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; function accumulate( map: Map, diff --git a/web/src/pages/topology/hooks/useTopologyCanvas.ts b/web/src/features/topology/hooks/useTopologyCanvas.ts similarity index 97% rename from web/src/pages/topology/hooks/useTopologyCanvas.ts rename to web/src/features/topology/hooks/useTopologyCanvas.ts index 916caba..1d33e33 100644 --- a/web/src/pages/topology/hooks/useTopologyCanvas.ts +++ b/web/src/features/topology/hooks/useTopologyCanvas.ts @@ -9,25 +9,25 @@ import { WORLD_LOCATE_HALF, WORLD_LOCATE_ZOOM, WORLD_MAP_ENABLED, -} from "../constants"; -import type { EdgeDefaults, EdgeStyleData } from "../edgeStyle"; -import { withEdgeVisual } from "../edgeStyle"; -import { graphFingerprint, graphToFlow, mergeOverlayIntoNodes, overlayFingerprint } from "../graphFlow"; +} from "../lib/constants"; +import type { EdgeDefaults, EdgeStyleData } from "../lib/edgeStyle"; +import { withEdgeVisual } from "../lib/edgeStyle"; +import { graphFingerprint, graphToFlow, mergeOverlayIntoNodes, overlayFingerprint } from "../lib/graphFlow"; import { buildLinkDisplayEdges, type LinkMember, -} from "../linkDisplay"; -import type { NeNodeData } from "../TopologyReactFlowView"; -import { TOPO_HANDLE_X, TOPO_HANDLE_Y } from "../topoGeometry"; -import type { ToolMode } from "../toolMode"; +} from "../lib/linkDisplay"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; +import { TOPO_HANDLE_X, TOPO_HANDLE_Y } from "../lib/topoGeometry"; +import type { ToolMode } from "../lib/toolMode"; import { displayViewName, isWorldFlatViewName, worldDisplayBounds, worldVisualLodFromZoom, -} from "../treeUtils"; -import { mergeFlatWorldGraph } from "../worldGraph"; -import type { HistorySnap } from "../pageTypes"; +} from "../lib/treeUtils"; +import { mergeFlatWorldGraph } from "../lib/worldGraph"; +import type { HistorySnap } from "../lib/pageTypes"; export type UseTopologyCanvasOptions = { mapId: string; @@ -300,7 +300,7 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) { useEffect(() => { if (!canvasMode) return; - void import("../TopologyReactFlowView"); + void import("../components/TopologyReactFlowView"); }, [canvasMode]); useEffect(() => { diff --git a/web/src/pages/topology/hooks/useTopologyConnectTest.ts b/web/src/features/topology/hooks/useTopologyConnectTest.ts similarity index 98% rename from web/src/pages/topology/hooks/useTopologyConnectTest.ts rename to web/src/features/topology/hooks/useTopologyConnectTest.ts index 0294c6f..1065f9f 100644 --- a/web/src/pages/topology/hooks/useTopologyConnectTest.ts +++ b/web/src/features/topology/hooks/useTopologyConnectTest.ts @@ -6,7 +6,7 @@ import { queryKeys } from "../../../constants/queryKeys"; import { useI18n } from "../../../i18n"; import { useToast } from "../../../hooks/useToast"; import type { ManagedNeItem } from "../../../types"; -import type { NeNodeData } from "../TopologyReactFlowView"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; type Args = { closeCtxMenu: () => void; diff --git a/web/src/pages/topology/hooks/useTopologyCreateNe.ts b/web/src/features/topology/hooks/useTopologyCreateNe.ts similarity index 94% rename from web/src/pages/topology/hooks/useTopologyCreateNe.ts rename to web/src/features/topology/hooks/useTopologyCreateNe.ts index 0ba5889..488f3d7 100644 --- a/web/src/pages/topology/hooks/useTopologyCreateNe.ts +++ b/web/src/features/topology/hooks/useTopologyCreateNe.ts @@ -18,12 +18,12 @@ import { queryKeys } from "../../../constants/queryKeys"; import { useI18n } from "../../../i18n"; import { useToast } from "../../../hooks/useToast"; import type { ManagedNeItem } from "../../../types"; -import { applyViewGraph } from "../graphFlow"; -import type { EdgeDefaults } from "../edgeStyle"; -import type { PlaceholderCreateDialogState } from "../modals/PlaceholderCreateDialog"; -import type { ManagedNeFormState } from "../../managedNe/formState"; -import { isWorldFlatViewName } from "../treeUtils"; -import type { NeNodeData } from "../TopologyReactFlowView"; +import { applyViewGraph } from "../lib/graphFlow"; +import type { EdgeDefaults } from "../lib/edgeStyle"; +import type { PlaceholderCreateDialogState } from "../components/modals/PlaceholderCreateDialog"; +import type { ManagedNeFormState } from "../../managed-ne/lib/formState"; +import { isWorldFlatViewName } from "../lib/treeUtils"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; type FocusNode = (fabricNodeId: string, fit: boolean) => void; diff --git a/web/src/pages/topology/hooks/useTopologyDiscover.ts b/web/src/features/topology/hooks/useTopologyDiscover.ts similarity index 96% rename from web/src/pages/topology/hooks/useTopologyDiscover.ts rename to web/src/features/topology/hooks/useTopologyDiscover.ts index ec55576..314f292 100644 --- a/web/src/pages/topology/hooks/useTopologyDiscover.ts +++ b/web/src/features/topology/hooks/useTopologyDiscover.ts @@ -21,12 +21,12 @@ import type { TopologyDiscoverOut, TopologyViewGraph, } from "../../../types"; -import type { DiscoverProgress, DiscoverSummary } from "../TopologyDiscoverPanel"; -import type { EdgeDefaults, EdgeStyleData } from "../edgeStyle"; -import { flowToPositions, graphToFlow } from "../graphFlow"; -import { discoverResultKind, isLocalPendingEdgeId } from "../idUtils"; -import { layoutGraph } from "../layoutGraph"; -import type { NeNodeData } from "../TopologyReactFlowView"; +import type { DiscoverProgress, DiscoverSummary } from "../components/TopologyDiscoverPanel"; +import type { EdgeDefaults, EdgeStyleData } from "../lib/edgeStyle"; +import { flowToPositions, graphToFlow } from "../lib/graphFlow"; +import { discoverResultKind, isLocalPendingEdgeId } from "../lib/idUtils"; +import { layoutGraph } from "../lib/layoutGraph"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; export type UseTopologyDiscoverOptions = { mapId: string; diff --git a/web/src/pages/topology/hooks/useTopologyHistory.ts b/web/src/features/topology/hooks/useTopologyHistory.ts similarity index 95% rename from web/src/pages/topology/hooks/useTopologyHistory.ts rename to web/src/features/topology/hooks/useTopologyHistory.ts index d57dcda..dece537 100644 --- a/web/src/pages/topology/hooks/useTopologyHistory.ts +++ b/web/src/features/topology/hooks/useTopologyHistory.ts @@ -1,8 +1,8 @@ import { useCallback, useRef, useState } from "react"; import type { Edge, Node } from "@xyflow/react"; -import { UNDO_MAX } from "../constants"; -import type { HistorySnap } from "../pageTypes"; -import type { NeNodeData } from "../TopologyReactFlowView"; +import { UNDO_MAX } from "../lib/constants"; +import type { HistorySnap } from "../lib/pageTypes"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; export function useTopologyHistory( nodes: Node[], diff --git a/web/src/pages/topology/hooks/useTopologyTreeNav.ts b/web/src/features/topology/hooks/useTopologyTreeNav.ts similarity index 99% rename from web/src/pages/topology/hooks/useTopologyTreeNav.ts rename to web/src/features/topology/hooks/useTopologyTreeNav.ts index 7f9db2e..fbeb824 100644 --- a/web/src/pages/topology/hooks/useTopologyTreeNav.ts +++ b/web/src/features/topology/hooks/useTopologyTreeNav.ts @@ -14,8 +14,8 @@ import { updateTopologyMap, } from "../../../services/api"; import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../../types"; -import { LAST_LEAF_KEY, TREE_EXPAND_KEY, WORLD_MAP_ENABLED } from "../constants"; -import type { CtxMenu } from "../pageTypes"; +import { LAST_LEAF_KEY, TREE_EXPAND_KEY, WORLD_MAP_ENABLED } from "../lib/constants"; +import type { CtxMenu } from "../lib/pageTypes"; import { findFolderInTree, findViewInRegion, @@ -26,8 +26,8 @@ import { isWorldDrillFolder, isWorldFlatViewName, regionDisplayName, -} from "../treeUtils"; -import type { NeNodeData } from "../TopologyReactFlowView"; +} from "../lib/treeUtils"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; export type UseTopologyTreeNavOptions = { liveSync: boolean; diff --git a/web/src/pages/topology/alarmOverlay.ts b/web/src/features/topology/lib/alarmOverlay.ts similarity index 100% rename from web/src/pages/topology/alarmOverlay.ts rename to web/src/features/topology/lib/alarmOverlay.ts diff --git a/web/src/pages/topology/constants.ts b/web/src/features/topology/lib/constants.ts similarity index 100% rename from web/src/pages/topology/constants.ts rename to web/src/features/topology/lib/constants.ts diff --git a/web/src/pages/topology/displayPrefs.ts b/web/src/features/topology/lib/displayPrefs.ts similarity index 100% rename from web/src/pages/topology/displayPrefs.ts rename to web/src/features/topology/lib/displayPrefs.ts diff --git a/web/src/pages/topology/edgeStyle.ts b/web/src/features/topology/lib/edgeStyle.ts similarity index 100% rename from web/src/pages/topology/edgeStyle.ts rename to web/src/features/topology/lib/edgeStyle.ts diff --git a/web/src/pages/topology/exportTopology.ts b/web/src/features/topology/lib/exportTopology.ts similarity index 99% rename from web/src/pages/topology/exportTopology.ts rename to web/src/features/topology/lib/exportTopology.ts index 1746f6a..9e65583 100644 --- a/web/src/pages/topology/exportTopology.ts +++ b/web/src/features/topology/lib/exportTopology.ts @@ -1,7 +1,7 @@ import { getStraightPath } from "@xyflow/react"; import type { Edge, Node } from "@xyflow/react"; -import type { TopologyViewGraph, TopologyViewItem, TopologyViewNodeItem } from "../../types"; -import type { NeNodeData } from "./TopologyReactFlowView"; +import type { TopologyViewGraph, TopologyViewItem, TopologyViewNodeItem } from "../../../types"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; import { isAggregateEdgeId, type LinkEdgeData } from "./linkDisplay"; import { TOPO_HANDLE_X, diff --git a/web/src/pages/topology/graphFlow.ts b/web/src/features/topology/lib/graphFlow.ts similarity index 98% rename from web/src/pages/topology/graphFlow.ts rename to web/src/features/topology/lib/graphFlow.ts index 0d87b24..2789a6b 100644 --- a/web/src/pages/topology/graphFlow.ts +++ b/web/src/features/topology/lib/graphFlow.ts @@ -1,6 +1,6 @@ import type { Edge, Node, Position } from "@xyflow/react"; -import type { TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem } from "../../types"; -import type { NeNodeData } from "./TopologyReactFlowView"; +import type { TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem } from "../../../types"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; import { formatPortPairLabel } from "./linkDisplay"; import type { EdgeDefaults, EdgeStyleData } from "./edgeStyle"; import { withEdgeVisual } from "./edgeStyle"; diff --git a/web/src/pages/topology/idUtils.ts b/web/src/features/topology/lib/idUtils.ts similarity index 91% rename from web/src/pages/topology/idUtils.ts rename to web/src/features/topology/lib/idUtils.ts index 3ecf12a..2e9a4de 100644 --- a/web/src/pages/topology/idUtils.ts +++ b/web/src/features/topology/lib/idUtils.ts @@ -1,4 +1,4 @@ -import type { TopologyDiscoverNeResult } from "../../types"; +import type { TopologyDiscoverNeResult } from "../../../types"; export function newId(): string { if (typeof crypto !== "undefined" && crypto.randomUUID) { diff --git a/web/src/pages/topology/layoutGraph.ts b/web/src/features/topology/lib/layoutGraph.ts similarity index 100% rename from web/src/pages/topology/layoutGraph.ts rename to web/src/features/topology/lib/layoutGraph.ts diff --git a/web/src/pages/topology/levelTone.ts b/web/src/features/topology/lib/levelTone.ts similarity index 100% rename from web/src/pages/topology/levelTone.ts rename to web/src/features/topology/lib/levelTone.ts diff --git a/web/src/pages/topology/linkDisplay.ts b/web/src/features/topology/lib/linkDisplay.ts similarity index 100% rename from web/src/pages/topology/linkDisplay.ts rename to web/src/features/topology/lib/linkDisplay.ts diff --git a/web/src/pages/topology/pageTypes.ts b/web/src/features/topology/lib/pageTypes.ts similarity index 90% rename from web/src/pages/topology/pageTypes.ts rename to web/src/features/topology/lib/pageTypes.ts index 7453a38..ff3e2fe 100644 --- a/web/src/pages/topology/pageTypes.ts +++ b/web/src/features/topology/lib/pageTypes.ts @@ -1,5 +1,5 @@ import type { Edge, Node } from "@xyflow/react"; -import type { NeNodeData } from "./TopologyReactFlowView"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; export type HistorySnap = { nodes: Node[]; diff --git a/web/src/pages/topology/searchUtils.ts b/web/src/features/topology/lib/searchUtils.ts similarity index 94% rename from web/src/pages/topology/searchUtils.ts rename to web/src/features/topology/lib/searchUtils.ts index f3b4c12..5e73fed 100644 --- a/web/src/pages/topology/searchUtils.ts +++ b/web/src/features/topology/lib/searchUtils.ts @@ -1,5 +1,5 @@ import type { Node } from "@xyflow/react"; -import type { NeNodeData } from "./TopologyReactFlowView"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; export function normalizeSearchText(s: string): string { return String(s || "") diff --git a/web/src/pages/topology/sidebarLayout.ts b/web/src/features/topology/lib/sidebarLayout.ts similarity index 100% rename from web/src/pages/topology/sidebarLayout.ts rename to web/src/features/topology/lib/sidebarLayout.ts diff --git a/web/src/pages/topology/toolMode.ts b/web/src/features/topology/lib/toolMode.ts similarity index 100% rename from web/src/pages/topology/toolMode.ts rename to web/src/features/topology/lib/toolMode.ts diff --git a/web/src/pages/topology/topoGeometry.ts b/web/src/features/topology/lib/topoGeometry.ts similarity index 100% rename from web/src/pages/topology/topoGeometry.ts rename to web/src/features/topology/lib/topoGeometry.ts diff --git a/web/src/pages/topology/treeUtils.ts b/web/src/features/topology/lib/treeUtils.ts similarity index 99% rename from web/src/pages/topology/treeUtils.ts rename to web/src/features/topology/lib/treeUtils.ts index 8d925ed..597124d 100644 --- a/web/src/pages/topology/treeUtils.ts +++ b/web/src/features/topology/lib/treeUtils.ts @@ -1,5 +1,5 @@ import type { CSSProperties } from "react"; -import type { TopologyTreeFolderItem, TopologyTreeViewItem, TopologyWorldTransform } from "../../types"; +import type { TopologyTreeFolderItem, TopologyTreeViewItem, TopologyWorldTransform } from "../../../types"; import { WORLD_MAP_ENABLED } from "./constants"; export { WORLD_MAP_ENABLED }; diff --git a/web/src/pages/topology/vendorTone.ts b/web/src/features/topology/lib/vendorTone.ts similarity index 94% rename from web/src/pages/topology/vendorTone.ts rename to web/src/features/topology/lib/vendorTone.ts index 694cf15..6b8c35f 100644 --- a/web/src/pages/topology/vendorTone.ts +++ b/web/src/features/topology/lib/vendorTone.ts @@ -1,4 +1,4 @@ -import type { NeNodeData } from "./TopologyReactFlowView"; +import type { NeNodeData } from "../components/TopologyReactFlowView"; import { VENDOR_TONE_KEYS, type VendorColors, type VendorToneKey } from "./displayPrefs"; export type { VendorToneKey, VendorColors }; diff --git a/web/src/pages/topology/worldGraph.ts b/web/src/features/topology/lib/worldGraph.ts similarity index 99% rename from web/src/pages/topology/worldGraph.ts rename to web/src/features/topology/lib/worldGraph.ts index 8ec3857..22dc853 100644 --- a/web/src/pages/topology/worldGraph.ts +++ b/web/src/features/topology/lib/worldGraph.ts @@ -2,7 +2,7 @@ import type { TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem, -} from "../../types"; +} from "../../../types"; import { WORLD_FLAT_ACCUM_CAP } from "./constants"; /** Merge newly fetched flat LOD into the existing cache so visited areas stay. */ diff --git a/web/src/pages/UsersPage.tsx b/web/src/features/users/UsersPage.tsx similarity index 96% rename from web/src/pages/UsersPage.tsx rename to web/src/features/users/UsersPage.tsx index 4c31263..71ec01b 100644 --- a/web/src/pages/UsersPage.tsx +++ b/web/src/features/users/UsersPage.tsx @@ -3,10 +3,10 @@ import { useMemo, useState, type FormEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Navigate } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; -import { FieldSelect } from "../components/ui/FieldSelect"; -import { useI18n } from "../i18n"; -import { useToast } from "../hooks/useToast"; -import { apiGet, apiPatch, apiPost } from "../services/api"; +import { FieldSelect } from "../../components/ui/FieldSelect"; +import { useI18n } from "../../i18n"; +import { useToast } from "../../hooks/useToast"; +import { apiGet, apiPatch, apiPost } from "../../services/api"; type UserRow = { id: string; diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/features/webcrt/WebcrtPage.tsx similarity index 99% rename from web/src/pages/WebcrtPage.tsx rename to web/src/features/webcrt/WebcrtPage.tsx index 6f2034a..a6a0f4c 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/features/webcrt/WebcrtPage.tsx @@ -13,10 +13,10 @@ import { import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { Button, Input, Modal } from "@heroui/react"; -import type { WebTerminalHandle } from "../components/WebTerminal"; -import { AppModalShell } from "../components/ui/AppModalShell"; -import { useI18n } from "../i18n"; -import { useToast } from "../hooks/useToast"; +import type { WebTerminalHandle } from "./components/WebTerminal"; +import { AppModalShell } from "../../components/ui/AppModalShell"; +import { useI18n } from "../../i18n"; +import { useToast } from "../../hooks/useToast"; import { ApiRequestError, closeWebcrtSession, @@ -35,10 +35,10 @@ import { webcrtSftpRename, webcrtSftpUpload, type WebcrtSftpItem, -} from "../services/api"; -import { pageCount } from "../utils/display"; -import { writeClipboardText } from "../utils/clipboard"; -import type { CliTargetItem, ManagedNeItem } from "../types"; +} from "../../services/api"; +import { pageCount } from "../../utils/display"; +import { writeClipboardText } from "../../utils/clipboard"; +import type { CliTargetItem, ManagedNeItem } from "../../types"; import { defaultKeywordHighlightConfig, loadKeywordHighlightConfig, @@ -46,11 +46,11 @@ import { saveKeywordHighlightConfig, type KeywordHighlightConfig, type KeywordRule, -} from "../utils/webcrtKeywordHighlight"; +} from "./lib/webcrtKeywordHighlight"; /** xterm is large — only pull it when a session pane actually mounts. */ const WebTerminal = lazy(() => - import("../components/WebTerminal").then((m) => ({ default: m.WebTerminal })), + import("./components/WebTerminal").then((m) => ({ default: m.WebTerminal })), ); const PAGE_SIZE = 10; const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions"; diff --git a/web/src/components/WebTerminal.tsx b/web/src/features/webcrt/components/WebTerminal.tsx similarity index 99% rename from web/src/components/WebTerminal.tsx rename to web/src/features/webcrt/components/WebTerminal.tsx index 693dbed..3bf6380 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/features/webcrt/components/WebTerminal.tsx @@ -3,13 +3,13 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "re import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; -import { useI18n } from "../i18n"; -import { webcrtWsUrl } from "../services/api"; +import { useI18n } from "../../../i18n"; +import { webcrtWsUrl } from "../../../services/api"; import { applyKeywordHighlight, type KeywordHighlightConfig, -} from "../utils/webcrtKeywordHighlight"; -import { readClipboardText, writeClipboardText } from "../utils/clipboard"; +} from "../lib/webcrtKeywordHighlight"; +import { readClipboardText, writeClipboardText } from "../../../utils/clipboard"; export type WebTerminalHandle = { clear: () => void; diff --git a/web/src/utils/webcrtKeywordHighlight.ts b/web/src/features/webcrt/lib/webcrtKeywordHighlight.ts similarity index 100% rename from web/src/utils/webcrtKeywordHighlight.ts rename to web/src/features/webcrt/lib/webcrtKeywordHighlight.ts diff --git a/web/src/pages/WorkbenchPage.tsx b/web/src/features/workbench/WorkbenchPage.tsx similarity index 98% rename from web/src/pages/WorkbenchPage.tsx rename to web/src/features/workbench/WorkbenchPage.tsx index c748cf3..c9a99e8 100644 --- a/web/src/pages/WorkbenchPage.tsx +++ b/web/src/features/workbench/WorkbenchPage.tsx @@ -1,15 +1,15 @@ import { useMemo, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@heroui/react"; -import { useI18n } from "../i18n"; -import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon"; -import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules"; -import { openOrFocusModule } from "../utils/moduleWindows"; +import { useI18n } from "../../i18n"; +import { WorkbenchCardIcon } from "./components/WorkbenchCardIcon"; +import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../../config/modules"; +import { openOrFocusModule } from "../../utils/moduleWindows"; import { useAuth } from "../auth/AuthContext"; -import { fetchFabricSummary, fetchManagedNeStats, fetchOpsTasks, fetchRuntimeMetrics, fetchTopologyTree, type OpsTaskItem } from "../services/api"; -import { formatSystemTime } from "../utils/time"; -import { isUmeWorldContainer, isWorldDrillFolder } from "./topology/treeUtils"; -import type { TopologyTreeFolderItem } from "../types"; +import { fetchFabricSummary, fetchManagedNeStats, fetchOpsTasks, fetchRuntimeMetrics, fetchTopologyTree, type OpsTaskItem } from "../../services/api"; +import { formatSystemTime } from "../../utils/time"; +import { isUmeWorldContainer, isWorldDrillFolder } from "../topology/lib/treeUtils"; +import type { TopologyTreeFolderItem } from "../../types"; const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"]; diff --git a/web/src/components/WorkbenchCardIcon.tsx b/web/src/features/workbench/components/WorkbenchCardIcon.tsx similarity index 97% rename from web/src/components/WorkbenchCardIcon.tsx rename to web/src/features/workbench/components/WorkbenchCardIcon.tsx index 4b244cb..2f6d5a5 100644 --- a/web/src/components/WorkbenchCardIcon.tsx +++ b/web/src/features/workbench/components/WorkbenchCardIcon.tsx @@ -1,4 +1,4 @@ -import type { ModuleIconKind, ModuleIconTone } from "../config/modules"; +import type { ModuleIconKind, ModuleIconTone } from "../../../config/modules"; type Props = { tone: ModuleIconTone; diff --git a/web/src/i18n/context.tsx b/web/src/i18n/context.tsx index e22ea22..dd41474 100644 --- a/web/src/i18n/context.tsx +++ b/web/src/i18n/context.tsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; -import en from "./en"; -import zh from "./zh"; +import en from "./locales/en"; +import zh from "./locales/zh"; export type Locale = "zh" | "en"; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts deleted file mode 100644 index ba5c1a6..0000000 --- a/web/src/i18n/en.ts +++ /dev/null @@ -1,2987 +0,0 @@ -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}}", - jumpPage: "Go to page", - pageSize: "Page size", - exportCsv: "Export CSV", - exporting: "Exporting…", - exportOk: "Exported {{count}} rows", - exportTruncated: "Exported first {{count}} of {{total}} rows (truncated)", - exportFailed: "Export failed", - opFailed: "Operation failed", - empty: "-", - cancel: "Cancel", - copy: "Copy", - copied: "Copied", - }, - workbench: { - title: "Workbench", - modules: "Modules", - sections: "Sections", - moduleCount: "{{count}} modules", - backToWorkbench: "Back to Workbench", - openInNewTab: "Open in new tab", - openModule: "Open or switch to this module tab", - monitoring: "Integration", - operations: "Operations", - system: "System", - welcomeEyebrow: "Workbench", - welcomeTitle: "Welcome back, {{name}}", - welcomeSub: "Monitor sync, topology, and ops tasks from one place.", - today: "Today · {{date}}", - kpi: { - uptime: "API uptime", - health: "System health", - tasks: "Active tasks", - healthOk: "OK", - healthWarn: "CHECK", - healthUnknown: "N/A", - }, - statusTitle: "Runtime status", - hostHealth: "HOST", - platformCapacity: "Platform capacity", - channels: "CHANNELS", - fleet: "FLEET", - fleetNe: "NE", - fleetNeUnit: "units", - fleetNeKnown: "Known", - fleetNePass: "Pass", - fleetNeFail: "Fail", - fleetNeTesting: "Testing", - fleetNeUnknown: "Untested", - fleetNeNoTag: "No tag", - fleetNeTags: "Top tags", - fleetTopo: "Topology", - fleetTopoNodes: "nodes", - fleetTopoEdges: "edges", - fleetTopoOther: "Other regions", - fleetTopoUnassigned: "Unassigned", - fleetTopoNoRegion: "No regions", - fleetTopoDiscover: "Last discover", - fleetTopoUpdated: "Summary updated", - fleetSched: "Schedulers", - fleetSchedConfig: "Config sync", - fleetSchedLldp: "LLDP", - fleetSchedPort: "Port traffic", - fleetSchedNeCollect: "NE collect", - fleetSchedFabric: "Fabric reconcile", - fleetSchedOn: "ON", - fleetSchedOff: "OFF", - fleetSchedMode: "Mode", - fleetSchedHeartbeat: "Heartbeat", - fleetSchedFresh: "Fresh", - fleetSchedStale: "Stale", - - sidePanel: "Console side panel", - consoleId: "OPS CONSOLE", - refreshedAt: "Updated {{time}}", - taskRail: "ACTIVE TASKS", - taskRailAll: "ALL", - taskRailEmpty: "No active tasks", - taskTotal: "POOL {{count}}", - announceTitle: "Announcements", - recentTitle: "Recent operations", - recentMore: "View all", - quickTitle: "Quick actions", - emptyRecent: "No recent audit entries.", - gauge: { - cpu: "CPU", - mem: "Memory", - storage: "Storage", - storageUsed: "Used", - storageUnavailable: "Unavailable", - cli: "CLI budget", - db: "DB pool", - webcrt: "WebCRT", - tasks: "Active tasks", - low: "Low load", - normal: "Normal", - high: "Busy", - }, - quick: { - tasks: "Task overview", - logs: "Audit logs", - users: "User admin", - apiKeys: "API keys", - sessions: "Sessions", - }, - announce: { - a1: "Dark workbench shell is live — other modules follow the same palette.", - a1Date: "2026-08-04", - a2: "LLDP collect supports pause / resume / stop; interrupted jobs resume after restart.", - a2Date: "2026-08-04", - a3: "Config sync and LLDP KPI boards refresh while jobs are running.", - a3Date: "2026-08-03", - }, - recentCols: { - action: "Action", - module: "Module", - time: "Time", - user: "User", - }, - cards: { - umeSync: "UME Sync", - network: "Network Management", - managedNe: "NE Management", - collect: "Batch Collect", - webcrt: "WebCRT", - topology: "Topology", - portTrafficWall: "Traffic wall", - users: "Users", - audit: "Audit", - apiKeys: "API Keys", - sessions: "Login sessions", - }, - }, - network: { - title: "Network Management", - nav: { - detail: "Network details", - tasks: "Monitoring tasks", - cutoverCompare: "Cutover compare", - topologyMgmt: "Topology management", - devices: "Device list", - topology: "Topology", - lldpLinks: "LLDP", - topoClassify: "Classify", - alarms: "Alarms", - configs: "Configurations", - webcrt: "WebCRT", - collectTasks: "Collection tasks", - configSync: "Config sync", - portTraffic: "Port traffic", - portTrafficWall: "Traffic wall", - bizState: "Business monitor", - bizCompare: "Business compare", - bizMigration: "Cutover monitor", - compareTemplates: "Compare templates", - monitorTemplates: "Monitor templates", - }, - collapseNav: "Collapse sidebar", - expandNav: "Expand sidebar", - placeholder: { - title: "Coming soon", - body: "This capability is not available yet.", - }, - }, - bizState: { - title: "Business monitor", - loadedTasks: "Loaded tasks", - loadedBatches: "Recent batches · {{n}} loaded (up to 200)", - collectErrors: "Recent collection errors", - retry: "Retry", - taskLoading: "Loading task configuration…", - tasksLoadError: "Could not load tasks", - taskLoadError: "Could not load task configuration", - batchLoadError: "Could not load batch", - sheetLoadError: "Could not load structured data", - statusQueued: "Queued", - batchSuccess: "Collected", - batchPartial: "Partially completed", - batchFailed: "Collection failed", - batchCancelled: "Cancelled", - create: "Create task", - createHint: "Pick any valid NE (managed / UME); search supported", - createModeDevice: "Pick device", - createModeImport: "Import log", - createImportHint: - "No NE needed: upload a session log or zip to create an Import task and parse the first batch. Re-import later on the task to append batches (same as multiple collects).", - createImportSubmit: "Create & import", - created: "Task created", - createFailed: "Create failed", - empty: "No business-state tasks yet.", - pickNe: "Select NE…", - neKeywordPh: "Name / IP / ID", - allSource: "All sources", - selectedNe: "Selected", - neEmpty: "No matching NEs.", - colSource: "Source", - colVendor: "Vendor", - colConnect: "Connect", - start: "Start schedule", - pause: "Pause", - statusPaused: "Manual only", - scheduleEnabled: "Enable schedule", - scheduleOn: "Scheduled", - scheduleOff: "Manual", - scheduleHint: "When checked, collect on interval; otherwise only Collect now.", - collectNow: "Collect now", - importLog: "Import log", - importLogHint: - "Append a new batch to this task (same as one collect): full session or zip; discard noise before show/display; split on hostname#show; fail if no show (no SSH).", - importVendor: "Vendor", - importVendorGeneric: "Generic (show+display)", - importFile: "Log file", - importNeName: "Task name (optional)", - importNeNamePh: "e.g. PE1-offline", - importSubmit: "Import as new batch", - importing: "Importing", - importStarted: "Import started ({{count}} segments)", - importOfflineOnly: "Import tasks are offline-only (no SSH collect)", - sourceImport: "Import", - stopCollect: "Stop collect", - stopCollectOk: "Stop collect requested", - stopCollectIdle: "No collect in progress", - delete: "Delete", - confirmDelete: "Delete this task and all batches?", - deleted: "Deleted", - started: "Schedule enabled", - paused: "Schedule paused (manual only)", - collecting: "Collecting", - statusCollecting: "Collecting", - detail: "Details", - interval: "Interval", - intervalUnit: "Interval unit", - intervalUnitDays: "days", - intervalUnitHours: "hours", - intervalUnitSeconds: "seconds", - retention: "Retention days", - retentionHint: "Batches older than this are purged; baselines and compare/cutover refs are kept.", - dailyKeepEnabled: "Daily keep policy", - dailyKeepCount: "Keep per day", - dailyKeepHint: "When on: keep only the newest N batches per past day; extras purge next day (off by default).", - markBaseline: "Mark baseline", - unmarkBaseline: "Unmark baseline", - baseline: "Baseline", - protected: "Protected", - colAlias: "Alias", - setAlias: "Set alias", - aliasPrompt: "Batch alias (preferred in compare/cutover pickers; empty clears)", - aliasSaved: "Alias saved", - aliasEmpty: "Untitled", - workbookStats: "Compare {{compare}} · With data {{withData}} · Sheets {{sheets}}", - deleteBatch: "Delete", - bulkDelete: "Bulk delete", - confirmDeleteBatch: "Delete this batch? Protected batches cannot be deleted.", - confirmBulkDelete: "Delete {{count}} selected batches? Protected ones are skipped.", - batchDeleted: "Batch deleted", - bulkDeleted: "Deleted {{deleted}}, skipped {{skipped}}", - purgeNow: "Purge now", - purgeOk: "Purge done: dropped {{dropped}}", - selectAll: "Select deletable", - colProtect: "Protect", - saveSchedule: "Save schedule", - scheduleSaved: "Schedule saved", - colInterval: "Interval", - profiles: "Monitor items", - profilesSelectAll: "Select all", - profilesDeselectAll: "Deselect all", - profilesSelectAllOk: "Enabled {{n}} item(s)", - profilesDeselectAllOk: "All items disabled", - profilesSelectionStats: "Selected {{enabled}} / {{total}}", - profilesUnboundStats: "{{n}} unbound", - colLane: "Collect lane", - laneLight: "Light", - laneHeavy: "Heavy", - laneLightHint: "Shared SSH with most commands; ~10 min lane cap by default", - laneHeavyHint: "Dedicated SSH + longer timeouts; ~40 min lane cap by default", - enable: "On", - command: "Command template", - colAux: "Aux commands", - params: "Bindings", - discoverVrf: "Discover VRFs", - discoverBind: "Discover / Bind", - saveBindings: "Save bindings", - cancel: "Close", - bindTitle: "Select bindings", - unbound: "Not bound", - bindHintRequired: "Bind params before collect", - paramsDblClickHint: "Click to view all bindings", - paramsClickHint: "Click to view all bindings", - paramsDetailTitle: "Bound parameters", - paramsDetailCount: "{{n}} binding(s)", - discoverLoading: "Discovering…", - discoverEmpty: "No candidates discovered", - discoverCacheHit: "reused (no re-login)", - discoverRefresh: "Refresh from device", - selectAllVrfs: "Select all", - deselectAllVrfs: "Deselect all", - batches: "Batches", - viewBatch: "Open", - batchDetail: "Detail", - export: "Export", - exportCommands: "Export collect commands", - exportCommandsOk: "Collect commands exported", - exportRawLog: "Export as text", - exportRawLogOk: "Log exported", - noBatches: "No batches yet", - batchCollectDetail: "Collect detail", - batchWorkbook: "Batch workbook", - batchId: "Batch ID", - copyBatchId: "Copy batch ID", - batchMessageEmpty: "No message or error", - batchCmdSummary: - "{{ok}} ok / {{fail}} failed / {{skipped}} skipped / {{other}} other · {{total}} counted (batch {{batchCmds}} cmds)", - sheetCommands: "Commands", - sheetLldp: "LLDP neighbors", - sheetVrfRoute: "VRF route summary", - sheetIsis: "ISIS adjacency", - sheetIfaceBrief: "Interface brief", - sheetArp: "ARP", - sheetNd6: "ND6 cache", - sheetBgpPeer: "BGP peers", - sheetConfigIface: "Interface intent", - sheetConfigVrf: "VRF intent", - sheetConfigBgp: "BGP intent", - sheetFilterPh: "Filter this sheet…", - sheetEmpty: "No rows in this sheet", - sheetLoading: "Loading…", - collectCommand: "Collect command", - viewRawLog: "Raw log", - rawLogTitle: "Raw collect log", - rawLogEmpty: "No raw output", - rawLogStats: "{{lines}} lines collected · {{rows}} rows stored", - rawLogDeclared: "declared {{declared}}", - openWorkbook: "Open workbook", - filterColumn: "Column", - filterAllCols: "All columns", - listFilterPh: "Filter NE / IP / vendor / status", - purposeFilter: "Task purpose", - purposeAll: "All purposes", - purposePortrait: "Portrait", - purposeCutoverHf: "Cutover HF", - colPurpose: "Purpose", - colNe: "NE", - colStatus: "Status", - colLast: "Last collect", - colStarted: "Started", - colEnded: "Ended", - colTime: "Time", - colRows: "DB rows", - colRawLines: "CLI lines", - colDeclared: "Declared", - colActions: "Actions", - colCommand: "Command", - colMessage: "Message", - pickTask: "Open details to manage a task", - noNeedBind: "This item needs no parameter bindings", - discoverFailed: "Discover failed", - bindingsSaved: "Bindings saved", - }, - bizCompare: { - title: "Business compare", - templates: "Compare templates", - newTemplate: "New template", - editTemplate: "Edit template", - saveTemplate: "Save template", - templateSaved: "Template saved", - templateDeleted: "Template deleted", - confirmDeleteTemplate: "Delete this compare template?", - confirmDeleteJob: "Delete this compare job and all runs?", - confirmDeleteRun: "Delete this compare run? Job config is kept.", - deleteJob: "Delete job", - deleteRun: "Delete run", - runDeleted: "Compare run deleted", - emptyTemplates: "No templates yet. Create one or import a JSON file.", - exportTemplate: "Export", - importTemplate: "Import", - templateExported: "Template exported", - templateImported: "Template imported", - templateImportInvalid: "Invalid file: need name and metrics", - templateValidationFailed: "Template validation failed; correct the indicated settings", - filterOperator: "Filter operator", - complexFiltersPreserved: "These nested filters are preserved in full. Export, edit the JSON and import it to update them.", - portPolicy: "Port change policy", - portPolicyHint: "Port mappings take priority. This policy controls whether interface identity fields participate in matching without a mapping.", - portPolicyAuto: "Automatic", - portPolicyStrict: "Interfaces must match", - portPolicyIgnore: "Allow port changes", - missingSheets: "Configured checks were removed or renamed; select the scope again", - enableCurrentSheets: "Enable all current checks", - templateUsage: "Used by {{n}} jobs", - templateImportHint: "Import/export JSON to share templates privately (no job/batch data)", - templateHint: - "Pick metrics, then set Key / interface / compare / display. Compare modes: exact, abs delta, or relative %. Display can include context-only columns. Empty compare = presence-only.", - stepPickMetrics: "① Pick metrics", - stepConfigRules: "② Configure compare rules", - pickMetricsHint: "Check metrics to add. Then configure each sheet below.", - pickMetricsHintShort: "Click to add/remove; switch sheets in the left list below.", - showMetrics: "Show metric picker", - hideMetrics: "Hide metric picker", - noMetricsYet: "Select at least one metric first", - selectedCount: "{{n}} selected", - tabFields: "Field rules", - tabFilters: "Row filters", - fieldsHintShort: - "Key aligns rows; Compare fields take part in value diff; empty compare = presence-only.", - tagCompareN: "{{n}} cmp", - colFields: "Fields", - keyFields: "Key", - ifaceFields: "Interface", - compareFields: "Compare", - compareMode: "Mode", - tolerance: "Tolerance", - displayField: "Display", - keyAlwaysDisplay: "Key columns are always shown", - modeEq: "Exact equal", - modeNumeric: "Abs delta", - modePercent: "Relative %", - normalizeField: "Norm", - ignoreFields: "Ignore", - field: "Field", - note: "Note", - keyRequired: "Select at least one key field", - keyIsIdentity: "Key aligns rows; not used as a value-compare field", - metricsRequired: "Add at least one metric sheet", - noMetricFields: "No fields for this metric", - rowFilters: "Row filter rules", - rowFiltersHint: - "Filter rows before compare. Conditions in a group are AND; groups are OR. Selecting ARP seeds editable default rules you can change.", - rowFiltersHintShort: "AND in group · OR across groups · empty = all rows", - rowFiltersEmpty: "No filters → compare all rows.", - filterClear: "Clear", - filterAddOr: "Add group (OR)", - filterAddAnd: "Add condition (AND)", - filterAddOrShort: "+ OR group", - filterAddAndShort: "+ AND", - filterRemoveGroup: "Remove group", - filterGroupN: "Group {{n}}", - filterOr: "OR", - filterAnd: "AND", - filterField: "Field", - filterOp: "Op", - filterValue: "Value", - normalizeNone: "None", - normalizeLower: "lower", - normalizeUpper: "upper", - normalizeMac: "MAC", - normalizeEmpty: "empty→blank", - templateFilterPh: "Filter template / metric", - jobFilterPh: "Filter job / template / status", - addMetric: "Add metric", - pickMetric: "Pick metric…", - addSheet: "Add to template", - removeSheet: "Remove sheet", - sheetIdRequired: "sheet_id is required (unique per split)", - duplicateSheetId: "sheet_id must be unique", - sheetTitle: "Sheet title", - sheetId: "sheet_id", - splitSheet: "Split / duplicate", - colSheets: "Sheets", - sheetsUnit: "sheets", - presenceShort: "presence", - modeFields: "Mode: compare selected field values", - modeFieldsShort: "fields", - modePresence: "Mode: presence-only (entry set match)", - mapping: "Port mapping (optional)", - newMapping: "New mapping…", - mapName: "Mapping name", - saveMapping: "Save mapping", - validateMapping: "Validate", - validateNeed: "Select mapping and before/after batches first", - mapHint: - "One pair per line: before_if,after_if (comma / tab / pipe). Example:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# comments start with #. Not slash-grouped A,B/C,D.", - mappingSaved: "Mapping saved", - mappingOptionalHint: - "Optional. Empty = ignore local port renames (match on non-interface keys). Fill to apply port map. Main-port pairs also cover .subinterfaces (suffix kept).", - ifaceNormalize: "Interface normalize", - ifaceNormalizeHint: - "Template rules, one per line: alias,canonical. Applied before port mapping. Example:\nGE,gei\nSG,smartgroup\nXGE,xgei", - createJob: "Create compare job", - defaultJobName: "Cutover compare", - jobName: "Name", - template: "Template", - mode: "Mode", - modeManual: "Manual batches", - modeAuto: "Auto (pin before; latest after batch)", - beforeTask: "Before task", - beforeBatch: "Before batch", - afterTask: "After task", - afterBatch: "After batch", - sideBefore: "Before", - sideAfter: "After", - sidesVs: "vs", - sidesTitle: "Compare sides", - configSnapshot: "Execution settings saved", - configSnapshotHint: "Template rules and port mapping are fixed for this run. Later edits do not affect historical lookup. The downloaded report includes the execution settings.", - configLegacy: "Legacy run: settings not saved", - configLegacyHint: "This run has no configuration snapshot. Success lookup and compact row reconstruction use the current settings, which may differ from the original execution.", - pick: "Select…", - sameAsBefore: "Same as before / select…", - autoHint: "auto: new after-task batches trigger compare", - createCompare: "Create job", - createSteps: "Create steps", - stepBasics: "Basics", - stepSheets: "Sheets", - stepBatches: "Batches", - stepMapping: "Port mapping", - next: "Next", - back: "Back", - enabledSheets: "Compare sheets", - enabledSheetsHint: "Toggle sheets; all on by default. Off sheets are skipped.", - noTemplateSheets: "This template has no sheets", - needJobName: "Enter a job name", - needTemplate: "Select a compare template", - needSheets: "Enable at least one sheet", - needBeforeBatch: "Select before task and batch", - needAfterBatch: "Select after batch", - runNow: "Run now", - runStarted: "Compare started in background — you can close this page; check run history for progress", - runFailed: "Compare failed", - runStatusRunning: "Running", - runStatusFailed: "Failed", - runStatusDoneSec: "Done {{s}}s", - runProgress: "{{phase}} · sheet {{i}}/{{n}} · {{sheet}} · {{s}}s elapsed", - runSheetProgress: "sheet {{i}}/{{n}}", - runElapsed: "{{s}}s elapsed", - runRowsLoaded: "Loaded {{side}} {{n}} rows", - runRowsSqlCount: "DB count {{side}} {{n}} rows", - runPersisting: "Wrote {{done}}/{{total}} rows", - runEngineSql: "engine SQL", - runEnginePython: "engine Python", - runEngineNote: "reason {{note}}", - phaseQueued: "Queued", - phaseLoading: "Loading", - phaseSqlCount: "Counting", - phaseSqlProject: "Preparing tables", - phaseSqlJoin: "Joining", - phaseSqlFailFetch: "Fetching fails", - phaseComparing: "Comparing", - phasePersisting: "Writing", - phasePersistingFail: "Writing fails", - phasePersistingOk: "Writing passes", - phaseDone: "Done", - phaseFailed: "Failed", - phaseCancelled: "Cancelled", - sheetPending: "Pending", - colProgress: "Progress / result", - passRateScope: "all rows", - keyFiltersToggle: "Field filters", - hideDisplayCols: "Hide display cols", - showDisplayCols: "Show display cols", - displayColsToggle: "Display cols", - hideDisplayColsHint: "Compact: display-only columns are hidden by default; highlight means they are shown", - ranWithDuration: "Compare finished ({{s}}s)", - unchangedNotStored: "Success rows were counted but not stored. Set “Store success rows” to sample and re-run for spot-check.", - unchangedSampleHint: "{{total}} success rows total; browsing a stratified sample of {{listed}}. Search any route for live source lookup. Pass rate uses all {{total}}.", - liveSearchHint: "Pairs are completed from source batches before filtering. Pass rate still uses the full sheet.", - liveSearchTruncatedHint: "Search limit reached; some entries are omitted. Narrow the filters. Incomplete key groups were skipped.", - clearSearch: "Clear filters", - resultLoading: "Loading comparison details…", - resultRange: "{{from}}–{{to}} of {{total}}", - resultLoadFailed: "Could not load comparison details", - retry: "Retry", - searchEmpty: "No results match these filters. Clear filters or select another result type.", - clearKeyFilters: "Clear fields", - storeUnchanged: "Store success rows", - storeUnchangedAuto: "Auto (full if small / sample 5k + hydrate if large)", - storeUnchangedSample: "Sample only (up to 5k keys + hydrate)", - storeUnchangedKeys: "All success keys (full browse; large write still slow)", - storeUnchangedAlways: "Always full JSON (slow on huge sheets)", - storeUnchangedNever: "Never (counts only)", - storeUnchangedHint: "Cutover focuses on fails and pass rate; success defaults to a stratified sample. Type a filter to look up any route from source tables. Pass rate uses the full success count.", - saveJob: "Save config", - jobSaved: "Job config saved", - jobDeleted: "Compare job deleted", - jobList: "Compare jobs", - detail: "Details", - edit: "Edit", - delete: "Delete", - tabConfig: "Job config", - tabRuns: "Compare batches", - tabResult: "Result", - detailTabs: "Business comparison workspace", - jobReady: "Ready", - configBasics: "Job parameters", - configSources: "Before and after sources", - configScope: "Check scope", - selectMapping: "Select mapping", - mappingPairs: "Interface pairs", - sourceBaseline: "Fixed baseline", - sourceTarget: "Acceptance target", - sourceLatest: "Latest batch", - sourceRows: "{{n}} collected rows", - autoSourceHint: "Keep the before batch fixed. Compare against the latest completed batch from the after task.", - runHistoryTitle: "Execution history", - runHistoryHint: "Showing the latest 50 runs. Execution status and business verdicts are displayed separately. Open results to inspect.", - refreshRuns: "Refresh history", - loadedRuns: "Loaded runs", - activeRuns: "In progress", - completedRuns: "Completed", - stoppedRuns: "Failed / cancelled", - runFinished: "Completed", - runStatusQueued: "Queued", - runsLoading: "Loading execution history…", - runsLoadError: "Could not load execution history", - runs: "Run history", - runsHint: "Each “Run now” creates a batch. Stuck “Running” after restart is auto-cancelled; you can also cancel manually and re-run.", - noRuns: "No runs yet — run a compare first", - result: "Result", - viewResult: "View result", - cancelRun: "Cancel", - confirmCancelRun: "Cancel this compare batch? You can start a new run afterward.", - runCancelled: "Compare cancelled", - runStatusCancelled: "Cancelled", - compareAlreadyRunning: "A compare is already running — cancel it under Compare batches or wait", - runBatchSummary: "Fail {{fail}} · Pass {{ok}} · Added {{added}}", - colTime: "Time", - pickBatchRun: "Select compare run", - pickRun: "Select run…", - runCount: "{{n}} runs", - kindAll: "All", - kindDiff: "Fail", - kindAdded: "Added", - kindRemoved: "Missing only", - kindChanged: "Mismatch only", - kindUnchanged: "Pass", - kindAddedShort: "Added", - kindRemovedShort: "Missing", - kindChangedShort: "Mismatch", - kindUnchangedShort: "Pass", - kindFail: "Fail", - kindPass: "Pass", - kindSuccess: "Pass", - successCount: "Pass", - passRateShort: "Pass %", - verdictPass: "Pass", - verdictFail: "Fail", - failCount: "Failed rows", - missCount: "Missing", - extraCount: "Extra", - mismatchCount: "Mismatch", - matchCount: "Match", - failFields: "Failed fields", - failFieldsEmpty: "—", - failWholeRow: "Whole row", - pairBefore: "pre", - pairAfter: "post", - sheetAllPass: "All match", - filterFailField: "Failed compare field", - filterFailFieldAll: "Any failed field", - resultEmpty: "No matching rows", - resultFilterPh: "Free text, or direction:out network:1.1.1.1", - diffCount: "{{n}} failed", - passRate: "Pass rate", - passOk: "All passed", - passWarn: "Has failures", - runOverview: "Verdict", - sheetCurrent: "Current check", - diffCountLabel: "Failed rows", - diffRate: "Fail rate", - beforeCount: "Before rows", - afterCount: "After rows", - matchedRows: "Matched", - totalRows: "Total rows", - sheetCount: "Sheets", - sheetNavTitle: "Checks", - sheetNavHint: "{{n}} checks · click to switch", - collapseSheetNav: "Collapse checks", - expandSheetNav: "Expand checks", - sheetFailCount: "{{n}} failed", - topChangedFields: "Top failed fields", - exportTables: "Download tables", - exportOk: "Compare tables exported", - fullscreen: "Fullscreen board", - exitFullscreen: "Exit fullscreen", - emptyJobs: "No compare jobs yet", - created: "Compare job created", - ran: "Compare finished", - colName: "Name", - colMode: "Mode", - colStatus: "Status", - colKind: "Verdict", - colResult: "Result", - colDetail: "Detail", - colFailFields: "Failed fields", - colActions: "Actions", - colKey: "Identity", - colChange: "Changes", - }, - bizMigration: { - fullscreen: "Wall mode", - exitFullscreen: "Exit wall mode", - fullscreenHint: "Display cutover status fullscreen. Press Esc to return; use a full-window layout when fullscreen is unavailable.", - wallTitle: "Cutover monitoring · Service migration", - wallProgress: "Wave migration progress · Current snapshot", - wallFilters: "Show filters and evaluation controls", - wallState_waiting: "Awaiting evaluation", - wallState_unknown: "Data needs confirmation", - wallState_anomaly: "Service anomalies detected", - wallState_normal: "No service anomalies in current samples", - pairCollectionHint: "Each metric has a paired collection round. Saving settings updates task bindings. Route tables are manual-only for final checks; use a separate business comparison for millions of routes. Actual cadence depends on command duration and device availability.", - pairedSampling: "Paired sampling · Timeliness", - manualRoute: "Manual final check", - pairedPeriodic: "Paired periodic", - samplingMode: "Sampling mode", - samplingDuration: "Duration old / new", - samplingQueue: "Queue old / new", - startSkew: "Start skew", - sameRound: "Round", - metricBaselineMissing: "This metric needs a matching baseline receipt", - samplePhase_idle: "Not collected", - samplePhase_queued: "Queued", - samplePhase_running: "Collecting", - samplePhase_success: "Complete", - samplePhase_partial: "Partial", - samplePhase_failed: "Failed", - samplePhase_cancelled: "Stopped", - collect_pair_invalid: "Incomplete collection pair", - collect_pair_missing: "No complete paired sample", - collect_pair_pending: "Waiting for both sides of this round", - collect_pair_failed: "Current round failed; older samples cannot replace it", - collect_round_mismatch: "Samples belong to different rounds", - sample_start_skew: "Actual collection starts are too far apart", - latestSnapshot: "Latest snapshot", - steadyOutside: "{{n}} stable items outside scope", - "followLive": "Follow live", - "historySnapshot": "Historical snapshot", - "evaluatedAt": "Evaluated", - "refreshBoard": "Refresh board", - "oldestSample": "Oldest sample", - "liveOverview": "Business migration overview", - "remaining": "Remaining items", - "anomalyInBatch": "Current wave anomalies", - "anomalyOutsideBatch": "Outside wave anomalies", - "coverageUnknown": "Uncertain coverage; acceptance cannot pass", - "coverageUnknownHint": "Resolve missing, stale or misaligned samples and duplicate keys. Healthy samples cover their own monitor items.", - "awaitingPeer": "Awaiting target sample", - "migratedPrevious": "Previous wave received", - "unfinishedPrevious": "Previous wave unfinished", - "sample_time_missing": "Sample time missing", - "sample_skew": "Samples too far apart", - "sample_stale": "Stale samples", - "duplicateKeys": "Duplicate business keys", - "uncoveredScope": "Missing scope sheets", - "refreshFailed": "Refresh failed; showing retained snapshot:", - "lastAcceptance": "Last acceptance", - "diffSearch": "Search business key / verdict / status", - "verdictFilter": "Verdict filter", - "allVerdicts": "All verdicts", - "warningVerdict": "In progress / warning", - "healthyVerdict": "Received / healthy", - "unknownVerdict": "Uncertain", - "loadingBoard": "Updating…", - "rowsMatched": "{{n}} matching items", - "showOutsideAnomalies": "Show outside anomalies", - "scopeCurrent": "Current wave", - "scopePrevious": "Previous wave", - "scopeOutside": "Outside scope", - "fieldChanges": "{{n}} changed fields", - "outsideProtection": "Protect businesses outside this wave", - "outsideProtectionHint": "Report drift outside this wave. Continue checking migrated businesses from previous waves without counting them in current progress.", - "reason_service_not_received": "Source departed; target not healthy", - "reason_target_unhealthy": "Target unhealthy", - "reason_old_service_not_in_baseline": "Business missing from source baseline", - "reason_service_content_changed": "Received business content differs", - "reason_old_service_still_present": "Business still on source", - "reason_target_sample_predates_source": "Target sample predates source departure", - "reason_previous_batch_received": "Previous wave remains healthy", - "reason_previous_batch_not_received": "Previous wave not received", - "reason_unmapped_same_iface": "Drift on unmapped interface", - "reason_out_of_expect:anomaly": "Business drift outside scope", - "reason_out_of_expect:lost": "Business lost outside scope", - title: "Cutover monitor", - hint: "Separate from business compare: baseline drift, expect set, dual-device verdict. Reuses collect tasks and port mappings.", - hintPort: - "Metric focus: port status (interface_brief / admin·phy·prot). Baseline → pick ports → evaluate board.", - hintShort: "Monitor template sets rules; bind collect + dual baselines → expect → evaluate board.", - create: "Create task", - createHint: - "Except portrait baselines, the full cutover flow runs on this page; create auto-spawns HF biz-monitor tasks on schedule.", - createHintShort: - "Pick old/new NEs → monitor template & metrics → interval/window → baselines → port map → create. HF tasks are auto-created.", - pickOldNe: "Old NE", - pickNewNe: "New NE", - pickNe: "Pick NE", - pickNeHint: "Click to pick a device", - hideNePicker: "Hide list", - nePairReady: "Both NEs selected — continue, or click a card to re-pick.", - needNePair: "Enter a name and select old/new NEs", - needProjectName: "Enter a task name", - needMonitorTemplate: "Select a monitor template", - needMetrics: "Select at least one metric", - noPortraitBaseline: - "No portrait task/batches for this NE yet — run portrait collect in business monitor first (baselines can be set later).", - baselineOptionalHint: "Baselines can be set later in detail; pick from portrait batches when available.", - metricInterval: "Per-metric interval (sec)", - metricIntervalOptional: "Per-metric interval (optional)", - metricIntervalHint: "Leave blank to use the global interval; a filled value creates a separate HF task.", - scheduleHint: - "Enter the window in your local time; stored as UTC. Scheduler pauses HF outside the window.", - sectionSchedule: "Collect interval & window", - sectionBaseline: "Baselines & mapping", - sectionDevices: "Old / new NEs", - sectionMetrics: "Collect metrics", - createSteps: "Create steps", - stepDevices: "Devices", - stepTemplate: "Template", - stepSchedule: "Schedule", - stepBaseline: "Baselines", - next: "Next", - back: "Back", - colHf: "HF", - hfReady: "Ready", - hfMissing: "Missing", - showCreateMore: "More: template / mapping / baselines", - hideCreateMore: "Hide more", - setupHintShort: "Portrait tasks feed baselines; HF tasks feed cutover collect. Configure template, metrics, window and dual baselines here.", - createProject: "Create task", - projectName: "Task name", - projectNamePh: "e.g. PE-A / PE-B cutover-1", - sectionName: "1. Task name", - sectionTasks: "2. Bind collect tasks (required)", - sectionTasksHint: "Pick the old-device and new-device business-monitor tasks.", - sectionOptional: "3. Monitor template, mapping & baselines (optional)", - sectionOptionalHint: - "Skip for now if needed; set later under Baseline. Defaults to the system monitor template when omitted.", - sectionPair: "Bound collect tasks", - sectionPairHint: "Collect tasks are fixed at create time; adjust monitor template, mapping and baselines here.", - sectionBaselineHint: "Both old and new baselines are required; old lists expect objects, new feeds dual-side verdict.", - optionalNone: "None", - monitorTemplate: "Monitor template", - monitorTemplateHint: "Chooses which metrics to compare and dual-side success semantics. Defaults to the system monitor template.", - compareTemplate: "Compare template", - compareTemplateBound: "Bound compare template: {{name}}", - portMappingHint: - "With a mapping, iface metrics compare only mapped ports (they must appear after cutover). Same-name ports outside the map are not a migration; old down + new up is an anomaly. No mapping means same-name compare. BGP/PW still use the batch expect set.", - baselineNeedTask: "Select the matching collect task first", - baselinePickHint: "Choose from that task’s collect batches", - oldTask: "Old-device portrait task", - newTask: "New-device portrait task", - portMapping: "Port mapping", - oldBaseline: "Old baseline batch", - newBaseline: "New baseline batch", - needProjectFields: "Enter a name and pick old/new portrait tasks", - needExpectPorts: "Select at least one expected object for this batch", - needBothBaselines: "Save both old and new baseline batches before evaluating", - errOldBaselineRequired: "Old baseline batch is required", - errNewBaselineRequired: "New baseline batch is required", - needBizStateTasks: "No portrait business-monitor tasks yet. Create one first:", - projectCreated: "Task created", - projects: "Tasks", - empty: "No cutover tasks yet.", - emptyProjects: "No cutover tasks yet", - listFilterPh: "Filter name / NE / IP / status", - kpiProjects: "Tasks", - kpiActive: "Active", - detail: "Detail", - cancel: "Close", - colName: "Task", - colPair: "Old → New", - colBaseline: "Baseline", - colCreated: "Created", - colActions: "Actions", - baselineSet: "Set", - baselineUnset: "Unset", - statusActive: "Active", - statusDone: "Done", - statusPaused: "Paused", - statusPending: "Pending", - statusReview: "Review", - tabSetup: "Baseline", - tabBatches: "Batches", - tabBoard: "Board", - saveBaseline: "Save", - baselineSaved: "Baseline saved", - errPairCapacity: "Paired sampling needs at least two collection slots. Check task concurrency, collection threads and the CLI budget.", - errPairBusy: "This metric is still collecting or queued on either side. Wait for the round to finish.", - errPairInvalid: "The collector pair is incomplete. Save configuration again to restore it.", - errPairSameDevice: "Cutover monitoring requires two different devices.", - pickExpectPorts: "Expect", - portFilterPh: "Filter", - emptyBaselinePorts: "No baseline data", - batchLabel: "Batch label", - batchId: "Batch ID", - copyBatchId: "Copy batch ID", - defaultBatchLabel: "Night N", - expectPorts: "Expected ports (old side)", - expectPortsPh: "Comma or newline separated", - createBatch: "New", - batchCreated: "Batch created", - batches: "Batches", - startBatch: "Start", - finishBatch: "Finish", - evaluate: "Evaluate", - evaluated: "Done", - statusUpdated: "Status updated", - board: "Board", - metricPort: "Port status", - progress: "Progress", - anomaly: "Anomaly", - onlyExpect: "Expect only", - onlyExpectHint: "Shows all drift by default; check to filter to this batch's expect set", - pinCurrentBatches: "Current collect batches", - pinOldBatch: "Old current", - pinNewBatch: "New current", - pinLatest: "Latest", - runHistory: "Runs", - runLatest: "Latest", - colRuleHit: "Rule hit", - colCommand: "Collect command", - mappingBound: "Mapping saved and bound to this project", - allMetrics: "All metrics", - colMetric: "Metric", - colKey: "Object", - colPort: "Old port", - openRedChip: "Red {{n}}", - metaPortrait: "Portrait", - metaHf: "HF", - metaBaseline: "Baseline", - baselineOk: "set", - baselineMissing: "missing", - needBaselineFirst: "Set baseline first", - enableHighfreq: "Enable HF", - highfreqReady: "HF ready", - collectNow: "Collect now", - collectTriggered: "Collect triggered", - openBizState: "Biz monitor", - hfNotBound: "Unbound", - projectCollectMetrics: "Metrics", - hfInterval: "Interval(s)", - hfStart: "Start", - hfEnd: "End", - markProjectDone: "Done / stop HF", - confirmProjectDone: "Complete project and pause all HF?", - projectMarkedDone: "Done; HF paused", - deleteProject: "Delete", - confirmDeleteProject: "Delete project? HF will pause and batches removed.", - projectDeleted: "Deleted", - acceptPassedShort: "Pass", - acceptFailedShort: "Fail", - colMapped: "Mapped new", - colDesc: "Description", - colOldPort: "Old business key", - colNewPort: "New business key", - colOldStatus: "Old admin/phy/prot", - colNewStatus: "New admin/phy/prot", - colOld: "Old", - colNew: "New", - colVerdict: "Verdict", - emptyDiffs: "No rows yet — run Evaluate", - emptyRed: "No red tickets", - verdictMigrated: "Migrated", - verdictMigrating: "Migrating", - verdictLost: "Lost", - verdictAnomaly: "Anomaly", - verdictNotInvolved: "Not involved", - verdictUnexpected: "Unexpected new", - verdictOk: "OK", - highfreqHint: "", - boundTasks: "Bound tasks", - oldPortraitTask: "Old portrait", - newPortraitTask: "New portrait", - oldHfTask: "Old HF", - newHfTask: "New HF", - projectCollectHint: "", - hfWindowInactive: "HF window inactive or project done", - baselinePortraitOnly: "", - collectSkipped: "skipped", - collectSkippedBanner: "Skipped: {{metrics}}", - currentMissing: "no current", - currentMissingBanner: "No current: {{metrics}}", - highfreqReadyInactive: "HF bound (window inactive, no collect)", - collectPartial: "Partial fail (ok {{ok}} / fail {{fail}})", - errMetricNeedsBindings: "Metric \"{{metric}}\" needs bindings ({{params}}); not supported for cutover HF — remove it", - errNoProfileForMetric: "Metric \"{{metric}}\" has no collect profile for this vendor", - acceptTitle: "Acceptance", - acceptPassed: "Passed", - acceptFailed: "Failed ({{n}} red)", - newBaselineMissingBanner: "Missing new baseline: {{metrics}}", - acceptCarryHint: "May continue with red", - openRedHint: "Red {{n}}", - redTitle: "Red tickets", - redOpen: "open", - resolveRed: "Resolve", - redResolved: "Resolved", - batchCreatedWithRed: "Batch created ({{n}} red)", - colStatus: "Status", - verdictUnfinished: "Unfinished", - }, - bizMonitorTpl: { - title: "Monitor templates", - help: "Compare templates define align/diff; monitor templates define migrate success on the same sheets.", - hint: "Compare templates define HOW to align/diff; monitor templates define WHEN dual-device migrate succeeds — one sheet tab per compare sheet.", - create: "New monitor template", - edit: "Edit", - save: "Save", - cancel: "Close", - created: "Created", - saved: "Saved", - deleted: "Deleted", - confirmDelete: "Delete this monitor template?", - delete: "Delete", - empty: "No monitor templates yet.", - listFilterPh: "Filter name / compare template / note", - kpiTotal: "Templates", - colName: "Name", - namePh: "e.g. Status cutover monitor", - colCompare: "Compare template", - colCollect: "HF collect metrics", - colNote: "Note", - colActions: "Actions", - needName: "Name is required", - defaultsInvalid: "Invalid defaults JSON", - overridesInvalid: "Invalid sheet_overrides JSON", - exportTemplate: "Export", - importTemplate: "Import", - templateExported: "Template exported", - templateImported: "Template imported", - templateImportInvalid: - "Invalid import file: need name and compare_template_id (or compare_template_name)", - templateImportHint: "Import/export JSON to share monitor templates (no job/batch data)", - templateImportCompareMissing: - "Matching compare template not found — import or create it first", - formHint: "Pick a compare template → same sheet tabs appear → set success conditions per sheet.", - compareHint: "Owns which sheets and Key/compare fields; here you only set dual verdict rules.", - pickCompare: "Pick compare template…", - openCompare: "Manage compare templates", - collectAll: "All sheets", - collectPh: "interface_brief, bgp_peer (empty = all)", - collectHint: "HF collect metrics; empty / “All sheets” uses every compare-template sheet.", - collectTitle: "High-freq collect subset", - defaults: "defaults (JSON)", - overrides: "sheet_overrides (JSON)", - overridesHint: "Optional per-metric status_fields / success patterns.", - globalPolicy: "Global policy", - outOfExpect: "Out-of-expect objects", - outOfExpectHint: "How to treat drift outside the batch expect set.", - ooeStrict: "Strict (red anomaly)", - ooeWarn: "Warn (yellow only, no red tickets)", - ooeIgnore: "Ignore (always not involved)", - sheetRules: "Per-sheet migrate rules", - applyAllPresets: "Apply recommended presets to all", - applyAllPresetsShort: "All presets", - sheetsUnit: " sheets", - sheetHowReadonly: "Compare template (read-only)", - iface: "Interface", - compare: "Compare", - presenceOnly: "presence only", - editInCompare: "Edit HOW", - presetAuto: "Recommended", - presetSkip: "Skip sheet", - skipDual: "Skip dual for this sheet", - skipDualHint: "Skipped in cutover evaluate (use a compare job for counters).", - statusFields: "Fields used for up/down", - statusFieldsHint: "Ports: admin/phy/prot; BGP: state; ARP: leave empty (presence only).", - statusOptionalHint: "With no status fields, only presence (gone/added) is used.", - downValues: "Down values", - upValues: "Up values", - noFields: "No fields (pick metrics on the compare template first)", - successPatterns: "Success rules (old OR-groups × new OR-groups)", - successPatternsShort: "Success", - successPatternsHint: - "Presence is whether the row exists. Value change binds a field and a strategy. AND inside a group, OR between groups.", - successPatternsHintShort: "AND in group · OR across groups · old+new both match ⇒ success", - successPatternsHintFree: - "Each side has two condition kinds: presence (no field) or value change (must bind a field). Inside a group everything must match (AND). Between groups, any group is enough (OR).", - anomalyPatternsShort: "Anomaly", - anomalyPatternsHint: - "Same shape as success. Match ⇒ red. Check “don’t care” on one side to watch only the other (e.g. new-side gone/down). Not-yet-success is still migrating, not anomaly.", - anomalyPatternsHintShort: "Success first; then anomaly. Don’t-care watches one side. Mid-state ≠ anomaly.", - successRow: "Success {{n}}", - anomalyRow: "Anomaly {{n}}", - removeRow: "Remove", - oldSideAny: "Old", - newSideAny: "New", - groupLogicHint: "AND inside a group, OR between groups", - sideDontCare: "Don’t care this side", - sideDontCareHint: "Judged by the other side only.", - groupN: "Group {{n}}", - addAnd: "Add to this group (AND)", - addOr: "Add another group (OR)", - addAndShort: "+ AND", - addOrShort: "+ OR group", - addSuccessRow: "+ Success rule", - addAnomalyRow: "+ Anomaly rule", - modeAny: "Match any", - modeAll: "Match all", - addCond: "Add condition", - pickField: "Pick field…", - condValue: "Value", - opEmpty: "empty", - opNotEmpty: "not empty", - opChanged: "changed vs baseline", - opUnchanged: "same as baseline", - op_eq: "equals", - op_ne: "not equals", - op_in: "in", - op_not_in: "not in", - op_empty: "empty", - op_not_empty: "not empty", - op_changed: "changed vs baseline", - op_unchanged: "same as baseline", - and: "and", - groupPresence: "Presence", - groupValue: "Value change", - groupStatus: "Status class", - groupField: "Field condition", - tokRemoved: "Gone", - tokAdded: "Added", - tokUnchanged: "Unchanged", - tokChanged: "Changed", - tokUp: "up", - tokDown: "down", - tokOther: "other", - or: "or", - tagSkip: "skip", - tagRules: "{{n}}", - tagEmpty: "empty", - needCompareFirst: "Pick a compare template that has sheets first.", - showMore: "More: note / HF collect / JSON", - hideMore: "Hide more", - showJson: "Advanced: edit JSON", - hideJson: "Back to visual editor", - }, - portTraffic: { - title: "Port traffic", - create: "Monitor device", - backList: "Back to list", - created: "Device monitoring added", - createFailed: "Create failed", - updated: "Saved", - interfacesSaved: "Interfaces updated", - started: "Started", - paused: "Paused", - stopped: "Stopped", - statusRunning: "Running", - statusPaused: "Paused", - statusStopped: "Stopped", - statusDraft: "Draft", - collecting: "Collecting", - deleted: "Deleted", - confirmDelete: "Delete this device monitoring and its samples?", - empty: "No devices monitored yet. Pick a NE and interfaces to start.", - keywordPh: "Name / IP / note / ID", - allStatus: "All statuses", - exportDevicesName: "port-traffic-devices", - exportEventsName: "port-traffic-events", - wall: "Ops wall", - wallTitle: "Port traffic wall", - toDevices: "Manage devices", - wallEmpty: "No monitored devices yet. Add a device and pick interfaces first.", - fullscreen: "Fullscreen", - exitFullscreen: "Exit fullscreen", - board: "Board", - boardPick: "Select board", - boardPickHint: "Select or create a traffic wall board.", - boardEmpty: "No boards yet. Create one for cutover or duty monitoring.", - boardDefaultName: "Untitled board", - boardName: "Name", - boardCols: "Columns", - boardCreate: "New board", - boardEdit: "Edit layout", - boardSave: "Save", - boardCancel: "Cancel", - boardSaved: "Board saved", - boardCreated: "Board created", - boardDeleted: "Board deleted", - boardConfirmDelete: "Delete this board and its panels?", - boardUnsavedLeave: "You have unsaved changes. Leave anyway?", - boardOpenTab: "Open in new tab", - boardOpen: "Open wall", - boardListHint: "Open a board in its own browser tab; launching Network from the workbench will not refresh open walls.", - boardPanelCount: "Panels", - boardUpdated: "Updated", - boardClosedHint: "You can close this tab, or reopen from Network → Traffic wall list.", - boardAddPanel: "Add panel", - boardNoPanels: "No panels yet. Edit the board and add interfaces.", - boardPickDevice: "Select device", - boardPanelSettings: "Panel settings", - boardPanelTitle: "Title (optional)", - boardColSpan: "Column span", - boardPanelStale: "Interface missing or inactive. Please reselect.", - boardRetarget: "Reselect port", - edit: "Edit", - save: "Save", - start: "Start", - pause: "Pause", - stop: "Stop", - delete: "Delete", - wizStep1: "Device", - wizStep2: "Interfaces", - wizStep3: "Params", - step1: "Params", - step2: "NEs", - step3: "Interfaces", - step4: "Confirm", - prev: "Back", - next: "Next", - fieldTitle: "Task name", - titlePh: "e.g. Uplink group", - titleRequired: "Task name is required", - portsRequired: "Select at least one interface", - interval: "Interval (sec)", - retention: "Retention (days)", - retentionHintWizard: "Use ≥8 days for week-over-week compare", - concurrency: "Concurrency", - note: "Note", - notePh: "Optional", - startNow: "Start after create", - neKeywordPh: "Name / IP / vendor", - deepLinkHint: "Opened from topology: NE {{ne}}{{ifname}}. Confirm selection, then continue.", - deepLinkIfname: ", interface {{ifname}}", - selectedNe: "{{count}} NE(s) selected", - selectedOneNe: "Selected", - selectedPorts: "{{count}} interface(s) selected", - pickNeFirst: "Select one NE first", - discover: "Discover interfaces", - discoverDetail: "Discover detail", - discoverCommand: "Command", - discoverRaw: "Raw log", - discoverOk: "Discover succeeded", - discoverFail: "Discover failed", - confirmCreate: "Start monitoring", - wallTask: "Task", - wallPort: "Interface", - wallDevice: "Device", - wallDeviceLabel: "Device", - pickPort: "Select interface", - range: "Range", - compare: "Compare", - compareOff: "Off", - compareDay: "Yesterday (same TOD)", - compareWeek: "Last week (same weekday)", - compareShift: "Previous window", - compareCustom: "Custom offset", - offsetHours: "Offset (hours)", - aheadHours: "Extend past now (hours)", - aheadHoursHint: - "Push the chart end past now. With period compare enabled, the overlay can show trend after the current clock; 0 ends at now.", - mapBaselineHint: - "Mapped baseline can be combined with period compare; turn on Yesterday yourself when you need that contrast.", - baseline: "Baseline", - baselineAt: "Baseline time", - baselineDevice: "Baseline device", - baselinePort: "Baseline interface", - seriesCurrentIn: "Current In", - seriesCurrentOut: "Current Out", - seriesBaselineIn: "Baseline In", - seriesBaselineOut: "Baseline Out", - mapBaselinePort: "Mapped baseline interface", - mapBaselineTask: "Mapped baseline task", - mapBaselineNone: "None", - retentionHint: "Week compare needs retention_days ≥ 8 or the baseline may be empty.", - collectNow: "Collect now", - collectStarted: "Collect triggered", - collectBusy: "Collect already running", - targetError: "Last interface error", - waitSamples: "Samples appear after the device runs; or click Collect now. Chart auto-refreshes.", - waitAfterError: "Collect failed — open Logs under Traffic monitoring for details, then Collect now.", - log: "Logs", - logTitle: "Collect logs", - logEmpty: "No log entries yet.", - logDevice: "Device", - logLevel: "Level", - logTime: "Time", - logMessage: "Message", - logClose: "Close", - logHasError: "Has error", - rebind: "Rebind NE", - rebindTitle: "Rebind network element", - rebindOk: "Rebound to selected NE; collect can resume", - rebindFailed: "Rebind failed", - rebindHint: - "Pick the correct NE and confirm. IPs may not be unique, and a matching IP does not mean the same device. History is kept.", - rebindCurrent: "Current binding", - rebindEmpty: "No NEs found — add the device back under inventory first.", - rebindSameIp: "Same IP", - rebindSameIpHint: "IP matches current monitor — still verify name and ID", - rebindSubmit: "Confirm rebind", - rebindConfirm: "Bind monitoring to {{ne}}? History samples are kept.", - err: { - managedNeNotFound: - "Managed NE not found (often deleted then re-created with a new id). Use Rebind NE and pick manually.", - targetErrors: "{{count}} interface(s) failed", - unsupportedVendor: "Unsupported vendor / device type", - invalidSource: "Invalid NE source", - rebindPickFirst: "Select an NE first", - rebindNeMissing: "Selected NE not found — refresh and try again", - rebindSourceMismatch: "NE source does not match this monitor device", - alreadyMonitored: "That NE is already monitored by another device entry", - }, - kpiLatest: "latest", - kpiBw: "Bandwidth", - latestAt: "Latest sample", - hoverPoint: "Cursor", - wallLiveTitle: "Live traffic", - filterView: "View", - filterMap: "Mapped baseline", - yMode: "Y axis", - yModeAuto: "Auto (incl. baseline)", - yModeCurrent: "Fit current", - yModeUtil: "Utilization %", - kpi: { - tasks: "Tasks", - devices: "Devices", - running: "Running", - ports: "Interfaces", - samples24h: "Samples 24h", - }, - col: { - title: "Task", - device: "Device", - status: "Status", - ports: "Interfaces", - interval: "Interval", - last: "Last collect", - actions: "Actions", - source: "Source", - name: "Device", - vendor: "Vendor", - ifname: "Interface", - bw: "BW", - upDown: "Admin / Phy / Prot", - admin: "Admin", - phy: "Phy", - prot: "Prot", - description: "Description", - }, - }, - lldpLinks: { - title: "LLDP links", - collectNow: "Collect now", - retryFailed: "Retry failed", - started: "LLDP collect started", - pause: "Pause", - resume: "Resume", - stop: "Stop", - confirmStop: "Stop this job and skip remaining NEs?", - paused: "Paused", - resumed: "Resumed", - stopped: "Collect stopped", - policyTitle: "Collect policy", - policySaved: "Policy saved", - enabled: "Enable scheduled collect", - autoAddUnmatched: "Auto-add unmatched neighbors as placeholders on discover", - autoAddUnmatchedHint: - "Shared by scheduled collect and canvas discover. Off = record unmatched only, no placeholder NE. Default on.", - intervalDays: "Interval (days)", - interval: "Collect interval", - intervalUnitDays: "days", - intervalUnitHours: "hours", - concurrency: "Concurrency", - historyKeep: "Jobs to keep", - scope: "Scope", - scopeAll: "All NEs (managed + UME)", - scopeSelected: "Selected NEs", - savePolicy: "Save policy", - selectedCount: "{{count}} selected", - targetKeywordPh: "Name / IP / vendor", - jobsTitle: "Collect jobs", - jobDetailTitle: "Job details", - expand: "Details", - edgesTitle: "Fabric links", - edgeKeywordPh: "Endpoint name or IP", - edgeStatusAll: "All statuses", - edgeStatusActive: "Active", - edgeStatusMissing: "Missing", - edgeSourceAll: "All sources", - exportEdgesName: "lldp-fabric-edges", - exportJobsName: "lldp-collect-jobs", - exportItemsName: "lldp-job-items", - statusAll: "All statuses", - jobKeywordPh: "Job ID / error / scope", - itemKeywordPh: "NE name / IP / error", - itemResultAll: "All results", - missCount: "Miss cycles", - replacedBy: "Replaced", - kpi: { - nodes: "Fabric nodes", - edges: "Active links", - missing: "Missing", - running: "In progress", - idle: "Idle", - last: "Last job", - nextDue: "Next due", - }, - col: { - source: "Source", - name: "Device", - vendor: "Vendor", - trigger: "Trigger", - scope: "Scope", - status: "Status", - progress: "Progress", - edges: "Edges", - missingDelta: "Missing/purged", - started: "Started", - ended: "Ended", - neighbors: "Neighbors", - error: "Error", - aSide: "Side A", - bSide: "Side B", - aPort: "Port A", - bPort: "Port B", - lastSeen: "Last seen", - }, - jobStatus: { - running: "Running", - pending: "Pending", - paused: "Paused", - done: "Done", - failed: "Failed", - cancelled: "Cancelled", - stopped: "Stopped", - }, - triggerMode: { - manual: "Manual", - schedule: "Scheduled", - topology: "Topology canvas", - retry_failed: "Retry failed", - }, - jobScope: { - all: "All NEs", - ne_ids: "Selected NEs", - selected: "Selected NEs", - }, - itemResult: { - ok: "OK", - warn: "Warn", - fail: "Fail", - }, - edgeSource: { - lldp: "LLDP", - manual: "Manual", - stale: "Stale", - }, - }, - configSync: { - title: "Config sync", - syncNow: "Sync now", - retryFailed: "Retry failed", - pause: "Pause", - resume: "Resume", - stop: "Stop", - confirmStop: "Stop this cycle and cancel remaining NE tasks?", - started: "Sync cycle started", - paused: "Paused", - resumed: "Resumed", - stopped: "Sync cycle stopped", - policyTitle: "Sync policy", - policySaved: "Policy saved", - enabled: "Enable scheduled sync", - intervalDays: "Interval (days)", - concurrency: "Concurrency", - historyKeep: "Config versions to keep", - cycleKeep: "Jobs to keep", - scope: "Scope", - scopeAll: "All NEs", - scopeSelected: "Selected NEs", - savePolicy: "Save policy", - selectedCount: "{{count}} selected", - targetKeywordPh: "Name / IP / vendor", - cyclesTitle: "Sync cycles", - cyclesEmpty: "No sync cycles yet", - tasksTitle: "Cycle tasks", - taskKeywordPh: "Name / IP / message", - keywordPh: "ID / trigger / message", - statusAll: "All statuses", - allStatus: "All statuses", - exportCyclesName: "config-sync-cycles", - exportTasksName: "config-sync-tasks", - expand: "Details", - collapse: "Collapse", - kpi: { - snapshots: "Snapshots", - running: "In progress", - idle: "Idle", - last: "Last cycle", - nextDue: "Next due", - }, - col: { - source: "Source", - name: "Device", - vendor: "Vendor", - trigger: "Trigger", - status: "Status", - progress: "Progress", - started: "Started", - ended: "Ended", - message: "Message", - actions: "Actions", - }, - }, - networkDevices: { - title: "Device list", - keywordPh: "Name / IP / ID", - allSource: "All sources", - empty: "No network elements.", - exportName: "network-devices", - col: { - source: "Source", - name: "Name", - vendor: "Vendor", - deviceType: "Device type", - connect: "Connect", - actions: "Actions", - }, - }, - networkConfigs: { - title: "Configurations", - keywordPh: "Name / IP / ID", - vendorPh: "Vendor", - allSource: "All sources", - empty: "No synced configs yet. Run Config sync first.", - view: "View", - export: "Export", - exportBoth: "Export all", - exportOk: "Config exported", - exportListName: "network-configs", - close: "Close", - tabSet: "Set format", - tabHier: "Hierarchical", - col: { - name: "Device", - vendor: "Vendor", - source: "Source", - size: "Size", - collected: "Collected", - }, - }, - layout: { - title: "UME Integration", - titleUme: "UME Sync", - titleNetwork: "Network Management", - titleManagedNe: "NE Management", - titleCollect: "Batch Collect", - titleWebcrt: "WebCRT", - titleTopology: "Topology", - titlePortTrafficWall: "Traffic wall", - titleUsers: "Users", - titleAudit: "Audit", - titleApiKeys: "API Keys", - titleSessions: "Login sessions", - navUme: "UME", - netxApi: "netx api", - oclawBridge: "oclaw WSS", - activeTasks: "Active {{count}}", - activeTasksHint: "Open Audit · Task overview", - language: "Language", - switchLanguage: "Switch language / 切换语言", - moreMenu: "More", - langZh: "中文", - langEn: "English", - }, - auth: { - loginTitle: "Sign in", - username: "Username", - password: "Password", - login: "Sign in", - loggingIn: "Signing in…", - loginFailed: "Login failed", - logout: "Sign out", - sessionsTitle: "Login sessions", - sessionsHelp: - "Behind a proxy, IP is the real client; use Client to tell browsers apart. Revoked sessions must sign in again.", - sessionsHint: - "Manage browser/device logins for this account. Behind a reverse proxy, IP is taken from trusted forwarded headers; use the Client column to tell browsers apart on the same public IP. Revoked sessions must sign in again.", - sessionsEmpty: "No active sessions.", - revokeOtherSessions: "Revoke other sessions", - revokeSession: "Revoke", - sessionCurrent: "Current", - sessionRevoked: "Session revoked", - sessionsRevokedOthers: "Revoked {{count}} other session(s)", - revokeCurrentConfirm: "This is your current session; revoking it requires signing in again. Continue?", - colSession: "Session", - colClient: "Client", - colLastSeen: "Last seen", - colCreated: "Created", - usersTitle: "User management", - usersHint: "Only admins can create and manage local accounts.", - addUser: "Add user", - role: "Role", - roleAdmin: "Admin", - roleUser: "User", - status: "Status", - active: "Active", - disabled: "Disabled", - enable: "Enable", - disable: "Disable", - actions: "Actions", - newPassword: "New password", - resetPassword: "Reset password", - userCreated: "User created", - userUpdated: "User updated", - auditTitle: "Audit log", - auditHintAdmin: "View login and operation records for all users.", - auditHintUser: "View your own operation records.", - filterUsername: "Filter username", - filterAction: "Filter action", - colTime: "Time", - colUser: "User", - colAction: "Action", - colMethod: "Method", - colPath: "Path", - colStatus: "Status", - colIp: "IP", - apiKeysTitle: "API Key management", - apiKeysHelp: - "For MCP/scripts and netxops (web UI uses sessions). Secret shown once; Edit scopes later. Cap {{max}} non-revoked keys.", - tokenQuota: "Active {{active}} / {{max}}", - tokenLimitReached: "API key limit reached ({{max}}). Revoke or delete unused keys first.", - tokenName: "Name", - expiresIn: "Expiry", - expire7d: "7 days", - expire30d: "30 days", - expire90d: "90 days", - expire365d: "1 year", - expireNever: "Never", - tokenOwner: "Owner", - tokenOwnerSelf: "Myself ({{user}})", - createToken: "Create key", - tokenCreated: "API key created", - tokenUpdated: "Scopes updated", - tokenRevoked: "Revoked", - tokenDeleted: "Deleted", - tokenOnceHint: "Copy and store this secret now; it will not be shown again:", - copyToken: "Copy", - tokenCopied: "Copied", - tokenCopyFailed: "Copy failed", - expiresAt: "Expires", - lastUsed: "Last used", - tokenStatusActive: "Active", - tokenStatusExpired: "Expired", - tokenStatusRevoked: "Revoked", - revokeToken: "Revoke", - revokeConfirm: "Revoke this API key? It stops working immediately; the record remains.", - deleteToken: "Delete", - deleteConfirm: "Permanently delete this API key? This cannot be undone.", - editScopes: "Edit scopes", - saveScopes: "Save scopes", - scopesUnsetLegacy: "Unrestricted (same as owner)", - scopesCol: "Scopes", - scopesRequired: "Select at least one scope", - scopesNoneAvailable: "This owner has no grantable scopes", - scopeAlarmsRead: "alarms:read — Alarms read (netx MCP)", - scopeNeRead: "ne:read — NE / topology read (MCP)", - scopeNeWrite: "ne:write — NE / topology write / draw (MCP)", - scopeNeExec: "ne:exec — Managed NE CLI (netx MCP)", - scopeBizMonitorRead: "biz-monitor:read — Biz monitor / cutover read (netxops)", - scopeSql: "sql:query — UME SQL (netx MCP)", - scopeWebcrt: "webcrt:session — WebCRT (reserved MCP)", - scopeAdminUsers: "admin:users — User admin (reserved MCP)", - scopeOpsWrite: "ops:write — Ops write / subscriptions (reserved MCP)", - - forceChangeTitle: "Change initial password", - forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.", - oldPassword: "Current password", - confirmPassword: "Confirm new password", - savePassword: "Save new password", - savingPassword: "Saving…", - passwordTooShort: "New password must be at least 8 characters", - passwordMismatch: "New passwords do not match", - passwordMustChange: "New password must differ from the default/old password", - }, - collect: { - statusAll: "All statuses", - keywordPh: "Job title / ID", - exportJobsName: "collect-jobs", - exportRunsName: "collect-runs", - policyTitle: "Collect policy", - policyHint: - "Periodic schedule is off by default (one-shot / manual). Enable it to auto-create and run jobs on an interval. Keeps the last 3 finished jobs by default.", - policySaved: "Policy saved", - enabled: "Enable scheduled collect", - interval: "Collect interval", - intervalUnitDays: "days", - intervalUnitHours: "hours", - historyKeep: "Jobs to keep", - scope: "Scope", - scopeAll: "All NEs (managed + UME)", - scopeSelected: "Selected NEs only", - savePolicy: "Save policy", - collectNow: "Collect now", - selectedCount: "{{count}} selected", - colSource: "Source", - policyCommandsRequired: "Add commands and save the policy before enabling schedule or collecting now", - policyTargetsRequired: "No eligible NEs in scope; adjust scope or select NEs", - create: { - title: "New collection job", - hint: "Configure commands and NEs, then create the job. Click Start in the job list to run collection.", - selectedTitle: "Selected NEs", - selectedEmpty: "Filter below and add NEs one by one.", - selectedCount: "{{count}} selected", - clearSelected: "Clear selection", - remove: "Remove", - pickTitle: "Eligible NEs", - pickHint: "All managed and UME inventory NEs are selectable (any connect status). UME uses CLI connect profile credentials.", - pickEmpty: "No matching NEs.", - filterKeyword: "Filter", - filterKeywordPh: "Name / IP / vendor / device type", - add: "Add", - addBatch: "Add selected ({{count}})", - added: "Added", - create: "Create job", - creating: "Creating…", - expand: "New", - collapse: "Close", - meta: "{{ne}} NE(s) selected · {{cmd}} command(s)", - }, - eligible: { - title: "Eligible NEs", - hint: "All managed and UME inventory NEs are selectable (any connect status).", - empty: "No NEs.", - }, - form: { - title: "Collection setup", - jobTitle: "Job title", - jobTitlePh: "Auto-generated if empty", - commands: "Commands (one per line, # for comments)", - commandsPh: "display version\ndisplay current-configuration", - commandsHint: "Commands run in order on each device; output is saved as a text log.", - meta: "{{ne}} NE(s) selected · {{cmd}} command(s)", - start: "Start collection", - starting: "Submitting…", - }, - created: "Collection job created: {{id}}", - started: "Collection started: {{id}}", - paused: "Job paused", - restarted: "Collection restarted", - retryFailedDone: "Retrying failed devices", - deleted: "Job deleted", - nothingToRetry: "No failed devices to retry", - neBusy: "Some NEs are busy in another collection job; wait for it to finish before starting", - jobRunning: "This job is already running", - confirmDelete: "Delete this collection job and its log files?", - jobs: { - title: "Collection jobs", - listTitle: "Job list", - col: { - title: "Job", - status: "Status", - progress: "Progress", - created: "Created", - lastRun: "Last run", - actions: "Actions", - }, - ok: "ok", - fail: "fail", - expand: "Details", - collapse: "Hide", - detailTitle: "Job details", - pause: "Pause", - start: "Start", - starting: "Starting…", - restart: "Restart", - retryFailed: "Retry failed", - downloadResults: "Download results", - delete: "Delete", - autoRefresh: "Auto-refresh every 2s while jobs are running", - runsInProgress: "Some devices are still being collected…", - }, - kpi: { - jobs: "Jobs", - running: "In progress", - idle: "Idle", - last: "Last job", - active: "Active jobs", - nextDue: "Next due", - }, - runs: { - status: "Status", - message: "Message", - download: "Log", - downloadFile: "Download", - filterStatus: "Status filter", - filterDevice: "Device filter", - deviceKeywordPh: "Name / IP", - allStatus: "All statuses", - }, - }, - managedNe: { - title: "Network Elements", - credsNotConfigured: - "NETX_CREDENTIAL_SECRET_KEY is not set. Configure a Fernet key in .env and restart the API to store passwords.", - add: "Add NE", - importBtn: "Import", - downloadTemplate: "Download template", - importing: "Importing…", - edit: "Edit", - delete: "Delete", - openTerminal: "Terminal", - confirmDelete: - "Delete this network element? If its Fabric node is managed-only (no UME binding), that node and its direct links will also be removed.", - batchDelete: "Batch delete", - batchDeleting: "Deleting…", - batchDeleteConfirm: - "Delete {{n}} selected network element(s)? Cannot be undone. Managed-only Fabric nodes cascade (node + direct links); nodes still bound to UME keep topology.", - batchDeleteDone: "Deleted {{n}} network element(s)", - batchDeleteSelectRequired: "Select network elements to delete", - keywordPh: "name / IP / user / tags", - allVendors: "All vendors", - allConnectStatus: "All connect status", - stats: { - title: "Overview", - total: "{{n}} NEs managed", - pass: "Connected", - fail: "Failed", - unknown: "Untested", - testing: "Testing", - batchProxy: "Batch add proxy", - batchTest: "Batch connect-test", - tagFilter: "Filter by tag", - all: "All", - allTag: "All (no tag filter)", - noTag: "No tags", - tagStats: "Top tags", - confirm: "Apply to {{n}} NE(s). Continue?", - proxyDone: "Proxy applied to {{n}} NE(s)", - testDone: "Connect-test submitted for {{n}} NE(s)", - loadingIds: "Loading…", - }, - col: { - name: "Name", - vendor: "Vendor", - deviceType: "Device type", - execPolicy: "CLI exec policy", - source: "Source", - ip: "Management IP", - port: "Port", - protocol: "Protocol", - user: "Username", - password: "Password", - tags: "Tags", - remark: "Remark", - connect: "Connect", - testedAt: "Tested at", - actions: "Actions", - }, - execPolicy: { - readonly: "Read-only (show/display/ping)", - linuxShell: "Shell / script (multiline / heredoc OK)", - unrestricted: "Lab open (multiline / heredoc OK)", - hint: "Applies to MCP/API execManagedNe only. Default is read-only; Linux and MikroTik (routeros/switchos) may use shell / lab-open for multiline scripts and heredoc.", - }, - source: { - manual: "Manual", - ume_sync: "UME sync", - webcrt: "WebCRT", - lldp: "LLDP placeholder", - topology: "Topology placeholder", - }, - connect: { - run: "Connectivity test", - running: "Testing…", - submitted: "Submitted tests for {{n}} device(s)", - retest: "Test again", - }, - connectDetail: "Details", - connectDetailTitle: "Connectivity test log", - connectDetailEmpty: - "No log yet. Run a connectivity test first; failures store full errors and hop context (passwords excluded).", - umeSync: { - sync: "Sync UME NEs", - syncing: "Syncing…", - delete: "Delete UME NEs", - deleting: "Deleting…", - deleteConfirm: - "Delete all managed NEs created from UME sync? Cannot be undone. Fabric nodes that become fully orphaned (no UME left) are removed with their direct links.", - done: "UME sync done: {{inserted}} inserted, {{updated}} updated, {{deleted}} deleted, {{total}} inventory total", - deletedDone: "Deleted {{n}} UME-synced NEs", - }, - account: { - batchAdd: "Batch add account", - batchByTag: "Batch account by tag", - batchTitle: "Batch add account", - batchHint: "Apply the account info below to {{n}} selected NE(s).", - batchByTagHint: "Apply the account info below to NEs under the selected tag; leave password blank to update only username.", - batchDone: "Account updated for {{n}} NE(s)", - selectRequired: "Select network elements first", - applying: "Applying…", - apply: "Apply to NEs", - usernameOrPasswordRequired: "Enter at least a username or a password", - passwordOptionalBatch: "leave blank to keep unchanged", - }, - help: - "[Bulk import]\n" + - "· Required: device_type, ip, username, port, protocol, name, vendor (download template first)\n" + - "· password may be empty for bastion-managed / later Batch add proxy; required for direct login\n" + - "· Flow: import → select rows → Batch add proxy\n\n" + - "[Jump / bastion]\n" + - "· Username template: {hop_user}@{target_user}@{target_ip} (bastion host filled separately)\n" + - "· Managed mode: Jump password (Vault); target password optional. Manual mode needs target password\n" + - "· JumpServer/CBH ports often 2222; some sites use 22\n\n" + - "[Connectivity / edit]\n" + - "· Storing passwords needs NETX_CREDENTIAL_SECRET_KEY\n" + - "· Blank password on edit keeps current; use Details hop context after failed connect (no secrets)", - importResult: { - done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)", - }, - form: { - requiredHint: "Fields marked with * are required", - nameConnectHint: "After a successful connectivity test, name is replaced with the device hostname", - createTitle: "Add network element", - editTitle: "Edit network element", - save: "Save", - saving: "Saving…", - cancel: "Cancel", - created: "Network element created", - updated: "Network element updated", - deleted: "Network element deleted", - passwordRequired: "Password is required", - passwordOptional: "leave blank to keep unchanged", - }, - hop: { - sectionTitle: "Jump host / proxy", - type: "Jump type", - enable: "Connect to target via jump host", - vendor: { - zte: "ZTE device (CLI jump)", - huawei: "Huawei device (CLI jump)", - cisco: "Cisco device (CLI jump)", - linux: "Linux server (SSH tunnel)", - bastion: "Bastion host (SSH protocol proxy)", - }, - zteHint: "Run ssh/telnet on the ZTE device to reach the target; target credentials use secondary auth.", - huaweiHint: "Run telnet / stelnet (SSH) on the Huawei hop; stelnet is SSH. Target credentials use secondary auth.", - ciscoHint: "Run Cisco ssh -vrf / telnet /vrf jump commands; target credentials use secondary auth.", - linuxHint: "SSH to the Linux bastion, then direct-tcpip tunnel to target IP:port (ProxyJump-style).", - bastionHint: - "SSH with composite username via bastion protocol proxy. Hop host may be an IP or FQDN. Examples: bastion-user@target-user@198.51.100.20@192.0.2.10 or bastion-user@target-user@198.51.100.20@ssh-bastion.example.com; password is the bastion/Vault password. JumpServer/CBH often use port 2222.", - targetAuthMode: "Target credentials", - targetAuthBastionManaged: "Bastion-managed (target password optional)", - targetAuthManual: "Manual (secondary auth after connect)", - targetAuthHint: "Bastion-managed needs only bastion password; manual mode requires target NE password.", - usernameTemplate: "SSH username template", - templateHintBastion: - "Default {hop_user}@{target_user}@{target_ip}. Bastion address (IP or FQDN) goes in Jump host. CLI example: ssh bastion-user@target-user@198.51.100.20@ssh-bastion.example.com.", - host: "Jump host", - hostPlaceholderBastion: "192.0.2.10 or ssh-bastion.example.com", - hostHintBastion: - "IP or FQDN. You can paste a full SSH destination (e.g. bastion-user@target-user@198.51.100.20@ssh-bastion.example.com); on blur it splits into jump username + host.", - port: "Jump port", - protocol: "Jump protocol", - protocolSshStelnet: "ssh (stelnet)", - username: "Jump username", - password: "Jump password", - vrf: "Mgmt VRF (optional)", - vpnInstance: "VPN-Instance (optional)", - vrfCisco: "VRF (optional, e.g. MGMT)", - enterSystemView: "Huawei system-view before jump", - enterSystemViewNo: "Stay in user-view (default)", - enterSystemViewYes: "Enter system-view first", - enterSystemViewHint: - "Some Huawei hops only accept stelnet/telnet in system-view. Choose explicitly; NetX will not auto-retry.", - commandTemplate: "Jump command template", - templateHint: - "ZTE CLI: telnet {target_ip}, telnet {target_ip} vrf {vrf}, ssh {target_ip}, ssh {target_ip} vrf {vrf}. Auto-suggested from jump protocol/VRF; same when left blank. Target credentials via secondary auth prompts.", - templateHintHuawei: - "Huawei CLI: telnet {target_ip}, telnet vpn-instance {vrf} {target_ip}, stelnet {target_ip}, stelnet {target_ip} -vpn-instance {vrf}. SSH protocol maps to stelnet. Auto-suggested when left blank.", - templateHintCisco: - "Cisco CLI: ssh {target_ip}, ssh -vrf {vrf} {target_ip}, telnet {target_ip}, telnet {target_ip} /vrf {vrf}. Auto-suggested when left blank.", - badge: { - zte: "ZTE hop", - huawei: "Huawei hop", - cisco: "Cisco hop", - linux: "Linux hop", - bastion: "Bastion", - }, - hostRequired: "Jump host is required", - userRequired: "Jump username is required", - passwordRequired: "Jump password is required", - batchAdd: "Add proxy", - batchTitle: "Batch add jump proxy", - batchHint: "Apply the jump-host settings below to {{n}} selected NE(s) (overwrites existing proxy config).", - batchDone: "Proxy applied to {{n}} NE(s)", - selectRequired: "Select network elements first", - applying: "Applying…", - apply: "Apply to selected", - }, - }, - 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: - "[Subscription]\n" + - "· Create / cancel manually\n" + - "· After establish (or restart with a valid DB record), backend auto-connects WSS\n\n" + - "[Task control]\n" + - "· alarms_current_ws_consumer pause/resume only stops/starts WSS\n" + - "· 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)", - }, - keyAlert: { - title: "AI alarm monitor", - showPanel: "Details", - hidePanel: "Close", - help: - "[Match]\n" + - "· Exact notificationId\n" + - "· Or description keyword (case-insensitive; includes nativeProbableCause)\n\n" + - "[Scope]\n" + - "· Optional Inventory ne_type filter; empty = all devices\n\n" + - "[Deliver]\n" + - "· NetX broadcasts hits to subscribed Netx Ops (DSH) clients", - hub: "DSH subscribers", - hubConnected: "subscribed", - hubEmpty: "no subscribers", - hubSubscribers: "subscribers", - hubPublished: "published", - hubDeliverOk: "deliver ok", - hubDeliverFail: "deliver fail", - hubConnections: "Active connections", - hubColId: "id", - hubColUser: "user", - hubColRemote: "remote", - hubColClient: "client", - hubColConnected: "connected", - hubColLastSeen: "last seen", - hubEmptyConnections: "No Netx Ops clients are connected to the alarm hub", - hubPath: "path", - matchType: "Match by", - matchNotificationId: "notificationId", - matchKeyword: "description keyword", - matchValuePhNotificationId: "Enter notificationId", - matchValuePhKeyword: "Enter keyword (case-insensitive)", - label: "Rule description", - labelPh: "Required, e.g. BGP Down", - neTypesLabel: "Device types (optional, multi-select)", - neTypesHint: "Leave empty to match all device types. Options come from synced UME Inventory ne_type.", - neTypesEmpty: "No device types yet — sync Inventory first", - neTypesAll: "All", - forwardOnClear: "Push on alarm clear", - forwardOnClearHelp: "Global switch for all monitor rules. By default only new/updated alarms are pushed; when enabled, clear notifications are also sent to subscribed Netx Ops clients.", - add: "Add rules", - adding: "Adding…", - delete: "Delete", - edit: "Edit", - editTitle: "Edit monitor rule", - save: "Save", - saving: "Saving…", - cancel: "Cancel", - editOk: "Rule updated", - pickFromAlarms: "Load alarm hints", - emptyRules: "No monitor rules yet", - colType: "type", - colMatch: "match", - colLabel: "description", - colNeTypes: "device types", - colForwardClear: "on clear", - colPublished: "published", - colAttempts: "attempts", - colLast: "last push", - colActions: "actions", - colMonitor: "monitor", - monitorOn: "on", - monitorOff: "off", - filterKeywordPh: "Filter match / label", - filterMatchAll: "All types", - filterEnabledAll: "All states", - filterEnabledOn: "Monitoring on", - filterEnabledOff: "Monitoring off", - addOk: "Monitor rule added", - deleteOk: "Deleted", - confirmDelete: "Delete this monitor rule?", - yes: "yes", - no: "no", - }, - 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", - lastErrorCol: "last_error", - runtimeError: { - startup_alarm_sync_before_ws: "Starting up: syncing current alarms (WSS connects after completion)…", - startup_gate_waiting: "Startup REST snapshot pending; scheduled REST and WSS on standby", - wss_active_skip_rest: "WSS receiving live alarms; REST sync skipped", - pulling_alarms_current: "Pulling UME current alarms…", - alarms_sync_in_progress_skip: "Another current-alarm REST sync in progress; skipped", - pulling_inventory: "Pulling UME inventory…", - pulling_topology: "Pulling UME topology (nodes/links)…", - topology_sync_busy: "Topology sync already running; skipped", - ume_ws_disabled_no_base_url: "Disabled or UME_BASE_URL not configured", - resumed_sync_soon: "Resumed: skipping debounce wait, sync soon", - resumed_wss_reconnect: "Resumed: reconnecting WSS soon", - resumed: "Resumed", - keepalive_failed: "Token keepalive failed", - }, - wsState: { - init: "Initializing", - connected: "Connected", - connecting: "Connecting", - disconnected: "Disconnected", - no_subscription: "No subscription", - waiting_token: "Waiting for token", - paused: "Paused", - error: "Connection error", - reconnecting: "Reconnecting", - subscription_lost: "UME subscription lost", - disabled_no_base_url: "Disabled or UME_BASE_URL not configured", - }, - runtimeTask: { - token_keepalive: "UME token keepalive", - alarms_current_auto_sync: "Current alarms auto sync", - alarms_current_ws_consumer: "UME alarm WSS", - inventory_auto_sync: "Inventory auto sync", - topology_auto_sync: "Topology auto sync", - }, - }, - syncStatus: { - title: "Sync history", - showPanel: "Details", - hidePanel: "Close", - empty: "No sync records", - summary: "{{total}} record(s), {{running}} running", - fabricGapTitle: "Topology dock is newer than Fabric / UME World", - fabricGapHint: - "Dock ME {{dock}}, Fabric UME {{fabric}}. Apply without pulling UME. Last status: {{status}}", - applyFabric: "Apply to topology", - applyingFabric: "Applying…", - applyFabricOk: "Applied to topology", - }, - ne: { - title: "Network elements", - showPanel: "Details", - hidePanel: "Close", - keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)", - clearTitle: "Clear keyword and reset to page 1", - expand: "Details", - collapse: "Close", - cliConnect: "CLI connect", - summary: "{{total}} inventory NE(s)", - }, - tabs: { - main: "UME sync", - cli: "CLI connect", - }, - cli: { - title: "UME CLI connection profile", - showPanel: "Details", - hidePanel: "Close", - hint: - "[Purpose]\n" + - "· Unified SSH credentials and hop/bastion for Inventory NEs\n\n" + - "[MCP]\n" + - "· Exec show via ume_ne_id without per-device managed_ne entries", - statusReady: "CLI profile is ready for UME NE command execution.", - statusNotReady: "Set target username and hop/bastion settings (if enabled).", - profileName: "Profile name", - targetUsername: "Target SSH username", - targetPassword: "Target SSH password", - passwordKeep: "Leave blank to keep unchanged", - newProfile: "New profile", - deleteConfirm: "Delete this CLI profile?", - deleted: "Profile deleted", - saved: "CLI profile saved", - connectTestTitle: "Sample NE connectivity test", - sampleNePh: "Filter UME NEs (name / IP / ID)", - sampleNeRequired: "Select a UME NE first", - sampleNeEmpty: "No matching NE", - sampleNeLoading: "Loading…", - connectTestSubmitted: "Connectivity test submitted", - connectTestWaiting: "Waiting for test result…", - connectTestRunning: "Connectivity test in progress…", - connectTestResult: "Test result", - existingProfiles: "Existing profiles", - default: "default", - }, - alarms: { - title: "Current alarms", - movedHint: "Current alarm query moved to Network Management →", - showPanel: "Expand", - hidePanel: "Collapse", - keywordPh: "keyword(alarm key/cause/ne_name/host_name/ip…)", - hostNamePh: "host_name (contains)", - allSeverity: "All severities", - clearedAll: "Cleared: all", - clearedYes: "Cleared", - clearedNo: "Active", - clearTitle: "Clear filters and reset to page 1", - filterByHost: "Filter by this host", - noHostName: "No host_name (sync inventory first)", - col: { - time: "Time", - severity: "Severity", - neId: "NE ID", - hostName: "Host", - neType: "NE type", - cause: "Cause", - }, - }, - }, - webcrt: { - title: "WebCRT", - deviceList: "Device list", - sessionManager: "Device list", - connect: "Connect", - hint: "Pick an NE and open an interactive terminal in the browser. Credentials stay on the server.", - hintCrt: "Click an NE on the left to open a terminal. Includes managed, Sessions, and UME; or use New Session for a quick login.", - filterKeyword: "Filter", - filterKeywordPh: "Filter by name / IP / tag", - empty: "No matching NEs. Switch source or refine the search.", - open: "Open terminal", - opening: "Connecting…", - opened: "Opened {{name}}", - disconnect: "Disconnect", - closed: "Session closed", - termPlaceholder: "No terminal session open", - sessionMeta: "{{name}} · {{ip}}", - source: { - all: "All", - managed: "Managed", - webcrt: "Sessions", - ume: "UME", - }, - newSession: { - title: "New Session", - claimTitle: "Complete placeholder session", - claimHint: - "Enter a reachable host. Session name defaults to the placeholder name. On success, source becomes WebCRT.", - connect: "Connect", - connecting: "Connecting…", - protocol: "Protocol", - host: "Hostname", - port: "Port", - sessionName: "Session name", - sessionNamePh: "Optional; defaults to IP", - authTitle: "SSH Authentication", - savePassword: "Save password", - ipRequired: "Hostname is required", - userRequired: "Username is required", - passwordRequired: "Password is required", - }, - status: { - idle: "Idle", - connecting: "Connecting", - connected: "Connected", - error: "Error", - closed: "Closed", - }, - phase: { - creating: "Creating session", - authenticating: "Authenticating", - waitingPrompt: "Waiting for device prompt", - hint: "Login progress streams into the terminal; slow devices may take >10s", - }, - tabMenu: { - close: "Close", - closeOthers: "Close others", - closeAll: "Close all", - copyIp: "Copy IP", - copyIpOk: "Copied {{ip}}", - copyIpEmpty: "No IP on this tab", - }, - treeMenu: { - rename: "Rename", - renameLabel: "Session name", - renameSave: "Save", - renameRequired: "Session name is required", - renamed: "Session renamed", - delete: "Delete session", - deleteConfirm: "Delete session \"{{name}}\"? This cannot be undone.", - deleted: "Session deleted", - }, - keywordHl: { - title: "Keyword Highlighting", - enabled: "Enabled", - newKeyword: "New keyword", - newKeywordPh: "Keyword or regex", - regex: "Regular expression", - add: "Add", - colKeyword: "Keyword", - colRegex: "Regex", - empty: "No keywords yet", - moveUp: "Move up", - moveDown: "Move down", - remove: "Remove", - caseSensitive: "Case sensitive", - color: "Text color", - saved: "Keyword highlighting saved", - }, - actions: { - reconnect: "Reconnect", - recordLog: "Record log", - clear: "Clear", - copy: "Copy", - recording: "Recording", - recordingOn: "Log recording started", - recordingSaved: "Log saved", - copied: "Copied to clipboard", - copyEmpty: "Terminal is empty", - copyFailed: "Copy failed", - break: "Break", - breakSent: "Break sent", - broadcast: "Broadcast", - broadcastSent: "Sent to all sessions", - postLogin: "Post-login cmds", - }, - favorites: "Favorites", - recent: "Recent", - favorite: "Toggle favorite", - encoding: "Encoding", - fontSize: "Font size", - theme: "Theme", - themeDark: "Dark", - themeLight: "Light", - colorScheme: "Color scheme", - backgroundColor: "Background", - foregroundColor: "Font color", - scheme: { - dark: "Deep blue (default)", - blackWhite: "Black on white", - whiteBlack: "White on black", - greenBlack: "Green on black", - amberBlack: "Amber on black", - custom: "Custom", - }, - copyOnSelect: "Copy on select", - rightClickPaste: "Paste on right click", - rightClickPasteHint: "Shift + right click opens the menu. Turn off to always show the menu.", - pasteDelay: "Paste max line wait", - pasteDelayOff: "No delay (bulk send)", - keepalive: "SSH keepalive", - keepaliveOff: "Off", - keepaliveSec: "{{n}} sec", - options: "Options", - globalSessionOptions: "Global Session Options", - sessionOptionsSave: "Save", - sessionOptionsCancel: "Cancel", - sessionOptionsSaved: "Global session options saved", - disconnectBanner: "Session disconnected", - disconnectBannerError: "Session error or connection lost", - broadcastPh: "Send to all tabs…", - buttonBarHint: "One per line: label|command", - postLoginPrompt: "Post-login commands (one per line, this device)", - postLoginSaved: "Post-login commands saved", - sftp: { - title: "SFTP", - refresh: "Refresh", - up: "Up", - upload: "Upload", - uploaded: "Upload complete", - uploading: "Upload {{done}}/{{total}} · {{name}} {{pct}}", - downloading: "Download {{done}}/{{total}} · {{name}} {{pct}}", - downloaded: "Downloaded {{count}} file(s)", - downloadPartial: "Downloaded {{ok}}, failed {{failed}}", - uploadPartial: "Uploaded {{ok}}, failed {{failed}}", - retrying: "Retrying {{name}} ({{attempt}}/{{max}})…", - cancel: "Cancel", - dropHint: "Drop to upload into current folder", - dropEmpty: "No uploadable files found", - mkdir: "New", - mkdirPrompt: "New folder name", - mkdirInvalid: "Invalid folder name (no / or ..)", - mkdirOk: "Folder created", - rename: "Rename", - renamePrompt: "New name", - renameInvalid: "Invalid name (no / or ..)", - renamed: "Renamed", - download: "Download", - chmod: "chmod", - chmodPrompt: "Mode (e.g. 755 or rwxr-xr-x)", - chmodOk: "Permissions updated", - chmodPartial: "chmod ok {{ok}}, failed {{failed}}", - delete: "Delete", - deleteConfirm: "Delete {{name}}?", - deleteRecursiveConfirm: "{{name}} includes folders; non-empty folders will be removed recursively. Continue?", - deletePartial: "Deleted {{ok}}, failed {{failed}}", - deleted: "Deleted", - selectedCount: "{{count}} selected", - empty: "This folder is empty", - listTruncated: "Folder is large; showing first {{count}} entries (limit {{max}})", - dir: "dir", - loading: "Loading…", - enterHint: "Click to select; Ctrl/Shift multi-select; double-click to open", - downloadHint: "Click to select; Ctrl/Shift multi-select; double-click to download", - colName: "Name", - colSize: "Size", - colMtime: "Modified", - colOwner: "Owner", - colGroup: "Group", - colMode: "Access", - resizeHint: "Drag to resize SFTP / terminal", - colResizeHint: "Drag to resize column", - }, - term: { - findPh: "Find…", - findPrev: "Previous", - findNext: "Next", - findClose: "Close find", - copy: "Copy", - paste: "Paste", - pasteEmpty: "Clipboard is empty", - pasteFailed: "Cannot read clipboard (on HTTP use the paste box, or Ctrl+V)", - pasteBridgeHint: - "This page is not HTTPS, so the browser blocks clipboard reads. Press Ctrl+V in this box to send text to the terminal.", - pasteBridgePh: "Press Ctrl+V here…", - pasteBridgeCancel: "Cancel", - clear: "Clear", - pasting: "Pasting {{done}}/{{total}} lines…", - wsError: "[WebSocket error]", - wsClosed: "[WebSocket closed code={{code}}]", - sessionStatus: "[session {{state}}{{detail}}]", - outputTruncated: "[output truncated: {{count}} chunk(s) dropped]", - }, - err: { - deviceType: "Unsupported device type", - ipConflictRestart: "Database still enforces unique IP; restart the netx API and retry", - sessionLimit: "Session limit reached; close other terminals first", - neNotFound: "Network element not found", - credsIncomplete: "Incomplete username/password on this NE", - authRejected: "Wrong username or password. Please try again.", - cliProfile: "UME NEs require a CLI connect profile (default account / hop)", - connectFailed: "Device login failed: {{detail}}", - sftpHop: "SFTP does not support hop devices yet (direct SSH only)", - sftpSsh: "SFTP requires SSH", - sftpNeedPassword: "SFTP needs a saved SSH password (one-shot dialog passwords are not reused for files)", - sftpUnsupported: "This SSH session has no SFTP channel (device may not offer it)", - sftpTooLarge: "File exceeds size limit (default 512MB)", - sftpChmodInvalid: "Invalid mode (use 755 or rwxr-xr-x)", - sftpAborted: "Transfer cancelled", - queueDropped: "Terminal output too fast; about {{count}} chunk(s) dropped — transcript may be incomplete", - websocket: "WebSocket connection failed", - }, - }, - topology: { - title: "Topology", - maps: "Maps", - tree: "Topology tree", - treeLoading: "Loading tree…", - treeLoadFailed: "Failed to load topology tree", - treeRetry: "Retry", - graphLoading: "Loading topology…", - graphLoadFailed: "Failed to load topology", - graphRefreshing: "Refreshing…", - canvasEmpty: "No NEs on this map yet", - canvasEmptyHint: "Use Add NE, or pick a region that already has devices.", - treeHint: "Top level is a Root (nav only); its unique Root Map is the canvas. Open the root map or a sub-region to edit devices.", - treeSearch: "Search NEs across maps", - treeSearchPh: "Name / IP", - treeSearchEmpty: "No matching NEs", - treeSearchNoViews: "Not on any map", - treeSearchNotOnMap: "This NE is not placed on any topology map yet", - locateNotOnCanvas: - "This NE is missing from the world-map sample and has no world coordinates. Open a region map instead.", - treeSearchViewCount: "{{count}} map(s)", - treeSearchTruncated: "Showing first 30 of {{total}}", - rootName: "Roots", - selectRegion: "Select a root or region", - openMap: "Open map", - openRegion: "Open", - kindPhysical: "Physical", - kindCustom: "Custom", - layerNodeHint: "{{count}} NE(s)", - layerNodeEmpty: "Click to create & open", - rootCanvasHint: "Pick a root from the left tree.", - regionCanvasHint: "Open the root map to edit devices; create sub-regions on that canvas.", - regionBrowseHint: "Root folder: unique root map below. Click to open.", - browserTitle: "Topology folder", - browserRegionSub: "{{count}} map(s)", - browserRegionsSub: "{{count}} item(s)", - browserEmptyMaps: "No root map under this root yet.", - addNe: "Add NE", - more: "More", - backUp: "Back", - newRegion: "New root", - newRegionName: "New root", - newRegionPrompt: "Root name", - newSubRegion: "New sub-region", - newSubRegionName: "New sub-region", - newSubRegionPrompt: "Sub-region name", - subRegionNotOnFlat: "The world map has no regions. Create sub-regions on World or a lower-level canvas.", - worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.", - worldMapName: "World map", - worldMapOffline: "World map is temporarily offline", - rootMapName: "Root map", - worldHud: "World {{total}} · close-up {{cached}} · z{{zoom}} · {{lod}}", - worldEmptyCoords: "World map has no coordinates yet", - worldEmptyCoordsHint: - "Dock has {{dock}} NEs, but Fabric / world coords are empty. Apply without pulling UME.", - worldApply: "Apply to world map", - worldApplying: "Applying…", - worldApplyOk: "Applied to world map", - subRegionNeedParent: "Open a root map or region canvas first, then create a sub-region.", - regionCreated: "Created", - regionDeleted: "Deleted", - deleteRegion: "Delete", - deleteRegionConfirm: "Delete \"{{name}}\"? Its maps will also be deleted.", - deleteUmeWorldConfirm: - "Delete \"{{name}}\"? This cascades all UME regions and maps under it. A later UME topology apply may recreate it.", - renameRegion: "Rename", - renameRegionPrompt: "Name", - regionRenamed: "Renamed", - collapseRegion: "Collapse", - expandRegion: "Expand", - newCustom: "Custom map", - newCustomName: "Custom topology", - newCustomPrompt: "Custom map name", - folderHint: "Creating a root auto-creates its unique root map. Add sub-regions on that canvas.", - folderHintShort: "Root folder", - outsidePeers: "{{count}} outside peer(s) linked but not on this map", - outsidePeersView: "Off-map NEs {{count}}", - outsidePeersTitle: "Outside peers", - outsidePeersHint: "Linked to this map but not placed. Select and add only what you need.", - outsidePeersVia: "via {{name}}", - outsidePeersFilterPh: "Filter: name / IP / via", - outsidePeersEmpty: "No matching outside peers", - projectNeighbors: "Project neighbors", - projectedNeighbors: "Projected {{count}} neighbor(s) onto the map", - localBrowserOnly: "this browser only", - deleteEdgeConfirm: - "Remove selected link(s) from the canvas? They are deleted from Fabric only when you Save. LLDP may recreate them on the next collect.", - edgeDeleted: "Queued {{count}} link delete(s) — Save to apply", - discoverCancelled: "Discovery cancelled", - truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify or a new map.", - truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze via classify workflow or create a new map.", - truncatedNodes: "Too many view nodes; display truncated. Narrow membership or create another map.", - truncatedEdges: "Too many edges; display truncated. Filter status or create another map.", - truncatedGeneric: "Graph data truncated. Narrow scope or create another map.", - deleteSelectionConfirm: - "Remove {{nodes}} NE(s) from this map and {{edges}} link(s)? Link deletes apply on Save; NE remove from map applies on Save.", - selectionDeleted: "Queued: remove {{nodes}} NE(s), delete {{edges}} link(s) — Save to apply", - newMap: "New", - newMapName: "New topology", - rename: "Rename", - renamePrompt: "Topology map name", - renameHint: "Double-click to rename", - renamed: "Topology map renamed", - deleteMap: "Delete map", - deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.", - emptyMaps: "No roots yet. Create a root first.", - palette: "NE library", - paletteHint: "Select multiple to add in batch, or drag one onto the canvas", - paletteDragHint: "Drag onto canvas to add", - selectAllVisible: "Select all visible", - addSelected: "Add selected ({{count}})", - addSelectedDone: "Added {{count}} NE(s)", - addingNe: "Adding…", - filterPh: "Type to filter: name / IP / vendor", - save: "Save", - saveDirty: "Save *", - unsavedConfirm: "You have unsaved changes. Discard and switch maps?", - saving: "Saving…", - saved: "Topology saved", - saveFailed: "Save failed: {{detail}}", - readOnlyBanner: "Read-only (ne:write scope required to edit)", - readOnlyHint: "Read-only — no write permission", - discoverTimeout: "Discovery timed out; check LLDP tasks for progress", - policySyncFailed: "Failed to sync LLDP policy; local toggle still applies to canvas discovery", - discover: "Discover links", - discovering: "Discovering…", - discoverOne: "Discover links", - discovered: "Discovery done: +{{added}} updated {{updated}} missing {{stale}}", - discoverFail: "Discovery failed: {{detail}}", - discoverOneNeedNe: "This node is not linked to a scannable NE", - selectMap: "Select or create a topology map", - renameNode: "Rename", - renameNodePrompt: "Node display name", - sourcePort: "Local port", - targetPort: "Remote port", - endpointA: "Endpoint A", - endpointB: "Endpoint B", - port: "port", - expandPhysicalLinks: "Expand physical links", - scaleBundleWidth: "Thicken bundles by link count", - linkMembers: "Physical members ({{count}})", - canvasHint: - "Left tree lists regions; add maps yourself, then open a map to edit devices with Add NE and save positions.", - selected: "Selected", - selectedEdge: "Selected edge", - selectedCount: "{{count}} selected", - toolModes: "Edit modes", - toolSelect: "Select", - toolPan: "Pan", - toolDrag: "Drag", - toolConnect: "Connect", - layout: "Layout", - layoutHierarchicalTb: "Hierarchical · top-bottom", - layoutHierarchicalLr: "Hierarchical · left-right", - layoutForce: "Force-directed", - layoutGrid: "Grid", - layoutRadial: "Radial", - layoutSelected: "Selected only (hierarchical)", - layoutNeedSelection: "Select nodes to lay out first", - align: "Align", - alignLeft: "Align left", - alignRight: "Align right", - alignTop: "Align top", - alignBottom: "Align bottom", - alignHCenter: "Align center X", - alignVCenter: "Align center Y", - alignHDistribute: "Distribute horizontally", - alignVDistribute: "Distribute vertically", - alignNeedSelection: "Select at least two nodes", - snapGrid: "Snap to grid", - autoLayoutDiscover: "Auto-layout after discover", - discoverAutoAddUnmatched: "Auto-add unmatched NEs as placeholders on discover", - discoverProjectNeighbors: - "After discover, project neighbors of scanned NEs onto the canvas (Save to keep permanently)", - undo: "Undo", - redo: "Redo", - discoverSelected: "Discover links ({{count}})", - removeSelected: "Remove selected ({{count}})", - openWebcrt: "Open terminal", - openNe: "NE details", - openAlarms: "View alarms", - noAlarmLink: "Cannot build an alarm filter for this node", - removeNode: "Remove from canvas", - deletePlaceholder: "Delete placeholder", - deletePlaceholderConfirm: - "Permanently delete this placeholder NE (inventory + Fabric links)? This cannot be undone with canvas Undo.", - deletePlaceholderDone: "Placeholder deleted", - removeEdge: "Delete edge", - nodeMenu: "Node", - paneMenu: "Canvas", - edgeMenu: "Edge", - createNe: "New NE", - createNeModeHint: - "Create a full managed NE, or a topology placeholder (credentials can be filled later).", - createNeManaged: "Managed NE", - createNeManagedHint: "Same full form as NE management; placed on the canvas after create.", - createNePlaceholder: "Placeholder NE", - createNePlaceholderHint: "Name/IP only; topology placeholder for sketching first.", - createNeTitle: "New topology placeholder NE", - createNeHint: - "Source is topology placeholder. Complete login via WebCRT, or promote in NE management.", - createNeName: "Name", - createNeNamePh: "e.g. SW-CORE-01", - createNeNameRequired: "NE name is required", - createNeIp: "IP (optional)", - createNeIpPh: "Can fill later during setup", - createNeDone: "Created placeholder {{name}}", - createManagedNeDone: "Created and placed {{name}}", - createManagedNePlaceFailed: - "{{name}} was created in inventory, but placing on the map failed: {{error}}. Add it again from NE management or the sidebar.", - createManagedNePosFailed: - "{{name}} is on the map, but could not move to the click position (kept default layout coords)", - createNeBusy: "Creating…", - connectTest: "Connectivity test", - connectTestSelected: "Connectivity test ({{count}})", - connectTestNeedManaged: "Node is not linked to a managed NE; cannot run connectivity test", - connectTestSkipped: "skipped {{count}} without managed link", - selectionMenu: "Selection", - edgeStyle: "Edge style", - edgeColor: "Color", - edgeLineStyle: "Line", - edgeLineSolid: "Solid", - edgeLineDashed: "Dashed", - edgeLineDotted: "Dotted", - edgeWidth: "Width", - edgeStyleReset: "Reset style", - edgeDefaults: "Default edge styles", - edgeDefaultsReset: "Factory reset", - noNeLink: "Not linked to a managed NE", - completeNeFirst: - "Login details are incomplete (vendor, hop/proxy, etc. belong in NE inventory). Complete the NE first, then open the terminal.", - edgeManual: "Manual", - edgeUme: "UME", - edgeDiscovered: "LLDP", - edgeStale: "Missing", - fit: "Fit view", - export: "Export", - exportSvg: "SVG vector", - exportXml: "XML data", - exporting: "Exporting…", - exportEmpty: "Nothing on the canvas to export", - exportedSvg: "SVG exported", - exportedXml: "XML exported", - exportFailed: "Export failed: {{detail}}", - liveSync: "Live sync", - liveSyncOn: "Syncing…", - liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default", - fullscreen: "Fullscreen", - exitFullscreen: "Exit fullscreen", - display: "Display", - canvasBg: "Canvas background", - canvasBgColor: "Background color", - canvasBgReset: "Reset default", - textColors: "Text colors", - textColorsReset: "Reset default", - labelColor: "NE name", - edgeLabelColor: "Link numbers", - vendorColors: "Vendor icon colors", - vendorColorsReset: "Reset default", - vendorTone: { - cisco: "Cisco", - huawei: "Huawei", - zte: "ZTE", - juniper: "Juniper", - nokia: "Nokia", - ericsson: "Ericsson", - h3c: "H3C", - ruijie: "Ruijie", - mikrotik: "MikroTik", - gray: "Unknown / unmanaged", - }, - hideIp: "Hide IP", - hideVendor: "Hide vendor", - showPlaceholderBadge: "Show placeholder badge (TOPO/LLDP)", - showConnectStatus: "Show connect-status dots", - showAlarmOverlay: "Show alarm overlay", - showLevelColors: "Color by classify level", - levelUnmatched: "Unclassified", - level: { - external: "External", - core: "Core", - aggregation: "Aggregation", - access: "Access", - edge: "Edge", - }, - hidePorts: "Hide ports", - edgeFlow: "Edge flow", - collapseSidebar: "Collapse sidebar", - expandSidebar: "Expand sidebar", - resizeSidebar: "Drag to resize sidebar", - hideAdded: "Hide added", - paletteEmpty: "No matching NEs.", - paletteHiddenAll: "Added NEs are hidden; uncheck to show them again.", - paletteSource: "NE source", - paletteManaged: "Managed", - paletteUme: "UME", - paletteLoading: "Loading…", - discoverReport: "Discovery progress", - discoverClose: "Close", - discoverCancel: "Cancel discovery", - discoverProgress: "Scanning {{count}} managed NE(s) on the map (serial LLDP; please wait)…", - discoverProgressLive: "Scanning {{index}}/{{total}}: {{name}}", - discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}", - discoverNeOk: "neighbors {{neighbors}} · added {{added}} · updated {{updated}}", - discoverNeFail: "Collect failed", - discoverUnmatched: "unmatched {{count}}", - discoverUnmatchedMore: "{{count}} more not listed", - discoverVendorRequired: "Vendor/device type missing; skipped LLDP command", - discoverParserStub: "Parser \"{{parser}}\" is still a stub; output was collected but neighbors were not parsed", - discoverNoTargets: - "No scannable NEs on the map. Add managed NEs, or UME NEs with a CLI connect profile.", - discoverViewDetail: "Detail", - discoverLinkCount: "{{count}} link(s)", - discoverLinksTitle: "Matched links ({{count}})", - discoverLinksEmpty: "No on-map peer links matched in this scan.", - discoverUnmatchedTitle: "Unmatched neighbors ({{count}})", - discoverUnmatchedEmpty: "No unmatched neighbors.", - discoverColPeer: "Peer NE", - discoverColRemote: "Neighbor", - discoverColLocalPort: "Local port", - discoverColRemotePort: "Remote port", - discoverColProtocol: "Protocol", - discoverColAction: "Result", - discoverLinkAdded: "Added", - discoverLinkUpdated: "Updated", - discoverLinkKeptManual: "Kept manual", - discoverLocatePeer: "Locate peer", - discoverLocateNe: "Locate NE", - discoverRawPreview: "Raw output preview (truncated when long; parsing uses full output)", - discoverGoLldp: "Details → LLDP links", - findNode: "Find", - findNodePh: "Type to search: name / IP / vendor", - findNoMatch: "No matching node on the canvas", - findMore: "{{count}} more matches…", - locateOnCanvas: "Click to locate on canvas", - openPortTraffic: "Open port traffic", - removeStale: "Clear missing ({{count}})", - removeStaleHint: - "Queue delete of red missing edges (applies on Save); scheduled collect also purges after 4 consecutive misses", - staleRemoved: "Queued {{count}} missing edge delete(s) — Save to apply", - connectHint: "Connect mode: drag from the NE center anchor to the target (Esc or Select to exit)", - }, - topoClassify: { - title: "Classify", - openTopo: "Topology", - inventory: "NE inventory", - keywordPh: "Filter by name/IP", - filterLevelAll: "All levels", - filterRegionAll: "All regions", - filterUnmatchedOff: "No unmatched filter", - kindAny: "Missing level or region", - kindLevel: "Missing level", - kindRegion: "Missing region", - filterLinkAll: "All link states", - filterLinkLinked: "Linked inventory", - filterLinkOrphaned: "Detached", - colLink: "Link", - linkManaged: "Managed", - linkUme: "UME", - linkBoth: "Managed+UME", - linkOrphaned: "Detached", - levelExternal: "L0 external", - levelCore: "L1 core", - levelCoreSub: "L1.1 core′", - levelAgg: "L2 aggregation", - levelAggSub: "L2.1 agg′", - levelAccess: "L3 access", - levelAccessSub: "L3.1 deeper access", - levelInvalid: "Invalid level (0–99.9, one decimal)", - sourceManual: "Manual", - sourceUmeSync: "UME sync", - sourceLldp: "LLDP placeholder", - sourceTopology: "Topology placeholder", - sourceWebcrt: "WebCRT", - regexTitle: "Regex find", - patternPh: "Regex, e.g. ^CORE- or -EDGE-", - fieldName: "Name", - fieldNameIp: "Name+IP", - findMatches: "Find matches", - matchOk: "Matched {{count}}; selected", - matchHint: "{{count}} matched", - assignLevel: "Level only", - assignRegion: "Region only", - assignBoth: "Level + region", - clearRegion: "Clear region", - confirmAssign: "Confirm assign", - bulkConfirm: "Write tags to {{count}} selected/matched NE(s)?", - bulkOk: "Updated {{count}}", - selectedCount: "{{count}} selected", - selectPage: "Select page", - colNe: "NE", - colLevel: "Level", - colRegion: "Region", - colActions: "Actions", - edit: "Edit", - save: "Save", - cancel: "Cancel", - delete: "Delete", - deleteSelected: "Delete nodes ({{count}})", - deleteConfirm: - "Delete {{count}} fabric node(s) (placements and edges)? Managed/UME inventory is not touched.", - deleteOk: "Deleted {{count}} node(s)", - deleteHint: - "Only orphaned or LLDP/WebCRT placeholder fabric nodes can be deleted; UME-only and real managed NEs cannot", - deleteBlocked: "UME-only or still linked to real managed inventory — cannot delete here", - rowSaved: "Saved", - loading: "Loading…", - empty: "No NEs.", - }, - audit: { - title: "Audit & ops", - logsTitle: "Operation logs", - colSummary: "Summary", - colDetail: "Detail", - showDetail: "Expand", - hideDetail: "Collapse", - filterBusiness: "Business", - filterAll: "All", - filterWebcrt: "Device terminal", - filterNeExec: "Device exec", - filterAuth: "Auth", - filterHttp: "HTTP", - summary: { - connecting: "Connecting to {{device}}", - loginOk: "Logged in to {{device}}", - loginFail: "Login failed {{device}}: {{error}}", - logout: "Disconnected {{device}}", - logoutReason: "Disconnected {{device}} ({{reason}})", - command: "{{device}} · cmd: {{command}}", - neExec: "{{device}} · exec: {{commands}}", - neExecBatch: "Batch exec {{n}} NE(s) (ok {{ok}} / fail {{fail}})", - }, - nav: { - tasks: "Task overview", - logs: "Operation logs", - }, - tasks: { - title: "Task overview", - hint: "Live view of running or pending background work. Actor prefers recent audit entries; scheduled work shows as scheduler/system.", - empty: "No tasks to show right now.", - open: "Open", - inflight: "running {{n}}", - kpiActive: "Active / in-flight", - kpiTotal: "Total rows", - kpiKinds: "By kind", - kpiStatuses: "By status", - colKind: "Kind", - colTitle: "Task", - colStatus: "Status", - colActor: "Actor", - colTrigger: "Trigger", - colProgress: "Progress", - colStarted: "Started", - colUpdated: "Updated", - colLink: "Open", - kind: { - port_traffic: "Port traffic", - config_sync: "Config sync", - ne_collect: "Batch collect", - lldp_discover: "LLDP discover", - ume_sync: "UME sync", - ne_connect: "Connect test", - ume_runtime: "UME runtime", - webcrt: "WebCRT", - }, - trigger: { - manual: "Manual", - schedule: "Schedule", - topology: "Topology", - system: "System", - retry_failed: "Retry failed", - }, - status: { - collecting: "Collecting", - running: "Running", - testing: "Testing", - connecting: "Logging in", - ready: "Connected", - detached: "Detached (grace)", - pending: "Pending", - paused: "Paused", - idle: "Idle", - error: "Error", - failed: "Failed", - }, - }, - }, -}; - -export default en; diff --git a/web/src/i18n/locales/en/audit.ts b/web/src/i18n/locales/en/audit.ts new file mode 100644 index 0000000..78ea8d9 --- /dev/null +++ b/web/src/i18n/locales/en/audit.ts @@ -0,0 +1,80 @@ +const audit = { + title: "Audit & ops", + logsTitle: "Operation logs", + colSummary: "Summary", + colDetail: "Detail", + showDetail: "Expand", + hideDetail: "Collapse", + filterBusiness: "Business", + filterAll: "All", + filterWebcrt: "Device terminal", + filterNeExec: "Device exec", + filterAuth: "Auth", + filterHttp: "HTTP", + summary: { + connecting: "Connecting to {{device}}", + loginOk: "Logged in to {{device}}", + loginFail: "Login failed {{device}}: {{error}}", + logout: "Disconnected {{device}}", + logoutReason: "Disconnected {{device}} ({{reason}})", + command: "{{device}} · cmd: {{command}}", + neExec: "{{device}} · exec: {{commands}}", + neExecBatch: "Batch exec {{n}} NE(s) (ok {{ok}} / fail {{fail}})", + }, + nav: { + tasks: "Task overview", + logs: "Operation logs", + }, + tasks: { + title: "Task overview", + hint: "Live view of running or pending background work. Actor prefers recent audit entries; scheduled work shows as scheduler/system.", + empty: "No tasks to show right now.", + open: "Open", + inflight: "running {{n}}", + kpiActive: "Active / in-flight", + kpiTotal: "Total rows", + kpiKinds: "By kind", + kpiStatuses: "By status", + colKind: "Kind", + colTitle: "Task", + colStatus: "Status", + colActor: "Actor", + colTrigger: "Trigger", + colProgress: "Progress", + colStarted: "Started", + colUpdated: "Updated", + colLink: "Open", + kind: { + port_traffic: "Port traffic", + config_sync: "Config sync", + ne_collect: "Batch collect", + lldp_discover: "LLDP discover", + ume_sync: "UME sync", + ne_connect: "Connect test", + ume_runtime: "UME runtime", + webcrt: "WebCRT", + }, + trigger: { + manual: "Manual", + schedule: "Schedule", + topology: "Topology", + system: "System", + retry_failed: "Retry failed", + }, + status: { + collecting: "Collecting", + running: "Running", + testing: "Testing", + connecting: "Logging in", + ready: "Connected", + detached: "Detached (grace)", + pending: "Pending", + paused: "Paused", + idle: "Idle", + error: "Error", + failed: "Failed", + }, + }, +}; + +export default audit; diff --git a/web/src/i18n/locales/en/auth.ts b/web/src/i18n/locales/en/auth.ts new file mode 100644 index 0000000..6f5b0f5 --- /dev/null +++ b/web/src/i18n/locales/en/auth.ts @@ -0,0 +1,112 @@ +const auth = { + loginTitle: "Sign in", + username: "Username", + password: "Password", + login: "Sign in", + loggingIn: "Signing in…", + loginFailed: "Login failed", + logout: "Sign out", + sessionsTitle: "Login sessions", + sessionsHelp: + "Behind a proxy, IP is the real client; use Client to tell browsers apart. Revoked sessions must sign in again.", + sessionsHint: + "Manage browser/device logins for this account. Behind a reverse proxy, IP is taken from trusted forwarded headers; use the Client column to tell browsers apart on the same public IP. Revoked sessions must sign in again.", + sessionsEmpty: "No active sessions.", + revokeOtherSessions: "Revoke other sessions", + revokeSession: "Revoke", + sessionCurrent: "Current", + sessionRevoked: "Session revoked", + sessionsRevokedOthers: "Revoked {{count}} other session(s)", + revokeCurrentConfirm: "This is your current session; revoking it requires signing in again. Continue?", + colSession: "Session", + colClient: "Client", + colLastSeen: "Last seen", + colCreated: "Created", + usersTitle: "User management", + usersHint: "Only admins can create and manage local accounts.", + addUser: "Add user", + role: "Role", + roleAdmin: "Admin", + roleUser: "User", + status: "Status", + active: "Active", + disabled: "Disabled", + enable: "Enable", + disable: "Disable", + actions: "Actions", + newPassword: "New password", + resetPassword: "Reset password", + userCreated: "User created", + userUpdated: "User updated", + auditTitle: "Audit log", + auditHintAdmin: "View login and operation records for all users.", + auditHintUser: "View your own operation records.", + filterUsername: "Filter username", + filterAction: "Filter action", + colTime: "Time", + colUser: "User", + colAction: "Action", + colMethod: "Method", + colPath: "Path", + colStatus: "Status", + colIp: "IP", + apiKeysTitle: "API Key management", + apiKeysHelp: + "For MCP/scripts and netxops (web UI uses sessions). Secret shown once; Edit scopes later. Cap {{max}} non-revoked keys.", + tokenQuota: "Active {{active}} / {{max}}", + tokenLimitReached: "API key limit reached ({{max}}). Revoke or delete unused keys first.", + tokenName: "Name", + expiresIn: "Expiry", + expire7d: "7 days", + expire30d: "30 days", + expire90d: "90 days", + expire365d: "1 year", + expireNever: "Never", + tokenOwner: "Owner", + tokenOwnerSelf: "Myself ({{user}})", + createToken: "Create key", + tokenCreated: "API key created", + tokenUpdated: "Scopes updated", + tokenRevoked: "Revoked", + tokenDeleted: "Deleted", + tokenOnceHint: "Copy and store this secret now; it will not be shown again:", + copyToken: "Copy", + tokenCopied: "Copied", + tokenCopyFailed: "Copy failed", + expiresAt: "Expires", + lastUsed: "Last used", + tokenStatusActive: "Active", + tokenStatusExpired: "Expired", + tokenStatusRevoked: "Revoked", + revokeToken: "Revoke", + revokeConfirm: "Revoke this API key? It stops working immediately; the record remains.", + deleteToken: "Delete", + deleteConfirm: "Permanently delete this API key? This cannot be undone.", + editScopes: "Edit scopes", + saveScopes: "Save scopes", + scopesUnsetLegacy: "Unrestricted (same as owner)", + scopesCol: "Scopes", + scopesRequired: "Select at least one scope", + scopesNoneAvailable: "This owner has no grantable scopes", + scopeAlarmsRead: "alarms:read — Alarms read (netx MCP)", + scopeNeRead: "ne:read — NE / topology read (MCP)", + scopeNeWrite: "ne:write — NE / topology write / draw (MCP)", + scopeNeExec: "ne:exec — Managed NE CLI (netx MCP)", + scopeBizMonitorRead: "biz-monitor:read — Biz monitor / cutover read (netxops)", + scopeSql: "sql:query — UME SQL (netx MCP)", + scopeWebcrt: "webcrt:session — WebCRT (reserved MCP)", + scopeAdminUsers: "admin:users — User admin (reserved MCP)", + scopeOpsWrite: "ops:write — Ops write / subscriptions (reserved MCP)", + + forceChangeTitle: "Change initial password", + forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.", + oldPassword: "Current password", + confirmPassword: "Confirm new password", + savePassword: "Save new password", + savingPassword: "Saving…", + passwordTooShort: "New password must be at least 8 characters", + passwordMismatch: "New passwords do not match", + passwordMustChange: "New password must differ from the default/old password", +}; + +export default auth; diff --git a/web/src/i18n/locales/en/bizCompare.ts b/web/src/i18n/locales/en/bizCompare.ts new file mode 100644 index 0000000..cb747c7 --- /dev/null +++ b/web/src/i18n/locales/en/bizCompare.ts @@ -0,0 +1,331 @@ +const bizCompare = { + title: "Business compare", + templates: "Compare templates", + newTemplate: "New template", + editTemplate: "Edit template", + saveTemplate: "Save template", + templateSaved: "Template saved", + templateDeleted: "Template deleted", + confirmDeleteTemplate: "Delete this compare template?", + confirmDeleteJob: "Delete this compare job and all runs?", + confirmDeleteRun: "Delete this compare run? Job config is kept.", + deleteJob: "Delete job", + deleteRun: "Delete run", + runDeleted: "Compare run deleted", + emptyTemplates: "No templates yet. Create one or import a JSON file.", + exportTemplate: "Export", + importTemplate: "Import", + templateExported: "Template exported", + templateImported: "Template imported", + templateImportInvalid: "Invalid file: need name and metrics", + templateValidationFailed: "Template validation failed; correct the indicated settings", + filterOperator: "Filter operator", + complexFiltersPreserved: "These nested filters are preserved in full. Export, edit the JSON and import it to update them.", + portPolicy: "Port change policy", + portPolicyHint: "Port mappings take priority. This policy controls whether interface identity fields participate in matching without a mapping.", + portPolicyAuto: "Automatic", + portPolicyStrict: "Interfaces must match", + portPolicyIgnore: "Allow port changes", + missingSheets: "Configured checks were removed or renamed; select the scope again", + enableCurrentSheets: "Enable all current checks", + templateUsage: "Used by {{n}} jobs", + templateImportHint: "Import/export JSON to share templates privately (no job/batch data)", + templateHint: + "Pick metrics, then set Key / interface / compare / display. Compare modes: exact, abs delta, or relative %. Display can include context-only columns. Empty compare = presence-only.", + stepPickMetrics: "① Pick metrics", + stepConfigRules: "② Configure compare rules", + pickMetricsHint: "Check metrics to add. Then configure each sheet below.", + pickMetricsHintShort: "Click to add/remove; switch sheets in the left list below.", + showMetrics: "Show metric picker", + hideMetrics: "Hide metric picker", + noMetricsYet: "Select at least one metric first", + selectedCount: "{{n}} selected", + tabFields: "Field rules", + tabFilters: "Row filters", + fieldsHintShort: + "Key aligns rows; Compare fields take part in value diff; empty compare = presence-only.", + tagCompareN: "{{n}} cmp", + colFields: "Fields", + keyFields: "Key", + ifaceFields: "Interface", + compareFields: "Compare", + compareMode: "Mode", + tolerance: "Tolerance", + displayField: "Display", + keyAlwaysDisplay: "Key columns are always shown", + modeEq: "Exact equal", + modeNumeric: "Abs delta", + modePercent: "Relative %", + normalizeField: "Norm", + ignoreFields: "Ignore", + field: "Field", + note: "Note", + keyRequired: "Select at least one key field", + keyIsIdentity: "Key aligns rows; not used as a value-compare field", + metricsRequired: "Add at least one metric sheet", + noMetricFields: "No fields for this metric", + rowFilters: "Row filter rules", + rowFiltersHint: + "Filter rows before compare. Conditions in a group are AND; groups are OR. Selecting ARP seeds editable default rules you can change.", + rowFiltersHintShort: "AND in group · OR across groups · empty = all rows", + rowFiltersEmpty: "No filters → compare all rows.", + filterClear: "Clear", + filterAddOr: "Add group (OR)", + filterAddAnd: "Add condition (AND)", + filterAddOrShort: "+ OR group", + filterAddAndShort: "+ AND", + filterRemoveGroup: "Remove group", + filterGroupN: "Group {{n}}", + filterOr: "OR", + filterAnd: "AND", + filterField: "Field", + filterOp: "Op", + filterValue: "Value", + normalizeNone: "None", + normalizeLower: "lower", + normalizeUpper: "upper", + normalizeMac: "MAC", + normalizeEmpty: "empty→blank", + templateFilterPh: "Filter template / metric", + jobFilterPh: "Filter job / template / status", + addMetric: "Add metric", + pickMetric: "Pick metric…", + addSheet: "Add to template", + removeSheet: "Remove sheet", + sheetIdRequired: "sheet_id is required (unique per split)", + duplicateSheetId: "sheet_id must be unique", + sheetTitle: "Sheet title", + sheetId: "sheet_id", + splitSheet: "Split / duplicate", + colSheets: "Sheets", + sheetsUnit: "sheets", + presenceShort: "presence", + modeFields: "Mode: compare selected field values", + modeFieldsShort: "fields", + modePresence: "Mode: presence-only (entry set match)", + mapping: "Port mapping (optional)", + newMapping: "New mapping…", + mapName: "Mapping name", + saveMapping: "Save mapping", + validateMapping: "Validate", + validateNeed: "Select mapping and before/after batches first", + mapHint: + "One pair per line: before_if,after_if (comma / tab / pipe). Example:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# comments start with #. Not slash-grouped A,B/C,D.", + mappingSaved: "Mapping saved", + mappingOptionalHint: + "Optional. Empty = ignore local port renames (match on non-interface keys). Fill to apply port map. Main-port pairs also cover .subinterfaces (suffix kept).", + ifaceNormalize: "Interface normalize", + ifaceNormalizeHint: + "Template rules, one per line: alias,canonical. Applied before port mapping. Example:\nGE,gei\nSG,smartgroup\nXGE,xgei", + createJob: "Create compare job", + defaultJobName: "Cutover compare", + jobName: "Name", + template: "Template", + mode: "Mode", + modeManual: "Manual batches", + modeAuto: "Auto (pin before; latest after batch)", + beforeTask: "Before task", + beforeBatch: "Before batch", + afterTask: "After task", + afterBatch: "After batch", + sideBefore: "Before", + sideAfter: "After", + sidesVs: "vs", + sidesTitle: "Compare sides", + configSnapshot: "Execution settings saved", + configSnapshotHint: "Template rules and port mapping are fixed for this run. Later edits do not affect historical lookup. The downloaded report includes the execution settings.", + configLegacy: "Legacy run: settings not saved", + configLegacyHint: "This run has no configuration snapshot. Success lookup and compact row reconstruction use the current settings, which may differ from the original execution.", + pick: "Select…", + sameAsBefore: "Same as before / select…", + autoHint: "auto: new after-task batches trigger compare", + createCompare: "Create job", + createSteps: "Create steps", + stepBasics: "Basics", + stepSheets: "Sheets", + stepBatches: "Batches", + stepMapping: "Port mapping", + next: "Next", + back: "Back", + enabledSheets: "Compare sheets", + enabledSheetsHint: "Toggle sheets; all on by default. Off sheets are skipped.", + noTemplateSheets: "This template has no sheets", + needJobName: "Enter a job name", + needTemplate: "Select a compare template", + needSheets: "Enable at least one sheet", + needBeforeBatch: "Select before task and batch", + needAfterBatch: "Select after batch", + runNow: "Run now", + runStarted: "Compare started in background — you can close this page; check run history for progress", + runFailed: "Compare failed", + runStatusRunning: "Running", + runStatusFailed: "Failed", + runStatusDoneSec: "Done {{s}}s", + runProgress: "{{phase}} · sheet {{i}}/{{n}} · {{sheet}} · {{s}}s elapsed", + runSheetProgress: "sheet {{i}}/{{n}}", + runElapsed: "{{s}}s elapsed", + runRowsLoaded: "Loaded {{side}} {{n}} rows", + runRowsSqlCount: "DB count {{side}} {{n}} rows", + runPersisting: "Wrote {{done}}/{{total}} rows", + runEngineSql: "engine SQL", + runEnginePython: "engine Python", + runEngineNote: "reason {{note}}", + phaseQueued: "Queued", + phaseLoading: "Loading", + phaseSqlCount: "Counting", + phaseSqlProject: "Preparing tables", + phaseSqlJoin: "Joining", + phaseSqlFailFetch: "Fetching fails", + phaseComparing: "Comparing", + phasePersisting: "Writing", + phasePersistingFail: "Writing fails", + phasePersistingOk: "Writing passes", + phaseDone: "Done", + phaseFailed: "Failed", + phaseCancelled: "Cancelled", + sheetPending: "Pending", + colProgress: "Progress / result", + passRateScope: "all rows", + keyFiltersToggle: "Field filters", + hideDisplayCols: "Hide display cols", + showDisplayCols: "Show display cols", + displayColsToggle: "Display cols", + hideDisplayColsHint: "Compact: display-only columns are hidden by default; highlight means they are shown", + ranWithDuration: "Compare finished ({{s}}s)", + unchangedNotStored: "Success rows were counted but not stored. Set “Store success rows” to sample and re-run for spot-check.", + unchangedSampleHint: "{{total}} success rows total; browsing a stratified sample of {{listed}}. Search any route for live source lookup. Pass rate uses all {{total}}.", + liveSearchHint: "Pairs are completed from source batches before filtering. Pass rate still uses the full sheet.", + liveSearchTruncatedHint: "Search limit reached; some entries are omitted. Narrow the filters. Incomplete key groups were skipped.", + clearSearch: "Clear filters", + resultLoading: "Loading comparison details…", + resultRange: "{{from}}–{{to}} of {{total}}", + resultLoadFailed: "Could not load comparison details", + retry: "Retry", + searchEmpty: "No results match these filters. Clear filters or select another result type.", + clearKeyFilters: "Clear fields", + storeUnchanged: "Store success rows", + storeUnchangedAuto: "Auto (full if small / sample 5k + hydrate if large)", + storeUnchangedSample: "Sample only (up to 5k keys + hydrate)", + storeUnchangedKeys: "All success keys (full browse; large write still slow)", + storeUnchangedAlways: "Always full JSON (slow on huge sheets)", + storeUnchangedNever: "Never (counts only)", + storeUnchangedHint: "Cutover focuses on fails and pass rate; success defaults to a stratified sample. Type a filter to look up any route from source tables. Pass rate uses the full success count.", + saveJob: "Save config", + jobSaved: "Job config saved", + jobDeleted: "Compare job deleted", + jobList: "Compare jobs", + detail: "Details", + edit: "Edit", + delete: "Delete", + tabConfig: "Job config", + tabRuns: "Compare batches", + tabResult: "Result", + detailTabs: "Business comparison workspace", + jobReady: "Ready", + configBasics: "Job parameters", + configSources: "Before and after sources", + configScope: "Check scope", + selectMapping: "Select mapping", + mappingPairs: "Interface pairs", + sourceBaseline: "Fixed baseline", + sourceTarget: "Acceptance target", + sourceLatest: "Latest batch", + sourceRows: "{{n}} collected rows", + autoSourceHint: "Keep the before batch fixed. Compare against the latest completed batch from the after task.", + runHistoryTitle: "Execution history", + runHistoryHint: "Showing the latest 50 runs. Execution status and business verdicts are displayed separately. Open results to inspect.", + refreshRuns: "Refresh history", + loadedRuns: "Loaded runs", + activeRuns: "In progress", + completedRuns: "Completed", + stoppedRuns: "Failed / cancelled", + runFinished: "Completed", + runStatusQueued: "Queued", + runsLoading: "Loading execution history…", + runsLoadError: "Could not load execution history", + runs: "Run history", + runsHint: "Each “Run now” creates a batch. Stuck “Running” after restart is auto-cancelled; you can also cancel manually and re-run.", + noRuns: "No runs yet — run a compare first", + result: "Result", + viewResult: "View result", + cancelRun: "Cancel", + confirmCancelRun: "Cancel this compare batch? You can start a new run afterward.", + runCancelled: "Compare cancelled", + runStatusCancelled: "Cancelled", + compareAlreadyRunning: "A compare is already running — cancel it under Compare batches or wait", + runBatchSummary: "Fail {{fail}} · Pass {{ok}} · Added {{added}}", + colTime: "Time", + pickBatchRun: "Select compare run", + pickRun: "Select run…", + runCount: "{{n}} runs", + kindAll: "All", + kindDiff: "Fail", + kindAdded: "Added", + kindRemoved: "Missing only", + kindChanged: "Mismatch only", + kindUnchanged: "Pass", + kindAddedShort: "Added", + kindRemovedShort: "Missing", + kindChangedShort: "Mismatch", + kindUnchangedShort: "Pass", + kindFail: "Fail", + kindPass: "Pass", + kindSuccess: "Pass", + successCount: "Pass", + passRateShort: "Pass %", + verdictPass: "Pass", + verdictFail: "Fail", + failCount: "Failed rows", + missCount: "Missing", + extraCount: "Extra", + mismatchCount: "Mismatch", + matchCount: "Match", + failFields: "Failed fields", + failFieldsEmpty: "—", + failWholeRow: "Whole row", + pairBefore: "pre", + pairAfter: "post", + sheetAllPass: "All match", + filterFailField: "Failed compare field", + filterFailFieldAll: "Any failed field", + resultEmpty: "No matching rows", + resultFilterPh: "Free text, or direction:out network:1.1.1.1", + diffCount: "{{n}} failed", + passRate: "Pass rate", + passOk: "All passed", + passWarn: "Has failures", + runOverview: "Verdict", + sheetCurrent: "Current check", + diffCountLabel: "Failed rows", + diffRate: "Fail rate", + beforeCount: "Before rows", + afterCount: "After rows", + matchedRows: "Matched", + totalRows: "Total rows", + sheetCount: "Sheets", + sheetNavTitle: "Checks", + sheetNavHint: "{{n}} checks · click to switch", + collapseSheetNav: "Collapse checks", + expandSheetNav: "Expand checks", + sheetFailCount: "{{n}} failed", + topChangedFields: "Top failed fields", + exportTables: "Download tables", + exportOk: "Compare tables exported", + fullscreen: "Fullscreen board", + exitFullscreen: "Exit fullscreen", + emptyJobs: "No compare jobs yet", + created: "Compare job created", + ran: "Compare finished", + colName: "Name", + colMode: "Mode", + colStatus: "Status", + colKind: "Verdict", + colResult: "Result", + colDetail: "Detail", + colFailFields: "Failed fields", + colActions: "Actions", + colKey: "Identity", + colChange: "Changes", +}; + +export default bizCompare; diff --git a/web/src/i18n/locales/en/bizMigration.ts b/web/src/i18n/locales/en/bizMigration.ts new file mode 100644 index 0000000..45fe9bd --- /dev/null +++ b/web/src/i18n/locales/en/bizMigration.ts @@ -0,0 +1,302 @@ +const bizMigration = { + fullscreen: "Wall mode", + exitFullscreen: "Exit wall mode", + fullscreenHint: "Display cutover status fullscreen. Press Esc to return; use a full-window layout when fullscreen is unavailable.", + wallTitle: "Cutover monitoring · Service migration", + wallProgress: "Wave migration progress · Current snapshot", + wallFilters: "Show filters and evaluation controls", + wallState_waiting: "Awaiting evaluation", + wallState_unknown: "Data needs confirmation", + wallState_anomaly: "Service anomalies detected", + wallState_normal: "No service anomalies in current samples", + pairCollectionHint: "Each metric has a paired collection round. Saving settings updates task bindings. Route tables are manual-only for final checks; use a separate business comparison for millions of routes. Actual cadence depends on command duration and device availability.", + pairedSampling: "Paired sampling · Timeliness", + manualRoute: "Manual final check", + pairedPeriodic: "Paired periodic", + samplingMode: "Sampling mode", + samplingDuration: "Duration old / new", + samplingQueue: "Queue old / new", + startSkew: "Start skew", + sameRound: "Round", + metricBaselineMissing: "This metric needs a matching baseline receipt", + samplePhase_idle: "Not collected", + samplePhase_queued: "Queued", + samplePhase_running: "Collecting", + samplePhase_success: "Complete", + samplePhase_partial: "Partial", + samplePhase_failed: "Failed", + samplePhase_cancelled: "Stopped", + collect_pair_invalid: "Incomplete collection pair", + collect_pair_missing: "No complete paired sample", + collect_pair_pending: "Waiting for both sides of this round", + collect_pair_failed: "Current round failed; older samples cannot replace it", + collect_round_mismatch: "Samples belong to different rounds", + sample_start_skew: "Actual collection starts are too far apart", + latestSnapshot: "Latest snapshot", + steadyOutside: "{{n}} stable items outside scope", + "followLive": "Follow live", + "historySnapshot": "Historical snapshot", + "evaluatedAt": "Evaluated", + "refreshBoard": "Refresh board", + "oldestSample": "Oldest sample", + "liveOverview": "Business migration overview", + "remaining": "Remaining items", + "anomalyInBatch": "Current wave anomalies", + "anomalyOutsideBatch": "Outside wave anomalies", + "coverageUnknown": "Uncertain coverage; acceptance cannot pass", + "coverageUnknownHint": "Resolve missing, stale or misaligned samples and duplicate keys. Healthy samples cover their own monitor items.", + "awaitingPeer": "Awaiting target sample", + "migratedPrevious": "Previous wave received", + "unfinishedPrevious": "Previous wave unfinished", + "sample_time_missing": "Sample time missing", + "sample_skew": "Samples too far apart", + "sample_stale": "Stale samples", + "duplicateKeys": "Duplicate business keys", + "uncoveredScope": "Missing scope sheets", + "refreshFailed": "Refresh failed; showing retained snapshot:", + "lastAcceptance": "Last acceptance", + "diffSearch": "Search business key / verdict / status", + "verdictFilter": "Verdict filter", + "allVerdicts": "All verdicts", + "warningVerdict": "In progress / warning", + "healthyVerdict": "Received / healthy", + "unknownVerdict": "Uncertain", + "loadingBoard": "Updating…", + "rowsMatched": "{{n}} matching items", + "showOutsideAnomalies": "Show outside anomalies", + "scopeCurrent": "Current wave", + "scopePrevious": "Previous wave", + "scopeOutside": "Outside scope", + "fieldChanges": "{{n}} changed fields", + "outsideProtection": "Protect businesses outside this wave", + "outsideProtectionHint": "Report drift outside this wave. Continue checking migrated businesses from previous waves without counting them in current progress.", + "reason_service_not_received": "Source departed; target not healthy", + "reason_target_unhealthy": "Target unhealthy", + "reason_old_service_not_in_baseline": "Business missing from source baseline", + "reason_service_content_changed": "Received business content differs", + "reason_old_service_still_present": "Business still on source", + "reason_target_sample_predates_source": "Target sample predates source departure", + "reason_previous_batch_received": "Previous wave remains healthy", + "reason_previous_batch_not_received": "Previous wave not received", + "reason_unmapped_same_iface": "Drift on unmapped interface", + "reason_out_of_expect:anomaly": "Business drift outside scope", + "reason_out_of_expect:lost": "Business lost outside scope", + title: "Cutover monitor", + hint: "Separate from business compare: baseline drift, expect set, dual-device verdict. Reuses collect tasks and port mappings.", + hintPort: + "Metric focus: port status (interface_brief / admin·phy·prot). Baseline → pick ports → evaluate board.", + hintShort: "Monitor template sets rules; bind collect + dual baselines → expect → evaluate board.", + create: "Create task", + createHint: + "Except portrait baselines, the full cutover flow runs on this page; create auto-spawns HF biz-monitor tasks on schedule.", + createHintShort: + "Pick old/new NEs → monitor template & metrics → interval/window → baselines → port map → create. HF tasks are auto-created.", + pickOldNe: "Old NE", + pickNewNe: "New NE", + pickNe: "Pick NE", + pickNeHint: "Click to pick a device", + hideNePicker: "Hide list", + nePairReady: "Both NEs selected — continue, or click a card to re-pick.", + needNePair: "Enter a name and select old/new NEs", + needProjectName: "Enter a task name", + needMonitorTemplate: "Select a monitor template", + needMetrics: "Select at least one metric", + noPortraitBaseline: + "No portrait task/batches for this NE yet — run portrait collect in business monitor first (baselines can be set later).", + baselineOptionalHint: "Baselines can be set later in detail; pick from portrait batches when available.", + metricInterval: "Per-metric interval (sec)", + metricIntervalOptional: "Per-metric interval (optional)", + metricIntervalHint: "Leave blank to use the global interval; a filled value creates a separate HF task.", + scheduleHint: + "Enter the window in your local time; stored as UTC. Scheduler pauses HF outside the window.", + sectionSchedule: "Collect interval & window", + sectionBaseline: "Baselines & mapping", + sectionDevices: "Old / new NEs", + sectionMetrics: "Collect metrics", + createSteps: "Create steps", + stepDevices: "Devices", + stepTemplate: "Template", + stepSchedule: "Schedule", + stepBaseline: "Baselines", + next: "Next", + back: "Back", + colHf: "HF", + hfReady: "Ready", + hfMissing: "Missing", + showCreateMore: "More: template / mapping / baselines", + hideCreateMore: "Hide more", + setupHintShort: "Portrait tasks feed baselines; HF tasks feed cutover collect. Configure template, metrics, window and dual baselines here.", + createProject: "Create task", + projectName: "Task name", + projectNamePh: "e.g. PE-A / PE-B cutover-1", + sectionName: "1. Task name", + sectionTasks: "2. Bind collect tasks (required)", + sectionTasksHint: "Pick the old-device and new-device business-monitor tasks.", + sectionOptional: "3. Monitor template, mapping & baselines (optional)", + sectionOptionalHint: + "Skip for now if needed; set later under Baseline. Defaults to the system monitor template when omitted.", + sectionPair: "Bound collect tasks", + sectionPairHint: "Collect tasks are fixed at create time; adjust monitor template, mapping and baselines here.", + sectionBaselineHint: "Both old and new baselines are required; old lists expect objects, new feeds dual-side verdict.", + optionalNone: "None", + monitorTemplate: "Monitor template", + monitorTemplateHint: "Chooses which metrics to compare and dual-side success semantics. Defaults to the system monitor template.", + compareTemplate: "Compare template", + compareTemplateBound: "Bound compare template: {{name}}", + portMappingHint: + "With a mapping, iface metrics compare only mapped ports (they must appear after cutover). Same-name ports outside the map are not a migration; old down + new up is an anomaly. No mapping means same-name compare. BGP/PW still use the batch expect set.", + baselineNeedTask: "Select the matching collect task first", + baselinePickHint: "Choose from that task’s collect batches", + oldTask: "Old-device portrait task", + newTask: "New-device portrait task", + portMapping: "Port mapping", + oldBaseline: "Old baseline batch", + newBaseline: "New baseline batch", + needProjectFields: "Enter a name and pick old/new portrait tasks", + needExpectPorts: "Select at least one expected object for this batch", + needBothBaselines: "Save both old and new baseline batches before evaluating", + errOldBaselineRequired: "Old baseline batch is required", + errNewBaselineRequired: "New baseline batch is required", + needBizStateTasks: "No portrait business-monitor tasks yet. Create one first:", + projectCreated: "Task created", + projects: "Tasks", + empty: "No cutover tasks yet.", + emptyProjects: "No cutover tasks yet", + listFilterPh: "Filter name / NE / IP / status", + kpiProjects: "Tasks", + kpiActive: "Active", + detail: "Detail", + cancel: "Close", + colName: "Task", + colPair: "Old → New", + colBaseline: "Baseline", + colCreated: "Created", + colActions: "Actions", + baselineSet: "Set", + baselineUnset: "Unset", + statusActive: "Active", + statusDone: "Done", + statusPaused: "Paused", + statusPending: "Pending", + statusReview: "Review", + tabSetup: "Baseline", + tabBatches: "Batches", + tabBoard: "Board", + saveBaseline: "Save", + baselineSaved: "Baseline saved", + errPairCapacity: "Paired sampling needs at least two collection slots. Check task concurrency, collection threads and the CLI budget.", + errPairBusy: "This metric is still collecting or queued on either side. Wait for the round to finish.", + errPairInvalid: "The collector pair is incomplete. Save configuration again to restore it.", + errPairSameDevice: "Cutover monitoring requires two different devices.", + pickExpectPorts: "Expect", + portFilterPh: "Filter", + emptyBaselinePorts: "No baseline data", + batchLabel: "Batch label", + batchId: "Batch ID", + copyBatchId: "Copy batch ID", + defaultBatchLabel: "Night N", + expectPorts: "Expected ports (old side)", + expectPortsPh: "Comma or newline separated", + createBatch: "New", + batchCreated: "Batch created", + batches: "Batches", + startBatch: "Start", + finishBatch: "Finish", + evaluate: "Evaluate", + evaluated: "Done", + statusUpdated: "Status updated", + board: "Board", + metricPort: "Port status", + progress: "Progress", + anomaly: "Anomaly", + onlyExpect: "Expect only", + onlyExpectHint: "Shows all drift by default; check to filter to this batch's expect set", + pinCurrentBatches: "Current collect batches", + pinOldBatch: "Old current", + pinNewBatch: "New current", + pinLatest: "Latest", + runHistory: "Runs", + runLatest: "Latest", + colRuleHit: "Rule hit", + colCommand: "Collect command", + mappingBound: "Mapping saved and bound to this project", + allMetrics: "All metrics", + colMetric: "Metric", + colKey: "Object", + colPort: "Old port", + openRedChip: "Red {{n}}", + metaPortrait: "Portrait", + metaHf: "HF", + metaBaseline: "Baseline", + baselineOk: "set", + baselineMissing: "missing", + needBaselineFirst: "Set baseline first", + enableHighfreq: "Enable HF", + highfreqReady: "HF ready", + collectNow: "Collect now", + collectTriggered: "Collect triggered", + openBizState: "Biz monitor", + hfNotBound: "Unbound", + projectCollectMetrics: "Metrics", + hfInterval: "Interval(s)", + hfStart: "Start", + hfEnd: "End", + markProjectDone: "Done / stop HF", + confirmProjectDone: "Complete project and pause all HF?", + projectMarkedDone: "Done; HF paused", + deleteProject: "Delete", + confirmDeleteProject: "Delete project? HF will pause and batches removed.", + projectDeleted: "Deleted", + acceptPassedShort: "Pass", + acceptFailedShort: "Fail", + colMapped: "Mapped new", + colDesc: "Description", + colOldPort: "Old business key", + colNewPort: "New business key", + colOldStatus: "Old admin/phy/prot", + colNewStatus: "New admin/phy/prot", + colOld: "Old", + colNew: "New", + colVerdict: "Verdict", + emptyDiffs: "No rows yet — run Evaluate", + emptyRed: "No red tickets", + verdictMigrated: "Migrated", + verdictMigrating: "Migrating", + verdictLost: "Lost", + verdictAnomaly: "Anomaly", + verdictNotInvolved: "Not involved", + verdictUnexpected: "Unexpected new", + verdictOk: "OK", + highfreqHint: "", + boundTasks: "Bound tasks", + oldPortraitTask: "Old portrait", + newPortraitTask: "New portrait", + oldHfTask: "Old HF", + newHfTask: "New HF", + projectCollectHint: "", + hfWindowInactive: "HF window inactive or project done", + baselinePortraitOnly: "", + collectSkipped: "skipped", + collectSkippedBanner: "Skipped: {{metrics}}", + currentMissing: "no current", + currentMissingBanner: "No current: {{metrics}}", + highfreqReadyInactive: "HF bound (window inactive, no collect)", + collectPartial: "Partial fail (ok {{ok}} / fail {{fail}})", + errMetricNeedsBindings: "Metric \"{{metric}}\" needs bindings ({{params}}); not supported for cutover HF — remove it", + errNoProfileForMetric: "Metric \"{{metric}}\" has no collect profile for this vendor", + acceptTitle: "Acceptance", + acceptPassed: "Passed", + acceptFailed: "Failed ({{n}} red)", + newBaselineMissingBanner: "Missing new baseline: {{metrics}}", + acceptCarryHint: "May continue with red", + openRedHint: "Red {{n}}", + redTitle: "Red tickets", + redOpen: "open", + resolveRed: "Resolve", + redResolved: "Resolved", + batchCreatedWithRed: "Batch created ({{n}} red)", + colStatus: "Status", + verdictUnfinished: "Unfinished", +}; + +export default bizMigration; diff --git a/web/src/i18n/locales/en/bizMonitorTpl.ts b/web/src/i18n/locales/en/bizMonitorTpl.ts new file mode 100644 index 0000000..3415681 --- /dev/null +++ b/web/src/i18n/locales/en/bizMonitorTpl.ts @@ -0,0 +1,137 @@ +const bizMonitorTpl = { + title: "Monitor templates", + help: "Compare templates define align/diff; monitor templates define migrate success on the same sheets.", + hint: "Compare templates define HOW to align/diff; monitor templates define WHEN dual-device migrate succeeds — one sheet tab per compare sheet.", + create: "New monitor template", + edit: "Edit", + save: "Save", + cancel: "Close", + created: "Created", + saved: "Saved", + deleted: "Deleted", + confirmDelete: "Delete this monitor template?", + delete: "Delete", + empty: "No monitor templates yet.", + listFilterPh: "Filter name / compare template / note", + kpiTotal: "Templates", + colName: "Name", + namePh: "e.g. Status cutover monitor", + colCompare: "Compare template", + colCollect: "HF collect metrics", + colNote: "Note", + colActions: "Actions", + needName: "Name is required", + defaultsInvalid: "Invalid defaults JSON", + overridesInvalid: "Invalid sheet_overrides JSON", + exportTemplate: "Export", + importTemplate: "Import", + templateExported: "Template exported", + templateImported: "Template imported", + templateImportInvalid: + "Invalid import file: need name and compare_template_id (or compare_template_name)", + templateImportHint: "Import/export JSON to share monitor templates (no job/batch data)", + templateImportCompareMissing: + "Matching compare template not found — import or create it first", + formHint: "Pick a compare template → same sheet tabs appear → set success conditions per sheet.", + compareHint: "Owns which sheets and Key/compare fields; here you only set dual verdict rules.", + pickCompare: "Pick compare template…", + openCompare: "Manage compare templates", + collectAll: "All sheets", + collectPh: "interface_brief, bgp_peer (empty = all)", + collectHint: "HF collect metrics; empty / “All sheets” uses every compare-template sheet.", + collectTitle: "High-freq collect subset", + defaults: "defaults (JSON)", + overrides: "sheet_overrides (JSON)", + overridesHint: "Optional per-metric status_fields / success patterns.", + globalPolicy: "Global policy", + outOfExpect: "Out-of-expect objects", + outOfExpectHint: "How to treat drift outside the batch expect set.", + ooeStrict: "Strict (red anomaly)", + ooeWarn: "Warn (yellow only, no red tickets)", + ooeIgnore: "Ignore (always not involved)", + sheetRules: "Per-sheet migrate rules", + applyAllPresets: "Apply recommended presets to all", + applyAllPresetsShort: "All presets", + sheetsUnit: " sheets", + sheetHowReadonly: "Compare template (read-only)", + iface: "Interface", + compare: "Compare", + presenceOnly: "presence only", + editInCompare: "Edit HOW", + presetAuto: "Recommended", + presetSkip: "Skip sheet", + skipDual: "Skip dual for this sheet", + skipDualHint: "Skipped in cutover evaluate (use a compare job for counters).", + statusFields: "Fields used for up/down", + statusFieldsHint: "Ports: admin/phy/prot; BGP: state; ARP: leave empty (presence only).", + statusOptionalHint: "With no status fields, only presence (gone/added) is used.", + downValues: "Down values", + upValues: "Up values", + noFields: "No fields (pick metrics on the compare template first)", + successPatterns: "Success rules (old OR-groups × new OR-groups)", + successPatternsShort: "Success", + successPatternsHint: + "Presence is whether the row exists. Value change binds a field and a strategy. AND inside a group, OR between groups.", + successPatternsHintShort: "AND in group · OR across groups · old+new both match ⇒ success", + successPatternsHintFree: + "Each side has two condition kinds: presence (no field) or value change (must bind a field). Inside a group everything must match (AND). Between groups, any group is enough (OR).", + anomalyPatternsShort: "Anomaly", + anomalyPatternsHint: + "Same shape as success. Match ⇒ red. Check “don’t care” on one side to watch only the other (e.g. new-side gone/down). Not-yet-success is still migrating, not anomaly.", + anomalyPatternsHintShort: "Success first; then anomaly. Don’t-care watches one side. Mid-state ≠ anomaly.", + successRow: "Success {{n}}", + anomalyRow: "Anomaly {{n}}", + removeRow: "Remove", + oldSideAny: "Old", + newSideAny: "New", + groupLogicHint: "AND inside a group, OR between groups", + sideDontCare: "Don’t care this side", + sideDontCareHint: "Judged by the other side only.", + groupN: "Group {{n}}", + addAnd: "Add to this group (AND)", + addOr: "Add another group (OR)", + addAndShort: "+ AND", + addOrShort: "+ OR group", + addSuccessRow: "+ Success rule", + addAnomalyRow: "+ Anomaly rule", + modeAny: "Match any", + modeAll: "Match all", + addCond: "Add condition", + pickField: "Pick field…", + condValue: "Value", + opEmpty: "empty", + opNotEmpty: "not empty", + opChanged: "changed vs baseline", + opUnchanged: "same as baseline", + op_eq: "equals", + op_ne: "not equals", + op_in: "in", + op_not_in: "not in", + op_empty: "empty", + op_not_empty: "not empty", + op_changed: "changed vs baseline", + op_unchanged: "same as baseline", + and: "and", + groupPresence: "Presence", + groupValue: "Value change", + groupStatus: "Status class", + groupField: "Field condition", + tokRemoved: "Gone", + tokAdded: "Added", + tokUnchanged: "Unchanged", + tokChanged: "Changed", + tokUp: "up", + tokDown: "down", + tokOther: "other", + or: "or", + tagSkip: "skip", + tagRules: "{{n}}", + tagEmpty: "empty", + needCompareFirst: "Pick a compare template that has sheets first.", + showMore: "More: note / HF collect / JSON", + hideMore: "Hide more", + showJson: "Advanced: edit JSON", + hideJson: "Back to visual editor", +}; + +export default bizMonitorTpl; diff --git a/web/src/i18n/locales/en/bizState.ts b/web/src/i18n/locales/en/bizState.ts new file mode 100644 index 0000000..361391c --- /dev/null +++ b/web/src/i18n/locales/en/bizState.ts @@ -0,0 +1,196 @@ +const bizState = { + title: "Business monitor", + loadedTasks: "Loaded tasks", + loadedBatches: "Recent batches · {{n}} loaded (up to 200)", + collectErrors: "Recent collection errors", + retry: "Retry", + taskLoading: "Loading task configuration…", + tasksLoadError: "Could not load tasks", + taskLoadError: "Could not load task configuration", + batchLoadError: "Could not load batch", + sheetLoadError: "Could not load structured data", + statusQueued: "Queued", + batchSuccess: "Collected", + batchPartial: "Partially completed", + batchFailed: "Collection failed", + batchCancelled: "Cancelled", + create: "Create task", + createHint: "Pick any valid NE (managed / UME); search supported", + createModeDevice: "Pick device", + createModeImport: "Import log", + createImportHint: + "No NE needed: upload a session log or zip to create an Import task and parse the first batch. Re-import later on the task to append batches (same as multiple collects).", + createImportSubmit: "Create & import", + created: "Task created", + createFailed: "Create failed", + empty: "No business-state tasks yet.", + pickNe: "Select NE…", + neKeywordPh: "Name / IP / ID", + allSource: "All sources", + selectedNe: "Selected", + neEmpty: "No matching NEs.", + colSource: "Source", + colVendor: "Vendor", + colConnect: "Connect", + start: "Start schedule", + pause: "Pause", + statusPaused: "Manual only", + scheduleEnabled: "Enable schedule", + scheduleOn: "Scheduled", + scheduleOff: "Manual", + scheduleHint: "When checked, collect on interval; otherwise only Collect now.", + collectNow: "Collect now", + importLog: "Import log", + importLogHint: + "Append a new batch to this task (same as one collect): full session or zip; discard noise before show/display; split on hostname#show; fail if no show (no SSH).", + importVendor: "Vendor", + importVendorGeneric: "Generic (show+display)", + importFile: "Log file", + importNeName: "Task name (optional)", + importNeNamePh: "e.g. PE1-offline", + importSubmit: "Import as new batch", + importing: "Importing", + importStarted: "Import started ({{count}} segments)", + importOfflineOnly: "Import tasks are offline-only (no SSH collect)", + sourceImport: "Import", + stopCollect: "Stop collect", + stopCollectOk: "Stop collect requested", + stopCollectIdle: "No collect in progress", + delete: "Delete", + confirmDelete: "Delete this task and all batches?", + deleted: "Deleted", + started: "Schedule enabled", + paused: "Schedule paused (manual only)", + collecting: "Collecting", + statusCollecting: "Collecting", + detail: "Details", + interval: "Interval", + intervalUnit: "Interval unit", + intervalUnitDays: "days", + intervalUnitHours: "hours", + intervalUnitSeconds: "seconds", + retention: "Retention days", + retentionHint: "Batches older than this are purged; baselines and compare/cutover refs are kept.", + dailyKeepEnabled: "Daily keep policy", + dailyKeepCount: "Keep per day", + dailyKeepHint: "When on: keep only the newest N batches per past day; extras purge next day (off by default).", + markBaseline: "Mark baseline", + unmarkBaseline: "Unmark baseline", + baseline: "Baseline", + protected: "Protected", + colAlias: "Alias", + setAlias: "Set alias", + aliasPrompt: "Batch alias (preferred in compare/cutover pickers; empty clears)", + aliasSaved: "Alias saved", + aliasEmpty: "Untitled", + workbookStats: "Compare {{compare}} · With data {{withData}} · Sheets {{sheets}}", + deleteBatch: "Delete", + bulkDelete: "Bulk delete", + confirmDeleteBatch: "Delete this batch? Protected batches cannot be deleted.", + confirmBulkDelete: "Delete {{count}} selected batches? Protected ones are skipped.", + batchDeleted: "Batch deleted", + bulkDeleted: "Deleted {{deleted}}, skipped {{skipped}}", + purgeNow: "Purge now", + purgeOk: "Purge done: dropped {{dropped}}", + selectAll: "Select deletable", + colProtect: "Protect", + saveSchedule: "Save schedule", + scheduleSaved: "Schedule saved", + colInterval: "Interval", + profiles: "Monitor items", + profilesSelectAll: "Select all", + profilesDeselectAll: "Deselect all", + profilesSelectAllOk: "Enabled {{n}} item(s)", + profilesDeselectAllOk: "All items disabled", + profilesSelectionStats: "Selected {{enabled}} / {{total}}", + profilesUnboundStats: "{{n}} unbound", + colLane: "Collect lane", + laneLight: "Light", + laneHeavy: "Heavy", + laneLightHint: "Shared SSH with most commands; ~10 min lane cap by default", + laneHeavyHint: "Dedicated SSH + longer timeouts; ~40 min lane cap by default", + enable: "On", + command: "Command template", + colAux: "Aux commands", + params: "Bindings", + discoverVrf: "Discover VRFs", + discoverBind: "Discover / Bind", + saveBindings: "Save bindings", + cancel: "Close", + bindTitle: "Select bindings", + unbound: "Not bound", + bindHintRequired: "Bind params before collect", + paramsDblClickHint: "Click to view all bindings", + paramsClickHint: "Click to view all bindings", + paramsDetailTitle: "Bound parameters", + paramsDetailCount: "{{n}} binding(s)", + discoverLoading: "Discovering…", + discoverEmpty: "No candidates discovered", + discoverCacheHit: "reused (no re-login)", + discoverRefresh: "Refresh from device", + selectAllVrfs: "Select all", + deselectAllVrfs: "Deselect all", + batches: "Batches", + viewBatch: "Open", + batchDetail: "Detail", + export: "Export", + exportCommands: "Export collect commands", + exportCommandsOk: "Collect commands exported", + exportRawLog: "Export as text", + exportRawLogOk: "Log exported", + noBatches: "No batches yet", + batchCollectDetail: "Collect detail", + batchWorkbook: "Batch workbook", + batchId: "Batch ID", + copyBatchId: "Copy batch ID", + batchMessageEmpty: "No message or error", + batchCmdSummary: + "{{ok}} ok / {{fail}} failed / {{skipped}} skipped / {{other}} other · {{total}} counted (batch {{batchCmds}} cmds)", + sheetCommands: "Commands", + sheetLldp: "LLDP neighbors", + sheetVrfRoute: "VRF route summary", + sheetIsis: "ISIS adjacency", + sheetIfaceBrief: "Interface brief", + sheetArp: "ARP", + sheetNd6: "ND6 cache", + sheetBgpPeer: "BGP peers", + sheetConfigIface: "Interface intent", + sheetConfigVrf: "VRF intent", + sheetConfigBgp: "BGP intent", + sheetFilterPh: "Filter this sheet…", + sheetEmpty: "No rows in this sheet", + sheetLoading: "Loading…", + collectCommand: "Collect command", + viewRawLog: "Raw log", + rawLogTitle: "Raw collect log", + rawLogEmpty: "No raw output", + rawLogStats: "{{lines}} lines collected · {{rows}} rows stored", + rawLogDeclared: "declared {{declared}}", + openWorkbook: "Open workbook", + filterColumn: "Column", + filterAllCols: "All columns", + listFilterPh: "Filter NE / IP / vendor / status", + purposeFilter: "Task purpose", + purposeAll: "All purposes", + purposePortrait: "Portrait", + purposeCutoverHf: "Cutover HF", + colPurpose: "Purpose", + colNe: "NE", + colStatus: "Status", + colLast: "Last collect", + colStarted: "Started", + colEnded: "Ended", + colTime: "Time", + colRows: "DB rows", + colRawLines: "CLI lines", + colDeclared: "Declared", + colActions: "Actions", + colCommand: "Command", + colMessage: "Message", + pickTask: "Open details to manage a task", + noNeedBind: "This item needs no parameter bindings", + discoverFailed: "Discover failed", + bindingsSaved: "Bindings saved", +}; + +export default bizState; diff --git a/web/src/i18n/locales/en/collect.ts b/web/src/i18n/locales/en/collect.ts new file mode 100644 index 0000000..80f2c69 --- /dev/null +++ b/web/src/i18n/locales/en/collect.ts @@ -0,0 +1,118 @@ +const collect = { + statusAll: "All statuses", + keywordPh: "Job title / ID", + exportJobsName: "collect-jobs", + exportRunsName: "collect-runs", + policyTitle: "Collect policy", + policyHint: + "Periodic schedule is off by default (one-shot / manual). Enable it to auto-create and run jobs on an interval. Keeps the last 3 finished jobs by default.", + policySaved: "Policy saved", + enabled: "Enable scheduled collect", + interval: "Collect interval", + intervalUnitDays: "days", + intervalUnitHours: "hours", + historyKeep: "Jobs to keep", + scope: "Scope", + scopeAll: "All NEs (managed + UME)", + scopeSelected: "Selected NEs only", + savePolicy: "Save policy", + collectNow: "Collect now", + selectedCount: "{{count}} selected", + colSource: "Source", + policyCommandsRequired: "Add commands and save the policy before enabling schedule or collecting now", + policyTargetsRequired: "No eligible NEs in scope; adjust scope or select NEs", + create: { + title: "New collection job", + hint: "Configure commands and NEs, then create the job. Click Start in the job list to run collection.", + selectedTitle: "Selected NEs", + selectedEmpty: "Filter below and add NEs one by one.", + selectedCount: "{{count}} selected", + clearSelected: "Clear selection", + remove: "Remove", + pickTitle: "Eligible NEs", + pickHint: "All managed and UME inventory NEs are selectable (any connect status). UME uses CLI connect profile credentials.", + pickEmpty: "No matching NEs.", + filterKeyword: "Filter", + filterKeywordPh: "Name / IP / vendor / device type", + add: "Add", + addBatch: "Add selected ({{count}})", + added: "Added", + create: "Create job", + creating: "Creating…", + expand: "New", + collapse: "Close", + meta: "{{ne}} NE(s) selected · {{cmd}} command(s)", + }, + eligible: { + title: "Eligible NEs", + hint: "All managed and UME inventory NEs are selectable (any connect status).", + empty: "No NEs.", + }, + form: { + title: "Collection setup", + jobTitle: "Job title", + jobTitlePh: "Auto-generated if empty", + commands: "Commands (one per line, # for comments)", + commandsPh: "display version\ndisplay current-configuration", + commandsHint: "Commands run in order on each device; output is saved as a text log.", + meta: "{{ne}} NE(s) selected · {{cmd}} command(s)", + start: "Start collection", + starting: "Submitting…", + }, + created: "Collection job created: {{id}}", + started: "Collection started: {{id}}", + paused: "Job paused", + restarted: "Collection restarted", + retryFailedDone: "Retrying failed devices", + deleted: "Job deleted", + nothingToRetry: "No failed devices to retry", + neBusy: "Some NEs are busy in another collection job; wait for it to finish before starting", + jobRunning: "This job is already running", + confirmDelete: "Delete this collection job and its log files?", + jobs: { + title: "Collection jobs", + listTitle: "Job list", + col: { + title: "Job", + status: "Status", + progress: "Progress", + created: "Created", + lastRun: "Last run", + actions: "Actions", + }, + ok: "ok", + fail: "fail", + expand: "Details", + collapse: "Hide", + detailTitle: "Job details", + pause: "Pause", + start: "Start", + starting: "Starting…", + restart: "Restart", + retryFailed: "Retry failed", + downloadResults: "Download results", + delete: "Delete", + autoRefresh: "Auto-refresh every 2s while jobs are running", + runsInProgress: "Some devices are still being collected…", + }, + kpi: { + jobs: "Jobs", + running: "In progress", + idle: "Idle", + last: "Last job", + active: "Active jobs", + nextDue: "Next due", + }, + runs: { + status: "Status", + message: "Message", + download: "Log", + downloadFile: "Download", + filterStatus: "Status filter", + filterDevice: "Device filter", + deviceKeywordPh: "Name / IP", + allStatus: "All statuses", + }, +}; + +export default collect; diff --git a/web/src/i18n/locales/en/common.ts b/web/src/i18n/locales/en/common.ts new file mode 100644 index 0000000..0c33504 --- /dev/null +++ b/web/src/i18n/locales/en/common.ts @@ -0,0 +1,25 @@ +const 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}}", + jumpPage: "Go to page", + pageSize: "Page size", + exportCsv: "Export CSV", + exporting: "Exporting…", + exportOk: "Exported {{count}} rows", + exportTruncated: "Exported first {{count}} of {{total}} rows (truncated)", + exportFailed: "Export failed", + opFailed: "Operation failed", + empty: "-", + cancel: "Cancel", + copy: "Copy", + copied: "Copied", +}; + +export default common; diff --git a/web/src/i18n/locales/en/configSync.ts b/web/src/i18n/locales/en/configSync.ts new file mode 100644 index 0000000..d17d5b7 --- /dev/null +++ b/web/src/i18n/locales/en/configSync.ts @@ -0,0 +1,58 @@ +const configSync = { + title: "Config sync", + syncNow: "Sync now", + retryFailed: "Retry failed", + pause: "Pause", + resume: "Resume", + stop: "Stop", + confirmStop: "Stop this cycle and cancel remaining NE tasks?", + started: "Sync cycle started", + paused: "Paused", + resumed: "Resumed", + stopped: "Sync cycle stopped", + policyTitle: "Sync policy", + policySaved: "Policy saved", + enabled: "Enable scheduled sync", + intervalDays: "Interval (days)", + concurrency: "Concurrency", + historyKeep: "Config versions to keep", + cycleKeep: "Jobs to keep", + scope: "Scope", + scopeAll: "All NEs", + scopeSelected: "Selected NEs", + savePolicy: "Save policy", + selectedCount: "{{count}} selected", + targetKeywordPh: "Name / IP / vendor", + cyclesTitle: "Sync cycles", + cyclesEmpty: "No sync cycles yet", + tasksTitle: "Cycle tasks", + taskKeywordPh: "Name / IP / message", + keywordPh: "ID / trigger / message", + statusAll: "All statuses", + allStatus: "All statuses", + exportCyclesName: "config-sync-cycles", + exportTasksName: "config-sync-tasks", + expand: "Details", + collapse: "Collapse", + kpi: { + snapshots: "Snapshots", + running: "In progress", + idle: "Idle", + last: "Last cycle", + nextDue: "Next due", + }, + col: { + source: "Source", + name: "Device", + vendor: "Vendor", + trigger: "Trigger", + status: "Status", + progress: "Progress", + started: "Started", + ended: "Ended", + message: "Message", + actions: "Actions", + }, +}; + +export default configSync; diff --git a/web/src/i18n/locales/en/index.ts b/web/src/i18n/locales/en/index.ts new file mode 100644 index 0000000..6cd476f --- /dev/null +++ b/web/src/i18n/locales/en/index.ts @@ -0,0 +1,48 @@ +// en messages, one file per namespace (key prefix of t("ns.key")). +import common from "./common"; +import workbench from "./workbench"; +import network from "./network"; +import bizState from "./bizState"; +import bizCompare from "./bizCompare"; +import bizMigration from "./bizMigration"; +import bizMonitorTpl from "./bizMonitorTpl"; +import portTraffic from "./portTraffic"; +import lldpLinks from "./lldpLinks"; +import configSync from "./configSync"; +import networkDevices from "./networkDevices"; +import networkConfigs from "./networkConfigs"; +import layout from "./layout"; +import auth from "./auth"; +import collect from "./collect"; +import managedNe from "./managedNe"; +import ume from "./ume"; +import webcrt from "./webcrt"; +import topology from "./topology"; +import topoClassify from "./topoClassify"; +import audit from "./audit"; + +const en = { + common, + workbench, + network, + bizState, + bizCompare, + bizMigration, + bizMonitorTpl, + portTraffic, + lldpLinks, + configSync, + networkDevices, + networkConfigs, + layout, + auth, + collect, + managedNe, + ume, + webcrt, + topology, + topoClassify, + audit, +}; + +export default en; diff --git a/web/src/i18n/locales/en/layout.ts b/web/src/i18n/locales/en/layout.ts new file mode 100644 index 0000000..1328329 --- /dev/null +++ b/web/src/i18n/locales/en/layout.ts @@ -0,0 +1,26 @@ +const layout = { + title: "UME Integration", + titleUme: "UME Sync", + titleNetwork: "Network Management", + titleManagedNe: "NE Management", + titleCollect: "Batch Collect", + titleWebcrt: "WebCRT", + titleTopology: "Topology", + titlePortTrafficWall: "Traffic wall", + titleUsers: "Users", + titleAudit: "Audit", + titleApiKeys: "API Keys", + titleSessions: "Login sessions", + navUme: "UME", + netxApi: "netx api", + oclawBridge: "oclaw WSS", + activeTasks: "Active {{count}}", + activeTasksHint: "Open Audit · Task overview", + language: "Language", + switchLanguage: "Switch language / 切换语言", + moreMenu: "More", + langZh: "中文", + langEn: "English", +}; + +export default layout; diff --git a/web/src/i18n/locales/en/lldpLinks.ts b/web/src/i18n/locales/en/lldpLinks.ts new file mode 100644 index 0000000..1a6b5de --- /dev/null +++ b/web/src/i18n/locales/en/lldpLinks.ts @@ -0,0 +1,110 @@ +const lldpLinks = { + title: "LLDP links", + collectNow: "Collect now", + retryFailed: "Retry failed", + started: "LLDP collect started", + pause: "Pause", + resume: "Resume", + stop: "Stop", + confirmStop: "Stop this job and skip remaining NEs?", + paused: "Paused", + resumed: "Resumed", + stopped: "Collect stopped", + policyTitle: "Collect policy", + policySaved: "Policy saved", + enabled: "Enable scheduled collect", + autoAddUnmatched: "Auto-add unmatched neighbors as placeholders on discover", + autoAddUnmatchedHint: + "Shared by scheduled collect and canvas discover. Off = record unmatched only, no placeholder NE. Default on.", + intervalDays: "Interval (days)", + interval: "Collect interval", + intervalUnitDays: "days", + intervalUnitHours: "hours", + concurrency: "Concurrency", + historyKeep: "Jobs to keep", + scope: "Scope", + scopeAll: "All NEs (managed + UME)", + scopeSelected: "Selected NEs", + savePolicy: "Save policy", + selectedCount: "{{count}} selected", + targetKeywordPh: "Name / IP / vendor", + jobsTitle: "Collect jobs", + jobDetailTitle: "Job details", + expand: "Details", + edgesTitle: "Fabric links", + edgeKeywordPh: "Endpoint name or IP", + edgeStatusAll: "All statuses", + edgeStatusActive: "Active", + edgeStatusMissing: "Missing", + edgeSourceAll: "All sources", + exportEdgesName: "lldp-fabric-edges", + exportJobsName: "lldp-collect-jobs", + exportItemsName: "lldp-job-items", + statusAll: "All statuses", + jobKeywordPh: "Job ID / error / scope", + itemKeywordPh: "NE name / IP / error", + itemResultAll: "All results", + missCount: "Miss cycles", + replacedBy: "Replaced", + kpi: { + nodes: "Fabric nodes", + edges: "Active links", + missing: "Missing", + running: "In progress", + idle: "Idle", + last: "Last job", + nextDue: "Next due", + }, + col: { + source: "Source", + name: "Device", + vendor: "Vendor", + trigger: "Trigger", + scope: "Scope", + status: "Status", + progress: "Progress", + edges: "Edges", + missingDelta: "Missing/purged", + started: "Started", + ended: "Ended", + neighbors: "Neighbors", + error: "Error", + aSide: "Side A", + bSide: "Side B", + aPort: "Port A", + bPort: "Port B", + lastSeen: "Last seen", + }, + jobStatus: { + running: "Running", + pending: "Pending", + paused: "Paused", + done: "Done", + failed: "Failed", + cancelled: "Cancelled", + stopped: "Stopped", + }, + triggerMode: { + manual: "Manual", + schedule: "Scheduled", + topology: "Topology canvas", + retry_failed: "Retry failed", + }, + jobScope: { + all: "All NEs", + ne_ids: "Selected NEs", + selected: "Selected NEs", + }, + itemResult: { + ok: "OK", + warn: "Warn", + fail: "Fail", + }, + edgeSource: { + lldp: "LLDP", + manual: "Manual", + stale: "Stale", + }, +}; + +export default lldpLinks; diff --git a/web/src/i18n/locales/en/managedNe.ts b/web/src/i18n/locales/en/managedNe.ts new file mode 100644 index 0000000..f208510 --- /dev/null +++ b/web/src/i18n/locales/en/managedNe.ts @@ -0,0 +1,202 @@ +const managedNe = { + title: "Network Elements", + credsNotConfigured: + "NETX_CREDENTIAL_SECRET_KEY is not set. Configure a Fernet key in .env and restart the API to store passwords.", + add: "Add NE", + importBtn: "Import", + downloadTemplate: "Download template", + importing: "Importing…", + edit: "Edit", + delete: "Delete", + openTerminal: "Terminal", + confirmDelete: + "Delete this network element? If its Fabric node is managed-only (no UME binding), that node and its direct links will also be removed.", + batchDelete: "Batch delete", + batchDeleting: "Deleting…", + batchDeleteConfirm: + "Delete {{n}} selected network element(s)? Cannot be undone. Managed-only Fabric nodes cascade (node + direct links); nodes still bound to UME keep topology.", + batchDeleteDone: "Deleted {{n}} network element(s)", + batchDeleteSelectRequired: "Select network elements to delete", + keywordPh: "name / IP / user / tags", + allVendors: "All vendors", + allConnectStatus: "All connect status", + stats: { + title: "Overview", + total: "{{n}} NEs managed", + pass: "Connected", + fail: "Failed", + unknown: "Untested", + testing: "Testing", + batchProxy: "Batch add proxy", + batchTest: "Batch connect-test", + tagFilter: "Filter by tag", + all: "All", + allTag: "All (no tag filter)", + noTag: "No tags", + tagStats: "Top tags", + confirm: "Apply to {{n}} NE(s). Continue?", + proxyDone: "Proxy applied to {{n}} NE(s)", + testDone: "Connect-test submitted for {{n}} NE(s)", + loadingIds: "Loading…", + }, + col: { + name: "Name", + vendor: "Vendor", + deviceType: "Device type", + execPolicy: "CLI exec policy", + source: "Source", + ip: "Management IP", + port: "Port", + protocol: "Protocol", + user: "Username", + password: "Password", + tags: "Tags", + remark: "Remark", + connect: "Connect", + testedAt: "Tested at", + actions: "Actions", + }, + execPolicy: { + readonly: "Read-only (show/display/ping)", + linuxShell: "Shell / script (multiline / heredoc OK)", + unrestricted: "Lab open (multiline / heredoc OK)", + hint: "Applies to MCP/API execManagedNe only. Default is read-only; Linux and MikroTik (routeros/switchos) may use shell / lab-open for multiline scripts and heredoc.", + }, + source: { + manual: "Manual", + ume_sync: "UME sync", + webcrt: "WebCRT", + lldp: "LLDP placeholder", + topology: "Topology placeholder", + }, + connect: { + run: "Connectivity test", + running: "Testing…", + submitted: "Submitted tests for {{n}} device(s)", + retest: "Test again", + }, + connectDetail: "Details", + connectDetailTitle: "Connectivity test log", + connectDetailEmpty: + "No log yet. Run a connectivity test first; failures store full errors and hop context (passwords excluded).", + umeSync: { + sync: "Sync UME NEs", + syncing: "Syncing…", + delete: "Delete UME NEs", + deleting: "Deleting…", + deleteConfirm: + "Delete all managed NEs created from UME sync? Cannot be undone. Fabric nodes that become fully orphaned (no UME left) are removed with their direct links.", + done: "UME sync done: {{inserted}} inserted, {{updated}} updated, {{deleted}} deleted, {{total}} inventory total", + deletedDone: "Deleted {{n}} UME-synced NEs", + }, + account: { + batchAdd: "Batch add account", + batchByTag: "Batch account by tag", + batchTitle: "Batch add account", + batchHint: "Apply the account info below to {{n}} selected NE(s).", + batchByTagHint: "Apply the account info below to NEs under the selected tag; leave password blank to update only username.", + batchDone: "Account updated for {{n}} NE(s)", + selectRequired: "Select network elements first", + applying: "Applying…", + apply: "Apply to NEs", + usernameOrPasswordRequired: "Enter at least a username or a password", + passwordOptionalBatch: "leave blank to keep unchanged", + }, + help: + "[Bulk import]\n" + + "· Required: device_type, ip, username, port, protocol, name, vendor (download template first)\n" + + "· password may be empty for bastion-managed / later Batch add proxy; required for direct login\n" + + "· Flow: import → select rows → Batch add proxy\n\n" + + "[Jump / bastion]\n" + + "· Username template: {hop_user}@{target_user}@{target_ip} (bastion host filled separately)\n" + + "· Managed mode: Jump password (Vault); target password optional. Manual mode needs target password\n" + + "· JumpServer/CBH ports often 2222; some sites use 22\n\n" + + "[Connectivity / edit]\n" + + "· Storing passwords needs NETX_CREDENTIAL_SECRET_KEY\n" + + "· Blank password on edit keeps current; use Details hop context after failed connect (no secrets)", + importResult: { + done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)", + }, + form: { + requiredHint: "Fields marked with * are required", + nameConnectHint: "After a successful connectivity test, name is replaced with the device hostname", + createTitle: "Add network element", + editTitle: "Edit network element", + save: "Save", + saving: "Saving…", + cancel: "Cancel", + created: "Network element created", + updated: "Network element updated", + deleted: "Network element deleted", + passwordRequired: "Password is required", + passwordOptional: "leave blank to keep unchanged", + }, + hop: { + sectionTitle: "Jump host / proxy", + type: "Jump type", + enable: "Connect to target via jump host", + vendor: { + zte: "ZTE device (CLI jump)", + huawei: "Huawei device (CLI jump)", + cisco: "Cisco device (CLI jump)", + linux: "Linux server (SSH tunnel)", + bastion: "Bastion host (SSH protocol proxy)", + }, + zteHint: "Run ssh/telnet on the ZTE device to reach the target; target credentials use secondary auth.", + huaweiHint: "Run telnet / stelnet (SSH) on the Huawei hop; stelnet is SSH. Target credentials use secondary auth.", + ciscoHint: "Run Cisco ssh -vrf / telnet /vrf jump commands; target credentials use secondary auth.", + linuxHint: "SSH to the Linux bastion, then direct-tcpip tunnel to target IP:port (ProxyJump-style).", + bastionHint: + "SSH with composite username via bastion protocol proxy. Hop host may be an IP or FQDN. Examples: bastion-user@target-user@198.51.100.20@192.0.2.10 or bastion-user@target-user@198.51.100.20@ssh-bastion.example.com; password is the bastion/Vault password. JumpServer/CBH often use port 2222.", + targetAuthMode: "Target credentials", + targetAuthBastionManaged: "Bastion-managed (target password optional)", + targetAuthManual: "Manual (secondary auth after connect)", + targetAuthHint: "Bastion-managed needs only bastion password; manual mode requires target NE password.", + usernameTemplate: "SSH username template", + templateHintBastion: + "Default {hop_user}@{target_user}@{target_ip}. Bastion address (IP or FQDN) goes in Jump host. CLI example: ssh bastion-user@target-user@198.51.100.20@ssh-bastion.example.com.", + host: "Jump host", + hostPlaceholderBastion: "192.0.2.10 or ssh-bastion.example.com", + hostHintBastion: + "IP or FQDN. You can paste a full SSH destination (e.g. bastion-user@target-user@198.51.100.20@ssh-bastion.example.com); on blur it splits into jump username + host.", + port: "Jump port", + protocol: "Jump protocol", + protocolSshStelnet: "ssh (stelnet)", + username: "Jump username", + password: "Jump password", + vrf: "Mgmt VRF (optional)", + vpnInstance: "VPN-Instance (optional)", + vrfCisco: "VRF (optional, e.g. MGMT)", + enterSystemView: "Huawei system-view before jump", + enterSystemViewNo: "Stay in user-view (default)", + enterSystemViewYes: "Enter system-view first", + enterSystemViewHint: + "Some Huawei hops only accept stelnet/telnet in system-view. Choose explicitly; NetX will not auto-retry.", + commandTemplate: "Jump command template", + templateHint: + "ZTE CLI: telnet {target_ip}, telnet {target_ip} vrf {vrf}, ssh {target_ip}, ssh {target_ip} vrf {vrf}. Auto-suggested from jump protocol/VRF; same when left blank. Target credentials via secondary auth prompts.", + templateHintHuawei: + "Huawei CLI: telnet {target_ip}, telnet vpn-instance {vrf} {target_ip}, stelnet {target_ip}, stelnet {target_ip} -vpn-instance {vrf}. SSH protocol maps to stelnet. Auto-suggested when left blank.", + templateHintCisco: + "Cisco CLI: ssh {target_ip}, ssh -vrf {vrf} {target_ip}, telnet {target_ip}, telnet {target_ip} /vrf {vrf}. Auto-suggested when left blank.", + badge: { + zte: "ZTE hop", + huawei: "Huawei hop", + cisco: "Cisco hop", + linux: "Linux hop", + bastion: "Bastion", + }, + hostRequired: "Jump host is required", + userRequired: "Jump username is required", + passwordRequired: "Jump password is required", + batchAdd: "Add proxy", + batchTitle: "Batch add jump proxy", + batchHint: "Apply the jump-host settings below to {{n}} selected NE(s) (overwrites existing proxy config).", + batchDone: "Proxy applied to {{n}} NE(s)", + selectRequired: "Select network elements first", + applying: "Applying…", + apply: "Apply to selected", + }, +}; + +export default managedNe; diff --git a/web/src/i18n/locales/en/network.ts b/web/src/i18n/locales/en/network.ts new file mode 100644 index 0000000..ac87f4c --- /dev/null +++ b/web/src/i18n/locales/en/network.ts @@ -0,0 +1,33 @@ +const network = { + title: "Network Management", + nav: { + detail: "Network details", + tasks: "Monitoring tasks", + cutoverCompare: "Cutover compare", + topologyMgmt: "Topology management", + devices: "Device list", + topology: "Topology", + lldpLinks: "LLDP", + topoClassify: "Classify", + alarms: "Alarms", + configs: "Configurations", + webcrt: "WebCRT", + collectTasks: "Collection tasks", + configSync: "Config sync", + portTraffic: "Port traffic", + portTrafficWall: "Traffic wall", + bizState: "Business monitor", + bizCompare: "Business compare", + bizMigration: "Cutover monitor", + compareTemplates: "Compare templates", + monitorTemplates: "Monitor templates", + }, + collapseNav: "Collapse sidebar", + expandNav: "Expand sidebar", + placeholder: { + title: "Coming soon", + body: "This capability is not available yet.", + }, +}; + +export default network; diff --git a/web/src/i18n/locales/en/networkConfigs.ts b/web/src/i18n/locales/en/networkConfigs.ts new file mode 100644 index 0000000..ee390fc --- /dev/null +++ b/web/src/i18n/locales/en/networkConfigs.ts @@ -0,0 +1,24 @@ +const networkConfigs = { + title: "Configurations", + keywordPh: "Name / IP / ID", + vendorPh: "Vendor", + allSource: "All sources", + empty: "No synced configs yet. Run Config sync first.", + view: "View", + export: "Export", + exportBoth: "Export all", + exportOk: "Config exported", + exportListName: "network-configs", + close: "Close", + tabSet: "Set format", + tabHier: "Hierarchical", + col: { + name: "Device", + vendor: "Vendor", + source: "Source", + size: "Size", + collected: "Collected", + }, +}; + +export default networkConfigs; diff --git a/web/src/i18n/locales/en/networkDevices.ts b/web/src/i18n/locales/en/networkDevices.ts new file mode 100644 index 0000000..bc31e5d --- /dev/null +++ b/web/src/i18n/locales/en/networkDevices.ts @@ -0,0 +1,17 @@ +const networkDevices = { + title: "Device list", + keywordPh: "Name / IP / ID", + allSource: "All sources", + empty: "No network elements.", + exportName: "network-devices", + col: { + source: "Source", + name: "Name", + vendor: "Vendor", + deviceType: "Device type", + connect: "Connect", + actions: "Actions", + }, +}; + +export default networkDevices; diff --git a/web/src/i18n/locales/en/portTraffic.ts b/web/src/i18n/locales/en/portTraffic.ts new file mode 100644 index 0000000..cca8188 --- /dev/null +++ b/web/src/i18n/locales/en/portTraffic.ts @@ -0,0 +1,207 @@ +const portTraffic = { + title: "Port traffic", + create: "Monitor device", + backList: "Back to list", + created: "Device monitoring added", + createFailed: "Create failed", + updated: "Saved", + interfacesSaved: "Interfaces updated", + started: "Started", + paused: "Paused", + stopped: "Stopped", + statusRunning: "Running", + statusPaused: "Paused", + statusStopped: "Stopped", + statusDraft: "Draft", + collecting: "Collecting", + deleted: "Deleted", + confirmDelete: "Delete this device monitoring and its samples?", + empty: "No devices monitored yet. Pick a NE and interfaces to start.", + keywordPh: "Name / IP / note / ID", + allStatus: "All statuses", + exportDevicesName: "port-traffic-devices", + exportEventsName: "port-traffic-events", + wall: "Ops wall", + wallTitle: "Port traffic wall", + toDevices: "Manage devices", + wallEmpty: "No monitored devices yet. Add a device and pick interfaces first.", + fullscreen: "Fullscreen", + exitFullscreen: "Exit fullscreen", + board: "Board", + boardPick: "Select board", + boardPickHint: "Select or create a traffic wall board.", + boardEmpty: "No boards yet. Create one for cutover or duty monitoring.", + boardDefaultName: "Untitled board", + boardName: "Name", + boardCols: "Columns", + boardCreate: "New board", + boardEdit: "Edit layout", + boardSave: "Save", + boardCancel: "Cancel", + boardSaved: "Board saved", + boardCreated: "Board created", + boardDeleted: "Board deleted", + boardConfirmDelete: "Delete this board and its panels?", + boardUnsavedLeave: "You have unsaved changes. Leave anyway?", + boardOpenTab: "Open in new tab", + boardOpen: "Open wall", + boardListHint: "Open a board in its own browser tab; launching Network from the workbench will not refresh open walls.", + boardPanelCount: "Panels", + boardUpdated: "Updated", + boardClosedHint: "You can close this tab, or reopen from Network → Traffic wall list.", + boardAddPanel: "Add panel", + boardNoPanels: "No panels yet. Edit the board and add interfaces.", + boardPickDevice: "Select device", + boardPanelSettings: "Panel settings", + boardPanelTitle: "Title (optional)", + boardColSpan: "Column span", + boardPanelStale: "Interface missing or inactive. Please reselect.", + boardRetarget: "Reselect port", + edit: "Edit", + save: "Save", + start: "Start", + pause: "Pause", + stop: "Stop", + delete: "Delete", + wizStep1: "Device", + wizStep2: "Interfaces", + wizStep3: "Params", + step1: "Params", + step2: "NEs", + step3: "Interfaces", + step4: "Confirm", + prev: "Back", + next: "Next", + fieldTitle: "Task name", + titlePh: "e.g. Uplink group", + titleRequired: "Task name is required", + portsRequired: "Select at least one interface", + interval: "Interval (sec)", + retention: "Retention (days)", + retentionHintWizard: "Use ≥8 days for week-over-week compare", + concurrency: "Concurrency", + note: "Note", + notePh: "Optional", + startNow: "Start after create", + neKeywordPh: "Name / IP / vendor", + deepLinkHint: "Opened from topology: NE {{ne}}{{ifname}}. Confirm selection, then continue.", + deepLinkIfname: ", interface {{ifname}}", + selectedNe: "{{count}} NE(s) selected", + selectedOneNe: "Selected", + selectedPorts: "{{count}} interface(s) selected", + pickNeFirst: "Select one NE first", + discover: "Discover interfaces", + discoverDetail: "Discover detail", + discoverCommand: "Command", + discoverRaw: "Raw log", + discoverOk: "Discover succeeded", + discoverFail: "Discover failed", + confirmCreate: "Start monitoring", + wallTask: "Task", + wallPort: "Interface", + wallDevice: "Device", + wallDeviceLabel: "Device", + pickPort: "Select interface", + range: "Range", + compare: "Compare", + compareOff: "Off", + compareDay: "Yesterday (same TOD)", + compareWeek: "Last week (same weekday)", + compareShift: "Previous window", + compareCustom: "Custom offset", + offsetHours: "Offset (hours)", + aheadHours: "Extend past now (hours)", + aheadHoursHint: + "Push the chart end past now. With period compare enabled, the overlay can show trend after the current clock; 0 ends at now.", + mapBaselineHint: + "Mapped baseline can be combined with period compare; turn on Yesterday yourself when you need that contrast.", + baseline: "Baseline", + baselineAt: "Baseline time", + baselineDevice: "Baseline device", + baselinePort: "Baseline interface", + seriesCurrentIn: "Current In", + seriesCurrentOut: "Current Out", + seriesBaselineIn: "Baseline In", + seriesBaselineOut: "Baseline Out", + mapBaselinePort: "Mapped baseline interface", + mapBaselineTask: "Mapped baseline task", + mapBaselineNone: "None", + retentionHint: "Week compare needs retention_days ≥ 8 or the baseline may be empty.", + collectNow: "Collect now", + collectStarted: "Collect triggered", + collectBusy: "Collect already running", + targetError: "Last interface error", + waitSamples: "Samples appear after the device runs; or click Collect now. Chart auto-refreshes.", + waitAfterError: "Collect failed — open Logs under Traffic monitoring for details, then Collect now.", + log: "Logs", + logTitle: "Collect logs", + logEmpty: "No log entries yet.", + logDevice: "Device", + logLevel: "Level", + logTime: "Time", + logMessage: "Message", + logClose: "Close", + logHasError: "Has error", + rebind: "Rebind NE", + rebindTitle: "Rebind network element", + rebindOk: "Rebound to selected NE; collect can resume", + rebindFailed: "Rebind failed", + rebindHint: + "Pick the correct NE and confirm. IPs may not be unique, and a matching IP does not mean the same device. History is kept.", + rebindCurrent: "Current binding", + rebindEmpty: "No NEs found — add the device back under inventory first.", + rebindSameIp: "Same IP", + rebindSameIpHint: "IP matches current monitor — still verify name and ID", + rebindSubmit: "Confirm rebind", + rebindConfirm: "Bind monitoring to {{ne}}? History samples are kept.", + err: { + managedNeNotFound: + "Managed NE not found (often deleted then re-created with a new id). Use Rebind NE and pick manually.", + targetErrors: "{{count}} interface(s) failed", + unsupportedVendor: "Unsupported vendor / device type", + invalidSource: "Invalid NE source", + rebindPickFirst: "Select an NE first", + rebindNeMissing: "Selected NE not found — refresh and try again", + rebindSourceMismatch: "NE source does not match this monitor device", + alreadyMonitored: "That NE is already monitored by another device entry", + }, + kpiLatest: "latest", + kpiBw: "Bandwidth", + latestAt: "Latest sample", + hoverPoint: "Cursor", + wallLiveTitle: "Live traffic", + filterView: "View", + filterMap: "Mapped baseline", + yMode: "Y axis", + yModeAuto: "Auto (incl. baseline)", + yModeCurrent: "Fit current", + yModeUtil: "Utilization %", + kpi: { + tasks: "Tasks", + devices: "Devices", + running: "Running", + ports: "Interfaces", + samples24h: "Samples 24h", + }, + col: { + title: "Task", + device: "Device", + status: "Status", + ports: "Interfaces", + interval: "Interval", + last: "Last collect", + actions: "Actions", + source: "Source", + name: "Device", + vendor: "Vendor", + ifname: "Interface", + bw: "BW", + upDown: "Admin / Phy / Prot", + admin: "Admin", + phy: "Phy", + prot: "Prot", + description: "Description", + }, +}; + +export default portTraffic; diff --git a/web/src/i18n/locales/en/topoClassify.ts b/web/src/i18n/locales/en/topoClassify.ts new file mode 100644 index 0000000..b6d5283 --- /dev/null +++ b/web/src/i18n/locales/en/topoClassify.ts @@ -0,0 +1,69 @@ +const topoClassify = { + title: "Classify", + openTopo: "Topology", + inventory: "NE inventory", + keywordPh: "Filter by name/IP", + filterLevelAll: "All levels", + filterRegionAll: "All regions", + filterUnmatchedOff: "No unmatched filter", + kindAny: "Missing level or region", + kindLevel: "Missing level", + kindRegion: "Missing region", + filterLinkAll: "All link states", + filterLinkLinked: "Linked inventory", + filterLinkOrphaned: "Detached", + colLink: "Link", + linkManaged: "Managed", + linkUme: "UME", + linkBoth: "Managed+UME", + linkOrphaned: "Detached", + levelExternal: "L0 external", + levelCore: "L1 core", + levelCoreSub: "L1.1 core′", + levelAgg: "L2 aggregation", + levelAggSub: "L2.1 agg′", + levelAccess: "L3 access", + levelAccessSub: "L3.1 deeper access", + levelInvalid: "Invalid level (0–99.9, one decimal)", + sourceManual: "Manual", + sourceUmeSync: "UME sync", + sourceLldp: "LLDP placeholder", + sourceTopology: "Topology placeholder", + sourceWebcrt: "WebCRT", + regexTitle: "Regex find", + patternPh: "Regex, e.g. ^CORE- or -EDGE-", + fieldName: "Name", + fieldNameIp: "Name+IP", + findMatches: "Find matches", + matchOk: "Matched {{count}}; selected", + matchHint: "{{count}} matched", + assignLevel: "Level only", + assignRegion: "Region only", + assignBoth: "Level + region", + clearRegion: "Clear region", + confirmAssign: "Confirm assign", + bulkConfirm: "Write tags to {{count}} selected/matched NE(s)?", + bulkOk: "Updated {{count}}", + selectedCount: "{{count}} selected", + selectPage: "Select page", + colNe: "NE", + colLevel: "Level", + colRegion: "Region", + colActions: "Actions", + edit: "Edit", + save: "Save", + cancel: "Cancel", + delete: "Delete", + deleteSelected: "Delete nodes ({{count}})", + deleteConfirm: + "Delete {{count}} fabric node(s) (placements and edges)? Managed/UME inventory is not touched.", + deleteOk: "Deleted {{count}} node(s)", + deleteHint: + "Only orphaned or LLDP/WebCRT placeholder fabric nodes can be deleted; UME-only and real managed NEs cannot", + deleteBlocked: "UME-only or still linked to real managed inventory — cannot delete here", + rowSaved: "Saved", + loading: "Loading…", + empty: "No NEs.", +}; + +export default topoClassify; diff --git a/web/src/i18n/locales/en/topology.ts b/web/src/i18n/locales/en/topology.ts new file mode 100644 index 0000000..495fcd1 --- /dev/null +++ b/web/src/i18n/locales/en/topology.ts @@ -0,0 +1,344 @@ +const topology = { + title: "Topology", + maps: "Maps", + tree: "Topology tree", + treeLoading: "Loading tree…", + treeLoadFailed: "Failed to load topology tree", + treeRetry: "Retry", + graphLoading: "Loading topology…", + graphLoadFailed: "Failed to load topology", + graphRefreshing: "Refreshing…", + canvasEmpty: "No NEs on this map yet", + canvasEmptyHint: "Use Add NE, or pick a region that already has devices.", + treeHint: "Top level is a Root (nav only); its unique Root Map is the canvas. Open the root map or a sub-region to edit devices.", + treeSearch: "Search NEs across maps", + treeSearchPh: "Name / IP", + treeSearchEmpty: "No matching NEs", + treeSearchNoViews: "Not on any map", + treeSearchNotOnMap: "This NE is not placed on any topology map yet", + locateNotOnCanvas: + "This NE is missing from the world-map sample and has no world coordinates. Open a region map instead.", + treeSearchViewCount: "{{count}} map(s)", + treeSearchTruncated: "Showing first 30 of {{total}}", + rootName: "Roots", + selectRegion: "Select a root or region", + openMap: "Open map", + openRegion: "Open", + kindPhysical: "Physical", + kindCustom: "Custom", + layerNodeHint: "{{count}} NE(s)", + layerNodeEmpty: "Click to create & open", + rootCanvasHint: "Pick a root from the left tree.", + regionCanvasHint: "Open the root map to edit devices; create sub-regions on that canvas.", + regionBrowseHint: "Root folder: unique root map below. Click to open.", + browserTitle: "Topology folder", + browserRegionSub: "{{count}} map(s)", + browserRegionsSub: "{{count}} item(s)", + browserEmptyMaps: "No root map under this root yet.", + addNe: "Add NE", + more: "More", + backUp: "Back", + newRegion: "New root", + newRegionName: "New root", + newRegionPrompt: "Root name", + newSubRegion: "New sub-region", + newSubRegionName: "New sub-region", + newSubRegionPrompt: "Sub-region name", + subRegionNotOnFlat: "The world map has no regions. Create sub-regions on World or a lower-level canvas.", + worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.", + worldMapName: "World map", + worldMapOffline: "World map is temporarily offline", + rootMapName: "Root map", + worldHud: "World {{total}} · close-up {{cached}} · z{{zoom}} · {{lod}}", + worldEmptyCoords: "World map has no coordinates yet", + worldEmptyCoordsHint: + "Dock has {{dock}} NEs, but Fabric / world coords are empty. Apply without pulling UME.", + worldApply: "Apply to world map", + worldApplying: "Applying…", + worldApplyOk: "Applied to world map", + subRegionNeedParent: "Open a root map or region canvas first, then create a sub-region.", + regionCreated: "Created", + regionDeleted: "Deleted", + deleteRegion: "Delete", + deleteRegionConfirm: "Delete \"{{name}}\"? Its maps will also be deleted.", + deleteUmeWorldConfirm: + "Delete \"{{name}}\"? This cascades all UME regions and maps under it. A later UME topology apply may recreate it.", + renameRegion: "Rename", + renameRegionPrompt: "Name", + regionRenamed: "Renamed", + collapseRegion: "Collapse", + expandRegion: "Expand", + newCustom: "Custom map", + newCustomName: "Custom topology", + newCustomPrompt: "Custom map name", + folderHint: "Creating a root auto-creates its unique root map. Add sub-regions on that canvas.", + folderHintShort: "Root folder", + outsidePeers: "{{count}} outside peer(s) linked but not on this map", + outsidePeersView: "Off-map NEs {{count}}", + outsidePeersTitle: "Outside peers", + outsidePeersHint: "Linked to this map but not placed. Select and add only what you need.", + outsidePeersVia: "via {{name}}", + outsidePeersFilterPh: "Filter: name / IP / via", + outsidePeersEmpty: "No matching outside peers", + projectNeighbors: "Project neighbors", + projectedNeighbors: "Projected {{count}} neighbor(s) onto the map", + localBrowserOnly: "this browser only", + deleteEdgeConfirm: + "Remove selected link(s) from the canvas? They are deleted from Fabric only when you Save. LLDP may recreate them on the next collect.", + edgeDeleted: "Queued {{count}} link delete(s) — Save to apply", + discoverCancelled: "Discovery cancelled", + truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify or a new map.", + truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze via classify workflow or create a new map.", + truncatedNodes: "Too many view nodes; display truncated. Narrow membership or create another map.", + truncatedEdges: "Too many edges; display truncated. Filter status or create another map.", + truncatedGeneric: "Graph data truncated. Narrow scope or create another map.", + deleteSelectionConfirm: + "Remove {{nodes}} NE(s) from this map and {{edges}} link(s)? Link deletes apply on Save; NE remove from map applies on Save.", + selectionDeleted: "Queued: remove {{nodes}} NE(s), delete {{edges}} link(s) — Save to apply", + newMap: "New", + newMapName: "New topology", + rename: "Rename", + renamePrompt: "Topology map name", + renameHint: "Double-click to rename", + renamed: "Topology map renamed", + deleteMap: "Delete map", + deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.", + emptyMaps: "No roots yet. Create a root first.", + palette: "NE library", + paletteHint: "Select multiple to add in batch, or drag one onto the canvas", + paletteDragHint: "Drag onto canvas to add", + selectAllVisible: "Select all visible", + addSelected: "Add selected ({{count}})", + addSelectedDone: "Added {{count}} NE(s)", + addingNe: "Adding…", + filterPh: "Type to filter: name / IP / vendor", + save: "Save", + saveDirty: "Save *", + unsavedConfirm: "You have unsaved changes. Discard and switch maps?", + saving: "Saving…", + saved: "Topology saved", + saveFailed: "Save failed: {{detail}}", + readOnlyBanner: "Read-only (ne:write scope required to edit)", + readOnlyHint: "Read-only — no write permission", + discoverTimeout: "Discovery timed out; check LLDP tasks for progress", + policySyncFailed: "Failed to sync LLDP policy; local toggle still applies to canvas discovery", + discover: "Discover links", + discovering: "Discovering…", + discoverOne: "Discover links", + discovered: "Discovery done: +{{added}} updated {{updated}} missing {{stale}}", + discoverFail: "Discovery failed: {{detail}}", + discoverOneNeedNe: "This node is not linked to a scannable NE", + selectMap: "Select or create a topology map", + renameNode: "Rename", + renameNodePrompt: "Node display name", + sourcePort: "Local port", + targetPort: "Remote port", + endpointA: "Endpoint A", + endpointB: "Endpoint B", + port: "port", + expandPhysicalLinks: "Expand physical links", + scaleBundleWidth: "Thicken bundles by link count", + linkMembers: "Physical members ({{count}})", + canvasHint: + "Left tree lists regions; add maps yourself, then open a map to edit devices with Add NE and save positions.", + selected: "Selected", + selectedEdge: "Selected edge", + selectedCount: "{{count}} selected", + toolModes: "Edit modes", + toolSelect: "Select", + toolPan: "Pan", + toolDrag: "Drag", + toolConnect: "Connect", + layout: "Layout", + layoutHierarchicalTb: "Hierarchical · top-bottom", + layoutHierarchicalLr: "Hierarchical · left-right", + layoutForce: "Force-directed", + layoutGrid: "Grid", + layoutRadial: "Radial", + layoutSelected: "Selected only (hierarchical)", + layoutNeedSelection: "Select nodes to lay out first", + align: "Align", + alignLeft: "Align left", + alignRight: "Align right", + alignTop: "Align top", + alignBottom: "Align bottom", + alignHCenter: "Align center X", + alignVCenter: "Align center Y", + alignHDistribute: "Distribute horizontally", + alignVDistribute: "Distribute vertically", + alignNeedSelection: "Select at least two nodes", + snapGrid: "Snap to grid", + autoLayoutDiscover: "Auto-layout after discover", + discoverAutoAddUnmatched: "Auto-add unmatched NEs as placeholders on discover", + discoverProjectNeighbors: + "After discover, project neighbors of scanned NEs onto the canvas (Save to keep permanently)", + undo: "Undo", + redo: "Redo", + discoverSelected: "Discover links ({{count}})", + removeSelected: "Remove selected ({{count}})", + openWebcrt: "Open terminal", + openNe: "NE details", + openAlarms: "View alarms", + noAlarmLink: "Cannot build an alarm filter for this node", + removeNode: "Remove from canvas", + deletePlaceholder: "Delete placeholder", + deletePlaceholderConfirm: + "Permanently delete this placeholder NE (inventory + Fabric links)? This cannot be undone with canvas Undo.", + deletePlaceholderDone: "Placeholder deleted", + removeEdge: "Delete edge", + nodeMenu: "Node", + paneMenu: "Canvas", + edgeMenu: "Edge", + createNe: "New NE", + createNeModeHint: + "Create a full managed NE, or a topology placeholder (credentials can be filled later).", + createNeManaged: "Managed NE", + createNeManagedHint: "Same full form as NE management; placed on the canvas after create.", + createNePlaceholder: "Placeholder NE", + createNePlaceholderHint: "Name/IP only; topology placeholder for sketching first.", + createNeTitle: "New topology placeholder NE", + createNeHint: + "Source is topology placeholder. Complete login via WebCRT, or promote in NE management.", + createNeName: "Name", + createNeNamePh: "e.g. SW-CORE-01", + createNeNameRequired: "NE name is required", + createNeIp: "IP (optional)", + createNeIpPh: "Can fill later during setup", + createNeDone: "Created placeholder {{name}}", + createManagedNeDone: "Created and placed {{name}}", + createManagedNePlaceFailed: + "{{name}} was created in inventory, but placing on the map failed: {{error}}. Add it again from NE management or the sidebar.", + createManagedNePosFailed: + "{{name}} is on the map, but could not move to the click position (kept default layout coords)", + createNeBusy: "Creating…", + connectTest: "Connectivity test", + connectTestSelected: "Connectivity test ({{count}})", + connectTestNeedManaged: "Node is not linked to a managed NE; cannot run connectivity test", + connectTestSkipped: "skipped {{count}} without managed link", + selectionMenu: "Selection", + edgeStyle: "Edge style", + edgeColor: "Color", + edgeLineStyle: "Line", + edgeLineSolid: "Solid", + edgeLineDashed: "Dashed", + edgeLineDotted: "Dotted", + edgeWidth: "Width", + edgeStyleReset: "Reset style", + edgeDefaults: "Default edge styles", + edgeDefaultsReset: "Factory reset", + noNeLink: "Not linked to a managed NE", + completeNeFirst: + "Login details are incomplete (vendor, hop/proxy, etc. belong in NE inventory). Complete the NE first, then open the terminal.", + edgeManual: "Manual", + edgeUme: "UME", + edgeDiscovered: "LLDP", + edgeStale: "Missing", + fit: "Fit view", + export: "Export", + exportSvg: "SVG vector", + exportXml: "XML data", + exporting: "Exporting…", + exportEmpty: "Nothing on the canvas to export", + exportedSvg: "SVG exported", + exportedXml: "XML exported", + exportFailed: "Export failed: {{detail}}", + liveSync: "Live sync", + liveSyncOn: "Syncing…", + liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default", + fullscreen: "Fullscreen", + exitFullscreen: "Exit fullscreen", + display: "Display", + canvasBg: "Canvas background", + canvasBgColor: "Background color", + canvasBgReset: "Reset default", + textColors: "Text colors", + textColorsReset: "Reset default", + labelColor: "NE name", + edgeLabelColor: "Link numbers", + vendorColors: "Vendor icon colors", + vendorColorsReset: "Reset default", + vendorTone: { + cisco: "Cisco", + huawei: "Huawei", + zte: "ZTE", + juniper: "Juniper", + nokia: "Nokia", + ericsson: "Ericsson", + h3c: "H3C", + ruijie: "Ruijie", + mikrotik: "MikroTik", + gray: "Unknown / unmanaged", + }, + hideIp: "Hide IP", + hideVendor: "Hide vendor", + showPlaceholderBadge: "Show placeholder badge (TOPO/LLDP)", + showConnectStatus: "Show connect-status dots", + showAlarmOverlay: "Show alarm overlay", + showLevelColors: "Color by classify level", + levelUnmatched: "Unclassified", + level: { + external: "External", + core: "Core", + aggregation: "Aggregation", + access: "Access", + edge: "Edge", + }, + hidePorts: "Hide ports", + edgeFlow: "Edge flow", + collapseSidebar: "Collapse sidebar", + expandSidebar: "Expand sidebar", + resizeSidebar: "Drag to resize sidebar", + hideAdded: "Hide added", + paletteEmpty: "No matching NEs.", + paletteHiddenAll: "Added NEs are hidden; uncheck to show them again.", + paletteSource: "NE source", + paletteManaged: "Managed", + paletteUme: "UME", + paletteLoading: "Loading…", + discoverReport: "Discovery progress", + discoverClose: "Close", + discoverCancel: "Cancel discovery", + discoverProgress: "Scanning {{count}} managed NE(s) on the map (serial LLDP; please wait)…", + discoverProgressLive: "Scanning {{index}}/{{total}}: {{name}}", + discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}", + discoverNeOk: "neighbors {{neighbors}} · added {{added}} · updated {{updated}}", + discoverNeFail: "Collect failed", + discoverUnmatched: "unmatched {{count}}", + discoverUnmatchedMore: "{{count}} more not listed", + discoverVendorRequired: "Vendor/device type missing; skipped LLDP command", + discoverParserStub: "Parser \"{{parser}}\" is still a stub; output was collected but neighbors were not parsed", + discoverNoTargets: + "No scannable NEs on the map. Add managed NEs, or UME NEs with a CLI connect profile.", + discoverViewDetail: "Detail", + discoverLinkCount: "{{count}} link(s)", + discoverLinksTitle: "Matched links ({{count}})", + discoverLinksEmpty: "No on-map peer links matched in this scan.", + discoverUnmatchedTitle: "Unmatched neighbors ({{count}})", + discoverUnmatchedEmpty: "No unmatched neighbors.", + discoverColPeer: "Peer NE", + discoverColRemote: "Neighbor", + discoverColLocalPort: "Local port", + discoverColRemotePort: "Remote port", + discoverColProtocol: "Protocol", + discoverColAction: "Result", + discoverLinkAdded: "Added", + discoverLinkUpdated: "Updated", + discoverLinkKeptManual: "Kept manual", + discoverLocatePeer: "Locate peer", + discoverLocateNe: "Locate NE", + discoverRawPreview: "Raw output preview (truncated when long; parsing uses full output)", + discoverGoLldp: "Details → LLDP links", + findNode: "Find", + findNodePh: "Type to search: name / IP / vendor", + findNoMatch: "No matching node on the canvas", + findMore: "{{count}} more matches…", + locateOnCanvas: "Click to locate on canvas", + openPortTraffic: "Open port traffic", + removeStale: "Clear missing ({{count}})", + removeStaleHint: + "Queue delete of red missing edges (applies on Save); scheduled collect also purges after 4 consecutive misses", + staleRemoved: "Queued {{count}} missing edge delete(s) — Save to apply", + connectHint: "Connect mode: drag from the NE center anchor to the target (Esc or Select to exit)", +}; + +export default topology; diff --git a/web/src/i18n/locales/en/ume.ts b/web/src/i18n/locales/en/ume.ts new file mode 100644 index 0000000..31354c5 --- /dev/null +++ b/web/src/i18n/locales/en/ume.ts @@ -0,0 +1,278 @@ +const 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: + "[Subscription]\n" + + "· Create / cancel manually\n" + + "· After establish (or restart with a valid DB record), backend auto-connects WSS\n\n" + + "[Task control]\n" + + "· alarms_current_ws_consumer pause/resume only stops/starts WSS\n" + + "· 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)", + }, + keyAlert: { + title: "AI alarm monitor", + showPanel: "Details", + hidePanel: "Close", + help: + "[Match]\n" + + "· Exact notificationId\n" + + "· Or description keyword (case-insensitive; includes nativeProbableCause)\n\n" + + "[Scope]\n" + + "· Optional Inventory ne_type filter; empty = all devices\n\n" + + "[Deliver]\n" + + "· NetX broadcasts hits to subscribed Netx Ops (DSH) clients", + hub: "DSH subscribers", + hubConnected: "subscribed", + hubEmpty: "no subscribers", + hubSubscribers: "subscribers", + hubPublished: "published", + hubDeliverOk: "deliver ok", + hubDeliverFail: "deliver fail", + hubConnections: "Active connections", + hubColId: "id", + hubColUser: "user", + hubColRemote: "remote", + hubColClient: "client", + hubColConnected: "connected", + hubColLastSeen: "last seen", + hubEmptyConnections: "No Netx Ops clients are connected to the alarm hub", + hubPath: "path", + matchType: "Match by", + matchNotificationId: "notificationId", + matchKeyword: "description keyword", + matchValuePhNotificationId: "Enter notificationId", + matchValuePhKeyword: "Enter keyword (case-insensitive)", + label: "Rule description", + labelPh: "Required, e.g. BGP Down", + neTypesLabel: "Device types (optional, multi-select)", + neTypesHint: "Leave empty to match all device types. Options come from synced UME Inventory ne_type.", + neTypesEmpty: "No device types yet — sync Inventory first", + neTypesAll: "All", + forwardOnClear: "Push on alarm clear", + forwardOnClearHelp: "Global switch for all monitor rules. By default only new/updated alarms are pushed; when enabled, clear notifications are also sent to subscribed Netx Ops clients.", + add: "Add rules", + adding: "Adding…", + delete: "Delete", + edit: "Edit", + editTitle: "Edit monitor rule", + save: "Save", + saving: "Saving…", + cancel: "Cancel", + editOk: "Rule updated", + pickFromAlarms: "Load alarm hints", + emptyRules: "No monitor rules yet", + colType: "type", + colMatch: "match", + colLabel: "description", + colNeTypes: "device types", + colForwardClear: "on clear", + colPublished: "published", + colAttempts: "attempts", + colLast: "last push", + colActions: "actions", + colMonitor: "monitor", + monitorOn: "on", + monitorOff: "off", + filterKeywordPh: "Filter match / label", + filterMatchAll: "All types", + filterEnabledAll: "All states", + filterEnabledOn: "Monitoring on", + filterEnabledOff: "Monitoring off", + addOk: "Monitor rule added", + deleteOk: "Deleted", + confirmDelete: "Delete this monitor rule?", + yes: "yes", + no: "no", + }, + 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", + lastErrorCol: "last_error", + runtimeError: { + startup_alarm_sync_before_ws: "Starting up: syncing current alarms (WSS connects after completion)…", + startup_gate_waiting: "Startup REST snapshot pending; scheduled REST and WSS on standby", + wss_active_skip_rest: "WSS receiving live alarms; REST sync skipped", + pulling_alarms_current: "Pulling UME current alarms…", + alarms_sync_in_progress_skip: "Another current-alarm REST sync in progress; skipped", + pulling_inventory: "Pulling UME inventory…", + pulling_topology: "Pulling UME topology (nodes/links)…", + topology_sync_busy: "Topology sync already running; skipped", + ume_ws_disabled_no_base_url: "Disabled or UME_BASE_URL not configured", + resumed_sync_soon: "Resumed: skipping debounce wait, sync soon", + resumed_wss_reconnect: "Resumed: reconnecting WSS soon", + resumed: "Resumed", + keepalive_failed: "Token keepalive failed", + }, + wsState: { + init: "Initializing", + connected: "Connected", + connecting: "Connecting", + disconnected: "Disconnected", + no_subscription: "No subscription", + waiting_token: "Waiting for token", + paused: "Paused", + error: "Connection error", + reconnecting: "Reconnecting", + subscription_lost: "UME subscription lost", + disabled_no_base_url: "Disabled or UME_BASE_URL not configured", + }, + runtimeTask: { + token_keepalive: "UME token keepalive", + alarms_current_auto_sync: "Current alarms auto sync", + alarms_current_ws_consumer: "UME alarm WSS", + inventory_auto_sync: "Inventory auto sync", + topology_auto_sync: "Topology auto sync", + }, + }, + syncStatus: { + title: "Sync history", + showPanel: "Details", + hidePanel: "Close", + empty: "No sync records", + summary: "{{total}} record(s), {{running}} running", + fabricGapTitle: "Topology dock is newer than Fabric / UME World", + fabricGapHint: + "Dock ME {{dock}}, Fabric UME {{fabric}}. Apply without pulling UME. Last status: {{status}}", + applyFabric: "Apply to topology", + applyingFabric: "Applying…", + applyFabricOk: "Applied to topology", + }, + ne: { + title: "Network elements", + showPanel: "Details", + hidePanel: "Close", + keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)", + clearTitle: "Clear keyword and reset to page 1", + expand: "Details", + collapse: "Close", + cliConnect: "CLI connect", + summary: "{{total}} inventory NE(s)", + }, + tabs: { + main: "UME sync", + cli: "CLI connect", + }, + cli: { + title: "UME CLI connection profile", + showPanel: "Details", + hidePanel: "Close", + hint: + "[Purpose]\n" + + "· Unified SSH credentials and hop/bastion for Inventory NEs\n\n" + + "[MCP]\n" + + "· Exec show via ume_ne_id without per-device managed_ne entries", + statusReady: "CLI profile is ready for UME NE command execution.", + statusNotReady: "Set target username and hop/bastion settings (if enabled).", + profileName: "Profile name", + targetUsername: "Target SSH username", + targetPassword: "Target SSH password", + passwordKeep: "Leave blank to keep unchanged", + newProfile: "New profile", + deleteConfirm: "Delete this CLI profile?", + deleted: "Profile deleted", + saved: "CLI profile saved", + connectTestTitle: "Sample NE connectivity test", + sampleNePh: "Filter UME NEs (name / IP / ID)", + sampleNeRequired: "Select a UME NE first", + sampleNeEmpty: "No matching NE", + sampleNeLoading: "Loading…", + connectTestSubmitted: "Connectivity test submitted", + connectTestWaiting: "Waiting for test result…", + connectTestRunning: "Connectivity test in progress…", + connectTestResult: "Test result", + existingProfiles: "Existing profiles", + default: "default", + }, + alarms: { + title: "Current alarms", + movedHint: "Current alarm query moved to Network Management →", + showPanel: "Expand", + hidePanel: "Collapse", + keywordPh: "keyword(alarm key/cause/ne_name/host_name/ip…)", + hostNamePh: "host_name (contains)", + allSeverity: "All severities", + clearedAll: "Cleared: all", + clearedYes: "Cleared", + clearedNo: "Active", + clearTitle: "Clear filters and reset to page 1", + filterByHost: "Filter by this host", + noHostName: "No host_name (sync inventory first)", + col: { + time: "Time", + severity: "Severity", + neId: "NE ID", + hostName: "Host", + neType: "NE type", + cause: "Cause", + }, + }, +}; + +export default ume; diff --git a/web/src/i18n/locales/en/webcrt.ts b/web/src/i18n/locales/en/webcrt.ts new file mode 100644 index 0000000..8d5bdd2 --- /dev/null +++ b/web/src/i18n/locales/en/webcrt.ts @@ -0,0 +1,235 @@ +const webcrt = { + title: "WebCRT", + deviceList: "Device list", + sessionManager: "Device list", + connect: "Connect", + hint: "Pick an NE and open an interactive terminal in the browser. Credentials stay on the server.", + hintCrt: "Click an NE on the left to open a terminal. Includes managed, Sessions, and UME; or use New Session for a quick login.", + filterKeyword: "Filter", + filterKeywordPh: "Filter by name / IP / tag", + empty: "No matching NEs. Switch source or refine the search.", + open: "Open terminal", + opening: "Connecting…", + opened: "Opened {{name}}", + disconnect: "Disconnect", + closed: "Session closed", + termPlaceholder: "No terminal session open", + sessionMeta: "{{name}} · {{ip}}", + source: { + all: "All", + managed: "Managed", + webcrt: "Sessions", + ume: "UME", + }, + newSession: { + title: "New Session", + claimTitle: "Complete placeholder session", + claimHint: + "Enter a reachable host. Session name defaults to the placeholder name. On success, source becomes WebCRT.", + connect: "Connect", + connecting: "Connecting…", + protocol: "Protocol", + host: "Hostname", + port: "Port", + sessionName: "Session name", + sessionNamePh: "Optional; defaults to IP", + authTitle: "SSH Authentication", + savePassword: "Save password", + ipRequired: "Hostname is required", + userRequired: "Username is required", + passwordRequired: "Password is required", + }, + status: { + idle: "Idle", + connecting: "Connecting", + connected: "Connected", + error: "Error", + closed: "Closed", + }, + phase: { + creating: "Creating session", + authenticating: "Authenticating", + waitingPrompt: "Waiting for device prompt", + hint: "Login progress streams into the terminal; slow devices may take >10s", + }, + tabMenu: { + close: "Close", + closeOthers: "Close others", + closeAll: "Close all", + copyIp: "Copy IP", + copyIpOk: "Copied {{ip}}", + copyIpEmpty: "No IP on this tab", + }, + treeMenu: { + rename: "Rename", + renameLabel: "Session name", + renameSave: "Save", + renameRequired: "Session name is required", + renamed: "Session renamed", + delete: "Delete session", + deleteConfirm: "Delete session \"{{name}}\"? This cannot be undone.", + deleted: "Session deleted", + }, + keywordHl: { + title: "Keyword Highlighting", + enabled: "Enabled", + newKeyword: "New keyword", + newKeywordPh: "Keyword or regex", + regex: "Regular expression", + add: "Add", + colKeyword: "Keyword", + colRegex: "Regex", + empty: "No keywords yet", + moveUp: "Move up", + moveDown: "Move down", + remove: "Remove", + caseSensitive: "Case sensitive", + color: "Text color", + saved: "Keyword highlighting saved", + }, + actions: { + reconnect: "Reconnect", + recordLog: "Record log", + clear: "Clear", + copy: "Copy", + recording: "Recording", + recordingOn: "Log recording started", + recordingSaved: "Log saved", + copied: "Copied to clipboard", + copyEmpty: "Terminal is empty", + copyFailed: "Copy failed", + break: "Break", + breakSent: "Break sent", + broadcast: "Broadcast", + broadcastSent: "Sent to all sessions", + postLogin: "Post-login cmds", + }, + favorites: "Favorites", + recent: "Recent", + favorite: "Toggle favorite", + encoding: "Encoding", + fontSize: "Font size", + theme: "Theme", + themeDark: "Dark", + themeLight: "Light", + colorScheme: "Color scheme", + backgroundColor: "Background", + foregroundColor: "Font color", + scheme: { + dark: "Deep blue (default)", + blackWhite: "Black on white", + whiteBlack: "White on black", + greenBlack: "Green on black", + amberBlack: "Amber on black", + custom: "Custom", + }, + copyOnSelect: "Copy on select", + rightClickPaste: "Paste on right click", + rightClickPasteHint: "Shift + right click opens the menu. Turn off to always show the menu.", + pasteDelay: "Paste max line wait", + pasteDelayOff: "No delay (bulk send)", + keepalive: "SSH keepalive", + keepaliveOff: "Off", + keepaliveSec: "{{n}} sec", + options: "Options", + globalSessionOptions: "Global Session Options", + sessionOptionsSave: "Save", + sessionOptionsCancel: "Cancel", + sessionOptionsSaved: "Global session options saved", + disconnectBanner: "Session disconnected", + disconnectBannerError: "Session error or connection lost", + broadcastPh: "Send to all tabs…", + buttonBarHint: "One per line: label|command", + postLoginPrompt: "Post-login commands (one per line, this device)", + postLoginSaved: "Post-login commands saved", + sftp: { + title: "SFTP", + refresh: "Refresh", + up: "Up", + upload: "Upload", + uploaded: "Upload complete", + uploading: "Upload {{done}}/{{total}} · {{name}} {{pct}}", + downloading: "Download {{done}}/{{total}} · {{name}} {{pct}}", + downloaded: "Downloaded {{count}} file(s)", + downloadPartial: "Downloaded {{ok}}, failed {{failed}}", + uploadPartial: "Uploaded {{ok}}, failed {{failed}}", + retrying: "Retrying {{name}} ({{attempt}}/{{max}})…", + cancel: "Cancel", + dropHint: "Drop to upload into current folder", + dropEmpty: "No uploadable files found", + mkdir: "New", + mkdirPrompt: "New folder name", + mkdirInvalid: "Invalid folder name (no / or ..)", + mkdirOk: "Folder created", + rename: "Rename", + renamePrompt: "New name", + renameInvalid: "Invalid name (no / or ..)", + renamed: "Renamed", + download: "Download", + chmod: "chmod", + chmodPrompt: "Mode (e.g. 755 or rwxr-xr-x)", + chmodOk: "Permissions updated", + chmodPartial: "chmod ok {{ok}}, failed {{failed}}", + delete: "Delete", + deleteConfirm: "Delete {{name}}?", + deleteRecursiveConfirm: "{{name}} includes folders; non-empty folders will be removed recursively. Continue?", + deletePartial: "Deleted {{ok}}, failed {{failed}}", + deleted: "Deleted", + selectedCount: "{{count}} selected", + empty: "This folder is empty", + listTruncated: "Folder is large; showing first {{count}} entries (limit {{max}})", + dir: "dir", + loading: "Loading…", + enterHint: "Click to select; Ctrl/Shift multi-select; double-click to open", + downloadHint: "Click to select; Ctrl/Shift multi-select; double-click to download", + colName: "Name", + colSize: "Size", + colMtime: "Modified", + colOwner: "Owner", + colGroup: "Group", + colMode: "Access", + resizeHint: "Drag to resize SFTP / terminal", + colResizeHint: "Drag to resize column", + }, + term: { + findPh: "Find…", + findPrev: "Previous", + findNext: "Next", + findClose: "Close find", + copy: "Copy", + paste: "Paste", + pasteEmpty: "Clipboard is empty", + pasteFailed: "Cannot read clipboard (on HTTP use the paste box, or Ctrl+V)", + pasteBridgeHint: + "This page is not HTTPS, so the browser blocks clipboard reads. Press Ctrl+V in this box to send text to the terminal.", + pasteBridgePh: "Press Ctrl+V here…", + pasteBridgeCancel: "Cancel", + clear: "Clear", + pasting: "Pasting {{done}}/{{total}} lines…", + wsError: "[WebSocket error]", + wsClosed: "[WebSocket closed code={{code}}]", + sessionStatus: "[session {{state}}{{detail}}]", + outputTruncated: "[output truncated: {{count}} chunk(s) dropped]", + }, + err: { + deviceType: "Unsupported device type", + ipConflictRestart: "Database still enforces unique IP; restart the netx API and retry", + sessionLimit: "Session limit reached; close other terminals first", + neNotFound: "Network element not found", + credsIncomplete: "Incomplete username/password on this NE", + authRejected: "Wrong username or password. Please try again.", + cliProfile: "UME NEs require a CLI connect profile (default account / hop)", + connectFailed: "Device login failed: {{detail}}", + sftpHop: "SFTP does not support hop devices yet (direct SSH only)", + sftpSsh: "SFTP requires SSH", + sftpNeedPassword: "SFTP needs a saved SSH password (one-shot dialog passwords are not reused for files)", + sftpUnsupported: "This SSH session has no SFTP channel (device may not offer it)", + sftpTooLarge: "File exceeds size limit (default 512MB)", + sftpChmodInvalid: "Invalid mode (use 755 or rwxr-xr-x)", + sftpAborted: "Transfer cancelled", + queueDropped: "Terminal output too fast; about {{count}} chunk(s) dropped — transcript may be incomplete", + websocket: "WebSocket connection failed", + }, +}; + +export default webcrt; diff --git a/web/src/i18n/locales/en/workbench.ts b/web/src/i18n/locales/en/workbench.ts new file mode 100644 index 0000000..2fc7def --- /dev/null +++ b/web/src/i18n/locales/en/workbench.ts @@ -0,0 +1,121 @@ +const workbench = { + title: "Workbench", + modules: "Modules", + sections: "Sections", + moduleCount: "{{count}} modules", + backToWorkbench: "Back to Workbench", + openInNewTab: "Open in new tab", + openModule: "Open or switch to this module tab", + monitoring: "Integration", + operations: "Operations", + system: "System", + welcomeEyebrow: "Workbench", + welcomeTitle: "Welcome back, {{name}}", + welcomeSub: "Monitor sync, topology, and ops tasks from one place.", + today: "Today · {{date}}", + kpi: { + uptime: "API uptime", + health: "System health", + tasks: "Active tasks", + healthOk: "OK", + healthWarn: "CHECK", + healthUnknown: "N/A", + }, + statusTitle: "Runtime status", + hostHealth: "HOST", + platformCapacity: "Platform capacity", + channels: "CHANNELS", + fleet: "FLEET", + fleetNe: "NE", + fleetNeUnit: "units", + fleetNeKnown: "Known", + fleetNePass: "Pass", + fleetNeFail: "Fail", + fleetNeTesting: "Testing", + fleetNeUnknown: "Untested", + fleetNeNoTag: "No tag", + fleetNeTags: "Top tags", + fleetTopo: "Topology", + fleetTopoNodes: "nodes", + fleetTopoEdges: "edges", + fleetTopoOther: "Other regions", + fleetTopoUnassigned: "Unassigned", + fleetTopoNoRegion: "No regions", + fleetTopoDiscover: "Last discover", + fleetTopoUpdated: "Summary updated", + fleetSched: "Schedulers", + fleetSchedConfig: "Config sync", + fleetSchedLldp: "LLDP", + fleetSchedPort: "Port traffic", + fleetSchedNeCollect: "NE collect", + fleetSchedFabric: "Fabric reconcile", + fleetSchedOn: "ON", + fleetSchedOff: "OFF", + fleetSchedMode: "Mode", + fleetSchedHeartbeat: "Heartbeat", + fleetSchedFresh: "Fresh", + fleetSchedStale: "Stale", + + sidePanel: "Console side panel", + consoleId: "OPS CONSOLE", + refreshedAt: "Updated {{time}}", + taskRail: "ACTIVE TASKS", + taskRailAll: "ALL", + taskRailEmpty: "No active tasks", + taskTotal: "POOL {{count}}", + announceTitle: "Announcements", + recentTitle: "Recent operations", + recentMore: "View all", + quickTitle: "Quick actions", + emptyRecent: "No recent audit entries.", + gauge: { + cpu: "CPU", + mem: "Memory", + storage: "Storage", + storageUsed: "Used", + storageUnavailable: "Unavailable", + cli: "CLI budget", + db: "DB pool", + webcrt: "WebCRT", + tasks: "Active tasks", + low: "Low load", + normal: "Normal", + high: "Busy", + }, + quick: { + tasks: "Task overview", + logs: "Audit logs", + users: "User admin", + apiKeys: "API keys", + sessions: "Sessions", + }, + announce: { + a1: "Dark workbench shell is live — other modules follow the same palette.", + a1Date: "2026-08-04", + a2: "LLDP collect supports pause / resume / stop; interrupted jobs resume after restart.", + a2Date: "2026-08-04", + a3: "Config sync and LLDP KPI boards refresh while jobs are running.", + a3Date: "2026-08-03", + }, + recentCols: { + action: "Action", + module: "Module", + time: "Time", + user: "User", + }, + cards: { + umeSync: "UME Sync", + network: "Network Management", + managedNe: "NE Management", + collect: "Batch Collect", + webcrt: "WebCRT", + topology: "Topology", + portTrafficWall: "Traffic wall", + users: "Users", + audit: "Audit", + apiKeys: "API Keys", + sessions: "Login sessions", + }, +}; + +export default workbench; diff --git a/web/src/i18n/locales/zh/audit.ts b/web/src/i18n/locales/zh/audit.ts new file mode 100644 index 0000000..c5f8502 --- /dev/null +++ b/web/src/i18n/locales/zh/audit.ts @@ -0,0 +1,80 @@ +const audit = { + title: "操作审计", + logsTitle: "操作日志", + colSummary: "摘要", + colDetail: "详情", + showDetail: "展开", + hideDetail: "收起", + filterBusiness: "业务", + filterAll: "全部", + filterWebcrt: "设备终端", + filterNeExec: "设备执行", + filterAuth: "登录认证", + filterHttp: "HTTP", + summary: { + connecting: "正在登录 {{device}}", + loginOk: "登录 {{device}}", + loginFail: "登录失败 {{device}}:{{error}}", + logout: "断开 {{device}}", + logoutReason: "断开 {{device}}({{reason}})", + command: "{{device}} · 命令: {{command}}", + neExec: "{{device}} · 执行: {{commands}}", + neExecBatch: "批量执行 {{n}} 台(成功 {{ok}} / 失败 {{fail}})", + }, + nav: { + tasks: "任务概览", + logs: "操作日志", + }, + tasks: { + title: "任务概览", + hint: "汇总当前系统中正在运行或挂起的各类后台任务;发起人优先取近期审计记录,调度类任务显示为 scheduler/system。", + empty: "当前没有可展示的任务。", + open: "打开", + inflight: "进行中 {{n}}", + kpiActive: "活跃/在途", + kpiTotal: "条目总数", + kpiKinds: "按类型", + kpiStatuses: "按状态", + colKind: "类型", + colTitle: "任务", + colStatus: "状态", + colActor: "发起人", + colTrigger: "触发", + colProgress: "进度", + colStarted: "开始", + colUpdated: "更新", + colLink: "入口", + kind: { + port_traffic: "端口流量", + config_sync: "配置同步", + ne_collect: "批量采集", + lldp_discover: "LLDP 发现", + ume_sync: "UME 同步", + ne_connect: "连通性测试", + ume_runtime: "UME 后台", + webcrt: "WebCRT", + }, + trigger: { + manual: "手动", + schedule: "调度", + topology: "拓扑", + system: "系统", + retry_failed: "重试失败", + }, + status: { + collecting: "采集中", + running: "运行中", + testing: "测试中", + connecting: "登录中", + ready: "已连接", + detached: "已断开(宽限)", + pending: "排队", + paused: "暂停", + idle: "空闲", + error: "错误", + failed: "失败", + }, + }, +}; + +export default audit; diff --git a/web/src/i18n/locales/zh/auth.ts b/web/src/i18n/locales/zh/auth.ts new file mode 100644 index 0000000..ecd1019 --- /dev/null +++ b/web/src/i18n/locales/zh/auth.ts @@ -0,0 +1,110 @@ +const auth = { + loginTitle: "登录", + username: "用户名", + password: "密码", + login: "登录", + loggingIn: "登录中…", + loginFailed: "登录失败", + logout: "退出", + sessionsTitle: "登录会话", + sessionsHelp: "反代后 IP 取真实客户端;同公网可用「客户端」列区分。踢掉后需重新登录。", + sessionsHint: + "管理当前账号在各浏览器/设备上的登录。IP 经反代时会取真实客户端地址;同公网出口可用「客户端」列区分浏览器/系统。踢掉会话后对方需重新登录。", + sessionsEmpty: "当前没有活跃会话。", + revokeOtherSessions: "踢掉其他会话", + revokeSession: "踢掉", + sessionCurrent: "当前", + sessionRevoked: "会话已吊销", + sessionsRevokedOthers: "已踢掉 {{count}} 个其他会话", + revokeCurrentConfirm: "这是当前会话,踢掉后需要重新登录。继续?", + colSession: "会话", + colClient: "客户端", + colLastSeen: "最近活动", + colCreated: "创建时间", + usersTitle: "用户管理", + usersHint: "仅管理员可创建与管理本地账号。", + addUser: "添加用户", + role: "角色", + roleAdmin: "管理员", + roleUser: "普通用户", + status: "状态", + active: "启用", + disabled: "禁用", + enable: "启用", + disable: "禁用", + actions: "操作", + newPassword: "新密码", + resetPassword: "重置密码", + userCreated: "用户已创建", + userUpdated: "用户已更新", + auditTitle: "操作审计", + auditHintAdmin: "查看所有用户的登录与操作记录。", + auditHintUser: "查看你自己的操作记录。", + filterUsername: "用户名筛选", + filterAction: "动作筛选", + colTime: "时间", + colUser: "用户", + colAction: "动作", + colMethod: "方法", + colPath: "路径", + colStatus: "状态码", + colIp: "IP", + apiKeysTitle: "API Key 管理", + apiKeysHelp: + "供 MCP/脚本与 netxops 使用(网页登录用会话)。明文仅创建时显示一次;可用「改权限」调整。未吊销上限 {{max}}。", + tokenQuota: "未吊销 {{active}} / {{max}}", + tokenLimitReached: "已达 API Key 上限({{max}}),请先吊销或删除不用的 Key", + tokenName: "名称", + expiresIn: "有效期", + expire7d: "7 天", + expire30d: "30 天", + expire90d: "90 天", + expire365d: "1 年", + expireNever: "永不过期", + tokenOwner: "所属用户", + tokenOwnerSelf: "自己({{user}})", + createToken: "生成 Key", + tokenCreated: "API Key 已生成", + tokenUpdated: "权限已更新", + tokenRevoked: "已吊销", + tokenDeleted: "已删除", + tokenOnceHint: "请立即复制保存,关闭后无法再次查看明文:", + copyToken: "复制", + tokenCopied: "已复制到剪贴板", + tokenCopyFailed: "复制失败", + expiresAt: "到期时间", + lastUsed: "最近使用", + tokenStatusActive: "有效", + tokenStatusExpired: "已过期", + tokenStatusRevoked: "已吊销", + revokeToken: "吊销", + revokeConfirm: "确定吊销该 API Key?吊销后立即失效,记录仍保留。", + deleteToken: "删除", + deleteConfirm: "确定永久删除该 API Key?删除后无法恢复。", + editScopes: "改权限", + saveScopes: "保存权限", + scopesUnsetLegacy: "未限定(等同所属用户)", + scopesCol: "权限", + scopesRequired: "请至少勾选一项权限", + scopesNoneAvailable: "当前所属用户没有可授予的权限", + scopeAlarmsRead: "alarms:read — 告警只读(netx MCP)", + scopeNeRead: "ne:read — 网元/拓扑只读(MCP)", + scopeNeWrite: "ne:write — 网元/拓扑写入、画图(MCP)", + scopeNeExec: "ne:exec — 托管网元 CLI(netx MCP)", + scopeBizMonitorRead: "biz-monitor:read — 业务监控/割接只读(netxops)", + scopeSql: "sql:query — UME SQL(netx MCP)", + scopeWebcrt: "webcrt:session — WebCRT(预留 MCP)", + scopeAdminUsers: "admin:users — 用户管理(预留 MCP)", + scopeOpsWrite: "ops:write — 运维写/订阅(预留 MCP)", + forceChangeTitle: "请修改初始密码", + forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。", + oldPassword: "当前密码", + confirmPassword: "确认新密码", + savePassword: "保存新密码", + savingPassword: "保存中…", + passwordTooShort: "新密码至少 8 位", + passwordMismatch: "两次输入的新密码不一致", + passwordMustChange: "新密码不能与默认/旧密码相同", +}; + +export default auth; diff --git a/web/src/i18n/locales/zh/bizCompare.ts b/web/src/i18n/locales/zh/bizCompare.ts new file mode 100644 index 0000000..d2e5000 --- /dev/null +++ b/web/src/i18n/locales/zh/bizCompare.ts @@ -0,0 +1,330 @@ +const bizCompare = { + title: "业务对比", + templates: "比对模板", + newTemplate: "新建模板", + editTemplate: "编辑模板", + saveTemplate: "保存模板", + templateSaved: "模板已保存", + templateDeleted: "模板已删除", + confirmDeleteTemplate: "删除该比对模板?", + confirmDeleteJob: "删除该比对任务及全部比对记录?", + confirmDeleteRun: "删除本次比对结果?任务配置将保留。", + deleteJob: "删除任务", + deleteRun: "删除本次", + runDeleted: "比对记录已删除", + emptyTemplates: "暂无模板。可新建,或从 JSON 文件导入。", + exportTemplate: "导出", + importTemplate: "导入", + templateExported: "模板已导出", + templateImported: "模板已导入", + templateImportInvalid: "导入文件无效:需要 name 与 metrics", + templateValidationFailed: "模板校验失败,请修正标明的配置项", + filterOperator: "过滤运算符", + complexFiltersPreserved: "该过滤规则包含复杂嵌套,已完整保留。可通过导出、修改 JSON 后重新导入来更新。", + portPolicy: "端口变化策略", + portPolicyHint: "存在端口映射时按映射匹配;此策略控制无映射时接口身份字段是否参与配对。", + portPolicyAuto: "自动判断", + portPolicyStrict: "接口必须匹配", + portPolicyIgnore: "允许端口变化", + missingSheets: "配置中的检查项已被移除或改名,请重新选择检查范围", + enableCurrentSheets: "启用当前全部检查项", + templateUsage: "已被 {{n}} 个任务引用", + templateImportHint: "导入/导出 JSON,便于私下传递模板(不含任务/批次数据)", + templateHint: + "先勾选监控项,再配置 Key / 接口 / 比对 / 展示。比对可设完全相等、绝对容差或相对偏差%;展示列可含不参与比对的上下文。不勾比对字段则只校验条目存在性。", + stepPickMetrics: "① 选择监控项", + stepConfigRules: "② 配置比对规则", + pickMetricsHint: "勾选后加入模板;可多选。选好后在下方按表配置参数。", + pickMetricsHintShort: "点选加入 / 取消;配置在下方左侧表列表中切换。", + showMetrics: "展开选表", + hideMetrics: "收起选表", + noMetricsYet: "请先勾选至少一个监控项", + selectedCount: "已选 {{n}} 项", + tabFields: "字段规则", + tabFilters: "行过滤", + fieldsHintShort: "Key 对齐行;比对勾选参与值 diff;不勾比对则只看存在性。", + tagCompareN: "{{n}} 比对", + colFields: "字段数", + keyFields: "Key", + ifaceFields: "接口", + compareFields: "比对", + compareMode: "方式", + tolerance: "容差", + displayField: "展示", + keyAlwaysDisplay: "Key 列始终展示", + modeEq: "完全相等", + modeNumeric: "绝对偏差", + modePercent: "相对偏差 %", + normalizeField: "归一", + ignoreFields: "忽略", + field: "字段", + note: "备注", + keyRequired: "至少选择一个 Key 字段", + keyIsIdentity: "Key 用于对齐行,不作为值比对字段", + metricsRequired: "请至少添加一张监控表", + noMetricFields: "该 metric 无字段定义", + rowFilters: "行过滤规则", + rowFiltersHint: + "比对前按规则筛选行。组内条件为「且」,组与组之间为「或」。勾选 ARP 等监控项时会带上默认可编辑规则,可按需改删。", + rowFiltersHintShort: "组内且 · 组间或 · 无过滤则比对全部行", + rowFiltersEmpty: "无过滤则比对全部行。", + filterClear: "清空", + filterAddOr: "添加条件组(或)", + filterAddAnd: "组内加条件(且)", + filterAddOrShort: "+ 或组", + filterAddAndShort: "+ 且", + filterRemoveGroup: "删除本组", + filterGroupN: "组 {{n}}", + filterOr: "或", + filterAnd: "且", + filterField: "字段", + filterOp: "运算", + filterValue: "值", + normalizeNone: "无", + normalizeLower: "小写", + normalizeUpper: "大写", + normalizeMac: "MAC", + normalizeEmpty: "空值归一", + templateFilterPh: "筛选模板名 / metric", + jobFilterPh: "筛选任务名 / 模板 / 状态", + addMetric: "添加监控项", + pickMetric: "选择监控项…", + addSheet: "加入模板", + removeSheet: "移出本表", + sheetIdRequired: "请填写 sheet_id(拆表唯一标识)", + duplicateSheetId: "sheet_id 不能重复", + sheetTitle: "表标题", + sheetId: "sheet_id", + splitSheet: "拆表复制", + colSheets: "监控表", + sheetsUnit: "张表", + presenceShort: "仅条目", + modeFields: "模式:按比对字段检查值", + modeFieldsShort: "字段比对", + modePresence: "模式:仅校验前后条目一致(不比具体值)", + mapping: "端口映射(可选)", + newMapping: "新建映射…", + mapName: "映射名称", + saveMapping: "保存映射", + validateMapping: "校验映射", + validateNeed: "请先选择映射与前后批次", + mapHint: + "每行一对:操作前接口,操作后接口(逗号/Tab/竖线分隔)。例:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# 井号开头为注释。不是 A,B/C,D 这种斜杠分组。", + mappingSaved: "映射已保存", + mappingOptionalHint: + "可选。留空=忽略本端端口改名,按非接口 Key 对齐。填写后才做端口映射校验。主接口映射会自动覆盖 .子接口(后缀不变)。", + ifaceNormalize: "接口归一化", + ifaceNormalizeHint: + "模板级规则,每行:简写,规范名。先归一化再端口映射。例:\nGE,gei\nSG,smartgroup\nXGE,xgei", + createJob: "建立比对任务", + defaultJobName: "割接比对", + jobName: "名称", + template: "模板", + mode: "模式", + modeManual: "手工选批次", + modeAuto: "自动(钉死操作前,操作后取最新)", + beforeTask: "操作前任务", + beforeBatch: "操作前批次", + afterTask: "操作后任务", + afterBatch: "操作后批次", + sideBefore: "操作前", + sideAfter: "操作后", + sidesVs: "比对", + sidesTitle: "比对双方", + configSnapshot: "执行配置已保存", + configSnapshotHint: "此记录的模板规则和端口映射已固定;后续编辑不影响历史回查,下载报告包含执行配置。", + configLegacy: "旧记录:未保存执行配置", + configLegacyHint: "此记录创建时未保存配置快照;成功行回查和精简明细还原使用当前配置,修改配置后可能与当时结果不同。", + pick: "选择…", + sameAsBefore: "同操作前 / 选择…", + autoHint: "auto:操作后任务新批次到达会自动跑比对", + createCompare: "创建比对任务", + createSteps: "创建步骤", + stepBasics: "基本信息", + stepSheets: "对比项", + stepBatches: "批次", + stepMapping: "端口映射", + next: "下一步", + back: "上一步", + enabledSheets: "对比项", + enabledSheetsHint: "点击开关;默认全部开启。关闭的项不参与本次比对。", + noTemplateSheets: "该模板暂无对比表", + needJobName: "请填写任务名称", + needTemplate: "请选择比对模板", + needSheets: "请至少开启一项对比", + needBeforeBatch: "请选择操作前任务与批次", + needAfterBatch: "请选择操作后批次", + runNow: "立即比对", + runStarted: "比对已在后台启动,可关闭本页;进度见历史记录", + runFailed: "比对失败", + runStatusRunning: "比对中", + runStatusFailed: "失败", + runStatusDoneSec: "完成 {{s}}s", + runProgress: "{{phase}} · 表 {{i}}/{{n}} · {{sheet}} · 已用 {{s}}s", + runSheetProgress: "表 {{i}}/{{n}}", + runElapsed: "已用 {{s}}s", + runRowsLoaded: "已加载 {{side}} {{n}} 行", + runRowsSqlCount: "库内统计 {{side}} {{n}} 行", + runPersisting: "已写入 {{done}}/{{total}} 行", + runEngineSql: "引擎 SQL", + runEnginePython: "引擎 Python", + runEngineNote: "原因 {{note}}", + phaseQueued: "排队中", + phaseLoading: "加载数据", + phaseSqlCount: "库内统计", + phaseSqlProject: "准备比对表", + phaseSqlJoin: "库内比对", + phaseSqlFailFetch: "拉取失败行", + phaseComparing: "比对中", + phasePersisting: "写入结果", + phasePersistingFail: "写入失败行", + phasePersistingOk: "写入成功行", + phaseDone: "完成", + phaseFailed: "失败", + phaseCancelled: "已取消", + sheetPending: "等待中", + colProgress: "进度 / 结果", + passRateScope: "全表", + keyFiltersToggle: "字段筛选", + hideDisplayCols: "隐藏展示列", + showDisplayCols: "显示展示列", + displayColsToggle: "展示列", + hideDisplayColsHint: "紧凑模式:默认隐藏不参与比对的展示列;高亮表示当前已显示", + ranWithDuration: "比对完成(耗时 {{s}} 秒)", + unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。", + unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(分层抽查)。要查任意路由请输入筛选条件——将按原表即时判定。通过率按全部 {{total}} 计。", + liveSearchHint: "从原始批次补齐同一业务的前后数据后筛选;通过率仍按全表统计。", + liveSearchTruncatedHint: "搜索已达上限,部分业务未展示。请收窄条件;不完整的业务分组已跳过。", + clearSearch: "清空筛选", + resultLoading: "正在加载对比明细…", + resultRange: "{{from}}–{{to}} / {{total}} 条", + resultLoadFailed: "对比明细加载失败", + retry: "重试", + searchEmpty: "当前筛选条件下没有匹配结果,可清空筛选或切换结果类型。", + clearKeyFilters: "清空字段", + storeUnchanged: "成功行保存", + storeUnchangedAuto: "自动(小表全量 / 大表抽样 5000+原表补全)", + storeUnchangedSample: "仅抽样(最多 5000,身份键+原表补全)", + storeUnchangedKeys: "全量身份键(可翻完全部成功,大表写入仍较久)", + storeUnchangedAlways: "始终全量 JSON(大表很慢,慎用)", + storeUnchangedNever: "不保存(只计数量)", + storeUnchangedHint: "割接优先看失败与通过率;成功默认分层抽样。输入筛选可回查原表(不依赖抽样)。通过率按全部成功计数。", + saveJob: "保存配置", + jobSaved: "任务配置已保存", + jobDeleted: "比对任务已删除", + jobList: "比对任务", + detail: "详情", + edit: "编辑", + delete: "删除", + tabConfig: "任务配置", + tabRuns: "对比批次", + tabResult: "比对结果", + detailTabs: "业务对比工作区", + jobReady: "已就绪", + configBasics: "作业参数", + configSources: "前后数据源", + configScope: "检查范围", + selectMapping: "选择映射", + mappingPairs: "接口对应关系", + sourceBaseline: "固定基准", + sourceTarget: "验收目标", + sourceLatest: "最新批次", + sourceRows: "采集 {{n}} 条", + autoSourceHint: "保持操作前批次固定;操作后任务采集完成时,自动取最新批次进行对比。", + runHistoryTitle: "执行记录", + runHistoryHint: "展示最近 50 条记录。执行状态与业务判定分别显示,可打开结果核查。", + refreshRuns: "刷新记录", + loadedRuns: "已载入批次", + activeRuns: "正在执行", + completedRuns: "执行完成", + stoppedRuns: "失败 / 取消", + runFinished: "已完成", + runStatusQueued: "排队中", + runsLoading: "正在读取执行记录…", + runsLoadError: "执行记录读取失败", + runs: "历史比对", + runsHint: "每次「立即比对」生成一条批次。重启后卡住的「比对中」会自动标为已取消;也可手动终止后重跑。", + noRuns: "尚无比对记录,请先执行比对", + result: "比对结果", + viewResult: "查看结果", + cancelRun: "终止", + confirmCancelRun: "确定终止该比对批次?终止后可重新发起比对。", + runCancelled: "比对已取消", + runStatusCancelled: "已取消", + compareAlreadyRunning: "已有比对在进行中,请先在「对比批次」中终止或等待完成", + runBatchSummary: "失败 {{fail}} · 成功 {{ok}} · 新增 {{added}}", + colTime: "时间", + pickBatchRun: "选择比对记录", + pickRun: "选择比对记录…", + runCount: "{{n}} 次", + resultEmpty: "无匹配失败/结果行", + resultFilterPh: "自由词,或 direction:out network:1.1.1.1", + kindAll: "全部", + kindDiff: "失败", + kindAdded: "新增", + kindRemoved: "仅缺失", + kindChanged: "仅不一致", + kindUnchanged: "成功", + kindAddedShort: "新增", + kindRemovedShort: "缺失", + kindChangedShort: "不一致", + kindUnchangedShort: "成功", + kindFail: "失败", + kindPass: "成功", + kindSuccess: "成功", + successCount: "成功", + passRateShort: "通过率", + verdictPass: "通过", + verdictFail: "不通过", + failCount: "失败条目", + missCount: "缺失", + extraCount: "多余", + mismatchCount: "不一致", + matchCount: "一致", + failFields: "失败项", + failFieldsEmpty: "—", + failWholeRow: "整行", + pairBefore: "pre", + pairAfter: "post", + sheetAllPass: "全部一致", + filterFailField: "失败比对项", + filterFailFieldAll: "全部失败项", + diffCount: "失败 {{n}}", + passRate: "通过率", + passOk: "全部通过", + passWarn: "存在失败", + runOverview: "总裁决", + sheetCurrent: "当前检查项", + diffCountLabel: "失败条目", + diffRate: "失败率", + beforeCount: "操作前行数", + afterCount: "操作后行数", + matchedRows: "对齐条目", + totalRows: "合计条目", + sheetCount: "监控表", + sheetNavTitle: "检查项", + sheetNavHint: "共 {{n}} 项 · 点击切换", + collapseSheetNav: "折叠检查项", + expandSheetNav: "展开检查项", + sheetFailCount: "{{n}} 项失败", + topChangedFields: "失败字段 Top", + exportTables: "下载表格", + exportOk: "已导出比对表格", + fullscreen: "大屏全屏", + exitFullscreen: "退出全屏", + emptyJobs: "暂无比对任务", + created: "比对任务已创建", + ran: "比对完成", + colName: "名称", + colMode: "模式", + colStatus: "状态", + colKind: "结论", + colResult: "结果", + colDetail: "明细", + colFailFields: "失败项", + colActions: "操作", + colKey: "身份", + colChange: "变更", +}; + +export default bizCompare; diff --git a/web/src/i18n/locales/zh/bizMigration.ts b/web/src/i18n/locales/zh/bizMigration.ts new file mode 100644 index 0000000..b8980e5 --- /dev/null +++ b/web/src/i18n/locales/zh/bizMigration.ts @@ -0,0 +1,298 @@ +const bizMigration = { + fullscreen: "大屏模式", + exitFullscreen: "退出大屏", + fullscreenHint: "全屏展示割接状态,按 Esc 返回看板;浏览器不支持全屏时使用窗口大屏。", + wallTitle: "割接监控 · 业务迁移", + wallProgress: "本批迁移进度 · 当前快照", + wallFilters: "显示筛选与判定操作", + wallState_waiting: "等待判定", + wallState_unknown: "数据待确认", + wallState_anomaly: "存在业务异常", + wallState_normal: "当前样本无业务异常", + pairCollectionHint: "每个指标两侧同轮关联采集。保存配置会同步增删采集任务;路由项仅手动采集,收尾时再选择,百万路由建议使用独立业务对比任务。频率是目标间隔,实际周期受命令耗时与设备占用影响。", + pairedSampling: "双端关联采集 · 及时性", + manualRoute: "手动收尾", + pairedPeriodic: "关联周期采集", + samplingMode: "采集方式", + samplingDuration: "采集耗时 老 / 新", + samplingQueue: "排队耗时 老 / 新", + startSkew: "启动时差", + sameRound: "同轮", + metricBaselineMissing: "基线未覆盖此指标,需要补充对应基线", + samplePhase_idle: "尚未采集", + samplePhase_queued: "排队", + samplePhase_running: "采集中", + samplePhase_success: "完成", + samplePhase_partial: "部分完成", + samplePhase_failed: "采集失败", + samplePhase_cancelled: "已停止", + collect_pair_invalid: "双端关联配置不完整", + collect_pair_missing: "尚无完整同轮样本", + collect_pair_pending: "等待两侧同轮采集完成", + collect_pair_failed: "本轮采集失败,不能复用旧样本代替", + collect_round_mismatch: "两侧样本不属于同一轮次", + sample_start_skew: "两侧实际启动时差过大", + latestSnapshot: "最新快照", + steadyOutside: "范围外稳定 {{n}} 条", + "followLive": "实时跟随", + "historySnapshot": "历史快照", + "evaluatedAt": "最近判定", + "refreshBoard": "刷新看板", + "oldestSample": "最早采集", + "liveOverview": "业务迁移概览", + "remaining": "待迁移条目", + "anomalyInBatch": "本批异常", + "anomalyOutsideBatch": "范围外异常", + "coverageUnknown": "数据待确认,不能视为验收通过", + "coverageUnknownHint": "采集缺失、过期、不同步或键冲突需要处理;正常样本仅代表对应监控条目。", + "awaitingPeer": "等待新侧采集确认", + "migratedPrevious": "前批已接续", + "unfinishedPrevious": "前批未接续", + "sample_time_missing": "采集时间缺失", + "sample_skew": "两侧采集间隔过大", + "sample_stale": "采集已过期", + "duplicateKeys": "重复业务键", + "uncoveredScope": "范围对应表已失效", + "refreshFailed": "获取失败,当前显示保留快照:", + "lastAcceptance": "上次终验", + "diffSearch": "搜索业务键 / 判定 / 状态", + "verdictFilter": "判定筛选", + "allVerdicts": "全部判定", + "warningVerdict": "迁移中 / 提示", + "healthyVerdict": "已接续 / 正常", + "unknownVerdict": "待确认", + "loadingBoard": "正在更新…", + "rowsMatched": "匹配 {{n}} 条", + "showOutsideAnomalies": "查看范围外异常", + "scopeCurrent": "本批范围", + "scopePrevious": "前批范围", + "scopeOutside": "范围之外", + "fieldChanges": "字段变化 {{n}} 项", + "outsideProtection": "范围外业务持续保护", + "outsideProtectionHint": "本批之外的业务漂移仍报异常;前批已迁移业务持续检查新侧健康,不计入本批进度。", + "reason_service_not_received": "老侧退出,新侧未健康接续", + "reason_target_unhealthy": "新侧业务状态异常", + "reason_old_service_not_in_baseline": "老基线中未找到业务", + "reason_service_content_changed": "接续业务内容不一致", + "reason_old_service_still_present": "老侧业务尚未退出", + "reason_target_sample_predates_source": "新侧样本早于老侧退出", + "reason_previous_batch_received": "前批业务持续健康", + "reason_previous_batch_not_received": "前批业务未正常接续", + "reason_unmapped_same_iface": "未映射端口发生迁移变化", + "reason_out_of_expect:anomaly": "范围外业务漂移", + "reason_out_of_expect:lost": "范围外业务丢失", + title: "割接监控", + hint: "与业务对比独立:相对基线漂移 + 本批预期 + 老/新双端判定。复用采集任务与端口映射。", + hintPort: "当前监控项:端口状态(interface_brief / admin·phy·prot)。选基线 → 勾选本批端口 → 判定看板。", + hintShort: "选老/新设备与监控模板,创建后自动高频采集;基线来自业务监控画像。", + create: "创建任务", + createHint: + "除画像基线外,整条割接流程在本页完成;创建后自动生成业务监控高频任务并按计划执行。", + createHintShort: + "选老/新设备 → 监控模板与采集项 → 周期/窗口 → 基线(画像)→ 端口映射 → 创建。后台自动建高频任务。", + pickOldNe: "老设备", + pickNewNe: "新设备", + pickNe: "选择设备", + pickNeHint: "点击选择网元", + hideNePicker: "收起列表", + nePairReady: "老/新设备已选好,可进入下一步;点击上方卡片可重新选择。", + needNePair: "请填写任务名并选择老/新设备", + needProjectName: "请填写任务名称", + needMonitorTemplate: "请选择监控模板", + needMetrics: "请至少勾选一项监控项", + noPortraitBaseline: "该设备尚无画像采集任务或批次,请先在业务监控跑通画像后再选基线(可稍后补)。", + baselineOptionalHint: "基线可稍后在详情里补;有画像批次时可在此直接选定。", + metricInterval: "分项周期(秒)", + metricIntervalOptional: "分项周期(可选)", + metricIntervalHint: "留空则使用上方全局周期;填写后该监控项会单独建高频任务。", + scheduleHint: "按本机时间填写窗口,入库为 UTC;窗口外调度器自动暂停高频任务。", + sectionSchedule: "采集周期与窗口", + sectionBaseline: "基线与映射", + sectionDevices: "老 / 新设备", + sectionMetrics: "监控项", + createSteps: "创建步骤", + stepDevices: "设备", + stepTemplate: "模板", + stepSchedule: "周期", + stepBaseline: "基线", + next: "下一步", + back: "上一步", + colHf: "高频", + hfReady: "已建", + hfMissing: "未建", + showCreateMore: "更多:模板 / 映射 / 基线", + hideCreateMore: "收起更多", + setupHintShort: "画像任务用于基线;高频任务用于窗口内采集。此处配置模板、采集项、窗口与双基线。", + createProject: "创建任务", + projectName: "任务名称", + projectNamePh: "例如:PE-A / PE-B 割接-1", + sectionName: "1. 任务名称", + sectionTasks: "2. 绑定采集任务(必填)", + sectionTasksHint: "分别选择老设备、新设备上的业务监控任务;两侧不能为空。", + sectionOptional: "3. 监控模板、映射与基线(可选)", + sectionOptionalHint: "可先跳过,创建后再到「基线配置」里设置。未选监控模板时使用系统默认。", + sectionPair: "已绑定采集任务", + sectionPairHint: "采集任务在创建时绑定;此处可调整监控模板、映射与基线。", + sectionBaselineHint: "老侧与新侧基线均必填;老侧用于列出本批预期对象,新侧参与双端判定。", + optionalNone: "不选", + monitorTemplate: "监控模板", + monitorTemplateHint: "决定对比哪些 metric 与双端成功语义;默认绑定系统默认监控模板。", + compareTemplate: "对比模板", + compareTemplateBound: "绑定对比模板:{{name}}", + portMappingHint: + "有映射时,带接口字段的监控项只比映射内的口(这些口必须在割接后出现)。映射外的同名口不参与迁移对比;老口 down、新口同名 up 记为异常。未配映射则按同名直比。BGP/PW 仍用本批预期勾选。", + baselineNeedTask: "请先选择对应采集任务", + baselinePickHint: "从该任务的历史采集批次中选择", + oldTask: "老设备画像任务", + newTask: "新设备画像任务", + portMapping: "端口映射", + oldBaseline: "老设备基线批次", + newBaseline: "新设备基线批次", + needProjectFields: "请填写任务名并选择老/新画像任务", + needExpectPorts: "请先勾选本批预期迁移的对象", + needBaselineFirst: "先设基线", + needBothBaselines: "请先保存老、新两侧基线批次后再评估", + errOldBaselineRequired: "缺少老设备基线批次", + errNewBaselineRequired: "缺少新设备基线批次", + needBizStateTasks: "尚无业务监控(画像)任务,请先创建:", + projectCreated: "任务已创建", + projects: "任务", + empty: "暂无割接任务。", + emptyProjects: "暂无割接任务", + listFilterPh: "筛选任务名 / 网元 / IP / 状态", + kpiProjects: "任务数", + kpiActive: "进行中", + detail: "详情", + cancel: "关闭", + colName: "任务", + colPair: "老 → 新", + colBaseline: "基线", + colCreated: "创建时间", + colActions: "操作", + baselineSet: "已设", + baselineUnset: "未设", + statusActive: "进行中", + statusDone: "已完成", + statusPaused: "已暂停", + statusPending: "待开始", + statusReview: "验收中", + tabSetup: "基线配置", + tabBatches: "割接批次", + tabBoard: "判定看板", + saveBaseline: "保存配置", + baselineSaved: "基线已保存", + errPairCapacity: "双端关联采集至少需要两个采集并发槽位,请检查任务并发、采集线程和 CLI 预算配置。", + errPairBusy: "该指标的两侧仍在采集或排队,请等待本轮结束。", + errPairInvalid: "双端采集关联不完整,请重新保存配置建立关联。", + errPairSameDevice: "割接监控需要选择两台不同设备。", + pickExpectPorts: "本批预期", + portFilterPh: "筛选", + emptyBaselinePorts: "基线无数据", + batchLabel: "批次标签", + batchId: "批次 ID", + copyBatchId: "复制批次 ID", + defaultBatchLabel: "第N晚", + expectPorts: "本批预期端口(老侧)", + expectPortsPh: "逗号或换行分隔", + createBatch: "新建", + batchCreated: "批次已创建", + batches: "批次", + startBatch: "启动", + finishBatch: "收工", + evaluate: "立即判定", + evaluated: "判定完成", + statusUpdated: "状态已更新", + board: "进度看板", + metricPort: "端口状态", + progress: "进度", + anomaly: "异常", + onlyExpect: "仅预期", + onlyExpectHint: "默认显示全部漂移;勾选后只看本批预期", + pinCurrentBatches: "当前采集批次", + pinOldBatch: "老侧当前", + pinNewBatch: "新侧当前", + pinLatest: "最新", + runHistory: "判定历史", + runLatest: "最新一次", + colRuleHit: "命中规则", + colCommand: "采集命令", + mappingBound: "映射已保存并绑定到本任务", + allMetrics: "全部监控项", + colMetric: "监控项", + colKey: "对象", + colPort: "老侧端口", + openRedChip: "红单 {{n}}", + metaPortrait: "画像", + metaHf: "HF", + metaBaseline: "基线", + baselineOk: "已设", + baselineMissing: "缺", + enableHighfreq: "开启高频", + highfreqReady: "高频已就绪", + collectNow: "立即采集", + collectTriggered: "已触发采集", + openBizState: "业务监控", + hfNotBound: "未绑定", + projectCollectMetrics: "采集项", + hfInterval: "周期(秒)", + hfStart: "开始", + hfEnd: "结束", + markProjectDone: "完成并停HF", + confirmProjectDone: "确认完成项目并暂停全部高频?", + projectMarkedDone: "已完成,高频已停", + deleteProject: "删除", + confirmDeleteProject: "确认删除项目?将停高频并清批次记录。", + projectDeleted: "已删除", + acceptPassedShort: "通过", + acceptFailedShort: "未过", + colMapped: "映射新口", + colDesc: "描述", + colOldPort: "老侧业务标识", + colNewPort: "新侧业务标识", + colOldStatus: "老状态 admin/phy/prot", + colNewStatus: "新状态 admin/phy/prot", + colOld: "老侧", + colNew: "新侧", + colVerdict: "判定", + emptyDiffs: "暂无明细,请先「立即判定」", + emptyRed: "暂无红单", + verdictMigrated: "已迁移", + verdictMigrating: "迁移中", + verdictLost: "丢失", + verdictAnomaly: "异常", + verdictNotInvolved: "未涉及", + verdictUnexpected: "意外新增", + verdictOk: "正常", + highfreqHint: "", + boundTasks: "绑定任务", + oldPortraitTask: "老画像", + newPortraitTask: "新画像", + oldHfTask: "老HF", + newHfTask: "新HF", + projectCollectHint: "", + hfWindowInactive: "高频窗口未到/已结束,或项目已完成,当前不可采集", + baselinePortraitOnly: "", + collectSkipped: "未采", + collectSkippedBanner: "未采:{{metrics}}", + currentMissing: "缺当前", + currentMissingBanner: "缺当前批次:{{metrics}}", + highfreqReadyInactive: "高频已绑定(窗口未开,未立即采集)", + collectPartial: "部分失败(成功 {{ok}} / 失败 {{fail}})", + errMetricNeedsBindings: "监控项「{{metric}}」需要参数绑定({{params}}),割接高频暂不支持,请去掉该项", + errNoProfileForMetric: "监控项「{{metric}}」在当前设备厂商下无可用采集 profile", + acceptTitle: "本批验收", + acceptPassed: "验收通过", + acceptFailed: "验收未过(红单 {{n}})", + newBaselineMissingBanner: "缺新基线:{{metrics}}", + acceptCarryHint: "可带红开下一批", + openRedHint: "红单 {{n}}", + redTitle: "红单", + redOpen: "未关闭", + resolveRed: "关闭", + redResolved: "已关闭", + batchCreatedWithRed: "已建批(红单 {{n}})", + colStatus: "状态", + verdictUnfinished: "未完成", +}; + +export default bizMigration; diff --git a/web/src/i18n/locales/zh/bizMonitorTpl.ts b/web/src/i18n/locales/zh/bizMonitorTpl.ts new file mode 100644 index 0000000..5c4c622 --- /dev/null +++ b/web/src/i18n/locales/zh/bizMonitorTpl.ts @@ -0,0 +1,134 @@ +const bizMonitorTpl = { + title: "监控模板", + help: "对比模板定义怎么对齐/比对;监控模板按同表定义搬迁成功条件。", + hint: "对比模板定义「怎么对齐/比对」;监控模板按同样的表(sheet)定义「怎样算搬迁成功/失败」。", + create: "新建监控模板", + edit: "编辑", + save: "保存", + cancel: "关闭", + created: "已创建", + saved: "已保存", + deleted: "已删除", + confirmDelete: "删除该监控模板?", + delete: "删除", + empty: "暂无监控模板。", + listFilterPh: "筛选名称 / 对比模板 / 备注", + kpiTotal: "模板数", + colName: "名称", + namePh: "例如:状态割接监控", + colCompare: "对比模板", + colCollect: "高频采集项", + colNote: "备注", + colActions: "操作", + needName: "请填写名称", + defaultsInvalid: "defaults JSON 无效", + overridesInvalid: "sheet_overrides JSON 无效", + exportTemplate: "导出", + importTemplate: "导入", + templateExported: "模板已导出", + templateImported: "模板已导入", + templateImportInvalid: "导入文件无效:需要 name 与 compare_template_id(或 compare_template_name)", + templateImportHint: "导入/导出 JSON,便于私下传递监控模板(不含任务/批次数据)", + templateImportCompareMissing: "未找到对应对比模板,请先导入或创建同名对比模板", + formHint: "先选对比模板 → 出现与对比模板相同的表页签 → 为每张表配置成功条件。", + compareHint: "决定有哪些表以及 Key/比对字段;此处只配置双端判定。", + pickCompare: "选择对比模板…", + openCompare: "管理对比模板", + collectAll: "模板全量", + collectPh: "interface_brief, bgp_peer(空=全量)", + collectHint: "勾选高频采集的 metric;全不勾或点「模板全量」表示对比模板全部 sheets。", + collectTitle: "高频采集子集", + defaults: "defaults(JSON)", + overrides: "sheet_overrides(JSON)", + overridesHint: "可按 metric 声明 status_fields / success 模式。", + globalPolicy: "全局策略", + outOfExpect: "预期外对象", + outOfExpectHint: "不在本批预期内的漂移如何处理。", + ooeStrict: "严格(异常标红)", + ooeWarn: "警告(仅标黄,不记红单)", + ooeIgnore: "忽略(一律未涉及)", + sheetRules: "各表搬迁判定", + applyAllPresets: "全部套用推荐预设", + applyAllPresetsShort: "全部预设", + sheetsUnit: "张表", + sheetHowReadonly: "对比模板(只读)", + iface: "接口", + compare: "比对", + presenceOnly: "仅存在性", + editInCompare: "改 HOW", + presetAuto: "推荐预设", + presetSkip: "跳过本表", + skipDual: "本表不参与双端判定", + skipDualHint: "该表在割接判定中跳过(计数器等请用业务对比 Job)。", + statusFields: "用哪些字段判断 up/down", + statusFieldsHint: "端口勾选 admin/phy/prot;BGP 勾选 state;ARP 可不勾(只看条目有无)。", + statusOptionalHint: "未勾选状态字段时,只按「消失 / 新增」等存在性判定。", + downValues: "算 down 的取值", + upValues: "算 up 的取值", + noFields: "无可用字段(先在对比模板勾选 metric)", + successPatterns: "成功规则(老侧组间或 × 新侧组间或)", + successPatternsShort: "成功规则", + successPatternsHint: "存在性看条目有无;值变更绑定字段并选择怎么变。组内是且,组之间是或。", + successPatternsHintShort: "组内且 · 组间或 · 老侧命中一组且新侧命中一组 → 成功", + successPatternsHintFree: + "每侧只有两种条件:存在性(不绑字段)或值变更(必须绑字段)。同一组内全部满足(且),组与组之间满足一组即可(或)。", + anomalyPatternsShort: "异常规则", + anomalyPatternsHint: + "与成功同结构。命中后标红。某一侧可勾「不限制」表示只看另一侧(如新侧现网消失/down)。未成功≠异常,中间态仍是迁移动态。", + anomalyPatternsHintShort: "先匹配成功;未命中再看异常。可勾「不限制」只盯一侧。中间态≠异常。", + successRow: "成功 {{n}}", + anomalyRow: "异常 {{n}}", + removeRow: "删除", + oldSideAny: "老设备", + newSideAny: "新设备", + groupLogicHint: "组内且,组之间或", + sideDontCare: "不限制此侧", + sideDontCareHint: "只按另一侧条件判定。", + groupN: "组 {{n}}", + addAnd: "组内再加一条(且)", + addOr: "再加一组(或)", + addAndShort: "+ 且", + addOrShort: "+ 或组", + addSuccessRow: "+ 成功规则", + addAnomalyRow: "+ 异常规则", + modeAny: "满足任一即可", + modeAll: "全部同时满足", + addCond: "添加条件", + pickField: "选择字段…", + condValue: "取值", + opEmpty: "为空", + opNotEmpty: "非空", + opChanged: "相对基线变了", + opUnchanged: "相对基线没变", + op_eq: "等于", + op_ne: "不等于", + op_in: "属于", + op_not_in: "不属于", + op_empty: "为空", + op_not_empty: "非空", + op_changed: "相对基线变了", + op_unchanged: "相对基线没变", + and: "且", + groupPresence: "存在性", + groupValue: "值变更", + groupStatus: "状态类", + groupField: "字段条件", + tokRemoved: "消失", + tokAdded: "新增", + tokUnchanged: "未变", + tokChanged: "有变化", + tokUp: "up", + tokDown: "down", + tokOther: "其它状态", + or: "或", + tagSkip: "跳过", + tagRules: "{{n}} 条", + tagEmpty: "未配", + needCompareFirst: "请先选择带 sheets 的对比模板。", + showMore: "更多:备注 / 高频采集 / JSON", + hideMore: "收起更多", + showJson: "高级:编辑 JSON", + hideJson: "回到可视化编辑", +}; + +export default bizMonitorTpl; diff --git a/web/src/i18n/locales/zh/bizState.ts b/web/src/i18n/locales/zh/bizState.ts new file mode 100644 index 0000000..186e8fb --- /dev/null +++ b/web/src/i18n/locales/zh/bizState.ts @@ -0,0 +1,196 @@ +const bizState = { + title: "业务监控", + loadedTasks: "已载入任务", + loadedBatches: "最近批次 · 已载入 {{n}} 条(最多 200)", + collectErrors: "最近采集异常", + retry: "重试", + taskLoading: "正在读取任务配置…", + tasksLoadError: "任务列表读取失败", + taskLoadError: "任务配置读取失败", + batchLoadError: "批次读取失败", + sheetLoadError: "结构化数据读取失败", + statusQueued: "排队中", + batchSuccess: "采集成功", + batchPartial: "部分完成", + batchFailed: "采集失败", + batchCancelled: "已取消", + create: "创建任务", + createHint: "从托管 / UME 等有效网元中选择(支持查找)", + createModeDevice: "选择设备", + createModeImport: "手动导入", + createImportHint: + "无网元也可建任务:上传会话日志或 zip,自动切分 show/display 并解析为首个批次。之后可在任务上多次「导入日志」追加批次(等同于多次采集)。", + createImportSubmit: "创建并导入", + created: "已创建任务", + createFailed: "创建失败", + empty: "暂无业务监控任务。", + pickNe: "选择网元…", + neKeywordPh: "名称 / IP / ID", + allSource: "全部来源", + selectedNe: "已选", + neEmpty: "无匹配网元。", + colSource: "来源", + colVendor: "厂商", + colConnect: "连通", + start: "启动周期", + pause: "暂停", + statusPaused: "仅手动", + scheduleEnabled: "启用周期调度", + scheduleOn: "周期", + scheduleOff: "手动", + scheduleHint: "勾选后按周期自动采集;不勾选则仅支持「立即采集」。", + collectNow: "立即采集", + importLog: "导入日志", + importLogHint: + "向当前任务追加一批次(等同于一次采集):支持整段会话或 zip,丢掉 show/display 前杂讯,按 hostname#show 切分;整份无 show 则失败(无需 SSH)。", + importVendor: "厂商", + importVendorGeneric: "通用(show+display)", + importFile: "日志文件", + importNeName: "任务名称(可选)", + importNeNamePh: "例如:PE1-offline", + importSubmit: "导入为新批次", + importing: "导入中", + importStarted: "已开始导入({{count}} 段命令)", + importOfflineOnly: "导入任务仅支持离线导入,无 SSH 采集", + sourceImport: "导入", + stopCollect: "停止采集", + stopCollectOk: "已请求停止采集", + stopCollectIdle: "当前没有进行中的采集", + delete: "删除", + confirmDelete: "删除该业务监控任务及所有批次?", + deleted: "已删除", + started: "已启用周期采集", + paused: "已暂停周期(仅手动采集)", + collecting: "采集中", + statusCollecting: "采集中", + detail: "详情", + interval: "采集周期", + intervalUnit: "周期单位", + intervalUnitDays: "天", + intervalUnitHours: "小时", + intervalUnitSeconds: "秒", + retention: "保留天数", + retentionHint: "超过天数的批次会自动清理;基线与比对/割接引用的批次不会删除。", + dailyKeepEnabled: "每日保留策略", + dailyKeepCount: "每天保留次数", + dailyKeepHint: "开启后:过去每一天只保留最近 N 次,多余在次日清理(默认关闭)。", + markBaseline: "标为基线", + unmarkBaseline: "撤销基线", + baseline: "基线", + protected: "已保护", + colAlias: "别名", + setAlias: "设别名", + aliasPrompt: "批次别名(比对/割接选择时优先显示;留空清除)", + aliasSaved: "别名已保存", + aliasEmpty: "未命名", + workbookStats: "对比项 {{compare}} · 有数据 {{withData}} · 表格 {{sheets}}", + deleteBatch: "删除", + bulkDelete: "批量删除", + confirmDeleteBatch: "删除该采集批次?受保护的批次无法删除。", + confirmBulkDelete: "删除选中的 {{count}} 个批次?受保护的会自动跳过。", + batchDeleted: "已删除批次", + bulkDeleted: "已删除 {{deleted}} 个,跳过 {{skipped}} 个", + purgeNow: "立即清理", + purgeOk: "清理完成:删除 {{dropped}} 个批次", + selectAll: "全选可删", + colProtect: "保护", + saveSchedule: "保存周期", + scheduleSaved: "周期配置已保存", + colInterval: "周期", + profiles: "监控项", + profilesSelectAll: "全选", + profilesDeselectAll: "取消全选", + profilesSelectAllOk: "已启用 {{n}} 项", + profilesDeselectAllOk: "已全部取消启用", + profilesSelectionStats: "已选 {{enabled}} / 共 {{total}} 项", + profilesUnboundStats: "未关联 {{n}}", + colLane: "采集车道", + laneLight: "轻车道", + laneHeavy: "重车道", + laneLightHint: "与多数命令共用 SSH;整轮默认约 10 分钟上限", + laneHeavyHint: "独立 SSH + 更长超时;整轮默认约 40 分钟上限", + enable: "启用", + command: "命令模板", + colAux: "辅命令", + params: "参数关联", + discoverVrf: "发现 VRF", + discoverBind: "发现 / 关联", + saveBindings: "保存关联", + cancel: "关闭", + bindTitle: "选择参数关联", + unbound: "未关联", + bindHintRequired: "需关联参数后才可采集", + paramsDblClickHint: "单击查看全部关联明细", + paramsClickHint: "单击查看全部关联明细", + paramsDetailTitle: "参数关联明细", + paramsDetailCount: "共 {{n}} 条", + discoverLoading: "正在发现…", + discoverEmpty: "未发现可用选项", + discoverCacheHit: "已复用(未重新登录)", + discoverRefresh: "重新从设备拉取", + selectAllVrfs: "全选", + deselectAllVrfs: "取消全选", + batches: "采集批次", + viewBatch: "查看", + batchDetail: "详情", + export: "导出", + exportCommands: "导出采集命令", + exportCommandsOk: "已导出采集命令", + exportRawLog: "导出为文本", + exportRawLogOk: "日志已导出", + noBatches: "尚无批次", + batchCollectDetail: "采集详情", + batchWorkbook: "批次工作簿", + batchId: "批次 ID", + copyBatchId: "复制批次 ID", + batchMessageEmpty: "无额外说明或报错", + batchCmdSummary: + "命令 {{ok}} 成功 / {{fail}} 失败 / {{skipped}} 跳过 / {{other}} 其他 · 统计 {{total}} 条(批次计数 {{batchCmds}})", + sheetCommands: "命令", + sheetLldp: "LLDP 邻居", + sheetVrfRoute: "VRF 路由摘要", + sheetIsis: "ISIS 邻居", + sheetIfaceBrief: "接口 Brief", + sheetArp: "ARP", + sheetNd6: "ND6 Cache", + sheetBgpPeer: "BGP 对等体", + sheetConfigIface: "接口 Intent", + sheetConfigVrf: "VRF Intent", + sheetConfigBgp: "BGP Intent", + sheetFilterPh: "筛选当前表…", + sheetEmpty: "本表无数据", + sheetLoading: "加载中…", + collectCommand: "采集命令", + viewRawLog: "原始日志", + rawLogTitle: "原始采集日志", + rawLogEmpty: "无原始输出", + rawLogStats: "采集 {{lines}} 行 · 入库 {{rows}} 条", + rawLogDeclared: "声明 {{declared}}", + openWorkbook: "打开工作簿", + filterColumn: "筛选列", + filterAllCols: "全部列", + listFilterPh: "筛选网元 / IP / 厂商 / 状态", + purposeFilter: "任务用途", + purposeAll: "全部用途", + purposePortrait: "画像", + purposeCutoverHf: "割接高频", + colPurpose: "用途", + colNe: "网元", + colStatus: "状态", + colLast: "最近采集", + colStarted: "开始时间", + colEnded: "结束时间", + colTime: "时间", + colRows: "入库条数", + colRawLines: "采集行数", + colDeclared: "设备声明", + colActions: "操作", + colCommand: "命令", + colMessage: "说明", + pickTask: "打开详情查看任务", + noNeedBind: "该监控项无需参数关联", + discoverFailed: "发现失败", + bindingsSaved: "参数关联已保存", +}; + +export default bizState; diff --git a/web/src/i18n/locales/zh/collect.ts b/web/src/i18n/locales/zh/collect.ts new file mode 100644 index 0000000..d7542ef --- /dev/null +++ b/web/src/i18n/locales/zh/collect.ts @@ -0,0 +1,117 @@ +const collect = { + statusAll: "全部状态", + keywordPh: "任务名称 / ID", + exportJobsName: "collect-jobs", + exportRunsName: "collect-runs", + policyTitle: "采集策略", + policyHint: "默认不启用周期(只采一次);勾选后按周期自动创建并执行。默认保留最近 3 次任务数据。", + policySaved: "策略已保存", + enabled: "启用周期调度", + interval: "采集周期", + intervalUnitDays: "天", + intervalUnitHours: "小时", + historyKeep: "任务保留数", + scope: "范围", + scopeAll: "全部网元(纳管+UME)", + scopeSelected: "仅选中网元", + savePolicy: "保存策略", + collectNow: "立即采集", + selectedCount: "已选 {{count}} 台", + colSource: "来源", + policyCommandsRequired: "启用周期或立即采集前,请先填写采集命令并保存策略", + policyTargetsRequired: "选定范围内没有可用网元,请调整范围或勾选网元", + create: { + title: "新建采集任务", + hint: "配置命令与网元后创建任务;创建后需在任务列表中点击「开始」执行采集。", + selectedTitle: "已选择网元", + selectedEmpty: "请从下方筛选并逐个添加网元。", + selectedCount: "共 {{count}} 台", + clearSelected: "清空已选", + remove: "移除", + pickTitle: "可选网元", + pickHint: "可选网元管理与 UME 清单中的全部网元(不限连通性状态)。UME 使用 CLI 连接模板凭据。", + pickEmpty: "没有匹配的网元。", + filterKeyword: "筛选", + filterKeywordPh: "名称 / IP / 厂商 / 设备类型", + add: "添加", + addBatch: "批量添加({{count}})", + added: "已添加", + create: "创建任务", + creating: "创建中…", + expand: "新建", + collapse: "关闭", + meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令", + }, + eligible: { + title: "可选网元", + hint: "可选网元管理与 UME 清单中的全部网元(不限连通性状态)。", + empty: "暂无网元。", + }, + form: { + title: "采集配置", + jobTitle: "任务名称", + jobTitlePh: "留空则自动生成", + commands: "采集命令(每行一条,# 开头为注释)", + commandsPh: "display version\ndisplay current-configuration", + commandsHint: "命令将按顺序在每台设备上执行,输出合并为一份文本日志。", + meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令", + start: "开始采集", + starting: "提交中…", + }, + created: "采集任务已创建:{{id}}", + started: "采集已开始:{{id}}", + paused: "任务已暂停", + restarted: "已重新开始采集", + retryFailedDone: "已开始重采失败网元", + deleted: "任务已删除", + nothingToRetry: "没有可重试的失败网元", + neBusy: "部分网元正在被其他采集任务占用,请等待其完成后再开始", + jobRunning: "该任务已在执行中", + confirmDelete: "确定删除该采集任务?相关日志文件将一并删除。", + jobs: { + title: "采集任务", + listTitle: "任务列表", + col: { + title: "任务", + status: "状态", + progress: "进度", + created: "创建时间", + lastRun: "最近执行", + actions: "操作", + }, + ok: "成功", + fail: "失败", + expand: "详情", + collapse: "收起", + detailTitle: "任务明细", + pause: "暂停", + start: "开始", + starting: "启动中…", + restart: "重新开始", + retryFailed: "重采失败网元", + downloadResults: "下载结果", + delete: "删除", + autoRefresh: "任务进行中,每 2 秒自动刷新", + runsInProgress: "部分网元仍在采集中,请稍候…", + }, + kpi: { + jobs: "任务总数", + running: "进行中", + idle: "空闲", + last: "最近任务", + active: "活跃任务", + nextDue: "下次周期", + }, + runs: { + status: "状态", + message: "说明", + download: "日志", + downloadFile: "下载", + filterStatus: "状态筛选", + filterDevice: "设备筛选", + deviceKeywordPh: "名称 / IP", + allStatus: "全部状态", + }, +}; + +export default collect; diff --git a/web/src/i18n/locales/zh/common.ts b/web/src/i18n/locales/zh/common.ts new file mode 100644 index 0000000..18a34b7 --- /dev/null +++ b/web/src/i18n/locales/zh/common.ts @@ -0,0 +1,25 @@ +const common = { + help: "帮助说明", + refresh: "刷新", + refreshing: "刷新中…", + query: "查询", + clearFilters: "清除筛选", + prevPage: "上一页", + nextPage: "下一页", + perPage: "{{n}}/页", + pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页", + jumpPage: "跳转到页", + pageSize: "每页条数", + exportCsv: "导出 CSV", + exporting: "导出中…", + exportOk: "已导出 {{count}} 条", + exportTruncated: "已导出前 {{count}} 条(共 {{total}},已截断)", + exportFailed: "导出失败", + opFailed: "操作失败", + empty: "-", + cancel: "取消", + copy: "复制", + copied: "已复制", +}; + +export default common; diff --git a/web/src/i18n/locales/zh/configSync.ts b/web/src/i18n/locales/zh/configSync.ts new file mode 100644 index 0000000..535dfa3 --- /dev/null +++ b/web/src/i18n/locales/zh/configSync.ts @@ -0,0 +1,58 @@ +const configSync = { + title: "配置同步", + syncNow: "立即全量同步", + retryFailed: "一键重试失败", + pause: "暂停", + resume: "继续", + stop: "停止", + confirmStop: "停止后将取消未执行的网元任务,确定停止本轮同步?", + started: "已启动同步周期", + paused: "已暂停", + resumed: "已继续", + stopped: "已停止同步周期", + policyTitle: "同步策略", + policySaved: "策略已保存", + enabled: "启用周期调度", + intervalDays: "周期(天)", + concurrency: "并发", + historyKeep: "配置历史保留", + cycleKeep: "任务保留数", + scope: "范围", + scopeAll: "全部网元", + scopeSelected: "指定网元", + savePolicy: "保存策略", + selectedCount: "已选 {{count}} 台", + targetKeywordPh: "名称 / IP / 厂商", + cyclesTitle: "同步周期", + cyclesEmpty: "暂无同步周期", + tasksTitle: "周期任务", + taskKeywordPh: "名称 / IP / 消息", + keywordPh: "ID / 触发 / 消息", + statusAll: "全部状态", + allStatus: "全部状态", + exportCyclesName: "config-sync-cycles", + exportTasksName: "config-sync-tasks", + expand: "详情", + collapse: "收起", + kpi: { + snapshots: "配置覆盖数", + running: "进行中", + idle: "空闲", + last: "最近一轮", + nextDue: "下次调度", + }, + col: { + source: "来源", + name: "设备", + vendor: "厂商", + trigger: "触发", + status: "状态", + progress: "进度", + started: "开始", + ended: "结束", + message: "消息", + actions: "操作", + }, +}; + +export default configSync; diff --git a/web/src/i18n/locales/zh/index.ts b/web/src/i18n/locales/zh/index.ts new file mode 100644 index 0000000..363047d --- /dev/null +++ b/web/src/i18n/locales/zh/index.ts @@ -0,0 +1,48 @@ +// zh messages, one file per namespace (key prefix of t("ns.key")). +import common from "./common"; +import workbench from "./workbench"; +import network from "./network"; +import bizState from "./bizState"; +import bizCompare from "./bizCompare"; +import bizMigration from "./bizMigration"; +import bizMonitorTpl from "./bizMonitorTpl"; +import portTraffic from "./portTraffic"; +import lldpLinks from "./lldpLinks"; +import configSync from "./configSync"; +import networkDevices from "./networkDevices"; +import networkConfigs from "./networkConfigs"; +import layout from "./layout"; +import auth from "./auth"; +import collect from "./collect"; +import managedNe from "./managedNe"; +import ume from "./ume"; +import webcrt from "./webcrt"; +import topology from "./topology"; +import topoClassify from "./topoClassify"; +import audit from "./audit"; + +const zh = { + common, + workbench, + network, + bizState, + bizCompare, + bizMigration, + bizMonitorTpl, + portTraffic, + lldpLinks, + configSync, + networkDevices, + networkConfigs, + layout, + auth, + collect, + managedNe, + ume, + webcrt, + topology, + topoClassify, + audit, +}; + +export default zh; diff --git a/web/src/i18n/locales/zh/layout.ts b/web/src/i18n/locales/zh/layout.ts new file mode 100644 index 0000000..5a73c6a --- /dev/null +++ b/web/src/i18n/locales/zh/layout.ts @@ -0,0 +1,26 @@ +const layout = { + title: "UME 对接", + titleUme: "UME同步", + titleNetwork: "网络管理", + titleManagedNe: "网元管理", + titleCollect: "批量采集", + titleWebcrt: "WebCRT", + titleTopology: "拓扑管理", + titlePortTrafficWall: "流量大屏", + titleUsers: "用户管理", + titleAudit: "操作审计", + titleApiKeys: "API Key", + titleSessions: "登录会话", + navUme: "UME 对接", + netxApi: "netx api", + oclawBridge: "oclaw WSS", + activeTasks: "活跃任务 {{count}}", + activeTasksHint: "打开操作审计 · 任务概览", + language: "语言", + switchLanguage: "切换语言 / Switch language", + moreMenu: "更多", + langZh: "中文", + langEn: "English", +}; + +export default layout; diff --git a/web/src/i18n/locales/zh/lldpLinks.ts b/web/src/i18n/locales/zh/lldpLinks.ts new file mode 100644 index 0000000..f233734 --- /dev/null +++ b/web/src/i18n/locales/zh/lldpLinks.ts @@ -0,0 +1,109 @@ +const lldpLinks = { + title: "LLDP 链路", + collectNow: "立即采集", + retryFailed: "一键重试失败", + started: "已启动 LLDP 采集", + pause: "暂停", + resume: "继续", + stop: "停止", + confirmStop: "停止后将取消未扫描的网元,确定停止本轮采集?", + paused: "已暂停", + resumed: "已继续", + stopped: "已停止采集", + policyTitle: "采集策略", + policySaved: "策略已保存", + enabled: "启用周期调度", + autoAddUnmatched: "发现时自动入库未匹配网元(占位)", + autoAddUnmatchedHint: "周期采集与画布单点发现共用;关闭则未匹配邻居只记未匹配、不建占位网元。默认开启。", + intervalDays: "周期(天)", + interval: "采集周期", + intervalUnitDays: "天", + intervalUnitHours: "小时", + concurrency: "并发", + historyKeep: "任务保留数", + scope: "范围", + scopeAll: "全部网元(纳管+UME)", + scopeSelected: "指定网元", + savePolicy: "保存策略", + selectedCount: "已选 {{count}} 台", + targetKeywordPh: "名称 / IP / 厂商", + jobsTitle: "采集任务", + jobDetailTitle: "任务明细", + expand: "详情", + edgesTitle: "Fabric 链路", + edgeKeywordPh: "本端 / 对端名称或 IP", + edgeStatusAll: "全部状态", + edgeStatusActive: "活跃", + edgeStatusMissing: "未发现", + edgeSourceAll: "全部来源", + exportEdgesName: "lldp-fabric-edges", + exportJobsName: "lldp-collect-jobs", + exportItemsName: "lldp-job-items", + statusAll: "全部状态", + jobKeywordPh: "任务 ID / 错误 / 范围", + itemKeywordPh: "网元名称 / IP / 错误", + itemResultAll: "全部结果", + missCount: "未发现周期", + replacedBy: "已被替换", + kpi: { + nodes: "Fabric 网元", + edges: "活跃链路", + missing: "未发现", + running: "进行中", + idle: "空闲", + last: "最近任务", + nextDue: "下次调度", + }, + col: { + source: "来源", + name: "设备", + vendor: "厂商", + trigger: "触发", + scope: "范围", + status: "状态", + progress: "进度", + edges: "链路", + missingDelta: "未发现/清理", + started: "开始", + ended: "结束", + neighbors: "邻居", + error: "错误", + aSide: "本端", + bSide: "对端", + aPort: "本端口", + bPort: "对端口", + lastSeen: "最近看见", + }, + jobStatus: { + running: "运行中", + pending: "排队中", + paused: "已暂停", + done: "已完成", + failed: "失败", + cancelled: "已取消", + stopped: "已停止", + }, + triggerMode: { + manual: "手动", + schedule: "周期", + topology: "拓扑画布", + retry_failed: "重试失败", + }, + jobScope: { + all: "全部网元", + ne_ids: "指定网元", + selected: "指定网元", + }, + itemResult: { + ok: "成功", + warn: "警告", + fail: "失败", + }, + edgeSource: { + lldp: "LLDP", + manual: "手动", + stale: "陈旧", + }, +}; + +export default lldpLinks; diff --git a/web/src/i18n/locales/zh/managedNe.ts b/web/src/i18n/locales/zh/managedNe.ts new file mode 100644 index 0000000..1ccf43d --- /dev/null +++ b/web/src/i18n/locales/zh/managedNe.ts @@ -0,0 +1,200 @@ +const managedNe = { + title: "网元管理", + credsNotConfigured: "未配置 NETX_CREDENTIAL_SECRET_KEY,无法保存密码。请在 .env 中设置 Fernet 密钥后重启 API。", + add: "新增网元", + importBtn: "批量导入", + downloadTemplate: "下载导入模板", + importing: "导入中…", + edit: "编辑", + delete: "删除", + openTerminal: "终端", + confirmDelete: + "确定删除该网元?若拓扑中仅绑定此运维网元(无 UME),将同时删除对应 Fabric 节点及其直连链路。", + batchDelete: "批量删除", + batchDeleting: "删除中…", + batchDeleteConfirm: + "确定删除选中的 {{n}} 台网元?不可恢复。仅绑定运维侧的节点会级联清除 Fabric 节点与直连链路;仍绑 UME 的会保留拓扑。", + batchDeleteDone: "已删除 {{n}} 台网元", + batchDeleteSelectRequired: "请先勾选要删除的网元", + keywordPh: "名称 / IP / 用户名 / 标签", + allVendors: "全部厂商", + allConnectStatus: "全部连通状态", + stats: { + title: "统计", + total: "共 {{n}} 台网元", + pass: "已连通", + fail: "失败", + unknown: "未测试", + testing: "测试中", + batchProxy: "一键添加代理", + batchTest: "一键连通性测试", + tagFilter: "按标签筛选", + all: "全部", + allTag: "全部(无标签筛选)", + noTag: "无标签", + tagStats: "按标签统计(Top)", + confirm: "将对 {{n}} 台网元执行操作,确认继续?", + proxyDone: "已为 {{n}} 台网元提交代理配置", + testDone: "已提交 {{n}} 台网元连通性测试", + loadingIds: "加载中…", + }, + col: { + name: "名称", + vendor: "厂商", + deviceType: "设备类型", + execPolicy: "CLI 执行策略", + source: "来源", + ip: "管理地址", + port: "端口", + protocol: "协议", + user: "用户名", + password: "密码", + tags: "标签", + remark: "备注", + connect: "连通性", + testedAt: "测试时间", + actions: "操作", + }, + execPolicy: { + readonly: "只读(show/display/ping)", + linuxShell: "Shell/脚本(允许多行/heredoc)", + unrestricted: "实验室全开(允许多行/heredoc)", + hint: "仅影响 MCP/API 的 execManagedNe。默认只读;Linux 与 MikroTik(routeros/switchos)可选 shell / 实验室全开,支持多行脚本与 heredoc。", + }, + source: { + manual: "手工", + ume_sync: "UME 同步", + webcrt: "WebCRT", + lldp: "LLDP 占位", + topology: "拓扑占位", + }, + connect: { + run: "连通性测试", + running: "测试中…", + submitted: "已提交 {{n}} 台设备测试", + retest: "重新测试", + }, + connectDetail: "详情", + connectDetailTitle: "连通性测试日志", + connectDetailEmpty: "暂无日志。请先执行连通性测试;失败时会记录完整错误与跳板上下文(不含密码)。", + umeSync: { + sync: "同步 UME 网元", + syncing: "同步中…", + delete: "删除 UME 网元", + deleting: "删除中…", + deleteConfirm: + "确定删除所有通过 UME 同步到网元管理中的网元?不可恢复。仅绑定这些 ManagedNE、且无 UME 投影的 Fabric 节点会连同直连链路一并清除。", + done: "UME 同步完成:新增 {{inserted}},更新 {{updated}},删除 {{deleted}},UME 清单共 {{total}} 台", + deletedDone: "已删除 {{n}} 台 UME 同步网元", + }, + account: { + batchAdd: "一键添加账号", + batchByTag: "按标签添加账号", + batchTitle: "批量添加账号", + batchHint: "将以下账号信息应用到已选中的 {{n}} 台网元。", + batchByTagHint: "将以下账号信息应用到指定标签下的网元;密码可留空,仅更新用户名。", + batchDone: "已为 {{n}} 台网元更新账号", + selectRequired: "请先勾选要配置账号的网元", + applying: "应用中…", + apply: "应用到网元", + usernameOrPasswordRequired: "用户名和密码至少填写一项", + passwordOptionalBatch: "留空则不修改", + }, + help: + "【批量导入】\n" + + "· 必填列:device_type、ip、username、port、protocol、name、vendor(可先下载模板)\n" + + "· password 可留空:直连需填;堡垒机托管或后续「批量添加代理」时可空\n" + + "· 推荐:导入网元 → 勾选 →「批量添加代理」统一配置跳板\n\n" + + "【跳板 / 堡垒机】\n" + + "· 用户名模板:{hop_user}@{target_user}@{target_ip}(堡垒机地址另填跳板地址)\n" + + "· 托管模式填跳板密码(Vault);目标密码可空。手动模式需填目标密码\n" + + "· JumpServer/CBH 跳板端口多为 2222,部分现场为 22\n\n" + + "【连通性 / 编辑】\n" + + "· 保存密码需配置 NETX_CREDENTIAL_SECRET_KEY\n" + + "· 编辑时密码留空表示不改;失败请看「详情」中的 hop 上下文(不含密码)", + importResult: { + done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行", + }, + form: { + requiredHint: "带 * 为必填项", + nameConnectHint: "连通性测试成功后将自动更新为设备名称", + createTitle: "新增网元", + editTitle: "编辑网元", + save: "保存", + saving: "保存中…", + cancel: "取消", + created: "网元已创建", + updated: "网元已更新", + deleted: "网元已删除", + passwordRequired: "请填写密码", + passwordOptional: "留空则不修改", + }, + hop: { + sectionTitle: "跳板 / 代理", + type: "跳板类型", + enable: "经跳板登录目标网元", + vendor: { + zte: "ZTE 设备(CLI 跳登)", + huawei: "华为设备(CLI 跳登)", + cisco: "思科设备(CLI 跳登)", + linux: "Linux 服务器(SSH 隧道)", + bastion: "堡垒机(SSH 协议代理)", + }, + zteHint: "在 ZTE 设备上执行 ssh/telnet 命令跳转到目标,目标账号由二次认证输入。", + huaweiHint: "在华为设备上执行 telnet / stelnet(SSH)跳登;stelnet 即 SSH。目标账号由二次认证输入。", + ciscoHint: "在思科设备上执行 ssh -vrf / telnet /vrf 跳登,目标账号由二次认证输入。", + linuxHint: "先 SSH 登录 Linux 跳板,经 direct-tcpip 隧道连接目标 IP:端口(等同 ProxyJump)。", + bastionHint: + "SSH 复合用户名直连堡垒机(跳板地址可为 IP 或域名),由堡垒机协议代理到目标。示例:bastion-user@target-user@198.51.100.20@192.0.2.10 或 bastion-user@target-user@198.51.100.20@ssh-bastion.example.com;密码为 Vault/堡垒机密码。JumpServer/CBH 常用端口 2222。", + targetAuthMode: "目标凭据", + targetAuthBastionManaged: "堡垒机托管(目标密码可留空)", + targetAuthManual: "手动输入(连接后二次认证)", + targetAuthHint: "堡垒机托管时仅需堡垒机密码;手动模式需填写目标网元密码。", + usernameTemplate: "SSH 用户名模板", + templateHintBastion: + "默认 {hop_user}@{target_user}@{target_ip},堡垒机地址(IP 或域名)填「跳板地址」。命令行示例:ssh bastion-user@target-user@198.51.100.20@ssh-bastion.example.com。", + host: "跳板地址", + hostPlaceholderBastion: "192.0.2.10 或 ssh-bastion.example.com", + hostHintBastion: + "支持 IP 或 FQDN。也可粘贴完整 SSH 目标串(如 bastion-user@target-user@198.51.100.20@ssh-bastion.example.com),失焦后自动拆成跳板用户名与地址。", + port: "跳板端口", + protocol: "跳板协议", + protocolSshStelnet: "ssh(stelnet)", + username: "跳板用户名", + password: "跳板密码", + vrf: "管理 VRF(可选)", + vpnInstance: "VPN-Instance(可选)", + vrfCisco: "VRF(可选,如 MGMT)", + enterSystemView: "跳登前是否进入系统视图", + enterSystemViewNo: "不进入(用户视图,默认)", + enterSystemViewYes: "进入系统视图(system-view)", + enterSystemViewHint: + "部分华为跳板仅在系统视图下支持 stelnet/telnet。需自行选择;不会因失败自动重试。", + commandTemplate: "跳登命令模板", + templateHint: + "ZTE 常用:telnet {target_ip}、telnet {target_ip} vrf {vrf}、ssh {target_ip}、ssh {target_ip} vrf {vrf}。按跳板协议与 VRF 自动推荐;留空时后端同样规则。目标账号密码由二次认证提示输入。", + templateHintHuawei: + "华为常用:telnet {target_ip}、telnet vpn-instance {vrf} {target_ip}、stelnet {target_ip}、stelnet {target_ip} -vpn-instance {vrf}。SSH 协议对应 stelnet。留空时按协议与 VPN-Instance 自动推荐。", + templateHintCisco: + "思科常用:ssh {target_ip}、ssh -vrf {vrf} {target_ip}、telnet {target_ip}、telnet {target_ip} /vrf {vrf}。留空时按协议与 VRF 自动推荐。", + badge: { + zte: "ZTE跳板", + huawei: "华为跳板", + cisco: "思科跳板", + linux: "Linux跳板", + bastion: "堡垒机", + }, + hostRequired: "请填写跳板地址", + userRequired: "请填写跳板用户名", + passwordRequired: "请填写跳板密码", + batchAdd: "添加代理", + batchTitle: "批量添加代理", + batchHint: "将以下跳板配置应用到已选中的 {{n}} 台网元(会覆盖原有跳板设置)。", + batchDone: "已为 {{n}} 台网元配置代理", + selectRequired: "请先勾选要配置的网元", + applying: "应用中…", + apply: "应用到选中网元", + }, +}; + +export default managedNe; diff --git a/web/src/i18n/locales/zh/network.ts b/web/src/i18n/locales/zh/network.ts new file mode 100644 index 0000000..02bce7f --- /dev/null +++ b/web/src/i18n/locales/zh/network.ts @@ -0,0 +1,33 @@ +const network = { + title: "网络管理", + nav: { + detail: "网络详情", + tasks: "监控任务", + cutoverCompare: "割接对比", + topologyMgmt: "拓扑管理", + devices: "设备列表", + topology: "拓扑信息", + lldpLinks: "LLDP", + topoClassify: "分类", + alarms: "告警信息", + configs: "配置信息", + webcrt: "WebCRT", + collectTasks: "采集任务", + configSync: "配置同步", + portTraffic: "流量监控", + portTrafficWall: "流量大屏", + bizState: "业务监控", + bizCompare: "业务对比", + bizMigration: "割接监控", + compareTemplates: "对比模板", + monitorTemplates: "监控模板", + }, + collapseNav: "折叠侧栏", + expandNav: "展开侧栏", + placeholder: { + title: "功能规划中", + body: "该能力尚未开放。", + }, +}; + +export default network; diff --git a/web/src/i18n/locales/zh/networkConfigs.ts b/web/src/i18n/locales/zh/networkConfigs.ts new file mode 100644 index 0000000..b60118d --- /dev/null +++ b/web/src/i18n/locales/zh/networkConfigs.ts @@ -0,0 +1,24 @@ +const networkConfigs = { + title: "配置信息", + keywordPh: "名称 / IP / ID", + vendorPh: "厂商", + allSource: "全部来源", + empty: "暂无已同步配置。请先在「配置同步」执行采集。", + view: "查看", + export: "导出", + exportBoth: "导出全部", + exportOk: "配置已导出", + exportListName: "network-configs", + close: "关闭", + tabSet: "Set 格式", + tabHier: "层级格式", + col: { + name: "设备", + vendor: "厂商", + source: "来源", + size: "大小", + collected: "采集时间", + }, +}; + +export default networkConfigs; diff --git a/web/src/i18n/locales/zh/networkDevices.ts b/web/src/i18n/locales/zh/networkDevices.ts new file mode 100644 index 0000000..5f55ae9 --- /dev/null +++ b/web/src/i18n/locales/zh/networkDevices.ts @@ -0,0 +1,17 @@ +const networkDevices = { + title: "设备列表", + keywordPh: "名称 / IP / ID", + allSource: "全部来源", + empty: "暂无网元。", + exportName: "network-devices", + col: { + source: "来源", + name: "名称", + vendor: "厂商", + deviceType: "设备类型", + connect: "连通状态", + actions: "操作", + }, +}; + +export default networkDevices; diff --git a/web/src/i18n/locales/zh/portTraffic.ts b/web/src/i18n/locales/zh/portTraffic.ts new file mode 100644 index 0000000..f56928e --- /dev/null +++ b/web/src/i18n/locales/zh/portTraffic.ts @@ -0,0 +1,204 @@ +const portTraffic = { + title: "端口流量监控", + create: "添加设备监控", + backList: "返回列表", + created: "已添加设备监控", + createFailed: "创建失败", + updated: "已保存", + interfacesSaved: "接口已更新", + started: "已启动", + paused: "已暂停", + stopped: "已停止", + statusRunning: "运行中", + statusPaused: "已暂停", + statusStopped: "已停止", + statusDraft: "草稿", + collecting: "采集中", + deleted: "已删除", + confirmDelete: "删除该设备监控及其采样数据?", + empty: "暂无设备监控。请先选择网元并勾选接口。", + keywordPh: "名称 / IP / 备注 / ID", + allStatus: "全部状态", + exportDevicesName: "port-traffic-devices", + exportEventsName: "port-traffic-events", + wall: "监控大屏", + wallTitle: "端口流量大屏", + toDevices: "设备管理", + wallEmpty: "暂无监控设备。请先添加设备并勾选接口。", + fullscreen: "全屏", + exitFullscreen: "退出全屏", + board: "大屏", + boardPick: "选择大屏", + boardPickHint: "请选择或新建一张流量大屏。", + boardEmpty: "还没有大屏。可新建一张用于割接/值班监控。", + boardDefaultName: "未命名大屏", + boardName: "名称", + boardCols: "列数", + boardCreate: "新建大屏", + boardEdit: "编辑布局", + boardSave: "保存", + boardCancel: "取消", + boardSaved: "大屏已保存", + boardCreated: "大屏已创建", + boardDeleted: "大屏已删除", + boardConfirmDelete: "删除该大屏及其图格配置?", + boardUnsavedLeave: "有未保存的修改,确定离开?", + boardOpenTab: "新页签打开", + boardOpen: "打开大屏", + boardListHint: "点击条目在独立页签打开大屏;从工作台打开网络管理不会刷新已打开的大屏。", + boardPanelCount: "图格数", + boardUpdated: "更新时间", + boardClosedHint: "可关闭本页签;也可回到网络管理 → 流量大屏列表重新打开。", + boardAddPanel: "添加图格", + boardNoPanels: "该大屏还没有图格,请编辑并添加监控接口。", + boardPickDevice: "选择设备", + boardPanelSettings: "图格设置", + boardPanelTitle: "标题(可选)", + boardColSpan: "跨列", + boardPanelStale: "接口已失效或不存在,请重新选择。", + boardRetarget: "重选接口", + edit: "编辑", + save: "保存", + start: "启动", + pause: "暂停", + stop: "停止", + delete: "删除", + wizStep1: "选设备", + wizStep2: "选接口", + wizStep3: "参数", + step1: "参数", + step2: "选网元", + step3: "选接口", + step4: "确认", + prev: "上一步", + next: "下一步", + fieldTitle: "任务名称", + titlePh: "例如:上联链路组", + titleRequired: "请填写任务名称", + portsRequired: "请至少选择一个接口", + interval: "周期(秒)", + retention: "保留(天)", + retentionHintWizard: "若需上周同日对比,建议 ≥8 天", + concurrency: "并发", + note: "备注", + notePh: "可选", + startNow: "创建后立即启动", + neKeywordPh: "名称 / IP / 厂商", + deepLinkHint: "已从拓扑跳转:网元 {{ne}}{{ifname}}。请确认选中后继续。", + deepLinkIfname: ",接口 {{ifname}}", + selectedNe: "已选 {{count}} 台网元", + selectedOneNe: "已选", + selectedPorts: "已选 {{count}} 个接口", + pickNeFirst: "请先选择一台网元", + discover: "拉取接口", + discoverDetail: "拉取详情", + discoverCommand: "命令", + discoverRaw: "原始日志", + discoverOk: "拉取成功", + discoverFail: "拉取失败", + confirmCreate: "开始监控", + wallTask: "任务", + wallDevice: "设备", + wallPort: "接口", + wallDeviceLabel: "网元", + pickPort: "选择接口", + range: "时间范围", + compare: "周期对比", + compareOff: "关闭", + compareDay: "昨日同时刻", + compareWeek: "上周同日", + compareShift: "前一窗", + compareCustom: "自定义偏移", + offsetHours: "偏移(小时)", + aheadHours: "向后延长(小时)", + aheadHoursHint: + "图表终点相对当前时刻再往后延长。开启周期对比时可看到对照曲线在「现在」之后的走势;0 表示截止到现在。", + mapBaselineHint: "映射基线可与周期对比叠加使用;需要昨日对照时请自行打开「昨日同时刻」。", + baseline: "基线", + baselineAt: "基线原时刻", + baselineDevice: "基线网元", + baselinePort: "基线接口", + seriesCurrentIn: "当前 In", + seriesCurrentOut: "当前 Out", + seriesBaselineIn: "基线 In", + seriesBaselineOut: "基线 Out", + mapBaselinePort: "映射基线接口", + mapBaselineTask: "映射基线任务", + mapBaselineNone: "不映射", + retentionHint: "周对比建议保留天数 ≥ 8;否则基线可能无数据。", + collectNow: "立即采集", + collectStarted: "已触发采集", + collectBusy: "采集进行中", + targetError: "接口最近错误", + waitSamples: "设备运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。", + waitAfterError: "采集失败,请到「流量监控」操作中的日志查看详情,修复后可点「立即采集」重试。", + log: "日志", + logTitle: "采集日志", + logEmpty: "暂无日志。", + logDevice: "设备", + logLevel: "级别", + logTime: "时间", + logMessage: "内容", + logClose: "关闭", + logHasError: "有错误", + rebind: "重新绑定网元", + rebindTitle: "重新绑定网元", + rebindCurrent: "当前监控", + rebindOk: "已重新绑定网元,可继续采集", + rebindFailed: "重新绑定失败", + rebindHint: "请手动选择正确的网元并确认。同 IP 不一定是同一台设备,且 IP 可能不唯一;历史采样会保留。", + rebindEmpty: "没有可选网元,请先在设备管理中加回。", + rebindSameIp: "同 IP", + rebindSameIpHint: "与当前监控记录的 IP 相同,请仍核对手动选择", + rebindSubmit: "确认绑定", + rebindConfirm: "确认将监控绑定到 {{ne}}?绑定后将按该网元采集,历史采样保留。", + err: { + managedNeNotFound: "管理网元不存在(可能已删除后重建,ID 已变)。请点「重新绑定网元」手动选择。", + targetErrors: "{{count}} 个接口采集失败", + unsupportedVendor: "不支持的厂商/设备类型", + invalidSource: "无效的网元来源", + rebindPickFirst: "请先选择要绑定的网元", + rebindNeMissing: "所选网元不存在,请刷新列表后重选", + rebindSourceMismatch: "网元来源与监控不一致", + alreadyMonitored: "目标网元已有另一条监控,无法绑定", + }, + kpiLatest: "最新", + kpiBw: "带宽", + latestAt: "最新样点", + hoverPoint: "光标位置", + wallLiveTitle: "实时流量", + filterView: "视图", + filterMap: "映射基线", + yMode: "纵轴", + yModeAuto: "自动(含基线)", + yModeCurrent: "按当前流量", + yModeUtil: "利用率 %", + kpi: { + tasks: "任务数", + devices: "监控设备", + running: "运行中", + ports: "监控接口", + samples24h: "24h 样点", + }, + col: { + title: "任务", + device: "设备", + status: "状态", + ports: "接口", + interval: "周期", + last: "最近采集", + actions: "操作", + source: "来源", + name: "设备", + vendor: "厂商", + ifname: "接口", + bw: "带宽", + upDown: "Admin / Phy / Prot", + admin: "Admin", + phy: "Phy", + prot: "Prot", + description: "描述", + }, +}; + +export default portTraffic; diff --git a/web/src/i18n/locales/zh/topoClassify.ts b/web/src/i18n/locales/zh/topoClassify.ts new file mode 100644 index 0000000..a040988 --- /dev/null +++ b/web/src/i18n/locales/zh/topoClassify.ts @@ -0,0 +1,67 @@ +const topoClassify = { + title: "分类", + openTopo: "拓扑画布", + inventory: "网元清单", + keywordPh: "关键字筛选名称/IP", + filterLevelAll: "全部层级", + filterRegionAll: "全部区域", + filterUnmatchedOff: "不过滤未分类", + kindAny: "层级或区域未填", + kindLevel: "层级未填", + kindRegion: "区域未填", + filterLinkAll: "全部关联", + filterLinkLinked: "已关联库存", + filterLinkOrphaned: "已解绑", + colLink: "关联", + linkManaged: "托管", + linkUme: "UME", + linkBoth: "托管+UME", + linkOrphaned: "已解绑", + levelExternal: "L0 外部", + levelCore: "L1 核心", + levelCoreSub: "L1.1 次核心", + levelAgg: "L2 汇聚", + levelAggSub: "L2.1 二级汇聚", + levelAccess: "L3 接入", + levelAccessSub: "L3.1 更深接入", + levelInvalid: "层级无效(0–99.9,一位小数)", + sourceManual: "手动", + sourceUmeSync: "UME 同步", + sourceLldp: "LLDP 占位", + sourceTopology: "拓扑占位", + sourceWebcrt: "WebCRT", + regexTitle: "正则查找", + patternPh: "正则,如 ^CORE- 或 -EDGE-", + fieldName: "名称", + fieldNameIp: "名称+IP", + findMatches: "查找匹配", + matchOk: "匹配 {{count}} 条,已勾选", + matchHint: "当前匹配 {{count}} 条", + assignLevel: "只改层级", + assignRegion: "只改区域", + assignBoth: "层级+区域", + clearRegion: "清空区域", + confirmAssign: "确认写入", + bulkConfirm: "将给已选/匹配的 {{count}} 条网元写入分类,确定?", + bulkOk: "已更新 {{count}} 条", + selectedCount: "已选 {{count}}", + selectPage: "全选本页", + colNe: "网元", + colLevel: "层级", + colRegion: "区域", + colActions: "操作", + edit: "编辑", + save: "保存", + cancel: "取消", + delete: "删除", + deleteSelected: "删除节点 ({{count}})", + deleteConfirm: "将从拓扑 Fabric 删除 {{count}} 个节点(含上图与边),不影响网元管理/UME 清单。确定?", + deleteOk: "已删除 {{count}} 个节点", + deleteHint: "仅可删除已解绑节点,或 LLDP/WebCRT 等非实际管理占位;仅 UME / 实际管理网元不可删", + deleteBlocked: "仅 UME 或仍关联实际管理网元,不可在此删除", + rowSaved: "已保存", + loading: "加载中…", + empty: "没有网元。", +}; + +export default topoClassify; diff --git a/web/src/i18n/locales/zh/topology.ts b/web/src/i18n/locales/zh/topology.ts new file mode 100644 index 0000000..5c0e80c --- /dev/null +++ b/web/src/i18n/locales/zh/topology.ts @@ -0,0 +1,334 @@ +const topology = { + title: "拓扑管理", + maps: "拓扑图", + tree: "拓扑树", + treeLoading: "加载拓扑树…", + treeLoadFailed: "拓扑树加载失败", + treeRetry: "重试", + graphLoading: "正在加载拓扑图…", + graphLoadFailed: "拓扑图加载失败", + graphRefreshing: "刷新中…", + canvasEmpty: "这张图上还没有网元", + canvasEmptyHint: "可用「添加网元」上图,或从左侧换到有设备的区域。", + treeHint: "顶级为「根」(导航);其下唯一「根图」为画布。点根图或子区域打开设备画布。", + treeSearch: "全拓扑搜索网元", + treeSearchPh: "名称 / IP", + treeSearchEmpty: "没有匹配的网元", + treeSearchNoViews: "未上图", + treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上", + locateNotOnCanvas: "当前世界地图抽样中没有该网元,且缺少世界坐标,请到下级区域画布查找。", + treeSearchViewCount: "{{count}} 张图", + treeSearchTruncated: "仅显示前 30 条(共 {{total}})", + rootName: "根", + selectRegion: "请选择根或区域", + openMap: "打开本图", + openRegion: "打开", + kindPhysical: "物理拓扑", + kindCustom: "自定义图", + layerNodeHint: "{{count}} 个网元", + layerNodeEmpty: "点击创建并打开", + rootCanvasHint: "从左侧选择根进入目录。", + regionCanvasHint: "点击根图打开设备画布;可在根图上新建子区域。", + regionBrowseHint: "根目录:下方为唯一根图。点击打开。", + browserTitle: "拓扑目录", + browserRegionSub: "共 {{count}} 张拓扑图", + browserRegionsSub: "共 {{count}} 个", + browserEmptyMaps: "此根下还没有根图。", + addNe: "添加网元", + more: "更多", + backUp: "返回", + newRegion: "新建根", + newRegionName: "新根", + newRegionPrompt: "根名称", + newSubRegion: "新建子区域", + newSubRegionName: "新子区域", + newSubRegionPrompt: "子区域名称", + subRegionNotOnFlat: "世界地图不支持子区域,请在 World 或下级区域画布上创建。", + worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。", + worldMapName: "世界地图", + worldMapOffline: "世界地图功能已暂时下线", + rootMapName: "根图", + worldHud: "世界 {{total}} · 近景 {{cached}} · z{{zoom}} · {{lod}}", + worldEmptyCoords: "世界地图还没有坐标", + worldEmptyCoordsHint: "码头已有 {{dock}} 个网元,但尚未灌入 Fabric / 世界坐标。可一键应用(不拉 UME)。", + worldApply: "应用到世界地图", + worldApplying: "正在应用…", + worldApplyOk: "已应用到世界地图", + subRegionNeedParent: "请先打开根图或区域画布,再新建子区域。", + regionCreated: "已创建", + regionDeleted: "已删除", + deleteRegion: "删除", + deleteRegionConfirm: "删除「{{name}}」?其下画布将一并删除。", + deleteUmeWorldConfirm: + "删除「{{name}}」?将级联删除其下全部 UME 区域与画布。之后若再跑 UME 拓扑同步/应用,可能重新生成。", + renameRegion: "重命名", + renameRegionPrompt: "名称", + regionRenamed: "已重命名", + collapseRegion: "折叠", + expandRegion: "展开", + newCustom: "自定义图", + newCustomName: "自定义拓扑", + newCustomPrompt: "自定义拓扑图名称", + folderHint: "新建根时自动创建唯一「根图」。子区域请在根图画布上创建。", + folderHintShort: "根目录", + outsidePeers: "图外对端 {{count}} 个(有链路未上图)", + outsidePeersView: "未上图网元 {{count}}", + outsidePeersTitle: "图外对端", + outsidePeersHint: "与画布有链路但未上图。勾选后按需添加,不必一次全部投影。", + outsidePeersVia: "经 {{name}}", + outsidePeersFilterPh: "筛选:名称 / IP / 经谁", + outsidePeersEmpty: "没有匹配的图外对端", + projectNeighbors: "投影邻居上图", + projectedNeighbors: "已投影 {{count}} 个邻居上图", + localBrowserOnly: "仅本浏览器", + deleteEdgeConfirm: "从画布移除选中链路?仅在点击「保存」后才会从 Fabric 删除;LLDP 下次采集若仍存在可能再次出现。", + edgeDeleted: "已标记删除 {{count}} 条链路(保存后生效)", + discoverCancelled: "发现已取消", + truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类页或新建视图。", + truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类流程中处理或新建视图。", + truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。", + truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。", + truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。", + deleteSelectionConfirm: + "从本图移除 {{nodes}} 个网元、删除 {{edges}} 条链路?移出与链路删除均在点击「保存」后生效。", + selectionDeleted: "已标记:移除 {{nodes}} 个网元、删除 {{edges}} 条链路(保存后生效)", + newMap: "新建", + newMapName: "新拓扑图", + rename: "重命名", + renamePrompt: "拓扑图名称", + renameHint: "双击可重命名", + renamed: "拓扑图已重命名", + deleteMap: "删除图", + deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。", + emptyMaps: "还没有根,先点「新建根」。", + palette: "网元库", + paletteHint: "勾选后批量添加;也可拖到画布添加单台", + paletteDragHint: "拖到画布添加", + selectAllVisible: "全选当前列表", + addSelected: "添加选中({{count}})", + addSelectedDone: "已添加 {{count}} 台网元", + addingNe: "添加中…", + filterPh: "输入即筛:名称 / IP / 厂商", + save: "保存", + saveDirty: "保存 *", + unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?", + saving: "保存中…", + saved: "拓扑已保存", + saveFailed: "保存失败:{{detail}}", + readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)", + readOnlyHint: "只读模式,无编辑权限", + discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度", + policySyncFailed: "同步 LLDP 策略失败,本地开关仍对本页发现生效", + discover: "发现链路", + discovering: "发现中…", + discoverOne: "发现链路", + discovered: "发现完成:新增 {{added}},更新 {{updated}},未发现 {{stale}}", + discoverFail: "发现失败:{{detail}}", + discoverOneNeedNe: "该节点未关联可扫描网元", + selectMap: "请选择或新建一张拓扑图", + renameNode: "重命名", + renameNodePrompt: "网元显示名称", + sourcePort: "本端端口", + targetPort: "对端端口", + endpointA: "端点 A", + endpointB: "端点 B", + port: "端口", + expandPhysicalLinks: "展开物理链路", + scaleBundleWidth: "聚合线按条数加粗", + linkMembers: "物理成员({{count}})", + canvasHint: + "左侧为区域目录(需自行添加画布);打开本图后进入设备画布,用「添加网元」上图并保存。", + selected: "已选节点", + selectedEdge: "已选链路", + selectedCount: "已选 {{count}}", + toolModes: "编辑模式", + toolSelect: "选择", + toolPan: "平移", + toolDrag: "拖动", + toolConnect: "连线", + layout: "布局", + layoutHierarchicalTb: "层次 · 自上而下", + layoutHierarchicalLr: "层次 · 从左到右", + layoutForce: "力导向", + layoutGrid: "网格", + layoutRadial: "环形", + layoutSelected: "仅布局选中(层次)", + layoutNeedSelection: "请先选中要布局的节点", + align: "对齐", + alignLeft: "左对齐", + alignRight: "右对齐", + alignTop: "顶对齐", + alignBottom: "底对齐", + alignHCenter: "水平居中", + alignVCenter: "垂直居中", + alignHDistribute: "水平分布", + alignVDistribute: "垂直分布", + alignNeedSelection: "请至少选中两个节点", + snapGrid: "网格吸附", + autoLayoutDiscover: "发现后自动布局", + discoverAutoAddUnmatched: "发现时自动入库未匹配网元(占位)", + discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布(需保存才永久保留)", + undo: "撤销", + redo: "重做", + discoverSelected: "发现链路({{count}})", + removeSelected: "移除选中 ({{count}})", + openWebcrt: "打开终端", + openNe: "网元详情", + openAlarms: "查看告警", + noAlarmLink: "该节点无法定位告警过滤条件", + removeNode: "从画布移除", + deletePlaceholder: "删除占位网元", + deletePlaceholderConfirm: "永久删除此占位网元(含库存记录与 Fabric 直连链路)?画布撤销无法恢复。", + deletePlaceholderDone: "占位网元已删除", + removeEdge: "删除链路", + nodeMenu: "网元", + paneMenu: "画布", + edgeMenu: "链路", + createNe: "新建网元", + createNeModeHint: "选择创建正式运维网元,或仅建拓扑占位(可稍后完善登录信息)。", + createNeManaged: "正式网元", + createNeManagedHint: "与网元管理相同的完整表单;创建后自动上图。", + createNePlaceholder: "占位网元", + createNePlaceholderHint: "仅名称/IP,来源为拓扑占位,适合先构图再补齐凭据。", + createNeTitle: "新建拓扑占位网元", + createNeHint: "来源为拓扑占位;完善登录信息后可在 WebCRT 连接,或在网元管理升级为手工。", + createNeName: "名称", + createNeNamePh: "例如 SW-CORE-01", + createNeNameRequired: "请填写网元名称", + createNeIp: "IP(可选)", + createNeIpPh: "可稍后在完善时填写", + createNeDone: "已新建占位网元 {{name}}", + createManagedNeDone: "已创建网元并上图 {{name}}", + createManagedNePlaceFailed: + "网元 {{name}} 已写入库存,但上图失败:{{error}}。请从网元管理或侧栏再次添加。", + createManagedNePosFailed: "网元 {{name}} 已上图,但未能移到点击位置(保留默认布局坐标)", + createNeBusy: "创建中…", + connectTest: "连通性测试", + connectTestSelected: "连通性测试 ({{count}})", + connectTestNeedManaged: "该节点未绑定运维网元,无法做连通性测试", + connectTestSkipped: "跳过 {{count}} 台无运维绑定", + selectionMenu: "多选", + edgeStyle: "链路样式", + edgeColor: "颜色", + edgeLineStyle: "线型", + edgeLineSolid: "实线", + edgeLineDashed: "虚线", + edgeLineDotted: "点线", + edgeWidth: "粗细", + edgeStyleReset: "恢复默认", + edgeDefaults: "默认链路样式", + edgeDefaultsReset: "恢复出厂", + noNeLink: "未关联托管网元", + completeNeFirst: + "该网元登录信息不完整(厂家、代理跳转等需在网元管理填写)。请先完善后再打开终端。", + edgeManual: "人工", + edgeUme: "UME", + edgeDiscovered: "LLDP 发现", + edgeStale: "未发现", + fit: "适应画布", + export: "导出", + exportSvg: "SVG 矢量", + exportXml: "XML 数据", + exporting: "导出中…", + exportEmpty: "画布上没有可导出的内容", + exportedSvg: "已导出 SVG", + exportedXml: "已导出 XML", + exportFailed: "导出失败:{{detail}}", + liveSync: "实时同步", + liveSyncOn: "同步中…", + liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新当前画布(观看 MCP 建图/画拓扑);默认关闭", + fullscreen: "全屏显示", + exitFullscreen: "退出全屏", + display: "显示", + canvasBg: "画布背景", + canvasBgColor: "背景色", + canvasBgReset: "恢复默认", + textColors: "文字颜色", + textColorsReset: "恢复默认", + labelColor: "网元名称", + edgeLabelColor: "链路数字", + vendorColors: "厂商图标色", + vendorColorsReset: "恢复默认", + vendorTone: { + cisco: "Cisco", + huawei: "华为", + zte: "中兴", + juniper: "Juniper", + nokia: "Nokia", + ericsson: "Ericsson", + h3c: "H3C", + ruijie: "锐捷", + mikrotik: "MikroTik", + gray: "未知/未托管", + }, + hideIp: "隐藏 IP", + hideVendor: "隐藏厂商", + showPlaceholderBadge: "显示占位角标(TOPO/LLDP)", + showConnectStatus: "显示连通状态点", + showAlarmOverlay: "显示告警叠层", + showLevelColors: "按分类层级着色", + levelUnmatched: "未分类", + level: { + external: "外部", + core: "核心", + aggregation: "汇聚", + access: "接入", + edge: "边缘", + }, + hidePorts: "隐藏端口", + edgeFlow: "链路流动", + collapseSidebar: "折叠侧栏", + expandSidebar: "展开侧栏", + resizeSidebar: "拖动调整侧栏宽度", + hideAdded: "隐藏已添加", + paletteEmpty: "没有匹配的网元。", + paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。", + paletteSource: "网元来源", + paletteManaged: "托管网元", + paletteUme: "UME 网元", + paletteLoading: "加载中…", + discoverReport: "发现进度", + discoverClose: "关闭", + discoverCancel: "取消发现", + discoverProgress: "正在扫描画布上 {{count}} 台托管网元(串行登录采集 LLDP,请稍候)…", + discoverProgressLive: "正在扫描 {{index}}/{{total}}:{{name}}", + discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}", + discoverNeOk: "邻居 {{neighbors}} · 新增 {{added}} · 更新 {{updated}}", + discoverNeFail: "采集失败", + discoverUnmatched: "未匹配 {{count}}", + discoverUnmatchedMore: "另有 {{count}} 条未列出", + discoverVendorRequired: "未设置厂商/设备类型,已跳过下发 LLDP 命令", + discoverParserStub: "厂商解析器「{{parser}}」仍为 stub,已采集到输出但未能解析邻居", + discoverNoTargets: "画布上没有可扫描网元。请添加托管网元,或添加已配置 CLI 凭据的 UME 网元。", + discoverViewDetail: "详情", + discoverLinkCount: "链路 {{count}}", + discoverLinksTitle: "匹配链路({{count}})", + discoverLinksEmpty: "本次未匹配到画布上的对端网元链路。", + discoverUnmatchedTitle: "未匹配邻居({{count}})", + discoverUnmatchedEmpty: "无未匹配邻居。", + discoverColPeer: "对端网元", + discoverColRemote: "邻居", + discoverColLocalPort: "本端端口", + discoverColRemotePort: "对端端口", + discoverColProtocol: "协议", + discoverColAction: "结果", + discoverLinkAdded: "新增", + discoverLinkUpdated: "更新", + discoverLinkKeptManual: "保留人工", + discoverLocatePeer: "定位对端", + discoverLocateNe: "定位本端", + discoverRawPreview: "采集原文预览(过长会截断,不影响解析)", + discoverGoLldp: "查看详情 → LLDP 链路", + findNode: "定位", + findNodePh: "输入即搜:名称 / IP / 厂商", + findNoMatch: "画布上没有匹配的节点", + findMore: "另有 {{count}} 条匹配…", + locateOnCanvas: "点击定位到画布", + openPortTraffic: "打开端口流量", + removeStale: "清除未发现 ({{count}})", + removeStaleHint: "标记删除红色未发现链路(保存后生效);周期采集连续 4 次未发现也会自动清理", + staleRemoved: "已标记删除 {{count}} 条未发现链路(保存后生效)", + connectHint: "连线模式:从网元中心锚点拖到目标网元(Esc 或切回选择退出)", +}; + +export default topology; diff --git a/web/src/i18n/locales/zh/ume.ts b/web/src/i18n/locales/zh/ume.ts new file mode 100644 index 0000000..2166074 --- /dev/null +++ b/web/src/i18n/locales/zh/ume.ts @@ -0,0 +1,278 @@ +const 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: + "【订阅】\n" + + "· 需手动建立 / 取消\n" + + "· 建立后(或重启且库中仍有效)后台自动连 WSS 收实时告警\n\n" + + "【任务控制】\n" + + "· alarms_current_ws_consumer 的 pause/resume 只停/启 WSS\n" + + "· 不会取消 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 后会出现连接、收包、重连等记录)", + }, + keyAlert: { + title: "AI 告警监控", + showPanel: "详情", + hidePanel: "关闭", + help: + "【匹配】\n" + + "· notificationId 精确匹配\n" + + "· 或告警描述关键字模糊匹配(不区分大小写,含 nativeProbableCause 等)\n\n" + + "【范围】\n" + + "· 可选限定 Inventory 的 ne_type;不选则匹配全部设备\n\n" + + "【投递】\n" + + "· 命中后由 NetX 广播给已订阅的 Netx Ops(DSH)客户端", + hub: "DSH 订阅", + hubConnected: "有订阅", + hubEmpty: "无订阅", + hubSubscribers: "订阅数", + hubPublished: "已广播", + hubDeliverOk: "投递成功", + hubDeliverFail: "投递失败", + hubConnections: "当前连接", + hubColId: "连接 ID", + hubColUser: "用户", + hubColRemote: "远端", + hubColClient: "客户端", + hubColConnected: "连入时间", + hubColLastSeen: "最近心跳", + hubEmptyConnections: "当前没有 Netx Ops 客户端连入告警 hub", + hubPath: "路径", + matchType: "匹配方式", + matchNotificationId: "告警码 (notificationId)", + matchKeyword: "告警描述关键字", + matchValuePhNotificationId: "输入 notificationId", + matchValuePhKeyword: "输入匹配关键字(不区分大小写)", + label: "规则描述", + labelPh: "必填,如 BGP Down", + neTypesLabel: "设备类型(可选,多选)", + neTypesHint: "不选表示全部设备类型;选项来自 UME Inventory 同步后的 ne_type。", + neTypesEmpty: "暂无设备类型,请先同步 Inventory", + neTypesAll: "全部", + forwardOnClear: "告警清除时也推送", + forwardOnClearHelp: "全局开关:对所有监控规则生效。默认只在告警新增/更新时推送;勾选后,UME 上报告警已清除时也会向已订阅的 Netx Ops 推送清除通知。", + add: "添加规则", + adding: "添加中…", + delete: "删除", + edit: "修改", + editTitle: "修改监控规则", + save: "保存", + saving: "保存中…", + cancel: "取消", + editOk: "规则已更新", + pickFromAlarms: "加载当前告警参考", + emptyRules: "尚未配置监控规则", + colType: "类型", + colMatch: "匹配内容", + colLabel: "描述", + colNeTypes: "设备类型", + colForwardClear: "清除推送", + colPublished: "发布次数", + colAttempts: "尝试次数", + colLast: "最近推送", + colActions: "操作", + colMonitor: "是否监控", + monitorOn: "开启", + monitorOff: "关闭", + filterKeywordPh: "筛选匹配内容 / 描述", + filterMatchAll: "全部类型", + filterEnabledAll: "全部状态", + filterEnabledOn: "监控中", + filterEnabledOff: "已暂停", + addOk: "已添加监控规则", + deleteOk: "已删除", + confirmDelete: "确定删除该监控规则?", + yes: "是", + no: "否", + }, + 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", + lastErrorCol: "last_error", + runtimeError: { + startup_alarm_sync_before_ws: "启动中:正在同步当前告警(完成后连接 WSS)…", + startup_gate_waiting: "启动 REST 全量同步未完成,定时 REST 与 WSS 均待命", + wss_active_skip_rest: "WSS 实时接收中,已跳过 REST 同步", + pulling_alarms_current: "正在拉取 UME 当前告警…", + alarms_sync_in_progress_skip: "另一条当前告警 REST 同步进行中,已跳过", + pulling_inventory: "正在拉取 UME 网元清单…", + pulling_topology: "正在拉取 UME 拓扑(节点/链路)…", + topology_sync_busy: "拓扑同步进行中,已跳过", + ume_ws_disabled_no_base_url: "未启用或未配置 UME_BASE_URL", + resumed_sync_soon: "已恢复:将跳过本轮周期等待并尽快同步", + resumed_wss_reconnect: "已恢复:将尽快重连 WSS", + resumed: "已恢复", + keepalive_failed: "Token 保活失败", + }, + wsState: { + init: "初始化", + connected: "已连接", + connecting: "连接中", + disconnected: "已断开", + no_subscription: "无订阅", + waiting_token: "等待 token", + paused: "已暂停", + error: "连接异常", + reconnecting: "重连等待", + subscription_lost: "UME 订阅已丢失", + disabled_no_base_url: "未启用或未配置 UME_BASE_URL", + }, + runtimeTask: { + token_keepalive: "UME Token 保活", + alarms_current_auto_sync: "当前告警定时同步", + alarms_current_ws_consumer: "UME 告警 WSS", + inventory_auto_sync: "Inventory 定时同步", + topology_auto_sync: "拓扑定时同步", + }, + }, + syncStatus: { + title: "同步状态", + showPanel: "详情", + hidePanel: "关闭", + empty: "暂无同步记录", + summary: "共 {{total}} 条记录,进行中 {{running}}", + fabricGapTitle: "拓扑码头已更新,但尚未灌入拓扑管理(Fabric / UME World)", + fabricGapHint: + "码头 ME {{dock}},Fabric UME {{fabric}};可一键应用(不拉 UME)。上次状态:{{status}}", + applyFabric: "应用到拓扑管理", + applyingFabric: "正在应用…", + applyFabricOk: "已应用到拓扑管理", + }, + ne: { + title: "网元清单", + showPanel: "详情", + hidePanel: "关闭", + keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)", + clearTitle: "清空 keyword,回到第 1 页", + expand: "详情", + collapse: "关闭", + cliConnect: "CLI 连通", + summary: "Inventory 网元 {{total}} 台", + }, + tabs: { + main: "UME 同步", + cli: "CLI 连接", + }, + cli: { + title: "UME CLI 连接配置", + showPanel: "详情", + hidePanel: "关闭", + hint: + "【用途】\n" + + "· 为 Inventory 网元配置统一 SSH 凭据与跳板/堡垒机\n\n" + + "【MCP】\n" + + "· 可用 ume_ne_id 直接执行 show,无需逐台录入 managed_ne", + statusReady: "CLI 配置已就绪,可对 UME 网元执行命令。", + statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。", + profileName: "配置名称", + targetUsername: "目标 SSH 用户名", + targetPassword: "目标 SSH 密码", + passwordKeep: "留空则不修改", + newProfile: "新建配置", + deleteConfirm: "确定删除该 CLI 配置?", + deleted: "配置已删除", + saved: "CLI 配置已保存", + connectTestTitle: "样本网元连通性测试", + sampleNePh: "筛选 UME 网元(名称 / IP / ID)", + sampleNeRequired: "请先选择一个 UME 网元", + sampleNeEmpty: "无匹配网元", + sampleNeLoading: "加载中…", + connectTestSubmitted: "已提交连通性测试", + connectTestWaiting: "等待测试结果…", + connectTestRunning: "连通性测试中…", + connectTestResult: "测试结果", + existingProfiles: "已有配置", + default: "默认", + }, + alarms: { + title: "当前告警", + movedHint: "当前告警查询已迁至网络管理 →", + showPanel: "展开", + hidePanel: "收起", + keywordPh: "keyword(告警键/原因/ne_name/host_name/ip 等)", + hostNamePh: "host_name(含匹配)", + allSeverity: "全部级别", + clearedAll: "清除状态:全部", + clearedYes: "已清除", + clearedNo: "未清除", + clearTitle: "清空 keyword、host_name、级别、is_cleared,回到第 1 页", + filterByHost: "按该主机名筛选", + noHostName: "无 host_name(需先同步网元)", + col: { + time: "告警时间", + severity: "级别", + neId: "网元 ID", + hostName: "主机名", + neType: "网元类型", + cause: "原因", + }, + }, +}; + +export default ume; diff --git a/web/src/i18n/locales/zh/webcrt.ts b/web/src/i18n/locales/zh/webcrt.ts new file mode 100644 index 0000000..383aada --- /dev/null +++ b/web/src/i18n/locales/zh/webcrt.ts @@ -0,0 +1,233 @@ +const webcrt = { + title: "WebCRT", + deviceList: "设备列表", + sessionManager: "设备列表", + connect: "连接", + hint: "选择网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。", + hintCrt: "在左侧点击网元即可打开终端;支持托管、会话与 UME;也可用菜单「新建会话」快速登录。", + filterKeyword: "筛选", + filterKeywordPh: "按名称 / IP / 标签筛选", + empty: "没有匹配的网元。可切换来源或调整搜索关键字。", + open: "打开终端", + opening: "连接中…", + opened: "已打开 {{name}}", + disconnect: "断开", + closed: "会话已断开", + termPlaceholder: "尚未打开终端会话", + sessionMeta: "{{name}} · {{ip}}", + source: { + all: "全部", + managed: "托管", + webcrt: "会话", + ume: "UME", + }, + newSession: { + title: "新建会话", + claimTitle: "完善占位会话", + claimHint: "请填写可达地址;会话名默认使用占位名。连接成功后来源将更新为 WebCRT。", + connect: "连接", + connecting: "连接中…", + protocol: "协议", + host: "主机", + port: "端口", + sessionName: "会话名", + sessionNamePh: "可选,默认使用 IP", + authTitle: "SSH 登录", + savePassword: "保存密码", + ipRequired: "请填写主机地址", + userRequired: "请填写用户名", + passwordRequired: "请填写密码", + }, + status: { + idle: "空闲", + connecting: "连接中", + connected: "已连接", + error: "错误", + closed: "已关闭", + }, + phase: { + creating: "创建会话", + authenticating: "正在认证", + waitingPrompt: "等待设备提示符", + hint: "登录过程会实时显示在终端中;设备较慢时可能需要十几秒", + }, + tabMenu: { + close: "关闭", + closeOthers: "关闭其他", + closeAll: "关闭全部", + copyIp: "复制 IP", + copyIpOk: "已复制 {{ip}}", + copyIpEmpty: "该标签没有 IP", + }, + treeMenu: { + rename: "重命名", + renameLabel: "会话名", + renameSave: "保存", + renameRequired: "请填写会话名", + renamed: "会话已重命名", + delete: "删除会话", + deleteConfirm: "确定删除会话「{{name}}」?此操作不可恢复。", + deleted: "会话已删除", + }, + keywordHl: { + title: "关键字突出显示", + enabled: "启用", + newKeyword: "新建关键字", + newKeywordPh: "输入关键字或正则", + regex: "正则表达式", + add: "添加", + colKeyword: "关键字", + colRegex: "正则", + empty: "尚未添加关键字", + moveUp: "上移", + moveDown: "下移", + remove: "删除", + caseSensitive: "区分大小写", + color: "字符颜色", + saved: "关键字突出显示已保存", + }, + actions: { + reconnect: "重新连接", + recordLog: "记录日志", + clear: "清空", + copy: "拷贝", + recording: "记录中", + recordingOn: "已开始记录日志", + recordingSaved: "日志已保存", + copied: "已复制到剪贴板", + copyEmpty: "终端内容为空", + copyFailed: "复制失败", + break: "Break", + breakSent: "已发送 Break", + broadcast: "广播", + broadcastSent: "已广播到全部会话", + postLogin: "登录后命令", + }, + favorites: "收藏", + recent: "最近", + favorite: "收藏/取消", + encoding: "编码", + fontSize: "字号", + theme: "主题", + themeDark: "深色", + themeLight: "浅色", + colorScheme: "配色方案", + backgroundColor: "背景色", + foregroundColor: "字体色", + scheme: { + dark: "深蓝(默认)", + blackWhite: "白底黑字", + whiteBlack: "黑底白字", + greenBlack: "黑底绿字", + amberBlack: "黑底琥珀", + custom: "自定义", + }, + copyOnSelect: "选中即复制", + rightClickPaste: "右键直接粘贴", + rightClickPasteHint: "Shift + 右键打开菜单;关闭此项后,右键始终打开菜单。", + pasteDelay: "粘贴最大行间隔", + pasteDelayOff: "无延迟(整块发送)", + keepalive: "SSH Keepalive", + keepaliveOff: "关闭", + keepaliveSec: "{{n}} 秒", + options: "选项", + globalSessionOptions: "全局会话选项", + sessionOptionsSave: "保存", + sessionOptionsCancel: "取消", + sessionOptionsSaved: "已保存全局会话选项", + disconnectBanner: "会话已断开", + disconnectBannerError: "会话出错或连接中断", + broadcastPh: "发送到全部标签…", + buttonBarHint: "每行:按钮名|命令", + postLoginPrompt: "登录后自动执行(每行一条,仅本设备)", + postLoginSaved: "登录后命令已保存", + sftp: { + title: "SFTP", + refresh: "刷新", + up: "上级", + upload: "上传", + uploaded: "上传成功", + uploading: "上传 {{done}}/{{total}} · {{name}} {{pct}}", + downloading: "下载 {{done}}/{{total}} · {{name}} {{pct}}", + downloaded: "已下载 {{count}} 个文件", + downloadPartial: "下载完成 {{ok}} 个,失败 {{failed}} 个", + uploadPartial: "上传完成 {{ok}} 个,失败 {{failed}} 个", + retrying: "重试 {{name}}({{attempt}}/{{max}})…", + cancel: "取消", + dropHint: "松开以上传到当前目录", + dropEmpty: "未识别到可上传的文件", + mkdir: "新建", + mkdirPrompt: "新建目录名称", + mkdirInvalid: "目录名无效(不要包含 / 或 ..)", + mkdirOk: "目录已创建", + rename: "重命名", + renamePrompt: "新名称", + renameInvalid: "名称无效(不要包含 / 或 ..)", + renamed: "已重命名", + download: "下载", + chmod: "权限", + chmodPrompt: "权限模式(如 755 或 rwxr-xr-x)", + chmodOk: "权限已更新", + chmodPartial: "权限更新 {{ok}} 个,失败 {{failed}} 个", + delete: "删除", + deleteConfirm: "确定删除 {{name}}?", + deleteRecursiveConfirm: "{{name}} 含目录,非空目录将递归删除,是否继续?", + deletePartial: "删除完成 {{ok}} 个,失败 {{failed}} 个", + deleted: "已删除", + selectedCount: "已选 {{count}} 项", + empty: "当前目录为空", + listTruncated: "目录条目过多,仅显示前 {{count}} 项(上限 {{max}})", + dir: "目录", + loading: "正在加载…", + enterHint: "单击选中;Ctrl/Shift 多选;双击进入", + downloadHint: "单击选中;Ctrl/Shift 多选;双击下载", + colName: "名称", + colSize: "大小", + colMtime: "修改时间", + colOwner: "所有者", + colGroup: "组", + colMode: "权限", + resizeHint: "拖动调整 SFTP / 终端宽度", + colResizeHint: "拖动调整列宽", + }, + term: { + findPh: "查找…", + findPrev: "上一个", + findNext: "下一个", + findClose: "关闭查找", + copy: "复制", + paste: "粘贴", + pasteEmpty: "剪贴板为空", + pasteFailed: "无法读取剪贴板(HTTP 远程请用下方粘贴框,或 Ctrl+V)", + pasteBridgeHint: "当前为非 HTTPS 访问,浏览器禁止直接读剪贴板。请在此框按 Ctrl+V 粘贴,内容会发送到终端。", + pasteBridgePh: "在此按 Ctrl+V…", + pasteBridgeCancel: "取消", + clear: "清空", + pasting: "粘贴中 {{done}}/{{total}} 行…", + wsError: "[WebSocket 错误]", + wsClosed: "[WebSocket 已关闭 code={{code}}]", + sessionStatus: "[会话 {{state}}{{detail}}]", + outputTruncated: "[输出过快,已丢弃 {{count}} 段]", + }, + err: { + deviceType: "不支持的设备类型", + ipConflictRestart: "数据库仍限制 IP 唯一,请重启 netx API 后再试", + sessionLimit: "并发会话已达上限,请先关闭其他终端", + neNotFound: "网元不存在", + credsIncomplete: "网元账号或密码不完整", + authRejected: "用户名或密码错误,请重新输入", + cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)", + connectFailed: "登录设备失败:{{detail}}", + sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH", + sftpSsh: "SFTP 仅支持 SSH 协议", + sftpNeedPassword: "SFTP 需要已保存的 SSH 密码(会话弹窗密码不会用于文件传输)", + sftpUnsupported: "当前 SSH 会话未提供 SFTP(设备可能未开启)", + sftpTooLarge: "文件超过大小限制(默认 512MB)", + sftpChmodInvalid: "权限模式无效(请用 755 或 rwxr-xr-x)", + sftpAborted: "传输已取消", + queueDropped: "终端输出过快,已丢弃约 {{count}} 段,内容可能不完整", + websocket: "WebSocket 连接失败", + }, +}; + +export default webcrt; diff --git a/web/src/i18n/locales/zh/workbench.ts b/web/src/i18n/locales/zh/workbench.ts new file mode 100644 index 0000000..5a2c5d9 --- /dev/null +++ b/web/src/i18n/locales/zh/workbench.ts @@ -0,0 +1,121 @@ +const workbench = { + title: "工作台", + modules: "可用模块", + sections: "功能分区", + moduleCount: "{{count}} 个模块", + backToWorkbench: "返回工作台", + openInNewTab: "在新标签页中打开", + openModule: "打开或切换到该模块页签", + monitoring: "系统对接", + operations: "运维", + system: "系统管理", + welcomeEyebrow: "Workbench", + welcomeTitle: "欢迎回来,{{name}}", + welcomeSub: "在此总览同步、拓扑与后台任务状态。", + today: "今天 · {{date}}", + kpi: { + uptime: "API 运行时长", + health: "系统健康", + tasks: "进行中任务", + healthOk: "OK", + healthWarn: "CHECK", + healthUnknown: "N/A", + }, + statusTitle: "运行状态", + hostHealth: "HOST", + platformCapacity: "平台容量", + channels: "CHANNELS", + fleet: "FLEET", + fleetNe: "网元", + fleetNeUnit: "台", + fleetNeKnown: "已知", + fleetNePass: "已连通", + fleetNeFail: "失败", + fleetNeTesting: "测试中", + fleetNeUnknown: "未测试", + fleetNeNoTag: "无标签", + fleetNeTags: "热门标签", + fleetTopo: "拓扑", + fleetTopoNodes: "节点", + fleetTopoEdges: "边", + fleetTopoOther: "其他区域", + fleetTopoUnassigned: "未分区", + fleetTopoNoRegion: "暂无区域", + fleetTopoDiscover: "最近发现", + fleetTopoUpdated: "摘要更新", + fleetSched: "调度", + fleetSchedConfig: "配置同步", + fleetSchedLldp: "LLDP", + fleetSchedPort: "端口流量", + fleetSchedNeCollect: "网元采集", + fleetSchedFabric: "拓扑对账", + fleetSchedOn: "ON", + fleetSchedOff: "OFF", + fleetSchedMode: "模式", + fleetSchedHeartbeat: "心跳", + fleetSchedFresh: "新鲜", + fleetSchedStale: "过期", + + sidePanel: "控制台侧栏", + consoleId: "OPS CONSOLE", + refreshedAt: "更新于 {{time}}", + taskRail: "ACTIVE TASKS", + taskRailAll: "ALL", + taskRailEmpty: "No active tasks", + taskTotal: "POOL {{count}}", + announceTitle: "系统公告", + recentTitle: "最近操作", + recentMore: "查看全部", + quickTitle: "快捷操作", + emptyRecent: "暂无操作记录。", + gauge: { + cpu: "CPU", + mem: "内存", + storage: "存储", + storageUsed: "已用", + storageUnavailable: "不可用", + cli: "CLI 并发", + db: "数据库连接", + webcrt: "WebCRT", + tasks: "活跃任务", + low: "低负载", + normal: "正常", + high: "繁忙", + }, + quick: { + tasks: "任务概览", + logs: "操作日志", + users: "用户管理", + apiKeys: "API Key", + sessions: "登录会话", + }, + announce: { + a1: "深色工作台已上线,其它模块将沿用同一套深色体系。", + a1Date: "2026-08-04", + a2: "LLDP 采集支持暂停/继续/停止;进程重启后会自动续跑。", + a2Date: "2026-08-04", + a3: "配置同步与 LLDP 看板在任务运行中可实时刷新 KPI。", + a3Date: "2026-08-03", + }, + recentCols: { + action: "操作内容", + module: "模块", + time: "时间", + user: "用户", + }, + cards: { + umeSync: "UME同步", + network: "网络管理", + managedNe: "网元管理", + collect: "批量采集", + webcrt: "WebCRT", + topology: "拓扑管理", + portTrafficWall: "流量大屏", + users: "用户管理", + audit: "操作审计", + apiKeys: "API Key", + sessions: "登录会话", + }, +}; + +export default workbench; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts deleted file mode 100644 index 14f793f..0000000 --- a/web/src/i18n/zh.ts +++ /dev/null @@ -1,2956 +0,0 @@ -const zh = { - common: { - help: "帮助说明", - refresh: "刷新", - refreshing: "刷新中…", - query: "查询", - clearFilters: "清除筛选", - prevPage: "上一页", - nextPage: "下一页", - perPage: "{{n}}/页", - pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页", - jumpPage: "跳转到页", - pageSize: "每页条数", - exportCsv: "导出 CSV", - exporting: "导出中…", - exportOk: "已导出 {{count}} 条", - exportTruncated: "已导出前 {{count}} 条(共 {{total}},已截断)", - exportFailed: "导出失败", - opFailed: "操作失败", - empty: "-", - cancel: "取消", - copy: "复制", - copied: "已复制", - }, - workbench: { - title: "工作台", - modules: "可用模块", - sections: "功能分区", - moduleCount: "{{count}} 个模块", - backToWorkbench: "返回工作台", - openInNewTab: "在新标签页中打开", - openModule: "打开或切换到该模块页签", - monitoring: "系统对接", - operations: "运维", - system: "系统管理", - welcomeEyebrow: "Workbench", - welcomeTitle: "欢迎回来,{{name}}", - welcomeSub: "在此总览同步、拓扑与后台任务状态。", - today: "今天 · {{date}}", - kpi: { - uptime: "API 运行时长", - health: "系统健康", - tasks: "进行中任务", - healthOk: "OK", - healthWarn: "CHECK", - healthUnknown: "N/A", - }, - statusTitle: "运行状态", - hostHealth: "HOST", - platformCapacity: "平台容量", - channels: "CHANNELS", - fleet: "FLEET", - fleetNe: "网元", - fleetNeUnit: "台", - fleetNeKnown: "已知", - fleetNePass: "已连通", - fleetNeFail: "失败", - fleetNeTesting: "测试中", - fleetNeUnknown: "未测试", - fleetNeNoTag: "无标签", - fleetNeTags: "热门标签", - fleetTopo: "拓扑", - fleetTopoNodes: "节点", - fleetTopoEdges: "边", - fleetTopoOther: "其他区域", - fleetTopoUnassigned: "未分区", - fleetTopoNoRegion: "暂无区域", - fleetTopoDiscover: "最近发现", - fleetTopoUpdated: "摘要更新", - fleetSched: "调度", - fleetSchedConfig: "配置同步", - fleetSchedLldp: "LLDP", - fleetSchedPort: "端口流量", - fleetSchedNeCollect: "网元采集", - fleetSchedFabric: "拓扑对账", - fleetSchedOn: "ON", - fleetSchedOff: "OFF", - fleetSchedMode: "模式", - fleetSchedHeartbeat: "心跳", - fleetSchedFresh: "新鲜", - fleetSchedStale: "过期", - - sidePanel: "控制台侧栏", - consoleId: "OPS CONSOLE", - refreshedAt: "更新于 {{time}}", - taskRail: "ACTIVE TASKS", - taskRailAll: "ALL", - taskRailEmpty: "No active tasks", - taskTotal: "POOL {{count}}", - announceTitle: "系统公告", - recentTitle: "最近操作", - recentMore: "查看全部", - quickTitle: "快捷操作", - emptyRecent: "暂无操作记录。", - gauge: { - cpu: "CPU", - mem: "内存", - storage: "存储", - storageUsed: "已用", - storageUnavailable: "不可用", - cli: "CLI 并发", - db: "数据库连接", - webcrt: "WebCRT", - tasks: "活跃任务", - low: "低负载", - normal: "正常", - high: "繁忙", - }, - quick: { - tasks: "任务概览", - logs: "操作日志", - users: "用户管理", - apiKeys: "API Key", - sessions: "登录会话", - }, - announce: { - a1: "深色工作台已上线,其它模块将沿用同一套深色体系。", - a1Date: "2026-08-04", - a2: "LLDP 采集支持暂停/继续/停止;进程重启后会自动续跑。", - a2Date: "2026-08-04", - a3: "配置同步与 LLDP 看板在任务运行中可实时刷新 KPI。", - a3Date: "2026-08-03", - }, - recentCols: { - action: "操作内容", - module: "模块", - time: "时间", - user: "用户", - }, - cards: { - umeSync: "UME同步", - network: "网络管理", - managedNe: "网元管理", - collect: "批量采集", - webcrt: "WebCRT", - topology: "拓扑管理", - portTrafficWall: "流量大屏", - users: "用户管理", - audit: "操作审计", - apiKeys: "API Key", - sessions: "登录会话", - }, - }, - network: { - title: "网络管理", - nav: { - detail: "网络详情", - tasks: "监控任务", - cutoverCompare: "割接对比", - topologyMgmt: "拓扑管理", - devices: "设备列表", - topology: "拓扑信息", - lldpLinks: "LLDP", - topoClassify: "分类", - alarms: "告警信息", - configs: "配置信息", - webcrt: "WebCRT", - collectTasks: "采集任务", - configSync: "配置同步", - portTraffic: "流量监控", - portTrafficWall: "流量大屏", - bizState: "业务监控", - bizCompare: "业务对比", - bizMigration: "割接监控", - compareTemplates: "对比模板", - monitorTemplates: "监控模板", - }, - collapseNav: "折叠侧栏", - expandNav: "展开侧栏", - placeholder: { - title: "功能规划中", - body: "该能力尚未开放。", - }, - }, - bizState: { - title: "业务监控", - loadedTasks: "已载入任务", - loadedBatches: "最近批次 · 已载入 {{n}} 条(最多 200)", - collectErrors: "最近采集异常", - retry: "重试", - taskLoading: "正在读取任务配置…", - tasksLoadError: "任务列表读取失败", - taskLoadError: "任务配置读取失败", - batchLoadError: "批次读取失败", - sheetLoadError: "结构化数据读取失败", - statusQueued: "排队中", - batchSuccess: "采集成功", - batchPartial: "部分完成", - batchFailed: "采集失败", - batchCancelled: "已取消", - create: "创建任务", - createHint: "从托管 / UME 等有效网元中选择(支持查找)", - createModeDevice: "选择设备", - createModeImport: "手动导入", - createImportHint: - "无网元也可建任务:上传会话日志或 zip,自动切分 show/display 并解析为首个批次。之后可在任务上多次「导入日志」追加批次(等同于多次采集)。", - createImportSubmit: "创建并导入", - created: "已创建任务", - createFailed: "创建失败", - empty: "暂无业务监控任务。", - pickNe: "选择网元…", - neKeywordPh: "名称 / IP / ID", - allSource: "全部来源", - selectedNe: "已选", - neEmpty: "无匹配网元。", - colSource: "来源", - colVendor: "厂商", - colConnect: "连通", - start: "启动周期", - pause: "暂停", - statusPaused: "仅手动", - scheduleEnabled: "启用周期调度", - scheduleOn: "周期", - scheduleOff: "手动", - scheduleHint: "勾选后按周期自动采集;不勾选则仅支持「立即采集」。", - collectNow: "立即采集", - importLog: "导入日志", - importLogHint: - "向当前任务追加一批次(等同于一次采集):支持整段会话或 zip,丢掉 show/display 前杂讯,按 hostname#show 切分;整份无 show 则失败(无需 SSH)。", - importVendor: "厂商", - importVendorGeneric: "通用(show+display)", - importFile: "日志文件", - importNeName: "任务名称(可选)", - importNeNamePh: "例如:PE1-offline", - importSubmit: "导入为新批次", - importing: "导入中", - importStarted: "已开始导入({{count}} 段命令)", - importOfflineOnly: "导入任务仅支持离线导入,无 SSH 采集", - sourceImport: "导入", - stopCollect: "停止采集", - stopCollectOk: "已请求停止采集", - stopCollectIdle: "当前没有进行中的采集", - delete: "删除", - confirmDelete: "删除该业务监控任务及所有批次?", - deleted: "已删除", - started: "已启用周期采集", - paused: "已暂停周期(仅手动采集)", - collecting: "采集中", - statusCollecting: "采集中", - detail: "详情", - interval: "采集周期", - intervalUnit: "周期单位", - intervalUnitDays: "天", - intervalUnitHours: "小时", - intervalUnitSeconds: "秒", - retention: "保留天数", - retentionHint: "超过天数的批次会自动清理;基线与比对/割接引用的批次不会删除。", - dailyKeepEnabled: "每日保留策略", - dailyKeepCount: "每天保留次数", - dailyKeepHint: "开启后:过去每一天只保留最近 N 次,多余在次日清理(默认关闭)。", - markBaseline: "标为基线", - unmarkBaseline: "撤销基线", - baseline: "基线", - protected: "已保护", - colAlias: "别名", - setAlias: "设别名", - aliasPrompt: "批次别名(比对/割接选择时优先显示;留空清除)", - aliasSaved: "别名已保存", - aliasEmpty: "未命名", - workbookStats: "对比项 {{compare}} · 有数据 {{withData}} · 表格 {{sheets}}", - deleteBatch: "删除", - bulkDelete: "批量删除", - confirmDeleteBatch: "删除该采集批次?受保护的批次无法删除。", - confirmBulkDelete: "删除选中的 {{count}} 个批次?受保护的会自动跳过。", - batchDeleted: "已删除批次", - bulkDeleted: "已删除 {{deleted}} 个,跳过 {{skipped}} 个", - purgeNow: "立即清理", - purgeOk: "清理完成:删除 {{dropped}} 个批次", - selectAll: "全选可删", - colProtect: "保护", - saveSchedule: "保存周期", - scheduleSaved: "周期配置已保存", - colInterval: "周期", - profiles: "监控项", - profilesSelectAll: "全选", - profilesDeselectAll: "取消全选", - profilesSelectAllOk: "已启用 {{n}} 项", - profilesDeselectAllOk: "已全部取消启用", - profilesSelectionStats: "已选 {{enabled}} / 共 {{total}} 项", - profilesUnboundStats: "未关联 {{n}}", - colLane: "采集车道", - laneLight: "轻车道", - laneHeavy: "重车道", - laneLightHint: "与多数命令共用 SSH;整轮默认约 10 分钟上限", - laneHeavyHint: "独立 SSH + 更长超时;整轮默认约 40 分钟上限", - enable: "启用", - command: "命令模板", - colAux: "辅命令", - params: "参数关联", - discoverVrf: "发现 VRF", - discoverBind: "发现 / 关联", - saveBindings: "保存关联", - cancel: "关闭", - bindTitle: "选择参数关联", - unbound: "未关联", - bindHintRequired: "需关联参数后才可采集", - paramsDblClickHint: "单击查看全部关联明细", - paramsClickHint: "单击查看全部关联明细", - paramsDetailTitle: "参数关联明细", - paramsDetailCount: "共 {{n}} 条", - discoverLoading: "正在发现…", - discoverEmpty: "未发现可用选项", - discoverCacheHit: "已复用(未重新登录)", - discoverRefresh: "重新从设备拉取", - selectAllVrfs: "全选", - deselectAllVrfs: "取消全选", - batches: "采集批次", - viewBatch: "查看", - batchDetail: "详情", - export: "导出", - exportCommands: "导出采集命令", - exportCommandsOk: "已导出采集命令", - exportRawLog: "导出为文本", - exportRawLogOk: "日志已导出", - noBatches: "尚无批次", - batchCollectDetail: "采集详情", - batchWorkbook: "批次工作簿", - batchId: "批次 ID", - copyBatchId: "复制批次 ID", - batchMessageEmpty: "无额外说明或报错", - batchCmdSummary: - "命令 {{ok}} 成功 / {{fail}} 失败 / {{skipped}} 跳过 / {{other}} 其他 · 统计 {{total}} 条(批次计数 {{batchCmds}})", - sheetCommands: "命令", - sheetLldp: "LLDP 邻居", - sheetVrfRoute: "VRF 路由摘要", - sheetIsis: "ISIS 邻居", - sheetIfaceBrief: "接口 Brief", - sheetArp: "ARP", - sheetNd6: "ND6 Cache", - sheetBgpPeer: "BGP 对等体", - sheetConfigIface: "接口 Intent", - sheetConfigVrf: "VRF Intent", - sheetConfigBgp: "BGP Intent", - sheetFilterPh: "筛选当前表…", - sheetEmpty: "本表无数据", - sheetLoading: "加载中…", - collectCommand: "采集命令", - viewRawLog: "原始日志", - rawLogTitle: "原始采集日志", - rawLogEmpty: "无原始输出", - rawLogStats: "采集 {{lines}} 行 · 入库 {{rows}} 条", - rawLogDeclared: "声明 {{declared}}", - openWorkbook: "打开工作簿", - filterColumn: "筛选列", - filterAllCols: "全部列", - listFilterPh: "筛选网元 / IP / 厂商 / 状态", - purposeFilter: "任务用途", - purposeAll: "全部用途", - purposePortrait: "画像", - purposeCutoverHf: "割接高频", - colPurpose: "用途", - colNe: "网元", - colStatus: "状态", - colLast: "最近采集", - colStarted: "开始时间", - colEnded: "结束时间", - colTime: "时间", - colRows: "入库条数", - colRawLines: "采集行数", - colDeclared: "设备声明", - colActions: "操作", - colCommand: "命令", - colMessage: "说明", - pickTask: "打开详情查看任务", - noNeedBind: "该监控项无需参数关联", - discoverFailed: "发现失败", - bindingsSaved: "参数关联已保存", - }, - bizCompare: { - title: "业务对比", - templates: "比对模板", - newTemplate: "新建模板", - editTemplate: "编辑模板", - saveTemplate: "保存模板", - templateSaved: "模板已保存", - templateDeleted: "模板已删除", - confirmDeleteTemplate: "删除该比对模板?", - confirmDeleteJob: "删除该比对任务及全部比对记录?", - confirmDeleteRun: "删除本次比对结果?任务配置将保留。", - deleteJob: "删除任务", - deleteRun: "删除本次", - runDeleted: "比对记录已删除", - emptyTemplates: "暂无模板。可新建,或从 JSON 文件导入。", - exportTemplate: "导出", - importTemplate: "导入", - templateExported: "模板已导出", - templateImported: "模板已导入", - templateImportInvalid: "导入文件无效:需要 name 与 metrics", - templateValidationFailed: "模板校验失败,请修正标明的配置项", - filterOperator: "过滤运算符", - complexFiltersPreserved: "该过滤规则包含复杂嵌套,已完整保留。可通过导出、修改 JSON 后重新导入来更新。", - portPolicy: "端口变化策略", - portPolicyHint: "存在端口映射时按映射匹配;此策略控制无映射时接口身份字段是否参与配对。", - portPolicyAuto: "自动判断", - portPolicyStrict: "接口必须匹配", - portPolicyIgnore: "允许端口变化", - missingSheets: "配置中的检查项已被移除或改名,请重新选择检查范围", - enableCurrentSheets: "启用当前全部检查项", - templateUsage: "已被 {{n}} 个任务引用", - templateImportHint: "导入/导出 JSON,便于私下传递模板(不含任务/批次数据)", - templateHint: - "先勾选监控项,再配置 Key / 接口 / 比对 / 展示。比对可设完全相等、绝对容差或相对偏差%;展示列可含不参与比对的上下文。不勾比对字段则只校验条目存在性。", - stepPickMetrics: "① 选择监控项", - stepConfigRules: "② 配置比对规则", - pickMetricsHint: "勾选后加入模板;可多选。选好后在下方按表配置参数。", - pickMetricsHintShort: "点选加入 / 取消;配置在下方左侧表列表中切换。", - showMetrics: "展开选表", - hideMetrics: "收起选表", - noMetricsYet: "请先勾选至少一个监控项", - selectedCount: "已选 {{n}} 项", - tabFields: "字段规则", - tabFilters: "行过滤", - fieldsHintShort: "Key 对齐行;比对勾选参与值 diff;不勾比对则只看存在性。", - tagCompareN: "{{n}} 比对", - colFields: "字段数", - keyFields: "Key", - ifaceFields: "接口", - compareFields: "比对", - compareMode: "方式", - tolerance: "容差", - displayField: "展示", - keyAlwaysDisplay: "Key 列始终展示", - modeEq: "完全相等", - modeNumeric: "绝对偏差", - modePercent: "相对偏差 %", - normalizeField: "归一", - ignoreFields: "忽略", - field: "字段", - note: "备注", - keyRequired: "至少选择一个 Key 字段", - keyIsIdentity: "Key 用于对齐行,不作为值比对字段", - metricsRequired: "请至少添加一张监控表", - noMetricFields: "该 metric 无字段定义", - rowFilters: "行过滤规则", - rowFiltersHint: - "比对前按规则筛选行。组内条件为「且」,组与组之间为「或」。勾选 ARP 等监控项时会带上默认可编辑规则,可按需改删。", - rowFiltersHintShort: "组内且 · 组间或 · 无过滤则比对全部行", - rowFiltersEmpty: "无过滤则比对全部行。", - filterClear: "清空", - filterAddOr: "添加条件组(或)", - filterAddAnd: "组内加条件(且)", - filterAddOrShort: "+ 或组", - filterAddAndShort: "+ 且", - filterRemoveGroup: "删除本组", - filterGroupN: "组 {{n}}", - filterOr: "或", - filterAnd: "且", - filterField: "字段", - filterOp: "运算", - filterValue: "值", - normalizeNone: "无", - normalizeLower: "小写", - normalizeUpper: "大写", - normalizeMac: "MAC", - normalizeEmpty: "空值归一", - templateFilterPh: "筛选模板名 / metric", - jobFilterPh: "筛选任务名 / 模板 / 状态", - addMetric: "添加监控项", - pickMetric: "选择监控项…", - addSheet: "加入模板", - removeSheet: "移出本表", - sheetIdRequired: "请填写 sheet_id(拆表唯一标识)", - duplicateSheetId: "sheet_id 不能重复", - sheetTitle: "表标题", - sheetId: "sheet_id", - splitSheet: "拆表复制", - colSheets: "监控表", - sheetsUnit: "张表", - presenceShort: "仅条目", - modeFields: "模式:按比对字段检查值", - modeFieldsShort: "字段比对", - modePresence: "模式:仅校验前后条目一致(不比具体值)", - mapping: "端口映射(可选)", - newMapping: "新建映射…", - mapName: "映射名称", - saveMapping: "保存映射", - validateMapping: "校验映射", - validateNeed: "请先选择映射与前后批次", - mapHint: - "每行一对:操作前接口,操作后接口(逗号/Tab/竖线分隔)。例:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# 井号开头为注释。不是 A,B/C,D 这种斜杠分组。", - mappingSaved: "映射已保存", - mappingOptionalHint: - "可选。留空=忽略本端端口改名,按非接口 Key 对齐。填写后才做端口映射校验。主接口映射会自动覆盖 .子接口(后缀不变)。", - ifaceNormalize: "接口归一化", - ifaceNormalizeHint: - "模板级规则,每行:简写,规范名。先归一化再端口映射。例:\nGE,gei\nSG,smartgroup\nXGE,xgei", - createJob: "建立比对任务", - defaultJobName: "割接比对", - jobName: "名称", - template: "模板", - mode: "模式", - modeManual: "手工选批次", - modeAuto: "自动(钉死操作前,操作后取最新)", - beforeTask: "操作前任务", - beforeBatch: "操作前批次", - afterTask: "操作后任务", - afterBatch: "操作后批次", - sideBefore: "操作前", - sideAfter: "操作后", - sidesVs: "比对", - sidesTitle: "比对双方", - configSnapshot: "执行配置已保存", - configSnapshotHint: "此记录的模板规则和端口映射已固定;后续编辑不影响历史回查,下载报告包含执行配置。", - configLegacy: "旧记录:未保存执行配置", - configLegacyHint: "此记录创建时未保存配置快照;成功行回查和精简明细还原使用当前配置,修改配置后可能与当时结果不同。", - pick: "选择…", - sameAsBefore: "同操作前 / 选择…", - autoHint: "auto:操作后任务新批次到达会自动跑比对", - createCompare: "创建比对任务", - createSteps: "创建步骤", - stepBasics: "基本信息", - stepSheets: "对比项", - stepBatches: "批次", - stepMapping: "端口映射", - next: "下一步", - back: "上一步", - enabledSheets: "对比项", - enabledSheetsHint: "点击开关;默认全部开启。关闭的项不参与本次比对。", - noTemplateSheets: "该模板暂无对比表", - needJobName: "请填写任务名称", - needTemplate: "请选择比对模板", - needSheets: "请至少开启一项对比", - needBeforeBatch: "请选择操作前任务与批次", - needAfterBatch: "请选择操作后批次", - runNow: "立即比对", - runStarted: "比对已在后台启动,可关闭本页;进度见历史记录", - runFailed: "比对失败", - runStatusRunning: "比对中", - runStatusFailed: "失败", - runStatusDoneSec: "完成 {{s}}s", - runProgress: "{{phase}} · 表 {{i}}/{{n}} · {{sheet}} · 已用 {{s}}s", - runSheetProgress: "表 {{i}}/{{n}}", - runElapsed: "已用 {{s}}s", - runRowsLoaded: "已加载 {{side}} {{n}} 行", - runRowsSqlCount: "库内统计 {{side}} {{n}} 行", - runPersisting: "已写入 {{done}}/{{total}} 行", - runEngineSql: "引擎 SQL", - runEnginePython: "引擎 Python", - runEngineNote: "原因 {{note}}", - phaseQueued: "排队中", - phaseLoading: "加载数据", - phaseSqlCount: "库内统计", - phaseSqlProject: "准备比对表", - phaseSqlJoin: "库内比对", - phaseSqlFailFetch: "拉取失败行", - phaseComparing: "比对中", - phasePersisting: "写入结果", - phasePersistingFail: "写入失败行", - phasePersistingOk: "写入成功行", - phaseDone: "完成", - phaseFailed: "失败", - phaseCancelled: "已取消", - sheetPending: "等待中", - colProgress: "进度 / 结果", - passRateScope: "全表", - keyFiltersToggle: "字段筛选", - hideDisplayCols: "隐藏展示列", - showDisplayCols: "显示展示列", - displayColsToggle: "展示列", - hideDisplayColsHint: "紧凑模式:默认隐藏不参与比对的展示列;高亮表示当前已显示", - ranWithDuration: "比对完成(耗时 {{s}} 秒)", - unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。", - unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(分层抽查)。要查任意路由请输入筛选条件——将按原表即时判定。通过率按全部 {{total}} 计。", - liveSearchHint: "从原始批次补齐同一业务的前后数据后筛选;通过率仍按全表统计。", - liveSearchTruncatedHint: "搜索已达上限,部分业务未展示。请收窄条件;不完整的业务分组已跳过。", - clearSearch: "清空筛选", - resultLoading: "正在加载对比明细…", - resultRange: "{{from}}–{{to}} / {{total}} 条", - resultLoadFailed: "对比明细加载失败", - retry: "重试", - searchEmpty: "当前筛选条件下没有匹配结果,可清空筛选或切换结果类型。", - clearKeyFilters: "清空字段", - storeUnchanged: "成功行保存", - storeUnchangedAuto: "自动(小表全量 / 大表抽样 5000+原表补全)", - storeUnchangedSample: "仅抽样(最多 5000,身份键+原表补全)", - storeUnchangedKeys: "全量身份键(可翻完全部成功,大表写入仍较久)", - storeUnchangedAlways: "始终全量 JSON(大表很慢,慎用)", - storeUnchangedNever: "不保存(只计数量)", - storeUnchangedHint: "割接优先看失败与通过率;成功默认分层抽样。输入筛选可回查原表(不依赖抽样)。通过率按全部成功计数。", - saveJob: "保存配置", - jobSaved: "任务配置已保存", - jobDeleted: "比对任务已删除", - jobList: "比对任务", - detail: "详情", - edit: "编辑", - delete: "删除", - tabConfig: "任务配置", - tabRuns: "对比批次", - tabResult: "比对结果", - detailTabs: "业务对比工作区", - jobReady: "已就绪", - configBasics: "作业参数", - configSources: "前后数据源", - configScope: "检查范围", - selectMapping: "选择映射", - mappingPairs: "接口对应关系", - sourceBaseline: "固定基准", - sourceTarget: "验收目标", - sourceLatest: "最新批次", - sourceRows: "采集 {{n}} 条", - autoSourceHint: "保持操作前批次固定;操作后任务采集完成时,自动取最新批次进行对比。", - runHistoryTitle: "执行记录", - runHistoryHint: "展示最近 50 条记录。执行状态与业务判定分别显示,可打开结果核查。", - refreshRuns: "刷新记录", - loadedRuns: "已载入批次", - activeRuns: "正在执行", - completedRuns: "执行完成", - stoppedRuns: "失败 / 取消", - runFinished: "已完成", - runStatusQueued: "排队中", - runsLoading: "正在读取执行记录…", - runsLoadError: "执行记录读取失败", - runs: "历史比对", - runsHint: "每次「立即比对」生成一条批次。重启后卡住的「比对中」会自动标为已取消;也可手动终止后重跑。", - noRuns: "尚无比对记录,请先执行比对", - result: "比对结果", - viewResult: "查看结果", - cancelRun: "终止", - confirmCancelRun: "确定终止该比对批次?终止后可重新发起比对。", - runCancelled: "比对已取消", - runStatusCancelled: "已取消", - compareAlreadyRunning: "已有比对在进行中,请先在「对比批次」中终止或等待完成", - runBatchSummary: "失败 {{fail}} · 成功 {{ok}} · 新增 {{added}}", - colTime: "时间", - pickBatchRun: "选择比对记录", - pickRun: "选择比对记录…", - runCount: "{{n}} 次", - resultEmpty: "无匹配失败/结果行", - resultFilterPh: "自由词,或 direction:out network:1.1.1.1", - kindAll: "全部", - kindDiff: "失败", - kindAdded: "新增", - kindRemoved: "仅缺失", - kindChanged: "仅不一致", - kindUnchanged: "成功", - kindAddedShort: "新增", - kindRemovedShort: "缺失", - kindChangedShort: "不一致", - kindUnchangedShort: "成功", - kindFail: "失败", - kindPass: "成功", - kindSuccess: "成功", - successCount: "成功", - passRateShort: "通过率", - verdictPass: "通过", - verdictFail: "不通过", - failCount: "失败条目", - missCount: "缺失", - extraCount: "多余", - mismatchCount: "不一致", - matchCount: "一致", - failFields: "失败项", - failFieldsEmpty: "—", - failWholeRow: "整行", - pairBefore: "pre", - pairAfter: "post", - sheetAllPass: "全部一致", - filterFailField: "失败比对项", - filterFailFieldAll: "全部失败项", - diffCount: "失败 {{n}}", - passRate: "通过率", - passOk: "全部通过", - passWarn: "存在失败", - runOverview: "总裁决", - sheetCurrent: "当前检查项", - diffCountLabel: "失败条目", - diffRate: "失败率", - beforeCount: "操作前行数", - afterCount: "操作后行数", - matchedRows: "对齐条目", - totalRows: "合计条目", - sheetCount: "监控表", - sheetNavTitle: "检查项", - sheetNavHint: "共 {{n}} 项 · 点击切换", - collapseSheetNav: "折叠检查项", - expandSheetNav: "展开检查项", - sheetFailCount: "{{n}} 项失败", - topChangedFields: "失败字段 Top", - exportTables: "下载表格", - exportOk: "已导出比对表格", - fullscreen: "大屏全屏", - exitFullscreen: "退出全屏", - emptyJobs: "暂无比对任务", - created: "比对任务已创建", - ran: "比对完成", - colName: "名称", - colMode: "模式", - colStatus: "状态", - colKind: "结论", - colResult: "结果", - colDetail: "明细", - colFailFields: "失败项", - colActions: "操作", - colKey: "身份", - colChange: "变更", - }, - bizMigration: { - fullscreen: "大屏模式", - exitFullscreen: "退出大屏", - fullscreenHint: "全屏展示割接状态,按 Esc 返回看板;浏览器不支持全屏时使用窗口大屏。", - wallTitle: "割接监控 · 业务迁移", - wallProgress: "本批迁移进度 · 当前快照", - wallFilters: "显示筛选与判定操作", - wallState_waiting: "等待判定", - wallState_unknown: "数据待确认", - wallState_anomaly: "存在业务异常", - wallState_normal: "当前样本无业务异常", - pairCollectionHint: "每个指标两侧同轮关联采集。保存配置会同步增删采集任务;路由项仅手动采集,收尾时再选择,百万路由建议使用独立业务对比任务。频率是目标间隔,实际周期受命令耗时与设备占用影响。", - pairedSampling: "双端关联采集 · 及时性", - manualRoute: "手动收尾", - pairedPeriodic: "关联周期采集", - samplingMode: "采集方式", - samplingDuration: "采集耗时 老 / 新", - samplingQueue: "排队耗时 老 / 新", - startSkew: "启动时差", - sameRound: "同轮", - metricBaselineMissing: "基线未覆盖此指标,需要补充对应基线", - samplePhase_idle: "尚未采集", - samplePhase_queued: "排队", - samplePhase_running: "采集中", - samplePhase_success: "完成", - samplePhase_partial: "部分完成", - samplePhase_failed: "采集失败", - samplePhase_cancelled: "已停止", - collect_pair_invalid: "双端关联配置不完整", - collect_pair_missing: "尚无完整同轮样本", - collect_pair_pending: "等待两侧同轮采集完成", - collect_pair_failed: "本轮采集失败,不能复用旧样本代替", - collect_round_mismatch: "两侧样本不属于同一轮次", - sample_start_skew: "两侧实际启动时差过大", - latestSnapshot: "最新快照", - steadyOutside: "范围外稳定 {{n}} 条", - "followLive": "实时跟随", - "historySnapshot": "历史快照", - "evaluatedAt": "最近判定", - "refreshBoard": "刷新看板", - "oldestSample": "最早采集", - "liveOverview": "业务迁移概览", - "remaining": "待迁移条目", - "anomalyInBatch": "本批异常", - "anomalyOutsideBatch": "范围外异常", - "coverageUnknown": "数据待确认,不能视为验收通过", - "coverageUnknownHint": "采集缺失、过期、不同步或键冲突需要处理;正常样本仅代表对应监控条目。", - "awaitingPeer": "等待新侧采集确认", - "migratedPrevious": "前批已接续", - "unfinishedPrevious": "前批未接续", - "sample_time_missing": "采集时间缺失", - "sample_skew": "两侧采集间隔过大", - "sample_stale": "采集已过期", - "duplicateKeys": "重复业务键", - "uncoveredScope": "范围对应表已失效", - "refreshFailed": "获取失败,当前显示保留快照:", - "lastAcceptance": "上次终验", - "diffSearch": "搜索业务键 / 判定 / 状态", - "verdictFilter": "判定筛选", - "allVerdicts": "全部判定", - "warningVerdict": "迁移中 / 提示", - "healthyVerdict": "已接续 / 正常", - "unknownVerdict": "待确认", - "loadingBoard": "正在更新…", - "rowsMatched": "匹配 {{n}} 条", - "showOutsideAnomalies": "查看范围外异常", - "scopeCurrent": "本批范围", - "scopePrevious": "前批范围", - "scopeOutside": "范围之外", - "fieldChanges": "字段变化 {{n}} 项", - "outsideProtection": "范围外业务持续保护", - "outsideProtectionHint": "本批之外的业务漂移仍报异常;前批已迁移业务持续检查新侧健康,不计入本批进度。", - "reason_service_not_received": "老侧退出,新侧未健康接续", - "reason_target_unhealthy": "新侧业务状态异常", - "reason_old_service_not_in_baseline": "老基线中未找到业务", - "reason_service_content_changed": "接续业务内容不一致", - "reason_old_service_still_present": "老侧业务尚未退出", - "reason_target_sample_predates_source": "新侧样本早于老侧退出", - "reason_previous_batch_received": "前批业务持续健康", - "reason_previous_batch_not_received": "前批业务未正常接续", - "reason_unmapped_same_iface": "未映射端口发生迁移变化", - "reason_out_of_expect:anomaly": "范围外业务漂移", - "reason_out_of_expect:lost": "范围外业务丢失", - title: "割接监控", - hint: "与业务对比独立:相对基线漂移 + 本批预期 + 老/新双端判定。复用采集任务与端口映射。", - hintPort: "当前监控项:端口状态(interface_brief / admin·phy·prot)。选基线 → 勾选本批端口 → 判定看板。", - hintShort: "选老/新设备与监控模板,创建后自动高频采集;基线来自业务监控画像。", - create: "创建任务", - createHint: - "除画像基线外,整条割接流程在本页完成;创建后自动生成业务监控高频任务并按计划执行。", - createHintShort: - "选老/新设备 → 监控模板与采集项 → 周期/窗口 → 基线(画像)→ 端口映射 → 创建。后台自动建高频任务。", - pickOldNe: "老设备", - pickNewNe: "新设备", - pickNe: "选择设备", - pickNeHint: "点击选择网元", - hideNePicker: "收起列表", - nePairReady: "老/新设备已选好,可进入下一步;点击上方卡片可重新选择。", - needNePair: "请填写任务名并选择老/新设备", - needProjectName: "请填写任务名称", - needMonitorTemplate: "请选择监控模板", - needMetrics: "请至少勾选一项监控项", - noPortraitBaseline: "该设备尚无画像采集任务或批次,请先在业务监控跑通画像后再选基线(可稍后补)。", - baselineOptionalHint: "基线可稍后在详情里补;有画像批次时可在此直接选定。", - metricInterval: "分项周期(秒)", - metricIntervalOptional: "分项周期(可选)", - metricIntervalHint: "留空则使用上方全局周期;填写后该监控项会单独建高频任务。", - scheduleHint: "按本机时间填写窗口,入库为 UTC;窗口外调度器自动暂停高频任务。", - sectionSchedule: "采集周期与窗口", - sectionBaseline: "基线与映射", - sectionDevices: "老 / 新设备", - sectionMetrics: "监控项", - createSteps: "创建步骤", - stepDevices: "设备", - stepTemplate: "模板", - stepSchedule: "周期", - stepBaseline: "基线", - next: "下一步", - back: "上一步", - colHf: "高频", - hfReady: "已建", - hfMissing: "未建", - showCreateMore: "更多:模板 / 映射 / 基线", - hideCreateMore: "收起更多", - setupHintShort: "画像任务用于基线;高频任务用于窗口内采集。此处配置模板、采集项、窗口与双基线。", - createProject: "创建任务", - projectName: "任务名称", - projectNamePh: "例如:PE-A / PE-B 割接-1", - sectionName: "1. 任务名称", - sectionTasks: "2. 绑定采集任务(必填)", - sectionTasksHint: "分别选择老设备、新设备上的业务监控任务;两侧不能为空。", - sectionOptional: "3. 监控模板、映射与基线(可选)", - sectionOptionalHint: "可先跳过,创建后再到「基线配置」里设置。未选监控模板时使用系统默认。", - sectionPair: "已绑定采集任务", - sectionPairHint: "采集任务在创建时绑定;此处可调整监控模板、映射与基线。", - sectionBaselineHint: "老侧与新侧基线均必填;老侧用于列出本批预期对象,新侧参与双端判定。", - optionalNone: "不选", - monitorTemplate: "监控模板", - monitorTemplateHint: "决定对比哪些 metric 与双端成功语义;默认绑定系统默认监控模板。", - compareTemplate: "对比模板", - compareTemplateBound: "绑定对比模板:{{name}}", - portMappingHint: - "有映射时,带接口字段的监控项只比映射内的口(这些口必须在割接后出现)。映射外的同名口不参与迁移对比;老口 down、新口同名 up 记为异常。未配映射则按同名直比。BGP/PW 仍用本批预期勾选。", - baselineNeedTask: "请先选择对应采集任务", - baselinePickHint: "从该任务的历史采集批次中选择", - oldTask: "老设备画像任务", - newTask: "新设备画像任务", - portMapping: "端口映射", - oldBaseline: "老设备基线批次", - newBaseline: "新设备基线批次", - needProjectFields: "请填写任务名并选择老/新画像任务", - needExpectPorts: "请先勾选本批预期迁移的对象", - needBaselineFirst: "先设基线", - needBothBaselines: "请先保存老、新两侧基线批次后再评估", - errOldBaselineRequired: "缺少老设备基线批次", - errNewBaselineRequired: "缺少新设备基线批次", - needBizStateTasks: "尚无业务监控(画像)任务,请先创建:", - projectCreated: "任务已创建", - projects: "任务", - empty: "暂无割接任务。", - emptyProjects: "暂无割接任务", - listFilterPh: "筛选任务名 / 网元 / IP / 状态", - kpiProjects: "任务数", - kpiActive: "进行中", - detail: "详情", - cancel: "关闭", - colName: "任务", - colPair: "老 → 新", - colBaseline: "基线", - colCreated: "创建时间", - colActions: "操作", - baselineSet: "已设", - baselineUnset: "未设", - statusActive: "进行中", - statusDone: "已完成", - statusPaused: "已暂停", - statusPending: "待开始", - statusReview: "验收中", - tabSetup: "基线配置", - tabBatches: "割接批次", - tabBoard: "判定看板", - saveBaseline: "保存配置", - baselineSaved: "基线已保存", - errPairCapacity: "双端关联采集至少需要两个采集并发槽位,请检查任务并发、采集线程和 CLI 预算配置。", - errPairBusy: "该指标的两侧仍在采集或排队,请等待本轮结束。", - errPairInvalid: "双端采集关联不完整,请重新保存配置建立关联。", - errPairSameDevice: "割接监控需要选择两台不同设备。", - pickExpectPorts: "本批预期", - portFilterPh: "筛选", - emptyBaselinePorts: "基线无数据", - batchLabel: "批次标签", - batchId: "批次 ID", - copyBatchId: "复制批次 ID", - defaultBatchLabel: "第N晚", - expectPorts: "本批预期端口(老侧)", - expectPortsPh: "逗号或换行分隔", - createBatch: "新建", - batchCreated: "批次已创建", - batches: "批次", - startBatch: "启动", - finishBatch: "收工", - evaluate: "立即判定", - evaluated: "判定完成", - statusUpdated: "状态已更新", - board: "进度看板", - metricPort: "端口状态", - progress: "进度", - anomaly: "异常", - onlyExpect: "仅预期", - onlyExpectHint: "默认显示全部漂移;勾选后只看本批预期", - pinCurrentBatches: "当前采集批次", - pinOldBatch: "老侧当前", - pinNewBatch: "新侧当前", - pinLatest: "最新", - runHistory: "判定历史", - runLatest: "最新一次", - colRuleHit: "命中规则", - colCommand: "采集命令", - mappingBound: "映射已保存并绑定到本任务", - allMetrics: "全部监控项", - colMetric: "监控项", - colKey: "对象", - colPort: "老侧端口", - openRedChip: "红单 {{n}}", - metaPortrait: "画像", - metaHf: "HF", - metaBaseline: "基线", - baselineOk: "已设", - baselineMissing: "缺", - enableHighfreq: "开启高频", - highfreqReady: "高频已就绪", - collectNow: "立即采集", - collectTriggered: "已触发采集", - openBizState: "业务监控", - hfNotBound: "未绑定", - projectCollectMetrics: "采集项", - hfInterval: "周期(秒)", - hfStart: "开始", - hfEnd: "结束", - markProjectDone: "完成并停HF", - confirmProjectDone: "确认完成项目并暂停全部高频?", - projectMarkedDone: "已完成,高频已停", - deleteProject: "删除", - confirmDeleteProject: "确认删除项目?将停高频并清批次记录。", - projectDeleted: "已删除", - acceptPassedShort: "通过", - acceptFailedShort: "未过", - colMapped: "映射新口", - colDesc: "描述", - colOldPort: "老侧业务标识", - colNewPort: "新侧业务标识", - colOldStatus: "老状态 admin/phy/prot", - colNewStatus: "新状态 admin/phy/prot", - colOld: "老侧", - colNew: "新侧", - colVerdict: "判定", - emptyDiffs: "暂无明细,请先「立即判定」", - emptyRed: "暂无红单", - verdictMigrated: "已迁移", - verdictMigrating: "迁移中", - verdictLost: "丢失", - verdictAnomaly: "异常", - verdictNotInvolved: "未涉及", - verdictUnexpected: "意外新增", - verdictOk: "正常", - highfreqHint: "", - boundTasks: "绑定任务", - oldPortraitTask: "老画像", - newPortraitTask: "新画像", - oldHfTask: "老HF", - newHfTask: "新HF", - projectCollectHint: "", - hfWindowInactive: "高频窗口未到/已结束,或项目已完成,当前不可采集", - baselinePortraitOnly: "", - collectSkipped: "未采", - collectSkippedBanner: "未采:{{metrics}}", - currentMissing: "缺当前", - currentMissingBanner: "缺当前批次:{{metrics}}", - highfreqReadyInactive: "高频已绑定(窗口未开,未立即采集)", - collectPartial: "部分失败(成功 {{ok}} / 失败 {{fail}})", - errMetricNeedsBindings: "监控项「{{metric}}」需要参数绑定({{params}}),割接高频暂不支持,请去掉该项", - errNoProfileForMetric: "监控项「{{metric}}」在当前设备厂商下无可用采集 profile", - acceptTitle: "本批验收", - acceptPassed: "验收通过", - acceptFailed: "验收未过(红单 {{n}})", - newBaselineMissingBanner: "缺新基线:{{metrics}}", - acceptCarryHint: "可带红开下一批", - openRedHint: "红单 {{n}}", - redTitle: "红单", - redOpen: "未关闭", - resolveRed: "关闭", - redResolved: "已关闭", - batchCreatedWithRed: "已建批(红单 {{n}})", - colStatus: "状态", - verdictUnfinished: "未完成", - }, - bizMonitorTpl: { - title: "监控模板", - help: "对比模板定义怎么对齐/比对;监控模板按同表定义搬迁成功条件。", - hint: "对比模板定义「怎么对齐/比对」;监控模板按同样的表(sheet)定义「怎样算搬迁成功/失败」。", - create: "新建监控模板", - edit: "编辑", - save: "保存", - cancel: "关闭", - created: "已创建", - saved: "已保存", - deleted: "已删除", - confirmDelete: "删除该监控模板?", - delete: "删除", - empty: "暂无监控模板。", - listFilterPh: "筛选名称 / 对比模板 / 备注", - kpiTotal: "模板数", - colName: "名称", - namePh: "例如:状态割接监控", - colCompare: "对比模板", - colCollect: "高频采集项", - colNote: "备注", - colActions: "操作", - needName: "请填写名称", - defaultsInvalid: "defaults JSON 无效", - overridesInvalid: "sheet_overrides JSON 无效", - exportTemplate: "导出", - importTemplate: "导入", - templateExported: "模板已导出", - templateImported: "模板已导入", - templateImportInvalid: "导入文件无效:需要 name 与 compare_template_id(或 compare_template_name)", - templateImportHint: "导入/导出 JSON,便于私下传递监控模板(不含任务/批次数据)", - templateImportCompareMissing: "未找到对应对比模板,请先导入或创建同名对比模板", - formHint: "先选对比模板 → 出现与对比模板相同的表页签 → 为每张表配置成功条件。", - compareHint: "决定有哪些表以及 Key/比对字段;此处只配置双端判定。", - pickCompare: "选择对比模板…", - openCompare: "管理对比模板", - collectAll: "模板全量", - collectPh: "interface_brief, bgp_peer(空=全量)", - collectHint: "勾选高频采集的 metric;全不勾或点「模板全量」表示对比模板全部 sheets。", - collectTitle: "高频采集子集", - defaults: "defaults(JSON)", - overrides: "sheet_overrides(JSON)", - overridesHint: "可按 metric 声明 status_fields / success 模式。", - globalPolicy: "全局策略", - outOfExpect: "预期外对象", - outOfExpectHint: "不在本批预期内的漂移如何处理。", - ooeStrict: "严格(异常标红)", - ooeWarn: "警告(仅标黄,不记红单)", - ooeIgnore: "忽略(一律未涉及)", - sheetRules: "各表搬迁判定", - applyAllPresets: "全部套用推荐预设", - applyAllPresetsShort: "全部预设", - sheetsUnit: "张表", - sheetHowReadonly: "对比模板(只读)", - iface: "接口", - compare: "比对", - presenceOnly: "仅存在性", - editInCompare: "改 HOW", - presetAuto: "推荐预设", - presetSkip: "跳过本表", - skipDual: "本表不参与双端判定", - skipDualHint: "该表在割接判定中跳过(计数器等请用业务对比 Job)。", - statusFields: "用哪些字段判断 up/down", - statusFieldsHint: "端口勾选 admin/phy/prot;BGP 勾选 state;ARP 可不勾(只看条目有无)。", - statusOptionalHint: "未勾选状态字段时,只按「消失 / 新增」等存在性判定。", - downValues: "算 down 的取值", - upValues: "算 up 的取值", - noFields: "无可用字段(先在对比模板勾选 metric)", - successPatterns: "成功规则(老侧组间或 × 新侧组间或)", - successPatternsShort: "成功规则", - successPatternsHint: "存在性看条目有无;值变更绑定字段并选择怎么变。组内是且,组之间是或。", - successPatternsHintShort: "组内且 · 组间或 · 老侧命中一组且新侧命中一组 → 成功", - successPatternsHintFree: - "每侧只有两种条件:存在性(不绑字段)或值变更(必须绑字段)。同一组内全部满足(且),组与组之间满足一组即可(或)。", - anomalyPatternsShort: "异常规则", - anomalyPatternsHint: - "与成功同结构。命中后标红。某一侧可勾「不限制」表示只看另一侧(如新侧现网消失/down)。未成功≠异常,中间态仍是迁移动态。", - anomalyPatternsHintShort: "先匹配成功;未命中再看异常。可勾「不限制」只盯一侧。中间态≠异常。", - successRow: "成功 {{n}}", - anomalyRow: "异常 {{n}}", - removeRow: "删除", - oldSideAny: "老设备", - newSideAny: "新设备", - groupLogicHint: "组内且,组之间或", - sideDontCare: "不限制此侧", - sideDontCareHint: "只按另一侧条件判定。", - groupN: "组 {{n}}", - addAnd: "组内再加一条(且)", - addOr: "再加一组(或)", - addAndShort: "+ 且", - addOrShort: "+ 或组", - addSuccessRow: "+ 成功规则", - addAnomalyRow: "+ 异常规则", - modeAny: "满足任一即可", - modeAll: "全部同时满足", - addCond: "添加条件", - pickField: "选择字段…", - condValue: "取值", - opEmpty: "为空", - opNotEmpty: "非空", - opChanged: "相对基线变了", - opUnchanged: "相对基线没变", - op_eq: "等于", - op_ne: "不等于", - op_in: "属于", - op_not_in: "不属于", - op_empty: "为空", - op_not_empty: "非空", - op_changed: "相对基线变了", - op_unchanged: "相对基线没变", - and: "且", - groupPresence: "存在性", - groupValue: "值变更", - groupStatus: "状态类", - groupField: "字段条件", - tokRemoved: "消失", - tokAdded: "新增", - tokUnchanged: "未变", - tokChanged: "有变化", - tokUp: "up", - tokDown: "down", - tokOther: "其它状态", - or: "或", - tagSkip: "跳过", - tagRules: "{{n}} 条", - tagEmpty: "未配", - needCompareFirst: "请先选择带 sheets 的对比模板。", - showMore: "更多:备注 / 高频采集 / JSON", - hideMore: "收起更多", - showJson: "高级:编辑 JSON", - hideJson: "回到可视化编辑", - }, - portTraffic: { - title: "端口流量监控", - create: "添加设备监控", - backList: "返回列表", - created: "已添加设备监控", - createFailed: "创建失败", - updated: "已保存", - interfacesSaved: "接口已更新", - started: "已启动", - paused: "已暂停", - stopped: "已停止", - statusRunning: "运行中", - statusPaused: "已暂停", - statusStopped: "已停止", - statusDraft: "草稿", - collecting: "采集中", - deleted: "已删除", - confirmDelete: "删除该设备监控及其采样数据?", - empty: "暂无设备监控。请先选择网元并勾选接口。", - keywordPh: "名称 / IP / 备注 / ID", - allStatus: "全部状态", - exportDevicesName: "port-traffic-devices", - exportEventsName: "port-traffic-events", - wall: "监控大屏", - wallTitle: "端口流量大屏", - toDevices: "设备管理", - wallEmpty: "暂无监控设备。请先添加设备并勾选接口。", - fullscreen: "全屏", - exitFullscreen: "退出全屏", - board: "大屏", - boardPick: "选择大屏", - boardPickHint: "请选择或新建一张流量大屏。", - boardEmpty: "还没有大屏。可新建一张用于割接/值班监控。", - boardDefaultName: "未命名大屏", - boardName: "名称", - boardCols: "列数", - boardCreate: "新建大屏", - boardEdit: "编辑布局", - boardSave: "保存", - boardCancel: "取消", - boardSaved: "大屏已保存", - boardCreated: "大屏已创建", - boardDeleted: "大屏已删除", - boardConfirmDelete: "删除该大屏及其图格配置?", - boardUnsavedLeave: "有未保存的修改,确定离开?", - boardOpenTab: "新页签打开", - boardOpen: "打开大屏", - boardListHint: "点击条目在独立页签打开大屏;从工作台打开网络管理不会刷新已打开的大屏。", - boardPanelCount: "图格数", - boardUpdated: "更新时间", - boardClosedHint: "可关闭本页签;也可回到网络管理 → 流量大屏列表重新打开。", - boardAddPanel: "添加图格", - boardNoPanels: "该大屏还没有图格,请编辑并添加监控接口。", - boardPickDevice: "选择设备", - boardPanelSettings: "图格设置", - boardPanelTitle: "标题(可选)", - boardColSpan: "跨列", - boardPanelStale: "接口已失效或不存在,请重新选择。", - boardRetarget: "重选接口", - edit: "编辑", - save: "保存", - start: "启动", - pause: "暂停", - stop: "停止", - delete: "删除", - wizStep1: "选设备", - wizStep2: "选接口", - wizStep3: "参数", - step1: "参数", - step2: "选网元", - step3: "选接口", - step4: "确认", - prev: "上一步", - next: "下一步", - fieldTitle: "任务名称", - titlePh: "例如:上联链路组", - titleRequired: "请填写任务名称", - portsRequired: "请至少选择一个接口", - interval: "周期(秒)", - retention: "保留(天)", - retentionHintWizard: "若需上周同日对比,建议 ≥8 天", - concurrency: "并发", - note: "备注", - notePh: "可选", - startNow: "创建后立即启动", - neKeywordPh: "名称 / IP / 厂商", - deepLinkHint: "已从拓扑跳转:网元 {{ne}}{{ifname}}。请确认选中后继续。", - deepLinkIfname: ",接口 {{ifname}}", - selectedNe: "已选 {{count}} 台网元", - selectedOneNe: "已选", - selectedPorts: "已选 {{count}} 个接口", - pickNeFirst: "请先选择一台网元", - discover: "拉取接口", - discoverDetail: "拉取详情", - discoverCommand: "命令", - discoverRaw: "原始日志", - discoverOk: "拉取成功", - discoverFail: "拉取失败", - confirmCreate: "开始监控", - wallTask: "任务", - wallDevice: "设备", - wallPort: "接口", - wallDeviceLabel: "网元", - pickPort: "选择接口", - range: "时间范围", - compare: "周期对比", - compareOff: "关闭", - compareDay: "昨日同时刻", - compareWeek: "上周同日", - compareShift: "前一窗", - compareCustom: "自定义偏移", - offsetHours: "偏移(小时)", - aheadHours: "向后延长(小时)", - aheadHoursHint: - "图表终点相对当前时刻再往后延长。开启周期对比时可看到对照曲线在「现在」之后的走势;0 表示截止到现在。", - mapBaselineHint: "映射基线可与周期对比叠加使用;需要昨日对照时请自行打开「昨日同时刻」。", - baseline: "基线", - baselineAt: "基线原时刻", - baselineDevice: "基线网元", - baselinePort: "基线接口", - seriesCurrentIn: "当前 In", - seriesCurrentOut: "当前 Out", - seriesBaselineIn: "基线 In", - seriesBaselineOut: "基线 Out", - mapBaselinePort: "映射基线接口", - mapBaselineTask: "映射基线任务", - mapBaselineNone: "不映射", - retentionHint: "周对比建议保留天数 ≥ 8;否则基线可能无数据。", - collectNow: "立即采集", - collectStarted: "已触发采集", - collectBusy: "采集进行中", - targetError: "接口最近错误", - waitSamples: "设备运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。", - waitAfterError: "采集失败,请到「流量监控」操作中的日志查看详情,修复后可点「立即采集」重试。", - log: "日志", - logTitle: "采集日志", - logEmpty: "暂无日志。", - logDevice: "设备", - logLevel: "级别", - logTime: "时间", - logMessage: "内容", - logClose: "关闭", - logHasError: "有错误", - rebind: "重新绑定网元", - rebindTitle: "重新绑定网元", - rebindCurrent: "当前监控", - rebindOk: "已重新绑定网元,可继续采集", - rebindFailed: "重新绑定失败", - rebindHint: "请手动选择正确的网元并确认。同 IP 不一定是同一台设备,且 IP 可能不唯一;历史采样会保留。", - rebindEmpty: "没有可选网元,请先在设备管理中加回。", - rebindSameIp: "同 IP", - rebindSameIpHint: "与当前监控记录的 IP 相同,请仍核对手动选择", - rebindSubmit: "确认绑定", - rebindConfirm: "确认将监控绑定到 {{ne}}?绑定后将按该网元采集,历史采样保留。", - err: { - managedNeNotFound: "管理网元不存在(可能已删除后重建,ID 已变)。请点「重新绑定网元」手动选择。", - targetErrors: "{{count}} 个接口采集失败", - unsupportedVendor: "不支持的厂商/设备类型", - invalidSource: "无效的网元来源", - rebindPickFirst: "请先选择要绑定的网元", - rebindNeMissing: "所选网元不存在,请刷新列表后重选", - rebindSourceMismatch: "网元来源与监控不一致", - alreadyMonitored: "目标网元已有另一条监控,无法绑定", - }, - kpiLatest: "最新", - kpiBw: "带宽", - latestAt: "最新样点", - hoverPoint: "光标位置", - wallLiveTitle: "实时流量", - filterView: "视图", - filterMap: "映射基线", - yMode: "纵轴", - yModeAuto: "自动(含基线)", - yModeCurrent: "按当前流量", - yModeUtil: "利用率 %", - kpi: { - tasks: "任务数", - devices: "监控设备", - running: "运行中", - ports: "监控接口", - samples24h: "24h 样点", - }, - col: { - title: "任务", - device: "设备", - status: "状态", - ports: "接口", - interval: "周期", - last: "最近采集", - actions: "操作", - source: "来源", - name: "设备", - vendor: "厂商", - ifname: "接口", - bw: "带宽", - upDown: "Admin / Phy / Prot", - admin: "Admin", - phy: "Phy", - prot: "Prot", - description: "描述", - }, - }, - lldpLinks: { - title: "LLDP 链路", - collectNow: "立即采集", - retryFailed: "一键重试失败", - started: "已启动 LLDP 采集", - pause: "暂停", - resume: "继续", - stop: "停止", - confirmStop: "停止后将取消未扫描的网元,确定停止本轮采集?", - paused: "已暂停", - resumed: "已继续", - stopped: "已停止采集", - policyTitle: "采集策略", - policySaved: "策略已保存", - enabled: "启用周期调度", - autoAddUnmatched: "发现时自动入库未匹配网元(占位)", - autoAddUnmatchedHint: "周期采集与画布单点发现共用;关闭则未匹配邻居只记未匹配、不建占位网元。默认开启。", - intervalDays: "周期(天)", - interval: "采集周期", - intervalUnitDays: "天", - intervalUnitHours: "小时", - concurrency: "并发", - historyKeep: "任务保留数", - scope: "范围", - scopeAll: "全部网元(纳管+UME)", - scopeSelected: "指定网元", - savePolicy: "保存策略", - selectedCount: "已选 {{count}} 台", - targetKeywordPh: "名称 / IP / 厂商", - jobsTitle: "采集任务", - jobDetailTitle: "任务明细", - expand: "详情", - edgesTitle: "Fabric 链路", - edgeKeywordPh: "本端 / 对端名称或 IP", - edgeStatusAll: "全部状态", - edgeStatusActive: "活跃", - edgeStatusMissing: "未发现", - edgeSourceAll: "全部来源", - exportEdgesName: "lldp-fabric-edges", - exportJobsName: "lldp-collect-jobs", - exportItemsName: "lldp-job-items", - statusAll: "全部状态", - jobKeywordPh: "任务 ID / 错误 / 范围", - itemKeywordPh: "网元名称 / IP / 错误", - itemResultAll: "全部结果", - missCount: "未发现周期", - replacedBy: "已被替换", - kpi: { - nodes: "Fabric 网元", - edges: "活跃链路", - missing: "未发现", - running: "进行中", - idle: "空闲", - last: "最近任务", - nextDue: "下次调度", - }, - col: { - source: "来源", - name: "设备", - vendor: "厂商", - trigger: "触发", - scope: "范围", - status: "状态", - progress: "进度", - edges: "链路", - missingDelta: "未发现/清理", - started: "开始", - ended: "结束", - neighbors: "邻居", - error: "错误", - aSide: "本端", - bSide: "对端", - aPort: "本端口", - bPort: "对端口", - lastSeen: "最近看见", - }, - jobStatus: { - running: "运行中", - pending: "排队中", - paused: "已暂停", - done: "已完成", - failed: "失败", - cancelled: "已取消", - stopped: "已停止", - }, - triggerMode: { - manual: "手动", - schedule: "周期", - topology: "拓扑画布", - retry_failed: "重试失败", - }, - jobScope: { - all: "全部网元", - ne_ids: "指定网元", - selected: "指定网元", - }, - itemResult: { - ok: "成功", - warn: "警告", - fail: "失败", - }, - edgeSource: { - lldp: "LLDP", - manual: "手动", - stale: "陈旧", - }, - }, - configSync: { - title: "配置同步", - syncNow: "立即全量同步", - retryFailed: "一键重试失败", - pause: "暂停", - resume: "继续", - stop: "停止", - confirmStop: "停止后将取消未执行的网元任务,确定停止本轮同步?", - started: "已启动同步周期", - paused: "已暂停", - resumed: "已继续", - stopped: "已停止同步周期", - policyTitle: "同步策略", - policySaved: "策略已保存", - enabled: "启用周期调度", - intervalDays: "周期(天)", - concurrency: "并发", - historyKeep: "配置历史保留", - cycleKeep: "任务保留数", - scope: "范围", - scopeAll: "全部网元", - scopeSelected: "指定网元", - savePolicy: "保存策略", - selectedCount: "已选 {{count}} 台", - targetKeywordPh: "名称 / IP / 厂商", - cyclesTitle: "同步周期", - cyclesEmpty: "暂无同步周期", - tasksTitle: "周期任务", - taskKeywordPh: "名称 / IP / 消息", - keywordPh: "ID / 触发 / 消息", - statusAll: "全部状态", - allStatus: "全部状态", - exportCyclesName: "config-sync-cycles", - exportTasksName: "config-sync-tasks", - expand: "详情", - collapse: "收起", - kpi: { - snapshots: "配置覆盖数", - running: "进行中", - idle: "空闲", - last: "最近一轮", - nextDue: "下次调度", - }, - col: { - source: "来源", - name: "设备", - vendor: "厂商", - trigger: "触发", - status: "状态", - progress: "进度", - started: "开始", - ended: "结束", - message: "消息", - actions: "操作", - }, - }, - networkDevices: { - title: "设备列表", - keywordPh: "名称 / IP / ID", - allSource: "全部来源", - empty: "暂无网元。", - exportName: "network-devices", - col: { - source: "来源", - name: "名称", - vendor: "厂商", - deviceType: "设备类型", - connect: "连通状态", - actions: "操作", - }, - }, - networkConfigs: { - title: "配置信息", - keywordPh: "名称 / IP / ID", - vendorPh: "厂商", - allSource: "全部来源", - empty: "暂无已同步配置。请先在「配置同步」执行采集。", - view: "查看", - export: "导出", - exportBoth: "导出全部", - exportOk: "配置已导出", - exportListName: "network-configs", - close: "关闭", - tabSet: "Set 格式", - tabHier: "层级格式", - col: { - name: "设备", - vendor: "厂商", - source: "来源", - size: "大小", - collected: "采集时间", - }, - }, - layout: { - title: "UME 对接", - titleUme: "UME同步", - titleNetwork: "网络管理", - titleManagedNe: "网元管理", - titleCollect: "批量采集", - titleWebcrt: "WebCRT", - titleTopology: "拓扑管理", - titlePortTrafficWall: "流量大屏", - titleUsers: "用户管理", - titleAudit: "操作审计", - titleApiKeys: "API Key", - titleSessions: "登录会话", - navUme: "UME 对接", - netxApi: "netx api", - oclawBridge: "oclaw WSS", - activeTasks: "活跃任务 {{count}}", - activeTasksHint: "打开操作审计 · 任务概览", - language: "语言", - switchLanguage: "切换语言 / Switch language", - moreMenu: "更多", - langZh: "中文", - langEn: "English", - }, - auth: { - loginTitle: "登录", - username: "用户名", - password: "密码", - login: "登录", - loggingIn: "登录中…", - loginFailed: "登录失败", - logout: "退出", - sessionsTitle: "登录会话", - sessionsHelp: "反代后 IP 取真实客户端;同公网可用「客户端」列区分。踢掉后需重新登录。", - sessionsHint: - "管理当前账号在各浏览器/设备上的登录。IP 经反代时会取真实客户端地址;同公网出口可用「客户端」列区分浏览器/系统。踢掉会话后对方需重新登录。", - sessionsEmpty: "当前没有活跃会话。", - revokeOtherSessions: "踢掉其他会话", - revokeSession: "踢掉", - sessionCurrent: "当前", - sessionRevoked: "会话已吊销", - sessionsRevokedOthers: "已踢掉 {{count}} 个其他会话", - revokeCurrentConfirm: "这是当前会话,踢掉后需要重新登录。继续?", - colSession: "会话", - colClient: "客户端", - colLastSeen: "最近活动", - colCreated: "创建时间", - usersTitle: "用户管理", - usersHint: "仅管理员可创建与管理本地账号。", - addUser: "添加用户", - role: "角色", - roleAdmin: "管理员", - roleUser: "普通用户", - status: "状态", - active: "启用", - disabled: "禁用", - enable: "启用", - disable: "禁用", - actions: "操作", - newPassword: "新密码", - resetPassword: "重置密码", - userCreated: "用户已创建", - userUpdated: "用户已更新", - auditTitle: "操作审计", - auditHintAdmin: "查看所有用户的登录与操作记录。", - auditHintUser: "查看你自己的操作记录。", - filterUsername: "用户名筛选", - filterAction: "动作筛选", - colTime: "时间", - colUser: "用户", - colAction: "动作", - colMethod: "方法", - colPath: "路径", - colStatus: "状态码", - colIp: "IP", - apiKeysTitle: "API Key 管理", - apiKeysHelp: - "供 MCP/脚本与 netxops 使用(网页登录用会话)。明文仅创建时显示一次;可用「改权限」调整。未吊销上限 {{max}}。", - tokenQuota: "未吊销 {{active}} / {{max}}", - tokenLimitReached: "已达 API Key 上限({{max}}),请先吊销或删除不用的 Key", - tokenName: "名称", - expiresIn: "有效期", - expire7d: "7 天", - expire30d: "30 天", - expire90d: "90 天", - expire365d: "1 年", - expireNever: "永不过期", - tokenOwner: "所属用户", - tokenOwnerSelf: "自己({{user}})", - createToken: "生成 Key", - tokenCreated: "API Key 已生成", - tokenUpdated: "权限已更新", - tokenRevoked: "已吊销", - tokenDeleted: "已删除", - tokenOnceHint: "请立即复制保存,关闭后无法再次查看明文:", - copyToken: "复制", - tokenCopied: "已复制到剪贴板", - tokenCopyFailed: "复制失败", - expiresAt: "到期时间", - lastUsed: "最近使用", - tokenStatusActive: "有效", - tokenStatusExpired: "已过期", - tokenStatusRevoked: "已吊销", - revokeToken: "吊销", - revokeConfirm: "确定吊销该 API Key?吊销后立即失效,记录仍保留。", - deleteToken: "删除", - deleteConfirm: "确定永久删除该 API Key?删除后无法恢复。", - editScopes: "改权限", - saveScopes: "保存权限", - scopesUnsetLegacy: "未限定(等同所属用户)", - scopesCol: "权限", - scopesRequired: "请至少勾选一项权限", - scopesNoneAvailable: "当前所属用户没有可授予的权限", - scopeAlarmsRead: "alarms:read — 告警只读(netx MCP)", - scopeNeRead: "ne:read — 网元/拓扑只读(MCP)", - scopeNeWrite: "ne:write — 网元/拓扑写入、画图(MCP)", - scopeNeExec: "ne:exec — 托管网元 CLI(netx MCP)", - scopeBizMonitorRead: "biz-monitor:read — 业务监控/割接只读(netxops)", - scopeSql: "sql:query — UME SQL(netx MCP)", - scopeWebcrt: "webcrt:session — WebCRT(预留 MCP)", - scopeAdminUsers: "admin:users — 用户管理(预留 MCP)", - scopeOpsWrite: "ops:write — 运维写/订阅(预留 MCP)", - forceChangeTitle: "请修改初始密码", - forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。", - oldPassword: "当前密码", - confirmPassword: "确认新密码", - savePassword: "保存新密码", - savingPassword: "保存中…", - passwordTooShort: "新密码至少 8 位", - passwordMismatch: "两次输入的新密码不一致", - passwordMustChange: "新密码不能与默认/旧密码相同", - }, - collect: { - statusAll: "全部状态", - keywordPh: "任务名称 / ID", - exportJobsName: "collect-jobs", - exportRunsName: "collect-runs", - policyTitle: "采集策略", - policyHint: "默认不启用周期(只采一次);勾选后按周期自动创建并执行。默认保留最近 3 次任务数据。", - policySaved: "策略已保存", - enabled: "启用周期调度", - interval: "采集周期", - intervalUnitDays: "天", - intervalUnitHours: "小时", - historyKeep: "任务保留数", - scope: "范围", - scopeAll: "全部网元(纳管+UME)", - scopeSelected: "仅选中网元", - savePolicy: "保存策略", - collectNow: "立即采集", - selectedCount: "已选 {{count}} 台", - colSource: "来源", - policyCommandsRequired: "启用周期或立即采集前,请先填写采集命令并保存策略", - policyTargetsRequired: "选定范围内没有可用网元,请调整范围或勾选网元", - create: { - title: "新建采集任务", - hint: "配置命令与网元后创建任务;创建后需在任务列表中点击「开始」执行采集。", - selectedTitle: "已选择网元", - selectedEmpty: "请从下方筛选并逐个添加网元。", - selectedCount: "共 {{count}} 台", - clearSelected: "清空已选", - remove: "移除", - pickTitle: "可选网元", - pickHint: "可选网元管理与 UME 清单中的全部网元(不限连通性状态)。UME 使用 CLI 连接模板凭据。", - pickEmpty: "没有匹配的网元。", - filterKeyword: "筛选", - filterKeywordPh: "名称 / IP / 厂商 / 设备类型", - add: "添加", - addBatch: "批量添加({{count}})", - added: "已添加", - create: "创建任务", - creating: "创建中…", - expand: "新建", - collapse: "关闭", - meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令", - }, - eligible: { - title: "可选网元", - hint: "可选网元管理与 UME 清单中的全部网元(不限连通性状态)。", - empty: "暂无网元。", - }, - form: { - title: "采集配置", - jobTitle: "任务名称", - jobTitlePh: "留空则自动生成", - commands: "采集命令(每行一条,# 开头为注释)", - commandsPh: "display version\ndisplay current-configuration", - commandsHint: "命令将按顺序在每台设备上执行,输出合并为一份文本日志。", - meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令", - start: "开始采集", - starting: "提交中…", - }, - created: "采集任务已创建:{{id}}", - started: "采集已开始:{{id}}", - paused: "任务已暂停", - restarted: "已重新开始采集", - retryFailedDone: "已开始重采失败网元", - deleted: "任务已删除", - nothingToRetry: "没有可重试的失败网元", - neBusy: "部分网元正在被其他采集任务占用,请等待其完成后再开始", - jobRunning: "该任务已在执行中", - confirmDelete: "确定删除该采集任务?相关日志文件将一并删除。", - jobs: { - title: "采集任务", - listTitle: "任务列表", - col: { - title: "任务", - status: "状态", - progress: "进度", - created: "创建时间", - lastRun: "最近执行", - actions: "操作", - }, - ok: "成功", - fail: "失败", - expand: "详情", - collapse: "收起", - detailTitle: "任务明细", - pause: "暂停", - start: "开始", - starting: "启动中…", - restart: "重新开始", - retryFailed: "重采失败网元", - downloadResults: "下载结果", - delete: "删除", - autoRefresh: "任务进行中,每 2 秒自动刷新", - runsInProgress: "部分网元仍在采集中,请稍候…", - }, - kpi: { - jobs: "任务总数", - running: "进行中", - idle: "空闲", - last: "最近任务", - active: "活跃任务", - nextDue: "下次周期", - }, - runs: { - status: "状态", - message: "说明", - download: "日志", - downloadFile: "下载", - filterStatus: "状态筛选", - filterDevice: "设备筛选", - deviceKeywordPh: "名称 / IP", - allStatus: "全部状态", - }, - }, - managedNe: { - title: "网元管理", - credsNotConfigured: "未配置 NETX_CREDENTIAL_SECRET_KEY,无法保存密码。请在 .env 中设置 Fernet 密钥后重启 API。", - add: "新增网元", - importBtn: "批量导入", - downloadTemplate: "下载导入模板", - importing: "导入中…", - edit: "编辑", - delete: "删除", - openTerminal: "终端", - confirmDelete: - "确定删除该网元?若拓扑中仅绑定此运维网元(无 UME),将同时删除对应 Fabric 节点及其直连链路。", - batchDelete: "批量删除", - batchDeleting: "删除中…", - batchDeleteConfirm: - "确定删除选中的 {{n}} 台网元?不可恢复。仅绑定运维侧的节点会级联清除 Fabric 节点与直连链路;仍绑 UME 的会保留拓扑。", - batchDeleteDone: "已删除 {{n}} 台网元", - batchDeleteSelectRequired: "请先勾选要删除的网元", - keywordPh: "名称 / IP / 用户名 / 标签", - allVendors: "全部厂商", - allConnectStatus: "全部连通状态", - stats: { - title: "统计", - total: "共 {{n}} 台网元", - pass: "已连通", - fail: "失败", - unknown: "未测试", - testing: "测试中", - batchProxy: "一键添加代理", - batchTest: "一键连通性测试", - tagFilter: "按标签筛选", - all: "全部", - allTag: "全部(无标签筛选)", - noTag: "无标签", - tagStats: "按标签统计(Top)", - confirm: "将对 {{n}} 台网元执行操作,确认继续?", - proxyDone: "已为 {{n}} 台网元提交代理配置", - testDone: "已提交 {{n}} 台网元连通性测试", - loadingIds: "加载中…", - }, - col: { - name: "名称", - vendor: "厂商", - deviceType: "设备类型", - execPolicy: "CLI 执行策略", - source: "来源", - ip: "管理地址", - port: "端口", - protocol: "协议", - user: "用户名", - password: "密码", - tags: "标签", - remark: "备注", - connect: "连通性", - testedAt: "测试时间", - actions: "操作", - }, - execPolicy: { - readonly: "只读(show/display/ping)", - linuxShell: "Shell/脚本(允许多行/heredoc)", - unrestricted: "实验室全开(允许多行/heredoc)", - hint: "仅影响 MCP/API 的 execManagedNe。默认只读;Linux 与 MikroTik(routeros/switchos)可选 shell / 实验室全开,支持多行脚本与 heredoc。", - }, - source: { - manual: "手工", - ume_sync: "UME 同步", - webcrt: "WebCRT", - lldp: "LLDP 占位", - topology: "拓扑占位", - }, - connect: { - run: "连通性测试", - running: "测试中…", - submitted: "已提交 {{n}} 台设备测试", - retest: "重新测试", - }, - connectDetail: "详情", - connectDetailTitle: "连通性测试日志", - connectDetailEmpty: "暂无日志。请先执行连通性测试;失败时会记录完整错误与跳板上下文(不含密码)。", - umeSync: { - sync: "同步 UME 网元", - syncing: "同步中…", - delete: "删除 UME 网元", - deleting: "删除中…", - deleteConfirm: - "确定删除所有通过 UME 同步到网元管理中的网元?不可恢复。仅绑定这些 ManagedNE、且无 UME 投影的 Fabric 节点会连同直连链路一并清除。", - done: "UME 同步完成:新增 {{inserted}},更新 {{updated}},删除 {{deleted}},UME 清单共 {{total}} 台", - deletedDone: "已删除 {{n}} 台 UME 同步网元", - }, - account: { - batchAdd: "一键添加账号", - batchByTag: "按标签添加账号", - batchTitle: "批量添加账号", - batchHint: "将以下账号信息应用到已选中的 {{n}} 台网元。", - batchByTagHint: "将以下账号信息应用到指定标签下的网元;密码可留空,仅更新用户名。", - batchDone: "已为 {{n}} 台网元更新账号", - selectRequired: "请先勾选要配置账号的网元", - applying: "应用中…", - apply: "应用到网元", - usernameOrPasswordRequired: "用户名和密码至少填写一项", - passwordOptionalBatch: "留空则不修改", - }, - help: - "【批量导入】\n" + - "· 必填列:device_type、ip、username、port、protocol、name、vendor(可先下载模板)\n" + - "· password 可留空:直连需填;堡垒机托管或后续「批量添加代理」时可空\n" + - "· 推荐:导入网元 → 勾选 →「批量添加代理」统一配置跳板\n\n" + - "【跳板 / 堡垒机】\n" + - "· 用户名模板:{hop_user}@{target_user}@{target_ip}(堡垒机地址另填跳板地址)\n" + - "· 托管模式填跳板密码(Vault);目标密码可空。手动模式需填目标密码\n" + - "· JumpServer/CBH 跳板端口多为 2222,部分现场为 22\n\n" + - "【连通性 / 编辑】\n" + - "· 保存密码需配置 NETX_CREDENTIAL_SECRET_KEY\n" + - "· 编辑时密码留空表示不改;失败请看「详情」中的 hop 上下文(不含密码)", - importResult: { - done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行", - }, - form: { - requiredHint: "带 * 为必填项", - nameConnectHint: "连通性测试成功后将自动更新为设备名称", - createTitle: "新增网元", - editTitle: "编辑网元", - save: "保存", - saving: "保存中…", - cancel: "取消", - created: "网元已创建", - updated: "网元已更新", - deleted: "网元已删除", - passwordRequired: "请填写密码", - passwordOptional: "留空则不修改", - }, - hop: { - sectionTitle: "跳板 / 代理", - type: "跳板类型", - enable: "经跳板登录目标网元", - vendor: { - zte: "ZTE 设备(CLI 跳登)", - huawei: "华为设备(CLI 跳登)", - cisco: "思科设备(CLI 跳登)", - linux: "Linux 服务器(SSH 隧道)", - bastion: "堡垒机(SSH 协议代理)", - }, - zteHint: "在 ZTE 设备上执行 ssh/telnet 命令跳转到目标,目标账号由二次认证输入。", - huaweiHint: "在华为设备上执行 telnet / stelnet(SSH)跳登;stelnet 即 SSH。目标账号由二次认证输入。", - ciscoHint: "在思科设备上执行 ssh -vrf / telnet /vrf 跳登,目标账号由二次认证输入。", - linuxHint: "先 SSH 登录 Linux 跳板,经 direct-tcpip 隧道连接目标 IP:端口(等同 ProxyJump)。", - bastionHint: - "SSH 复合用户名直连堡垒机(跳板地址可为 IP 或域名),由堡垒机协议代理到目标。示例:bastion-user@target-user@198.51.100.20@192.0.2.10 或 bastion-user@target-user@198.51.100.20@ssh-bastion.example.com;密码为 Vault/堡垒机密码。JumpServer/CBH 常用端口 2222。", - targetAuthMode: "目标凭据", - targetAuthBastionManaged: "堡垒机托管(目标密码可留空)", - targetAuthManual: "手动输入(连接后二次认证)", - targetAuthHint: "堡垒机托管时仅需堡垒机密码;手动模式需填写目标网元密码。", - usernameTemplate: "SSH 用户名模板", - templateHintBastion: - "默认 {hop_user}@{target_user}@{target_ip},堡垒机地址(IP 或域名)填「跳板地址」。命令行示例:ssh bastion-user@target-user@198.51.100.20@ssh-bastion.example.com。", - host: "跳板地址", - hostPlaceholderBastion: "192.0.2.10 或 ssh-bastion.example.com", - hostHintBastion: - "支持 IP 或 FQDN。也可粘贴完整 SSH 目标串(如 bastion-user@target-user@198.51.100.20@ssh-bastion.example.com),失焦后自动拆成跳板用户名与地址。", - port: "跳板端口", - protocol: "跳板协议", - protocolSshStelnet: "ssh(stelnet)", - username: "跳板用户名", - password: "跳板密码", - vrf: "管理 VRF(可选)", - vpnInstance: "VPN-Instance(可选)", - vrfCisco: "VRF(可选,如 MGMT)", - enterSystemView: "跳登前是否进入系统视图", - enterSystemViewNo: "不进入(用户视图,默认)", - enterSystemViewYes: "进入系统视图(system-view)", - enterSystemViewHint: - "部分华为跳板仅在系统视图下支持 stelnet/telnet。需自行选择;不会因失败自动重试。", - commandTemplate: "跳登命令模板", - templateHint: - "ZTE 常用:telnet {target_ip}、telnet {target_ip} vrf {vrf}、ssh {target_ip}、ssh {target_ip} vrf {vrf}。按跳板协议与 VRF 自动推荐;留空时后端同样规则。目标账号密码由二次认证提示输入。", - templateHintHuawei: - "华为常用:telnet {target_ip}、telnet vpn-instance {vrf} {target_ip}、stelnet {target_ip}、stelnet {target_ip} -vpn-instance {vrf}。SSH 协议对应 stelnet。留空时按协议与 VPN-Instance 自动推荐。", - templateHintCisco: - "思科常用:ssh {target_ip}、ssh -vrf {vrf} {target_ip}、telnet {target_ip}、telnet {target_ip} /vrf {vrf}。留空时按协议与 VRF 自动推荐。", - badge: { - zte: "ZTE跳板", - huawei: "华为跳板", - cisco: "思科跳板", - linux: "Linux跳板", - bastion: "堡垒机", - }, - hostRequired: "请填写跳板地址", - userRequired: "请填写跳板用户名", - passwordRequired: "请填写跳板密码", - batchAdd: "添加代理", - batchTitle: "批量添加代理", - batchHint: "将以下跳板配置应用到已选中的 {{n}} 台网元(会覆盖原有跳板设置)。", - batchDone: "已为 {{n}} 台网元配置代理", - selectRequired: "请先勾选要配置的网元", - applying: "应用中…", - apply: "应用到选中网元", - }, - }, - 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: - "【订阅】\n" + - "· 需手动建立 / 取消\n" + - "· 建立后(或重启且库中仍有效)后台自动连 WSS 收实时告警\n\n" + - "【任务控制】\n" + - "· alarms_current_ws_consumer 的 pause/resume 只停/启 WSS\n" + - "· 不会取消 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 后会出现连接、收包、重连等记录)", - }, - keyAlert: { - title: "AI 告警监控", - showPanel: "详情", - hidePanel: "关闭", - help: - "【匹配】\n" + - "· notificationId 精确匹配\n" + - "· 或告警描述关键字模糊匹配(不区分大小写,含 nativeProbableCause 等)\n\n" + - "【范围】\n" + - "· 可选限定 Inventory 的 ne_type;不选则匹配全部设备\n\n" + - "【投递】\n" + - "· 命中后由 NetX 广播给已订阅的 Netx Ops(DSH)客户端", - hub: "DSH 订阅", - hubConnected: "有订阅", - hubEmpty: "无订阅", - hubSubscribers: "订阅数", - hubPublished: "已广播", - hubDeliverOk: "投递成功", - hubDeliverFail: "投递失败", - hubConnections: "当前连接", - hubColId: "连接 ID", - hubColUser: "用户", - hubColRemote: "远端", - hubColClient: "客户端", - hubColConnected: "连入时间", - hubColLastSeen: "最近心跳", - hubEmptyConnections: "当前没有 Netx Ops 客户端连入告警 hub", - hubPath: "路径", - matchType: "匹配方式", - matchNotificationId: "告警码 (notificationId)", - matchKeyword: "告警描述关键字", - matchValuePhNotificationId: "输入 notificationId", - matchValuePhKeyword: "输入匹配关键字(不区分大小写)", - label: "规则描述", - labelPh: "必填,如 BGP Down", - neTypesLabel: "设备类型(可选,多选)", - neTypesHint: "不选表示全部设备类型;选项来自 UME Inventory 同步后的 ne_type。", - neTypesEmpty: "暂无设备类型,请先同步 Inventory", - neTypesAll: "全部", - forwardOnClear: "告警清除时也推送", - forwardOnClearHelp: "全局开关:对所有监控规则生效。默认只在告警新增/更新时推送;勾选后,UME 上报告警已清除时也会向已订阅的 Netx Ops 推送清除通知。", - add: "添加规则", - adding: "添加中…", - delete: "删除", - edit: "修改", - editTitle: "修改监控规则", - save: "保存", - saving: "保存中…", - cancel: "取消", - editOk: "规则已更新", - pickFromAlarms: "加载当前告警参考", - emptyRules: "尚未配置监控规则", - colType: "类型", - colMatch: "匹配内容", - colLabel: "描述", - colNeTypes: "设备类型", - colForwardClear: "清除推送", - colPublished: "发布次数", - colAttempts: "尝试次数", - colLast: "最近推送", - colActions: "操作", - colMonitor: "是否监控", - monitorOn: "开启", - monitorOff: "关闭", - filterKeywordPh: "筛选匹配内容 / 描述", - filterMatchAll: "全部类型", - filterEnabledAll: "全部状态", - filterEnabledOn: "监控中", - filterEnabledOff: "已暂停", - addOk: "已添加监控规则", - deleteOk: "已删除", - confirmDelete: "确定删除该监控规则?", - yes: "是", - no: "否", - }, - 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", - lastErrorCol: "last_error", - runtimeError: { - startup_alarm_sync_before_ws: "启动中:正在同步当前告警(完成后连接 WSS)…", - startup_gate_waiting: "启动 REST 全量同步未完成,定时 REST 与 WSS 均待命", - wss_active_skip_rest: "WSS 实时接收中,已跳过 REST 同步", - pulling_alarms_current: "正在拉取 UME 当前告警…", - alarms_sync_in_progress_skip: "另一条当前告警 REST 同步进行中,已跳过", - pulling_inventory: "正在拉取 UME 网元清单…", - pulling_topology: "正在拉取 UME 拓扑(节点/链路)…", - topology_sync_busy: "拓扑同步进行中,已跳过", - ume_ws_disabled_no_base_url: "未启用或未配置 UME_BASE_URL", - resumed_sync_soon: "已恢复:将跳过本轮周期等待并尽快同步", - resumed_wss_reconnect: "已恢复:将尽快重连 WSS", - resumed: "已恢复", - keepalive_failed: "Token 保活失败", - }, - wsState: { - init: "初始化", - connected: "已连接", - connecting: "连接中", - disconnected: "已断开", - no_subscription: "无订阅", - waiting_token: "等待 token", - paused: "已暂停", - error: "连接异常", - reconnecting: "重连等待", - subscription_lost: "UME 订阅已丢失", - disabled_no_base_url: "未启用或未配置 UME_BASE_URL", - }, - runtimeTask: { - token_keepalive: "UME Token 保活", - alarms_current_auto_sync: "当前告警定时同步", - alarms_current_ws_consumer: "UME 告警 WSS", - inventory_auto_sync: "Inventory 定时同步", - topology_auto_sync: "拓扑定时同步", - }, - }, - syncStatus: { - title: "同步状态", - showPanel: "详情", - hidePanel: "关闭", - empty: "暂无同步记录", - summary: "共 {{total}} 条记录,进行中 {{running}}", - fabricGapTitle: "拓扑码头已更新,但尚未灌入拓扑管理(Fabric / UME World)", - fabricGapHint: - "码头 ME {{dock}},Fabric UME {{fabric}};可一键应用(不拉 UME)。上次状态:{{status}}", - applyFabric: "应用到拓扑管理", - applyingFabric: "正在应用…", - applyFabricOk: "已应用到拓扑管理", - }, - ne: { - title: "网元清单", - showPanel: "详情", - hidePanel: "关闭", - keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)", - clearTitle: "清空 keyword,回到第 1 页", - expand: "详情", - collapse: "关闭", - cliConnect: "CLI 连通", - summary: "Inventory 网元 {{total}} 台", - }, - tabs: { - main: "UME 同步", - cli: "CLI 连接", - }, - cli: { - title: "UME CLI 连接配置", - showPanel: "详情", - hidePanel: "关闭", - hint: - "【用途】\n" + - "· 为 Inventory 网元配置统一 SSH 凭据与跳板/堡垒机\n\n" + - "【MCP】\n" + - "· 可用 ume_ne_id 直接执行 show,无需逐台录入 managed_ne", - statusReady: "CLI 配置已就绪,可对 UME 网元执行命令。", - statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。", - profileName: "配置名称", - targetUsername: "目标 SSH 用户名", - targetPassword: "目标 SSH 密码", - passwordKeep: "留空则不修改", - newProfile: "新建配置", - deleteConfirm: "确定删除该 CLI 配置?", - deleted: "配置已删除", - saved: "CLI 配置已保存", - connectTestTitle: "样本网元连通性测试", - sampleNePh: "筛选 UME 网元(名称 / IP / ID)", - sampleNeRequired: "请先选择一个 UME 网元", - sampleNeEmpty: "无匹配网元", - sampleNeLoading: "加载中…", - connectTestSubmitted: "已提交连通性测试", - connectTestWaiting: "等待测试结果…", - connectTestRunning: "连通性测试中…", - connectTestResult: "测试结果", - existingProfiles: "已有配置", - default: "默认", - }, - alarms: { - title: "当前告警", - movedHint: "当前告警查询已迁至网络管理 →", - showPanel: "展开", - hidePanel: "收起", - keywordPh: "keyword(告警键/原因/ne_name/host_name/ip 等)", - hostNamePh: "host_name(含匹配)", - allSeverity: "全部级别", - clearedAll: "清除状态:全部", - clearedYes: "已清除", - clearedNo: "未清除", - clearTitle: "清空 keyword、host_name、级别、is_cleared,回到第 1 页", - filterByHost: "按该主机名筛选", - noHostName: "无 host_name(需先同步网元)", - col: { - time: "告警时间", - severity: "级别", - neId: "网元 ID", - hostName: "主机名", - neType: "网元类型", - cause: "原因", - }, - }, - }, - webcrt: { - title: "WebCRT", - deviceList: "设备列表", - sessionManager: "设备列表", - connect: "连接", - hint: "选择网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。", - hintCrt: "在左侧点击网元即可打开终端;支持托管、会话与 UME;也可用菜单「新建会话」快速登录。", - filterKeyword: "筛选", - filterKeywordPh: "按名称 / IP / 标签筛选", - empty: "没有匹配的网元。可切换来源或调整搜索关键字。", - open: "打开终端", - opening: "连接中…", - opened: "已打开 {{name}}", - disconnect: "断开", - closed: "会话已断开", - termPlaceholder: "尚未打开终端会话", - sessionMeta: "{{name}} · {{ip}}", - source: { - all: "全部", - managed: "托管", - webcrt: "会话", - ume: "UME", - }, - newSession: { - title: "新建会话", - claimTitle: "完善占位会话", - claimHint: "请填写可达地址;会话名默认使用占位名。连接成功后来源将更新为 WebCRT。", - connect: "连接", - connecting: "连接中…", - protocol: "协议", - host: "主机", - port: "端口", - sessionName: "会话名", - sessionNamePh: "可选,默认使用 IP", - authTitle: "SSH 登录", - savePassword: "保存密码", - ipRequired: "请填写主机地址", - userRequired: "请填写用户名", - passwordRequired: "请填写密码", - }, - status: { - idle: "空闲", - connecting: "连接中", - connected: "已连接", - error: "错误", - closed: "已关闭", - }, - phase: { - creating: "创建会话", - authenticating: "正在认证", - waitingPrompt: "等待设备提示符", - hint: "登录过程会实时显示在终端中;设备较慢时可能需要十几秒", - }, - tabMenu: { - close: "关闭", - closeOthers: "关闭其他", - closeAll: "关闭全部", - copyIp: "复制 IP", - copyIpOk: "已复制 {{ip}}", - copyIpEmpty: "该标签没有 IP", - }, - treeMenu: { - rename: "重命名", - renameLabel: "会话名", - renameSave: "保存", - renameRequired: "请填写会话名", - renamed: "会话已重命名", - delete: "删除会话", - deleteConfirm: "确定删除会话「{{name}}」?此操作不可恢复。", - deleted: "会话已删除", - }, - keywordHl: { - title: "关键字突出显示", - enabled: "启用", - newKeyword: "新建关键字", - newKeywordPh: "输入关键字或正则", - regex: "正则表达式", - add: "添加", - colKeyword: "关键字", - colRegex: "正则", - empty: "尚未添加关键字", - moveUp: "上移", - moveDown: "下移", - remove: "删除", - caseSensitive: "区分大小写", - color: "字符颜色", - saved: "关键字突出显示已保存", - }, - actions: { - reconnect: "重新连接", - recordLog: "记录日志", - clear: "清空", - copy: "拷贝", - recording: "记录中", - recordingOn: "已开始记录日志", - recordingSaved: "日志已保存", - copied: "已复制到剪贴板", - copyEmpty: "终端内容为空", - copyFailed: "复制失败", - break: "Break", - breakSent: "已发送 Break", - broadcast: "广播", - broadcastSent: "已广播到全部会话", - postLogin: "登录后命令", - }, - favorites: "收藏", - recent: "最近", - favorite: "收藏/取消", - encoding: "编码", - fontSize: "字号", - theme: "主题", - themeDark: "深色", - themeLight: "浅色", - colorScheme: "配色方案", - backgroundColor: "背景色", - foregroundColor: "字体色", - scheme: { - dark: "深蓝(默认)", - blackWhite: "白底黑字", - whiteBlack: "黑底白字", - greenBlack: "黑底绿字", - amberBlack: "黑底琥珀", - custom: "自定义", - }, - copyOnSelect: "选中即复制", - rightClickPaste: "右键直接粘贴", - rightClickPasteHint: "Shift + 右键打开菜单;关闭此项后,右键始终打开菜单。", - pasteDelay: "粘贴最大行间隔", - pasteDelayOff: "无延迟(整块发送)", - keepalive: "SSH Keepalive", - keepaliveOff: "关闭", - keepaliveSec: "{{n}} 秒", - options: "选项", - globalSessionOptions: "全局会话选项", - sessionOptionsSave: "保存", - sessionOptionsCancel: "取消", - sessionOptionsSaved: "已保存全局会话选项", - disconnectBanner: "会话已断开", - disconnectBannerError: "会话出错或连接中断", - broadcastPh: "发送到全部标签…", - buttonBarHint: "每行:按钮名|命令", - postLoginPrompt: "登录后自动执行(每行一条,仅本设备)", - postLoginSaved: "登录后命令已保存", - sftp: { - title: "SFTP", - refresh: "刷新", - up: "上级", - upload: "上传", - uploaded: "上传成功", - uploading: "上传 {{done}}/{{total}} · {{name}} {{pct}}", - downloading: "下载 {{done}}/{{total}} · {{name}} {{pct}}", - downloaded: "已下载 {{count}} 个文件", - downloadPartial: "下载完成 {{ok}} 个,失败 {{failed}} 个", - uploadPartial: "上传完成 {{ok}} 个,失败 {{failed}} 个", - retrying: "重试 {{name}}({{attempt}}/{{max}})…", - cancel: "取消", - dropHint: "松开以上传到当前目录", - dropEmpty: "未识别到可上传的文件", - mkdir: "新建", - mkdirPrompt: "新建目录名称", - mkdirInvalid: "目录名无效(不要包含 / 或 ..)", - mkdirOk: "目录已创建", - rename: "重命名", - renamePrompt: "新名称", - renameInvalid: "名称无效(不要包含 / 或 ..)", - renamed: "已重命名", - download: "下载", - chmod: "权限", - chmodPrompt: "权限模式(如 755 或 rwxr-xr-x)", - chmodOk: "权限已更新", - chmodPartial: "权限更新 {{ok}} 个,失败 {{failed}} 个", - delete: "删除", - deleteConfirm: "确定删除 {{name}}?", - deleteRecursiveConfirm: "{{name}} 含目录,非空目录将递归删除,是否继续?", - deletePartial: "删除完成 {{ok}} 个,失败 {{failed}} 个", - deleted: "已删除", - selectedCount: "已选 {{count}} 项", - empty: "当前目录为空", - listTruncated: "目录条目过多,仅显示前 {{count}} 项(上限 {{max}})", - dir: "目录", - loading: "正在加载…", - enterHint: "单击选中;Ctrl/Shift 多选;双击进入", - downloadHint: "单击选中;Ctrl/Shift 多选;双击下载", - colName: "名称", - colSize: "大小", - colMtime: "修改时间", - colOwner: "所有者", - colGroup: "组", - colMode: "权限", - resizeHint: "拖动调整 SFTP / 终端宽度", - colResizeHint: "拖动调整列宽", - }, - term: { - findPh: "查找…", - findPrev: "上一个", - findNext: "下一个", - findClose: "关闭查找", - copy: "复制", - paste: "粘贴", - pasteEmpty: "剪贴板为空", - pasteFailed: "无法读取剪贴板(HTTP 远程请用下方粘贴框,或 Ctrl+V)", - pasteBridgeHint: "当前为非 HTTPS 访问,浏览器禁止直接读剪贴板。请在此框按 Ctrl+V 粘贴,内容会发送到终端。", - pasteBridgePh: "在此按 Ctrl+V…", - pasteBridgeCancel: "取消", - clear: "清空", - pasting: "粘贴中 {{done}}/{{total}} 行…", - wsError: "[WebSocket 错误]", - wsClosed: "[WebSocket 已关闭 code={{code}}]", - sessionStatus: "[会话 {{state}}{{detail}}]", - outputTruncated: "[输出过快,已丢弃 {{count}} 段]", - }, - err: { - deviceType: "不支持的设备类型", - ipConflictRestart: "数据库仍限制 IP 唯一,请重启 netx API 后再试", - sessionLimit: "并发会话已达上限,请先关闭其他终端", - neNotFound: "网元不存在", - credsIncomplete: "网元账号或密码不完整", - authRejected: "用户名或密码错误,请重新输入", - cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)", - connectFailed: "登录设备失败:{{detail}}", - sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH", - sftpSsh: "SFTP 仅支持 SSH 协议", - sftpNeedPassword: "SFTP 需要已保存的 SSH 密码(会话弹窗密码不会用于文件传输)", - sftpUnsupported: "当前 SSH 会话未提供 SFTP(设备可能未开启)", - sftpTooLarge: "文件超过大小限制(默认 512MB)", - sftpChmodInvalid: "权限模式无效(请用 755 或 rwxr-xr-x)", - sftpAborted: "传输已取消", - queueDropped: "终端输出过快,已丢弃约 {{count}} 段,内容可能不完整", - websocket: "WebSocket 连接失败", - }, - }, - topology: { - title: "拓扑管理", - maps: "拓扑图", - tree: "拓扑树", - treeLoading: "加载拓扑树…", - treeLoadFailed: "拓扑树加载失败", - treeRetry: "重试", - graphLoading: "正在加载拓扑图…", - graphLoadFailed: "拓扑图加载失败", - graphRefreshing: "刷新中…", - canvasEmpty: "这张图上还没有网元", - canvasEmptyHint: "可用「添加网元」上图,或从左侧换到有设备的区域。", - treeHint: "顶级为「根」(导航);其下唯一「根图」为画布。点根图或子区域打开设备画布。", - treeSearch: "全拓扑搜索网元", - treeSearchPh: "名称 / IP", - treeSearchEmpty: "没有匹配的网元", - treeSearchNoViews: "未上图", - treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上", - locateNotOnCanvas: "当前世界地图抽样中没有该网元,且缺少世界坐标,请到下级区域画布查找。", - treeSearchViewCount: "{{count}} 张图", - treeSearchTruncated: "仅显示前 30 条(共 {{total}})", - rootName: "根", - selectRegion: "请选择根或区域", - openMap: "打开本图", - openRegion: "打开", - kindPhysical: "物理拓扑", - kindCustom: "自定义图", - layerNodeHint: "{{count}} 个网元", - layerNodeEmpty: "点击创建并打开", - rootCanvasHint: "从左侧选择根进入目录。", - regionCanvasHint: "点击根图打开设备画布;可在根图上新建子区域。", - regionBrowseHint: "根目录:下方为唯一根图。点击打开。", - browserTitle: "拓扑目录", - browserRegionSub: "共 {{count}} 张拓扑图", - browserRegionsSub: "共 {{count}} 个", - browserEmptyMaps: "此根下还没有根图。", - addNe: "添加网元", - more: "更多", - backUp: "返回", - newRegion: "新建根", - newRegionName: "新根", - newRegionPrompt: "根名称", - newSubRegion: "新建子区域", - newSubRegionName: "新子区域", - newSubRegionPrompt: "子区域名称", - subRegionNotOnFlat: "世界地图不支持子区域,请在 World 或下级区域画布上创建。", - worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。", - worldMapName: "世界地图", - worldMapOffline: "世界地图功能已暂时下线", - rootMapName: "根图", - worldHud: "世界 {{total}} · 近景 {{cached}} · z{{zoom}} · {{lod}}", - worldEmptyCoords: "世界地图还没有坐标", - worldEmptyCoordsHint: "码头已有 {{dock}} 个网元,但尚未灌入 Fabric / 世界坐标。可一键应用(不拉 UME)。", - worldApply: "应用到世界地图", - worldApplying: "正在应用…", - worldApplyOk: "已应用到世界地图", - subRegionNeedParent: "请先打开根图或区域画布,再新建子区域。", - regionCreated: "已创建", - regionDeleted: "已删除", - deleteRegion: "删除", - deleteRegionConfirm: "删除「{{name}}」?其下画布将一并删除。", - deleteUmeWorldConfirm: - "删除「{{name}}」?将级联删除其下全部 UME 区域与画布。之后若再跑 UME 拓扑同步/应用,可能重新生成。", - renameRegion: "重命名", - renameRegionPrompt: "名称", - regionRenamed: "已重命名", - collapseRegion: "折叠", - expandRegion: "展开", - newCustom: "自定义图", - newCustomName: "自定义拓扑", - newCustomPrompt: "自定义拓扑图名称", - folderHint: "新建根时自动创建唯一「根图」。子区域请在根图画布上创建。", - folderHintShort: "根目录", - outsidePeers: "图外对端 {{count}} 个(有链路未上图)", - outsidePeersView: "未上图网元 {{count}}", - outsidePeersTitle: "图外对端", - outsidePeersHint: "与画布有链路但未上图。勾选后按需添加,不必一次全部投影。", - outsidePeersVia: "经 {{name}}", - outsidePeersFilterPh: "筛选:名称 / IP / 经谁", - outsidePeersEmpty: "没有匹配的图外对端", - projectNeighbors: "投影邻居上图", - projectedNeighbors: "已投影 {{count}} 个邻居上图", - localBrowserOnly: "仅本浏览器", - deleteEdgeConfirm: "从画布移除选中链路?仅在点击「保存」后才会从 Fabric 删除;LLDP 下次采集若仍存在可能再次出现。", - edgeDeleted: "已标记删除 {{count}} 条链路(保存后生效)", - discoverCancelled: "发现已取消", - truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类页或新建视图。", - truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类流程中处理或新建视图。", - truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。", - truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。", - truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。", - deleteSelectionConfirm: - "从本图移除 {{nodes}} 个网元、删除 {{edges}} 条链路?移出与链路删除均在点击「保存」后生效。", - selectionDeleted: "已标记:移除 {{nodes}} 个网元、删除 {{edges}} 条链路(保存后生效)", - newMap: "新建", - newMapName: "新拓扑图", - rename: "重命名", - renamePrompt: "拓扑图名称", - renameHint: "双击可重命名", - renamed: "拓扑图已重命名", - deleteMap: "删除图", - deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。", - emptyMaps: "还没有根,先点「新建根」。", - palette: "网元库", - paletteHint: "勾选后批量添加;也可拖到画布添加单台", - paletteDragHint: "拖到画布添加", - selectAllVisible: "全选当前列表", - addSelected: "添加选中({{count}})", - addSelectedDone: "已添加 {{count}} 台网元", - addingNe: "添加中…", - filterPh: "输入即筛:名称 / IP / 厂商", - save: "保存", - saveDirty: "保存 *", - unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?", - saving: "保存中…", - saved: "拓扑已保存", - saveFailed: "保存失败:{{detail}}", - readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)", - readOnlyHint: "只读模式,无编辑权限", - discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度", - policySyncFailed: "同步 LLDP 策略失败,本地开关仍对本页发现生效", - discover: "发现链路", - discovering: "发现中…", - discoverOne: "发现链路", - discovered: "发现完成:新增 {{added}},更新 {{updated}},未发现 {{stale}}", - discoverFail: "发现失败:{{detail}}", - discoverOneNeedNe: "该节点未关联可扫描网元", - selectMap: "请选择或新建一张拓扑图", - renameNode: "重命名", - renameNodePrompt: "网元显示名称", - sourcePort: "本端端口", - targetPort: "对端端口", - endpointA: "端点 A", - endpointB: "端点 B", - port: "端口", - expandPhysicalLinks: "展开物理链路", - scaleBundleWidth: "聚合线按条数加粗", - linkMembers: "物理成员({{count}})", - canvasHint: - "左侧为区域目录(需自行添加画布);打开本图后进入设备画布,用「添加网元」上图并保存。", - selected: "已选节点", - selectedEdge: "已选链路", - selectedCount: "已选 {{count}}", - toolModes: "编辑模式", - toolSelect: "选择", - toolPan: "平移", - toolDrag: "拖动", - toolConnect: "连线", - layout: "布局", - layoutHierarchicalTb: "层次 · 自上而下", - layoutHierarchicalLr: "层次 · 从左到右", - layoutForce: "力导向", - layoutGrid: "网格", - layoutRadial: "环形", - layoutSelected: "仅布局选中(层次)", - layoutNeedSelection: "请先选中要布局的节点", - align: "对齐", - alignLeft: "左对齐", - alignRight: "右对齐", - alignTop: "顶对齐", - alignBottom: "底对齐", - alignHCenter: "水平居中", - alignVCenter: "垂直居中", - alignHDistribute: "水平分布", - alignVDistribute: "垂直分布", - alignNeedSelection: "请至少选中两个节点", - snapGrid: "网格吸附", - autoLayoutDiscover: "发现后自动布局", - discoverAutoAddUnmatched: "发现时自动入库未匹配网元(占位)", - discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布(需保存才永久保留)", - undo: "撤销", - redo: "重做", - discoverSelected: "发现链路({{count}})", - removeSelected: "移除选中 ({{count}})", - openWebcrt: "打开终端", - openNe: "网元详情", - openAlarms: "查看告警", - noAlarmLink: "该节点无法定位告警过滤条件", - removeNode: "从画布移除", - deletePlaceholder: "删除占位网元", - deletePlaceholderConfirm: "永久删除此占位网元(含库存记录与 Fabric 直连链路)?画布撤销无法恢复。", - deletePlaceholderDone: "占位网元已删除", - removeEdge: "删除链路", - nodeMenu: "网元", - paneMenu: "画布", - edgeMenu: "链路", - createNe: "新建网元", - createNeModeHint: "选择创建正式运维网元,或仅建拓扑占位(可稍后完善登录信息)。", - createNeManaged: "正式网元", - createNeManagedHint: "与网元管理相同的完整表单;创建后自动上图。", - createNePlaceholder: "占位网元", - createNePlaceholderHint: "仅名称/IP,来源为拓扑占位,适合先构图再补齐凭据。", - createNeTitle: "新建拓扑占位网元", - createNeHint: "来源为拓扑占位;完善登录信息后可在 WebCRT 连接,或在网元管理升级为手工。", - createNeName: "名称", - createNeNamePh: "例如 SW-CORE-01", - createNeNameRequired: "请填写网元名称", - createNeIp: "IP(可选)", - createNeIpPh: "可稍后在完善时填写", - createNeDone: "已新建占位网元 {{name}}", - createManagedNeDone: "已创建网元并上图 {{name}}", - createManagedNePlaceFailed: - "网元 {{name}} 已写入库存,但上图失败:{{error}}。请从网元管理或侧栏再次添加。", - createManagedNePosFailed: "网元 {{name}} 已上图,但未能移到点击位置(保留默认布局坐标)", - createNeBusy: "创建中…", - connectTest: "连通性测试", - connectTestSelected: "连通性测试 ({{count}})", - connectTestNeedManaged: "该节点未绑定运维网元,无法做连通性测试", - connectTestSkipped: "跳过 {{count}} 台无运维绑定", - selectionMenu: "多选", - edgeStyle: "链路样式", - edgeColor: "颜色", - edgeLineStyle: "线型", - edgeLineSolid: "实线", - edgeLineDashed: "虚线", - edgeLineDotted: "点线", - edgeWidth: "粗细", - edgeStyleReset: "恢复默认", - edgeDefaults: "默认链路样式", - edgeDefaultsReset: "恢复出厂", - noNeLink: "未关联托管网元", - completeNeFirst: - "该网元登录信息不完整(厂家、代理跳转等需在网元管理填写)。请先完善后再打开终端。", - edgeManual: "人工", - edgeUme: "UME", - edgeDiscovered: "LLDP 发现", - edgeStale: "未发现", - fit: "适应画布", - export: "导出", - exportSvg: "SVG 矢量", - exportXml: "XML 数据", - exporting: "导出中…", - exportEmpty: "画布上没有可导出的内容", - exportedSvg: "已导出 SVG", - exportedXml: "已导出 XML", - exportFailed: "导出失败:{{detail}}", - liveSync: "实时同步", - liveSyncOn: "同步中…", - liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新当前画布(观看 MCP 建图/画拓扑);默认关闭", - fullscreen: "全屏显示", - exitFullscreen: "退出全屏", - display: "显示", - canvasBg: "画布背景", - canvasBgColor: "背景色", - canvasBgReset: "恢复默认", - textColors: "文字颜色", - textColorsReset: "恢复默认", - labelColor: "网元名称", - edgeLabelColor: "链路数字", - vendorColors: "厂商图标色", - vendorColorsReset: "恢复默认", - vendorTone: { - cisco: "Cisco", - huawei: "华为", - zte: "中兴", - juniper: "Juniper", - nokia: "Nokia", - ericsson: "Ericsson", - h3c: "H3C", - ruijie: "锐捷", - mikrotik: "MikroTik", - gray: "未知/未托管", - }, - hideIp: "隐藏 IP", - hideVendor: "隐藏厂商", - showPlaceholderBadge: "显示占位角标(TOPO/LLDP)", - showConnectStatus: "显示连通状态点", - showAlarmOverlay: "显示告警叠层", - showLevelColors: "按分类层级着色", - levelUnmatched: "未分类", - level: { - external: "外部", - core: "核心", - aggregation: "汇聚", - access: "接入", - edge: "边缘", - }, - hidePorts: "隐藏端口", - edgeFlow: "链路流动", - collapseSidebar: "折叠侧栏", - expandSidebar: "展开侧栏", - resizeSidebar: "拖动调整侧栏宽度", - hideAdded: "隐藏已添加", - paletteEmpty: "没有匹配的网元。", - paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。", - paletteSource: "网元来源", - paletteManaged: "托管网元", - paletteUme: "UME 网元", - paletteLoading: "加载中…", - discoverReport: "发现进度", - discoverClose: "关闭", - discoverCancel: "取消发现", - discoverProgress: "正在扫描画布上 {{count}} 台托管网元(串行登录采集 LLDP,请稍候)…", - discoverProgressLive: "正在扫描 {{index}}/{{total}}:{{name}}", - discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}", - discoverNeOk: "邻居 {{neighbors}} · 新增 {{added}} · 更新 {{updated}}", - discoverNeFail: "采集失败", - discoverUnmatched: "未匹配 {{count}}", - discoverUnmatchedMore: "另有 {{count}} 条未列出", - discoverVendorRequired: "未设置厂商/设备类型,已跳过下发 LLDP 命令", - discoverParserStub: "厂商解析器「{{parser}}」仍为 stub,已采集到输出但未能解析邻居", - discoverNoTargets: "画布上没有可扫描网元。请添加托管网元,或添加已配置 CLI 凭据的 UME 网元。", - discoverViewDetail: "详情", - discoverLinkCount: "链路 {{count}}", - discoverLinksTitle: "匹配链路({{count}})", - discoverLinksEmpty: "本次未匹配到画布上的对端网元链路。", - discoverUnmatchedTitle: "未匹配邻居({{count}})", - discoverUnmatchedEmpty: "无未匹配邻居。", - discoverColPeer: "对端网元", - discoverColRemote: "邻居", - discoverColLocalPort: "本端端口", - discoverColRemotePort: "对端端口", - discoverColProtocol: "协议", - discoverColAction: "结果", - discoverLinkAdded: "新增", - discoverLinkUpdated: "更新", - discoverLinkKeptManual: "保留人工", - discoverLocatePeer: "定位对端", - discoverLocateNe: "定位本端", - discoverRawPreview: "采集原文预览(过长会截断,不影响解析)", - discoverGoLldp: "查看详情 → LLDP 链路", - findNode: "定位", - findNodePh: "输入即搜:名称 / IP / 厂商", - findNoMatch: "画布上没有匹配的节点", - findMore: "另有 {{count}} 条匹配…", - locateOnCanvas: "点击定位到画布", - openPortTraffic: "打开端口流量", - removeStale: "清除未发现 ({{count}})", - removeStaleHint: "标记删除红色未发现链路(保存后生效);周期采集连续 4 次未发现也会自动清理", - staleRemoved: "已标记删除 {{count}} 条未发现链路(保存后生效)", - connectHint: "连线模式:从网元中心锚点拖到目标网元(Esc 或切回选择退出)", - }, - topoClassify: { - title: "分类", - openTopo: "拓扑画布", - inventory: "网元清单", - keywordPh: "关键字筛选名称/IP", - filterLevelAll: "全部层级", - filterRegionAll: "全部区域", - filterUnmatchedOff: "不过滤未分类", - kindAny: "层级或区域未填", - kindLevel: "层级未填", - kindRegion: "区域未填", - filterLinkAll: "全部关联", - filterLinkLinked: "已关联库存", - filterLinkOrphaned: "已解绑", - colLink: "关联", - linkManaged: "托管", - linkUme: "UME", - linkBoth: "托管+UME", - linkOrphaned: "已解绑", - levelExternal: "L0 外部", - levelCore: "L1 核心", - levelCoreSub: "L1.1 次核心", - levelAgg: "L2 汇聚", - levelAggSub: "L2.1 二级汇聚", - levelAccess: "L3 接入", - levelAccessSub: "L3.1 更深接入", - levelInvalid: "层级无效(0–99.9,一位小数)", - sourceManual: "手动", - sourceUmeSync: "UME 同步", - sourceLldp: "LLDP 占位", - sourceTopology: "拓扑占位", - sourceWebcrt: "WebCRT", - regexTitle: "正则查找", - patternPh: "正则,如 ^CORE- 或 -EDGE-", - fieldName: "名称", - fieldNameIp: "名称+IP", - findMatches: "查找匹配", - matchOk: "匹配 {{count}} 条,已勾选", - matchHint: "当前匹配 {{count}} 条", - assignLevel: "只改层级", - assignRegion: "只改区域", - assignBoth: "层级+区域", - clearRegion: "清空区域", - confirmAssign: "确认写入", - bulkConfirm: "将给已选/匹配的 {{count}} 条网元写入分类,确定?", - bulkOk: "已更新 {{count}} 条", - selectedCount: "已选 {{count}}", - selectPage: "全选本页", - colNe: "网元", - colLevel: "层级", - colRegion: "区域", - colActions: "操作", - edit: "编辑", - save: "保存", - cancel: "取消", - delete: "删除", - deleteSelected: "删除节点 ({{count}})", - deleteConfirm: "将从拓扑 Fabric 删除 {{count}} 个节点(含上图与边),不影响网元管理/UME 清单。确定?", - deleteOk: "已删除 {{count}} 个节点", - deleteHint: "仅可删除已解绑节点,或 LLDP/WebCRT 等非实际管理占位;仅 UME / 实际管理网元不可删", - deleteBlocked: "仅 UME 或仍关联实际管理网元,不可在此删除", - rowSaved: "已保存", - loading: "加载中…", - empty: "没有网元。", - }, - audit: { - title: "操作审计", - logsTitle: "操作日志", - colSummary: "摘要", - colDetail: "详情", - showDetail: "展开", - hideDetail: "收起", - filterBusiness: "业务", - filterAll: "全部", - filterWebcrt: "设备终端", - filterNeExec: "设备执行", - filterAuth: "登录认证", - filterHttp: "HTTP", - summary: { - connecting: "正在登录 {{device}}", - loginOk: "登录 {{device}}", - loginFail: "登录失败 {{device}}:{{error}}", - logout: "断开 {{device}}", - logoutReason: "断开 {{device}}({{reason}})", - command: "{{device}} · 命令: {{command}}", - neExec: "{{device}} · 执行: {{commands}}", - neExecBatch: "批量执行 {{n}} 台(成功 {{ok}} / 失败 {{fail}})", - }, - nav: { - tasks: "任务概览", - logs: "操作日志", - }, - tasks: { - title: "任务概览", - hint: "汇总当前系统中正在运行或挂起的各类后台任务;发起人优先取近期审计记录,调度类任务显示为 scheduler/system。", - empty: "当前没有可展示的任务。", - open: "打开", - inflight: "进行中 {{n}}", - kpiActive: "活跃/在途", - kpiTotal: "条目总数", - kpiKinds: "按类型", - kpiStatuses: "按状态", - colKind: "类型", - colTitle: "任务", - colStatus: "状态", - colActor: "发起人", - colTrigger: "触发", - colProgress: "进度", - colStarted: "开始", - colUpdated: "更新", - colLink: "入口", - kind: { - port_traffic: "端口流量", - config_sync: "配置同步", - ne_collect: "批量采集", - lldp_discover: "LLDP 发现", - ume_sync: "UME 同步", - ne_connect: "连通性测试", - ume_runtime: "UME 后台", - webcrt: "WebCRT", - }, - trigger: { - manual: "手动", - schedule: "调度", - topology: "拓扑", - system: "系统", - retry_failed: "重试失败", - }, - status: { - collecting: "采集中", - running: "运行中", - testing: "测试中", - connecting: "登录中", - ready: "已连接", - detached: "已断开(宽限)", - pending: "排队", - paused: "暂停", - idle: "空闲", - error: "错误", - failed: "失败", - }, - }, - }, -}; - -export default zh; diff --git a/web/src/index.css b/web/src/index.css index b257605..6796170 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,16007 +1,29 @@ -/* HeroUI v3 + Tailwind CSS v4 — must come before app chrome styles */ +/* + * NetX stylesheet entry. Order is the cascade — do not reorder imports. + * Layout: styles/{base,layout,components,pages,theme,legacy}. See WEB.md "样式规范". + */ @import "tailwindcss"; @import "@heroui/styles"; -/* Align HeroUI tokens with NetX dark ops shell */ -.dark, -[data-theme="dark"] { - --background: oklch(0.14 0.02 255); - --foreground: oklch(0.92 0.01 250); - --surface: oklch(0.2 0.025 255); - --surface-secondary: oklch(0.24 0.025 255); - --overlay: oklch(0.2 0.025 255); - --accent: oklch(0.62 0.17 255); - --accent-foreground: oklch(0.99 0 0); - --muted: oklch(0.7 0.02 250); - --border: color-mix(in oklab, var(--foreground) 14%, transparent); - --field-background: oklch(0.18 0.02 255); -} - -* { - box-sizing: border-box; -} - -html { - color-scheme: dark; - scrollbar-color: #475569 #0f172a; - scrollbar-width: thin; -} - -*::-webkit-scrollbar { - width: 10px; - height: 10px; -} - -*::-webkit-scrollbar-track { - background: #0f172a; -} - -*::-webkit-scrollbar-thumb { - background: #475569; - border: 2px solid #0f172a; - border-radius: 999px; -} - -*::-webkit-scrollbar-thumb:hover { - background: #64748b; -} - -*::-webkit-scrollbar-corner { - background: #0f172a; -} - -body { - margin: 0; - font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; - background: #070b14; - color: #e2e8f0; -} - -#root { - min-height: 100vh; -} - -.app--shell { - /* Shared NetX chrome tokens — dark ops shell (global foundation) */ - --nm-accent: #3b82f6; - --nm-accent-deep: #2563eb; - --nm-header: #060a12; - --nm-chrome: #080d18; - --nm-main: #0a101c; - --nm-nav: #101826; - --nm-nav-border: #2b3748; - --nm-nav-hover: rgba(107, 169, 236, 0.08); - --nm-nav-active: rgba(107, 169, 236, 0.12); - --nm-nav-ink: #e2e8f0; - --nm-nav-muted: #9aa9bc; - --nm-panel: #0c1521; - --nm-panel-solid: #0c1521; - --nm-panel-border: #2b3748; - --nm-brand: #f1f5f9; - --nm-text: #e2e8f0; - --nm-muted: #9aa9bc; - --nm-glass: rgba(12, 21, 33, 0.9); - --nm-glass-border: #2b3748; - /* Compact industrial ops (shared by network + list modules) */ - --nm-ops-line: #2b3748; - --nm-ops-muted: #9aa9bc; - --nm-ops-accent: #6ba9ec; - --nm-ops-surface: #111c2a; - display: flex; - flex-direction: column; - min-height: 100vh; - color: var(--nm-text); -} - -.app-brand { - display: flex; - align-items: center; - justify-content: space-between; - height: 48px; - padding: 0 20px; - background: var(--nm-header, #0f1b2d); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: none; -} - -.app-brand__logo { - font-size: 18px; - font-weight: 700; - letter-spacing: 0.12em; - color: #fff; - text-decoration: none; - flex-shrink: 0; - line-height: 1; -} - -.app-brand__logo--static { - display: inline-block; -} - -.app-brand__sep { - width: 1px; - height: 16px; - background: rgba(255, 255, 255, 0.22); - flex-shrink: 0; -} - -.app-brand__start { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; -} - -.app-brand__apps { - display: inline-flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - padding: 0; - border: none; - border-radius: 4px; - background: transparent; - color: rgba(255, 255, 255, 0.88); - cursor: pointer; -} - -.app-brand__apps:hover, -.app-brand__apps:focus-visible { - background: rgba(59, 130, 246, 0.22); - color: #fff; - outline: none; -} - -.app-brand__module-title { - font-size: 14px; - font-weight: 500; - color: rgba(255, 255, 255, 0.92); - white-space: nowrap; - letter-spacing: 0.02em; -} - -.app-brand__actions { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.app-brand__actions-group { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.app-brand__actions-sep { - width: 1px; - height: 14px; - margin: 0 4px; - background: rgba(255, 255, 255, 0.18); - flex-shrink: 0; -} - -.app-main { - flex: 1; - padding: 20px 28px 32px; - background: var(--nm-chrome, #e8eef6); - color: var(--nm-text, #1e293b); -} - - -.workbench { - width: 100%; - max-width: none; - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - gap: 14px; -} - -.workbench--console { - position: relative; - --wb-corner: rgba(107, 169, 236, 0.45); - --wb-ink: #e2e8f0; - --wb-muted: var(--nm-ops-muted, #9aa9bc); - --wb-cyan: var(--nm-ops-accent, #6ba9ec); -} - -.app-main:has(> .workbench) { - display: flex; - flex-direction: column; - flex: 1 1 auto; - min-height: 0; - padding: 16px 20px 20px; - background: - linear-gradient(180deg, rgba(8, 14, 28, 0.92), rgba(7, 11, 20, 0.98)), - repeating-linear-gradient( - 0deg, - transparent, - transparent 23px, - rgba(148, 163, 184, 0.035) 23px, - rgba(148, 163, 184, 0.035) 24px - ), - repeating-linear-gradient( - 90deg, - transparent, - transparent 23px, - rgba(148, 163, 184, 0.03) 23px, - rgba(148, 163, 184, 0.03) 24px - ), - var(--nm-chrome, #070b14); -} - -.wb-panel { - position: relative; - border: 1px solid var(--nm-ops-line, #2b3748); - border-radius: 6px; - background: - linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 0 0 / 11px 2px no-repeat, - linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 0 0 / 2px 11px no-repeat, - linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 100% 0 / 11px 2px no-repeat, - linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 100% 0 / 2px 11px no-repeat, - linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 0 100% / 11px 2px no-repeat, - linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 0 100% / 2px 11px no-repeat, - linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 100% 100% / 11px 2px no-repeat, - linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 100% 100% / 2px 11px no-repeat, - #0c1521; -} - -.wb-panel::before, -.wb-panel::after { - content: none; -} - -.wb-head { - display: flex; - flex-direction: column; - gap: 0; -} - -.wb-head__title-row { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.wb-head__title { - margin: 0; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 14px; - font-weight: 700; - letter-spacing: 0.14em; - color: #38bdf8; - line-height: 1.2; -} - -.wb-head__health { - display: inline-flex; - align-items: center; - gap: 6px; - height: 22px; - padding: 0 8px; - border-radius: 3px; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 11px; - font-weight: 650; - letter-spacing: 0.04em; - border: 1px solid transparent; -} - -.wb-head__health-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; - box-shadow: 0 0 8px currentColor; -} - -.wb-head__health.is-ok { - color: #4ade80; - background: rgba(34, 197, 94, 0.08); - border-color: rgba(34, 197, 94, 0.28); -} - -.wb-head__health.is-busy { - color: #fbbf24; - background: rgba(245, 158, 11, 0.1); - border-color: rgba(245, 158, 11, 0.3); -} - -.wb-head__sub { - margin: 0; - font-size: 13px; - line-height: 1.45; - color: var(--nm-muted, #94a3b8); - max-width: 52ch; -} - -.wb-strip { - display: flex; - flex-direction: column; - gap: 8px; - padding: 11px 14px; -} - -.wb-strip__row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 18px; - min-width: 0; -} - -.wb-strip__tag { - flex: 0 0 auto; - min-width: 3.5em; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 11px; - font-weight: 650; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #7dd3fc; -} - -.wb-strip__tag--soft { - color: #94a3b8; -} - -.wb-strip__divider { - flex: 0 0 auto; - width: 1px; - height: 28px; - margin: 0 2px; - background: linear-gradient( - 180deg, - transparent, - rgba(56, 189, 248, 0.35), - transparent - ); -} - -.wb-stat, -.app-main .wb-stat { - display: inline-grid; - grid-template-columns: auto auto; - grid-template-rows: auto auto; - column-gap: 8px; - row-gap: 3px; - align-items: baseline; - flex: 0 0 auto; - width: auto; - min-width: 0; - margin: 0; - padding: 0; - border: 0; - background: transparent; - box-shadow: none; - color: inherit; - text-align: left; - border-radius: 0; -} - -.wb-stat__label { - grid-column: 1; - grid-row: 1; - font-size: 12px; - font-weight: 500; - color: #94a3b8; -} - -.wb-stat__value { - grid-column: 2; - grid-row: 1; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 12px; - font-weight: 700; - font-variant-numeric: tabular-nums; - color: #e2e8f0; -} - -.wb-stat__track { - grid-column: 1 / -1; - grid-row: 2; - display: block; - width: 84px; - height: 2px; - border-radius: 0; - background: rgba(148, 163, 184, 0.16); - overflow: hidden; -} - -.wb-stat__fill { - display: block; - height: 100%; - background: #64748b; -} - -.wb-stat.is-ok .wb-stat__fill { - background: #22c55e; -} - -.wb-stat.is-mid .wb-stat__fill { - background: #38bdf8; -} - -.wb-stat.is-high .wb-stat__fill { - background: #f59e0b; -} - -.wb-stat.is-muted { - opacity: 0.55; -} - -.wb-body { - display: grid; - grid-template-columns: minmax(0, 1fr) clamp(300px, 26vw, 380px); - gap: 14px; - align-items: stretch; - flex: 1 1 auto; - min-height: 0; -} - -.wb-main { - display: flex; - flex-direction: column; - gap: 12px; - min-width: 0; - height: 100%; -} - -.wb-modules { - display: flex; - flex-direction: column; - gap: 14px; - padding: 14px 16px 16px; - flex: 1 1 auto; - min-height: 0; - justify-content: center; -} - -.wb-section { - margin: 0; - display: flex; - flex-direction: column; - gap: 10px; -} - -.wb-section__title { - margin: 0; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 11px; - font-weight: 650; - letter-spacing: 0.1em; - text-transform: uppercase; - color: #7dd3fc; -} - -.wb-tiles { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 10px; - align-items: stretch; - width: 100%; -} - -.wb-tile, -.app-main .wb-tile { - display: inline-flex; - align-items: center; - gap: 12px; - width: 100%; - max-width: none; - min-height: 64px; - margin: 0; - padding: 12px 14px; - border: 1px solid rgba(148, 163, 184, 0.2); - border-radius: 4px; - background: rgba(15, 23, 42, 0.5); - box-shadow: none; - cursor: pointer; - text-align: left; - color: inherit; - transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease; -} - -.wb-tile:hover, -.wb-tile:focus-visible { - border-color: rgba(56, 189, 248, 0.42); - background: rgba(30, 41, 59, 0.72); - box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.1); - outline: none; - transform: translateY(-1px); -} - -.wb-tile__label { - font-size: 13px; - font-weight: 600; - color: #e2e8f0; - line-height: 1.25; -} - -.wb-tile .wb-card__icon { - opacity: 0.9; -} - - -.wb-fleet { - margin-top: 0; - flex: 0 0 auto; - min-height: 0; - display: flex; - flex-direction: column; - gap: 8px; - padding: 10px 12px 12px; -} - -.wb-fleet__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding-bottom: 2px; - border-bottom: 1px solid rgba(148, 163, 184, 0.1); -} - -.wb-fleet__title { - margin: 0; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 11px; - font-weight: 650; - letter-spacing: 0.1em; - text-transform: uppercase; - color: #7dd3fc; -} - -.wb-fleet__grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - min-height: 0; - align-items: stretch; -} - -.wb-fleet__card { - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - gap: 8px; - width: 100%; - max-width: 100%; - min-width: 0; - margin: 0; - padding: 10px 10px 8px; - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: 4px; - background: rgba(8, 15, 30, 0.45); - color: inherit; - text-align: left; - cursor: default; - overflow: hidden; - transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease; -} - -button.wb-fleet__card { - cursor: pointer; - font: inherit; - appearance: none; - -webkit-appearance: none; -} - -button.wb-fleet__card:hover, -button.wb-fleet__card:focus-visible { - border-color: rgba(56, 189, 248, 0.4); - background: rgba(20, 32, 52, 0.72); - outline: none; - transform: translateY(-1px); -} - -button.wb-fleet__card:disabled { - cursor: default; - opacity: 0.72; -} - -button.wb-fleet__card:disabled:hover, -button.wb-fleet__card:disabled:focus-visible { - border-color: rgba(148, 163, 184, 0.2); - background: rgba(15, 23, 42, 0.45); -} - -.wb-fleet__card--sched { - cursor: pointer; -} - -.wb-fleet__card--sched:hover, -.wb-fleet__card--sched:focus-visible { - border-color: rgba(56, 189, 248, 0.4); - background: rgba(20, 32, 52, 0.72); - outline: none; - transform: translateY(-1px); -} - -.wb-fleet__card-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-height: 18px; -} - -.wb-fleet__card-label { - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 11px; - font-weight: 650; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #7dd3fc; -} - -.wb-fleet__card-badge { - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 10px; - font-weight: 650; - letter-spacing: 0.02em; - color: #64748b; - white-space: nowrap; -} - -.wb-fleet__card-badge.is-ok { - color: #4ade80; -} - -.wb-fleet__card-badge.is-warn { - color: #fbbf24; -} - -.wb-fleet__viz { - display: grid; - grid-template-columns: 84px minmax(0, 1fr); - gap: 6px 12px; - align-items: center; - min-height: 0; -} - -.wb-fleet__donut { - position: relative; - width: 84px; - height: 84px; - border-radius: 50%; - flex: 0 0 auto; - margin: 0; - box-shadow: - inset 0 0 0 1px rgba(56, 189, 248, 0.12), - 0 0 14px rgba(56, 189, 248, 0.05); -} - -.wb-fleet__donut--dart { - background: transparent; - box-shadow: none; -} - -.wb-fleet__dart { - display: block; - width: 100%; - height: 100%; - overflow: visible; -} - -.wb-fleet__dart-slice { - cursor: pointer; - transform-origin: 50% 50%; - transition: filter 0.14s ease, opacity 0.14s ease; - outline: none; -} - -.wb-fleet__dart-slice:hover, -.wb-fleet__dart-slice:focus-visible { - filter: brightness(1.18) drop-shadow(0 0 6px rgba(56, 189, 248, 0.45)); -} - -.wb-fleet__dart-slice:not(.is-on) { - opacity: 0.55; -} - -.wb-fleet__dart-slice:not(.is-on):hover, -.wb-fleet__dart-slice:not(.is-on):focus-visible { - opacity: 0.85; -} - -.wb-fleet__donut-hole { - position: absolute; - inset: 20%; - border-radius: 50%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 1px; - background: - radial-gradient(circle at 35% 30%, rgba(56, 189, 248, 0.08), transparent 55%), - linear-gradient(165deg, rgba(15, 23, 42, 0.98), rgba(2, 8, 23, 0.96)); - box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.9); -} - -.wb-fleet__donut-hole--dart { - inset: 28%; - pointer-events: none; -} - -.wb-fleet__donut-value { - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 13px; - font-weight: 700; - font-variant-numeric: tabular-nums; - line-height: 1; - color: #e2e8f0; - letter-spacing: -0.02em; -} - -.wb-fleet__donut-unit { - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 9px; - font-weight: 650; - letter-spacing: 0.06em; - text-transform: uppercase; - color: #64748b; -} - -.wb-fleet__rows { - display: flex; - flex-direction: column; - justify-content: center; - gap: 2px; - margin-top: 0; - min-width: 0; - min-height: 84px; -} - -.wb-fleet__row, -button.wb-fleet__row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - width: 100%; - margin: 0; - padding: 1px 0; - border: 0; - background: transparent; - font: inherit; - font-size: 12px; - line-height: 1.35; - color: #94a3b8; - text-align: left; - cursor: default; -} - -button.wb-fleet__row { - cursor: pointer; - border-radius: 3px; - padding: 1px 4px; - margin: 0 -4px; -} - -button.wb-fleet__row:hover, -button.wb-fleet__row:focus-visible { - color: #e2e8f0; - background: rgba(56, 189, 248, 0.08); - outline: none; -} - -.wb-fleet__legend { - display: inline-flex; - align-items: center; - gap: 7px; - min-width: 0; -} - -.wb-fleet__legend-text { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.wb-fleet__swatch { - display: inline-block; - width: 7px; - height: 7px; - border-radius: 50%; - background: #64748b; - flex: 0 0 auto; -} - -.wb-fleet__swatch.is-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.45); } -.wb-fleet__swatch.is-mid { background: #38bdf8; box-shadow: 0 0 6px rgba(56, 189, 248, 0.4); } -.wb-fleet__swatch.is-warn { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.4); } -.wb-fleet__swatch.is-bad { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); } -.wb-fleet__swatch.is-muted { background: #64748b; } - -.wb-fleet__row strong { - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 12px; - font-weight: 700; - font-variant-numeric: tabular-nums; - color: #e2e8f0; - text-align: right; - white-space: nowrap; -} - -.wb-fleet__row strong.is-ok { color: #4ade80; } -.wb-fleet__row strong.is-mid { color: #7dd3fc; } -.wb-fleet__row strong.is-warn { color: #fbbf24; } -.wb-fleet__row strong.is-bad { color: #f87171; } -.wb-fleet__row strong.is-muted { color: #64748b; } - -.wb-fleet__foot { - display: flex; - flex-wrap: wrap; - align-items: baseline; - justify-content: space-between; - gap: 4px 10px; - margin-top: 0; - padding-top: 6px; - border-top: 1px solid rgba(148, 163, 184, 0.1); - min-height: 22px; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 10px; - line-height: 1.3; - color: #64748b; -} - -.wb-fleet__foot em { - font-style: normal; - font-weight: 650; - color: #94a3b8; -} - -.wb-fleet__foot em.is-ok { - color: #4ade80; -} - -.wb-fleet__foot em.is-warn { - color: #fbbf24; -} - -.wb-fleet__row-age { - margin-left: 4px; - font-weight: 550; - color: #64748b; -} - -.wb-fleet__row.is-on .wb-fleet__legend-text { - color: #e2e8f0; -} - -.wb-fleet__row--sched, -button.wb-fleet__row--sched { - display: grid; - grid-template-columns: minmax(0, 1fr) 2.75em 2.75em; - align-items: center; - justify-content: stretch; - column-gap: 6px; -} - -.wb-fleet__sched-state { - display: block; - width: 100%; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.04em; - text-align: left; - color: #64748b; -} - -.wb-fleet__sched-state.is-ok { - color: #4ade80; -} - -.wb-fleet__sched-state.is-muted { - color: #64748b; -} - -.wb-fleet__sched-age { - display: block; - width: 100%; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 11px; - font-variant-numeric: tabular-nums; - font-weight: 550; - text-align: right; - color: #64748b; -} - -.wb-side { - display: flex; - flex-direction: column; - gap: 0; - min-width: 0; - height: 100%; - align-self: stretch; -} - -.wb-rail { - min-width: 0; - padding: 12px 14px; - display: flex; - flex-direction: column; - min-height: 0; -} - -.wb-rail--tasks { - flex: 1 1 auto; - min-height: 0; - height: 100%; -} - -.wb-rail--channels { - display: none; -} - -.wb-rail__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 10px; -} - -.wb-rail__title { - margin: 0; - display: inline-flex; - align-items: center; - gap: 8px; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 11px; - font-weight: 650; - letter-spacing: 0.1em; - text-transform: uppercase; - color: #7dd3fc; -} - -.wb-rail__count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 5px; - border-radius: 3px; - background: rgba(56, 189, 248, 0.14); - color: #e0f2fe; - font-size: 11px; - font-weight: 700; - letter-spacing: 0; - text-transform: none; -} - -.wb-rail__meta { - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 11px; - color: #64748b; -} - -.wb-rail__all, -.app-main .wb-rail__all { - height: 26px; - min-height: 26px; - padding: 0 8px; - font-size: 12px; - color: #7dd3fc; - background: transparent; - border: 0; - box-shadow: none; - border-radius: 3px; -} - -.wb-rail__all:hover, -.wb-rail__all:focus-visible { - color: #e0f2fe; - background: rgba(56, 189, 248, 0.1); - outline: none; -} - -.wb-rail__empty { - margin: 0; - padding: 12px 0; - font-size: 12px; - line-height: 1.45; - color: #64748b; -} - -.wb-rail__list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 6px; - overflow: auto; - min-height: 0; - flex: 1 1 auto; -} - -.wb-rail__item, -.app-main .wb-rail__item, -.app-main button.wb-rail__item { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - gap: 4px; - width: 100%; - height: auto; - min-height: 0; - margin: 0; - padding: 10px 10px; - border: 1px solid rgba(148, 163, 184, 0.12); - border-radius: 4px; - background: rgba(2, 8, 23, 0.35); - box-shadow: none; - color: inherit; - text-align: left; - cursor: pointer; - white-space: normal; - line-height: 1.4; - overflow: visible; -} - -.wb-rail__item:hover, -.wb-rail__item:focus-visible, -.app-main button.wb-rail__item:hover, -.app-main button.wb-rail__item:focus-visible { - background: rgba(56, 189, 248, 0.1); - border-color: rgba(56, 189, 248, 0.28); - outline: none; -} - -.wb-rail__item-title { - display: block; - width: 100%; - font-size: 12px; - font-weight: 600; - color: #e2e8f0; - line-height: 1.45; - white-space: normal; - overflow-wrap: anywhere; - word-break: break-word; -} - -.wb-rail__item-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0; - width: 100%; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 11px; - color: #94a3b8; - line-height: 1.4; -} - -.wb-rail__status { - display: inline-flex; - align-items: center; - gap: 5px; - font-weight: 650; -} - -.wb-rail__status::before { - content: ""; - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; - box-shadow: 0 0 6px currentColor; - flex-shrink: 0; -} - -.wb-rail__status--running { - color: #38bdf8; -} - -.wb-rail__status--ok { - color: #4ade80; -} - -.wb-rail__status--paused { - color: #fbbf24; -} - -.wb-rail__status--stopped { - color: #f87171; -} - -.wb-rail__status--other { - color: #94a3b8; -} - -.wb-rail__progress { - color: #64748b; - font-weight: 500; -} - -.wb-channels { - display: flex; - flex-direction: column; - gap: 10px; -} - -.wb-channel { - display: flex; - flex-direction: column; - gap: 5px; -} - -.wb-channel__top { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; -} - -.wb-channel__label { - font-size: 12px; - color: #94a3b8; -} - -.wb-channel__value { - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 12px; - font-weight: 700; - color: #e2e8f0; - font-variant-numeric: tabular-nums; -} - -.wb-channel__sep { - margin: 0 2px; - color: #64748b; - font-weight: 500; -} - -.wb-channel__track { - display: block; - height: 3px; - background: rgba(148, 163, 184, 0.14); - overflow: hidden; -} - -.wb-channel__fill { - display: block; - height: 100%; - background: #38bdf8; -} - -.wb-channel.is-ok .wb-channel__fill { - background: #22c55e; -} - -.wb-channel.is-mid .wb-channel__fill { - background: #38bdf8; -} - -.wb-channel.is-high .wb-channel__fill { - background: #f59e0b; -} - -.wb-channel.is-muted { - opacity: 0.55; -} - -@media (max-width: 960px) { - .wb-body { - grid-template-columns: 1fr; - } - - .wb-side { - min-height: 0; - } - - .wb-rail--tasks { - flex: 0 0 auto; - max-height: 280px; - } - - .wb-fleet { - margin-top: 0; - flex: 0 0 auto; - } - - .wb-fleet__grid { - grid-template-columns: 1fr; - } - - .wb-fleet__viz { - grid-template-columns: 72px minmax(0, 1fr); - } - - .wb-fleet__donut { - width: 72px; - height: 72px; - } - - .wb-fleet__rows { - min-height: 72px; - } - - .wb-modules { - justify-content: flex-start; - } - - .wb-tile, - .app-main .wb-tile { - min-height: 52px; - } - - .wb-strip__divider { - display: none; - } - - .wb-tiles { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 640px) { - .wb-tiles { - grid-template-columns: 1fr; - } - - .wb-strip__tag { - width: 100%; - } -} - -.header-menu__panel--light { - background: #0f172a; - border-color: #1e293b; - box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); -} - -.header-menu__label--light { - color: #94a3b8; -} - -.header-menu__item--light { - color: #e2e8f0; -} - -.header-menu__item--light:hover { - background: #f1f5f9; - color: #0f172a; -} - -.header-menu__item--active-light { - background: #e3f2fd; - color: var(--nm-accent, #3b82f6); -} - -.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; -} - -.header-menu__item--active { - background: rgba(61, 130, 247, 0.22); - color: #dce8ff; -} - -.app-main button, -.app-main select, -.app-main input, -.app-main textarea { - border: 1px solid var(--nm-panel-border, #1e293b); - background: #0b1220; - color: var(--nm-text, #e2e8f0); - border-radius: 6px; - padding: 8px 10px; -} - -button, -select, -input, -textarea { - border: 1px solid var(--nm-panel-border, #1e293b); - background: #0b1220; - color: var(--nm-text, #e2e8f0); - border-radius: 6px; - padding: 8px 10px; -} - -textarea { - width: 100%; - resize: vertical; -} - -button { - cursor: pointer; -} - -.cards { - display: grid; - grid-template-columns: repeat(3, minmax(220px, 1fr)); - gap: 12px; - margin-bottom: 12px; -} - -.app-main .card, -.app-main .panel { - border: 1px solid var(--nm-ops-line, var(--nm-panel-border, #2b3748)); - background: var(--nm-panel-solid, #0c1521); - border-radius: 6px; - padding: 14px; - box-shadow: none; - color: var(--nm-text, #e2e8f0); -} - -.card, -.panel { - border: 1px solid var(--nm-ops-line, var(--nm-panel-border, #2b3748)); - background: var(--nm-panel-solid, #0c1521); - border-radius: 6px; - padding: 14px; - box-shadow: none; - color: var(--nm-text, #e2e8f0); -} - -.card h3, -.panel h2 { - margin: 0 0 10px; - font-size: 16px; -} - -.card__section-title { - margin: 14px 0 8px; - font-size: 14px; - font-weight: 600; -} - -.card input:not([type="checkbox"]):not([type="radio"]), -.card select, -.card textarea { - width: 100%; - margin-bottom: 8px; -} - -.card input[type="checkbox"], -.card input[type="radio"] { - width: auto; - margin-bottom: 0; - flex: 0 0 auto; -} - -.pager input, -.pager select { - width: auto; - margin-bottom: 0; -} - -.actions-row { - margin-top: 8px; -} - -.actions-row--inline { - display: flex; - gap: 8px; - flex-wrap: nowrap; - overflow-x: auto; -} - -.card--full { - grid-column: 1 / -1; -} - -.filter-inline { - display: grid; - grid-template-columns: minmax(280px, 2fr) minmax(180px, 1fr) minmax(180px, 1fr) 180px; - gap: 8px; - align-items: center; -} - -.filter-inline input, -.filter-inline select { - width: 100%; - margin-bottom: 0; -} - -a { - color: var(--nm-accent, #3b82f6); - text-decoration: none; -} - -.muted { - color: #94a3b8; -} - -table { - width: 100%; - border-collapse: collapse; -} - -th, -td { - border-bottom: 1px solid var(--nm-panel-border, #1e293b); - padding: 8px; - text-align: left; - font-size: 13px; - vertical-align: top; - color: var(--nm-text, #e2e8f0); -} - -th { - color: var(--nm-muted, #94a3b8); - font-weight: 600; -} - -.split { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} - -pre { - white-space: pre-wrap; - margin: 0; - background: #0f172a; - color: #e2e8f0; - border: 1px solid #223651; - border-radius: 8px; - padding: 10px; -} - -.ws-log-panel { - margin-top: 12px; - max-height: 240px; - overflow-y: auto; - background: rgba(2, 8, 23, 0.55); - border: 1px solid rgba(148, 163, 184, 0.2); - border-radius: 8px; - padding: 8px 10px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - line-height: 1.45; - color: #cbd5e1; -} - -.ws-log-line { - padding: 3px 0; - border-bottom: 1px solid rgba(148, 163, 184, 0.12); - word-break: break-word; -} - -.ws-log-line:last-child { - border-bottom: none; -} - -.ws-log-line--error { - color: #fca5a5; -} - -.ws-log-line--warning { - color: #fdba74; -} - -.ws-log-line--info { - color: #94a3b8; -} - -.ws-log-ts { - color: #94a3b8; - margin-right: 8px; -} - -.pill { - margin-top: 8px; - padding: 6px 10px; - border-radius: 6px; - width: fit-content; - font-size: 12px; - background: rgba(148, 163, 184, 0.12); - color: #cbd5e1; - border: 1px solid rgba(148, 163, 184, 0.22); -} - -.pill--high { - background: rgba(239, 68, 68, 0.16); - border-color: rgba(248, 113, 113, 0.35); - color: #fecaca; -} - -.pill--medium { - background: rgba(245, 158, 11, 0.14); - border-color: rgba(251, 191, 36, 0.35); - color: #fde68a; -} - -.pill--low { - background: rgba(34, 197, 94, 0.14); - border-color: rgba(74, 222, 128, 0.3); - color: #bbf7d0; -} - -.link-btn { - border: none; - background: transparent; - color: var(--nm-accent, #3b82f6); - padding: 0; - cursor: pointer; -} - -.link-btn--danger { - color: #f87171; -} - -.link-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.table-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; -} - -.status { - margin-top: 12px; - color: #9db2cf; - font-size: 12px; -} - -.app-main .conn-pill:not(.conn-pill--on-brand) { - font-size: 12px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 999px; - padding: 4px 10px; - background: rgba(15, 23, 42, 0.75); - color: #cbd5e1; -} - -.conn-pill { - font-size: 12px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 999px; - padding: 4px 10px; - background: rgba(15, 23, 42, 0.75); - color: #cbd5e1; -} - -.conn-pill--up { - border-color: rgba(74, 222, 128, 0.4); - color: #86efac; - background: rgba(34, 197, 94, 0.14); -} - -.conn-pill--down { - border-color: rgba(248, 113, 113, 0.4); - color: #fca5a5; - background: rgba(239, 68, 68, 0.14); -} - -.conn-pill--unknown { - border-color: rgba(148, 163, 184, 0.3); - color: #94a3b8; - background: rgba(148, 163, 184, 0.1); -} - -/* Brand header chips/buttons — must follow global button/conn-pill rules. */ -.app-brand .conn-pill.conn-pill--on-brand { - display: inline-flex; - align-items: center; - gap: 6px; - height: 24px; - padding: 0 10px; - border: 1px solid rgba(255, 255, 255, 0.16); - border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.82); - font-size: 11px; - font-weight: 400; - line-height: 1; - white-space: nowrap; -} - -.app-brand .conn-pill.conn-pill--on-brand::before { - content: ""; - width: 6px; - height: 6px; - border-radius: 50%; - background: #94a3b8; - flex-shrink: 0; -} - -.app-brand .conn-pill.conn-pill--on-brand.conn-pill--up { - border-color: rgba(255, 255, 255, 0.16); - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.82); -} - -.app-brand .conn-pill.conn-pill--on-brand.conn-pill--up::before { - background: #4ade80; -} - -.app-brand .conn-pill.conn-pill--on-brand.conn-pill--down { - border-color: rgba(248, 113, 113, 0.35); - background: rgba(127, 29, 29, 0.35); - color: #fecaca; -} - -.app-brand .conn-pill.conn-pill--on-brand.conn-pill--down::before { - background: #f87171; -} - -.app-brand .conn-pill.conn-pill--on-brand.conn-pill--unknown { - border-color: rgba(255, 255, 255, 0.16); - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.7); -} - -.app-brand .conn-pill.conn-pill--on-brand.conn-pill--unknown::before { - background: #94a3b8; -} - -.app-brand .conn-pill.conn-pill--on-brand.conn-pill--user { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.16); - color: rgba(255, 255, 255, 0.78); - font-size: 11px; - font-weight: 400; -} - -.app-brand .conn-pill.conn-pill--on-brand.conn-pill--user::before { - display: none; -} - -.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks { - cursor: pointer; - font: inherit; - font-size: 11px; - font-weight: 400; -} - -.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:hover, -.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:focus-visible { - border-color: rgba(59, 130, 246, 0.55); - background: rgba(59, 130, 246, 0.22); - color: #fff; - outline: none; -} - -.app-brand .header-menu__trigger.header-menu__trigger--on-brand { - display: inline-flex; - align-items: center; - justify-content: center; - height: 24px; - min-width: 24px; - padding: 0 10px; - border: 1px solid rgba(255, 255, 255, 0.16); - border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.78); - font-size: 11px; - font-weight: 400; - letter-spacing: normal; - line-height: 1; - box-shadow: none; -} - -.app-brand .header-menu__trigger.header-menu__trigger--on-brand.header-menu__trigger--icon { - width: 24px; - padding: 0; - font-size: 14px; - letter-spacing: 0.06em; -} - -.app-brand .header-menu__trigger.header-menu__trigger--on-brand.header-menu__trigger--locale { - min-width: 36px; - padding: 0 8px; - font-size: 11px; - font-weight: 650; - letter-spacing: 0.04em; -} - -.app-brand .header-menu__trigger.header-menu__trigger--on-brand:hover, -.app-brand .header-menu__trigger.header-menu__trigger--on-brand:focus-visible { - background: rgba(59, 130, 246, 0.22); - border-color: rgba(59, 130, 246, 0.55); - color: #fff; -} - -.app-brand .conn-pill.conn-pill--on-brand.conn-pill--up { - border-color: rgba(59, 130, 246, 0.35); -} - -.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:hover, -.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:focus-visible { - border-color: rgba(59, 130, 246, 0.55); - background: rgba(59, 130, 246, 0.22); - color: #fff; - outline: none; -} - -.toast, -.app-toast { - position: fixed; - right: max(12px, env(safe-area-inset-right, 0px)); - bottom: max(12px, env(safe-area-inset-bottom, 0px)); - left: auto; - top: auto; - margin: 0; - padding: 8px 12px; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - background: #0f172a; - color: #e2e8f0; - font-size: 12.5px; - font-weight: 500; - line-height: 1.35; - z-index: 10050; - width: max-content; - max-width: min(280px, calc(100vw - 24px)); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.45); - pointer-events: none; - word-break: break-word; -} - -.toast--ok, -.app-toast--ok { - border-color: rgba(74, 222, 128, 0.35); - background: rgba(20, 83, 45, 0.92); - color: #bbf7d0; -} - -.toast--error, -.app-toast--error { - border-color: rgba(248, 113, 113, 0.4); - background: rgba(127, 29, 29, 0.92); - color: #fecaca; -} - -.inline-spinner { - display: inline-block; - width: 14px; - height: 14px; - margin-right: 8px; - border: 2px solid rgba(255, 255, 255, 0.22); - border-top-color: rgba(255, 255, 255, 0.85); - border-radius: 50%; - vertical-align: -2px; - animation: inline-spin 0.65s linear infinite; -} - -@keyframes inline-spin { - to { - transform: rotate(360deg); - } -} - -.conn-pill--warn { - border-color: rgba(251, 191, 36, 0.4); - color: #fde68a; - background: rgba(245, 158, 11, 0.14); -} - -.app-main .inline-spinner { - border-color: rgba(21, 101, 192, 0.2); - border-top-color: var(--nm-accent, #3b82f6); -} - -.skeleton-line { - height: 32px; - border-radius: 8px; - margin-bottom: 10px; - background: linear-gradient(90deg, #14253e 25%, #1d3558 50%, #14253e 75%); - background-size: 200% 100%; - animation: shimmer 1.4s infinite; -} - -.mini-chart { - margin-top: 10px; -} - -.mini-chart__row { - display: grid; - grid-template-columns: 70px 1fr 36px; - gap: 8px; - align-items: center; - margin-bottom: 8px; -} - -.mini-chart__label, -.mini-chart__value { - font-size: 12px; - color: #a8bad5; -} - -.card-title-with-hint { - display: flex; - align-items: center; - gap: 8px; - margin: 0; -} - -.help-hint { - position: relative; - display: inline-flex; - vertical-align: middle; -} - -.app-main .help-hint__trigger, -.help-hint__trigger { - width: auto; - height: auto; - min-width: 0; - padding: 0 2px; - border: none; - border-radius: 0; - background: transparent; - box-shadow: none; - color: #64748b; - font-size: 13px; - font-weight: 700; - line-height: 1; - cursor: pointer; -} - -.app-main .help-hint__trigger:hover, -.app-main .help-hint__trigger:focus-visible, -.help-hint__trigger:hover, -.help-hint__trigger:focus-visible { - border: none; - background: transparent; - box-shadow: none; - color: var(--nm-accent, #3b82f6); - outline: none; -} - -.help-hint__popover { - position: absolute; - top: calc(100% + 6px); - left: 0; - z-index: 40; - width: min(360px, 80vw); - padding: 10px 12px; - border: 1px solid var(--nm-ops-line, #2b3748); - border-radius: 6px; - background: #0c1521; - color: #d7e3f2; - font-size: 12px; - font-weight: 400; - line-height: 1.5; - white-space: pre-line; - box-shadow: none; - overflow: auto; -} - -.help-hint--align-end .help-hint__popover { - left: auto; - right: 0; -} - -.help-hint__popover--portal { - z-index: 2000; - width: auto; -} - -.help-hint__popover--structured { - width: min(420px, 86vw); - white-space: normal; - padding: 12px 14px; -} - -.help-hint__popover--nowrap { - width: max-content; - max-width: min(90vw, 720px); - white-space: nowrap; -} - -.help-hint__body { - display: flex; - flex-direction: column; - gap: 10px; - min-width: 0; -} - -.help-hint__plain { - white-space: pre-line; -} - -.help-hint__section-title { - margin: 2px 0 0; - padding-bottom: 5px; - border-bottom: 1px solid var(--nm-ops-line, #2b3748); - color: var(--nm-ops-accent, #6ba9ec); - font-size: 11px; - font-weight: 650; - letter-spacing: 0.06em; - text-transform: uppercase; - line-height: 1.35; -} - -.help-hint__section-title:first-child { - margin-top: 0; -} - -.help-hint__list { - margin: 0; - padding: 0 0 0 1.05rem; - display: flex; - flex-direction: column; - gap: 5px; - color: var(--nm-ops-muted, #9aa9bc); -} - -.help-hint__list > li { - padding-left: 2px; - line-height: 1.45; -} - -.help-hint__list > li::marker { - color: #526f94; -} - -.help-hint__para { - margin: 0; - color: var(--nm-ops-muted, #9aa9bc); - line-height: 1.5; -} - -.help-hint__code { - display: inline; - padding: 0 4px; - border: 1px solid rgba(43, 55, 72, 0.9); - border-radius: 3px; - background: #111c2a; - color: #c4ddfa; - font: 11px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; - white-space: nowrap; -} - -.panel__toolbar-end { - margin-left: auto; - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.stat-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 12px; -} - -.stat-card { - border: 1px solid var(--border, rgba(148, 163, 184, 0.22)); - border-radius: 8px; - padding: 12px 14px; - background: var(--panel-bg, rgba(15, 23, 42, 0.72)); - color: var(--nm-text, #e2e8f0); -} - -.stat-card strong { - display: block; - margin-top: 4px; - font-size: 1.05rem; -} - -.btn-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; -} - -.btn-primary { - background: #2563eb; - color: #fff; - border-color: #1d4ed8; -} - -.btn-primary:disabled { - opacity: 0.55; -} - -.data-table { - width: 100%; -} - -.config-sync-policy-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px 16px; -} - -.config-sync-policy-check { - display: inline-flex; - align-items: center; - gap: 6px; - margin: 0; - font-size: 13px; - color: #cbd5e1; - white-space: nowrap; - cursor: pointer; -} - -.config-sync-policy-check input { - width: 14px; - height: 14px; - margin: 0; - flex-shrink: 0; -} - -.config-sync-policy-field { - display: inline-flex; - align-items: center; - gap: 6px; - margin: 0; - font-size: 13px; - color: #cbd5e1; - white-space: nowrap; -} - -.config-sync-policy-field input[type="number"] { - width: 64px; - margin: 0; -} - -.config-sync-policy-field select { - width: auto; - min-width: 110px; - margin: 0; -} - -.ne-stats-card { - padding: 12px 14px; -} - -.ne-stats-card__header { - display: flex; - align-items: center; - gap: 12px; - justify-content: space-between; - margin-bottom: 10px; -} - -.ne-stats-card__header-left { - display: flex; - align-items: center; - gap: 10px; -} - -.ne-stats-card__header-actions { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.ne-stats-card__title { - font-size: 15px; - font-weight: 600; - color: #e2e8f0; -} - -.ne-stats-pill { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 9px; - border-radius: 999px; - font-size: 12px; - font-weight: 500; -} - -.ne-stats-pill--pass { background: rgba(34, 197, 94, 0.16); color: #86efac; } -.ne-stats-pill--fail { background: rgba(239, 68, 68, 0.16); color: #fca5a5; } -.ne-stats-pill--testing{ background: rgba(245, 158, 11, 0.16); color: #fde68a; } -.ne-stats-pill--unknown{ background: rgba(148, 163, 184, 0.14); color: #94a3b8; } - -.ne-stats-card__actions { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.ne-stats-card__tag-select { - height: 32px; - font-size: 13px; - padding: 0 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - color: #e2e8f0; - min-width: 150px; -} - -.ne-stats-card__loading { - color: #94a3b8; - font-size: 13px; - margin: 0; -} - -.ne-stats-card__tag-cards { - width: 100%; - display: grid; - grid-template-columns: repeat(4, minmax(190px, 1fr)); - gap: 10px; -} - -.ne-tag-card { - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 8px; - background: rgba(15, 23, 42, 0.72); - padding: 10px 12px; -} - -.ne-tag-card__title { - font-size: 13px; - font-weight: 650; - color: #e2e8f0; - margin-bottom: 4px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ne-tag-card__total { - font-size: 12px; - color: #94a3b8; - margin-bottom: 8px; -} - -.ne-tag-card__pills { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -/* Managed NE / UME / System pages — light content on shared chrome tokens */ -.ne-page, -.ume-page, -.system-page { - margin: -20px -28px -32px; - padding: 20px 22px 28px; - min-height: calc(100vh - 48px); - background: - radial-gradient(1200px 480px at 0% 0%, rgba(59, 130, 246, 0.07), transparent 55%), - var(--nm-main, var(--nm-chrome)); - gap: 16px; -} - -/* Already inside network/audit shell — don't re-bleed or force full-viewport height - (that left a huge empty panel void under short lists like 任务概览). */ -.network-main .ne-page, -.network-main .ume-page, -.network-main .system-page { - margin: 0; - padding: 0; - min-height: 0; - background: transparent; -} - -.ne-page > .panel, -.ume-page > .panel, -.ume-page > .cards > .panel, -.ume-page > .cards > .card, -.system-page > .panel { - padding: 18px 20px 20px; - border: 1px solid var(--nm-ops-line); - border-radius: 6px; - background: #0c1521; - box-shadow: none; -} - -.ume-page > .cards { - display: flex; - flex-direction: column; - gap: 16px; - margin: 0; - grid-template-columns: none; -} - -.ne-page .panel__toolbar, -.ume-page .panel__toolbar, -.system-page .panel__toolbar { - margin-bottom: 14px; - padding-bottom: 12px; - border-bottom: 1px solid var(--nm-ops-line); -} - -.ne-page .button, -.ume-page .button, -.system-page .button, -.ne-page .input, -.ume-page .input, -.system-page .input, -.ne-page .ui-field__select, -.ume-page .ui-field__select, -.system-page .ui-field__select { - border-radius: 4px; - box-shadow: none; -} - -.ne-page .panel__toolbar h2, -.ume-page .panel__toolbar h2, -.system-page .panel__toolbar h2, -.ume-page .panel h2, -.ume-page .panel > h3, -.ume-page .card > h3, -.ume-page .card-title-with-hint { - font-size: 17px; - font-weight: 650; - color: var(--nm-brand); - letter-spacing: -0.02em; - margin: 0; -} - -.system-page .panel__hint { - margin: 0 0 10px; - color: var(--nm-ops-muted, var(--nm-muted)); - font-size: 12px; -} - -.ume-page .panel > h3, -.ume-page .card > h3, -.ume-page .card > .panel__toolbar { - margin-bottom: 14px; - padding-bottom: 12px; - border-bottom: 1px solid var(--nm-ops-line); -} - -.ume-page .card__section-title { - margin: 16px 0 10px; - padding-top: 12px; - border-top: 1px solid var(--nm-ops-line); - font-size: 14px; - font-weight: 650; - color: var(--nm-brand); -} - -.ume-page .ume-status-row { - gap: 8px; - flex-wrap: wrap; - align-items: center; -} - -.ume-conn-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px 28px; - align-items: start; -} - -.ume-conn-col { - display: flex; - flex-direction: column; - gap: 10px; - min-width: 0; -} - -.ume-conn-col .panel__toolbar { - margin-bottom: 0; - padding-bottom: 10px; -} - -.ume-conn-col + .ume-conn-col { - padding-left: 28px; - border-left: 1px solid var(--nm-ops-line); -} - -.ume-conn-panel .ume-ws-log { - margin-top: 16px; - padding-top: 14px; - border-top: 1px solid var(--nm-ops-line); -} - -@media (max-width: 960px) { - .ume-conn-grid { - grid-template-columns: 1fr; - gap: 18px; - } - - .ume-conn-col + .ume-conn-col { - padding-left: 0; - padding-top: 14px; - border-left: 0; - border-top: 1px solid var(--nm-ops-line); - } -} - -.ne-page .pt-list-table-wrap, -.ume-page .pt-list-table-wrap, -.system-page .pt-list-table-wrap { - border: 1px solid var(--nm-ops-line); - border-radius: 4px; - overflow: hidden; - background: rgba(8, 13, 24, 0.28); -} - -.ne-page .pt-list-table thead th, -.ume-page .pt-list-table thead th, -.system-page .pt-list-table thead th, -.ne-page .data-table thead th, -.ume-page .data-table thead th, -.system-page .data-table thead th { - background: #162232; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; - text-transform: uppercase; -} - -.ne-page .pt-list-table tbody td, -.ume-page .pt-list-table tbody td, -.system-page .pt-list-table tbody td { - font-size: 12px; - border-bottom-color: var(--nm-ops-line); -} - -.ume-page .ume-ws-log { - margin-top: 14px; - display: flex; - flex-direction: column; - gap: 8px; -} - -.ume-page .ume-ws-log__head { - display: flex; - flex-wrap: wrap; - align-items: baseline; - justify-content: space-between; - gap: 8px 12px; -} - -.ume-page .ume-ws-log__head strong { - font-size: 13px; - color: var(--nm-brand); -} - -.ume-page .ume-ws-log__head .muted { - font-size: 12px; -} - -.ume-page .ws-log-panel { - margin-top: 0; - max-height: 180px; - border: 1px solid var(--nm-ops-line); - border-radius: 4px; - background: rgba(8, 13, 24, 0.55); -} - -.ume-page .error-text { - color: #fca5a5; -} - -.ne-page .filter-inline, -.ume-page .filter-inline, -.system-page .filter-inline { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - margin-bottom: 0; - padding: 10px 12px; - border: 1px solid var(--nm-ops-line); - border-radius: 4px; - background: rgba(8, 13, 24, 0.45); - grid-template-columns: none; -} - -.ne-page .filter-inline input, -.ne-page .filter-inline > select, -.ume-page .filter-inline input, -.ume-page .filter-inline > select, -.system-page .filter-inline input, -.system-page .filter-inline > select { - width: auto; - min-width: 140px; - flex: 1 1 160px; - max-width: 280px; - height: 32px; - margin: 0; - border: 1px solid var(--nm-ops-line); - border-radius: 4px; - background: #0b1220; - color: #e2e8f0; - padding: 0 10px; - font-size: 13px; -} - -.ne-page .filter-inline > .ui-field, -.ume-page .filter-inline > .ui-field, -.system-page .filter-inline > .ui-field { - flex: 1 1 160px; - max-width: 280px; - min-width: 140px; - align-self: center; - gap: 0; -} - -.ne-page .filter-inline > .ui-field > .ui-field__select, -.ume-page .filter-inline > .ui-field > .ui-field__select, -.system-page .filter-inline > .ui-field > .ui-field__select { - width: 100%; - height: 32px; - min-height: 32px; - max-height: 32px; - flex: none; - margin: 0; - border: 1px solid var(--nm-ops-line); - border-radius: 4px; - background: #0b1220; - color: #e2e8f0; - padding: 0 10px; - font-size: 13px; -} - -.ne-page .filter-inline button, -.ume-page .filter-inline button, -.system-page .filter-inline button { - height: 32px; - flex: 0 0 auto; -} - -.ne-page .pt-list, -.ume-page .pt-list, -.system-page .pt-list { - gap: 12px; -} - -.ne-page .pt-list-table-wrap, -.ume-page .pt-list-table-wrap, -.system-page .pt-list-table-wrap { - border-color: rgba(148, 163, 184, 0.2); -} - -.ne-page .pt-list-table thead th, -.ume-page .pt-list-table thead th, -.system-page .pt-list-table thead th { - background: rgba(15, 23, 42, 0.9); - color: #94a3b8; -} - -.ne-page .pager.pt-list-pager, -.ne-page .pt-list-pager, -.ume-page .pager.pt-list-pager, -.ume-page .pt-list-pager, -.ume-page .pager, -.system-page .pager.pt-list-pager, -.system-page .pt-list-pager { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-top: 2px; -} - -.ne-page .pt-list-pager select, -.ume-page .pt-list-pager select, -.ume-page .pager select, -.system-page .pt-list-pager select { - width: auto; - min-width: 110px; - height: 32px; - margin: 0; -} - -.system-page .pt-list-actions select, -.system-page .pt-list-actions input { - width: auto; - min-width: 0; - height: 30px; - margin: 0; - flex: 0 0 auto; -} - -.system-page .token-once { - padding: 12px 14px; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 8px; - background: rgba(15, 23, 42, 0.85); - color: #e2e8f0; -} - -.system-page .token-once .panel__hint { - color: #94a3b8; -} - -.system-page .token-once code { - display: block; - margin: 8px 0; - word-break: break-all; - font-size: 12px; - padding: 8px 10px; - border-radius: 6px; - border: 1px solid rgba(148, 163, 184, 0.2); - background: #0b1220; - color: #93c5fd; -} - -.system-page .token-create { - display: flex; - flex-direction: column; - gap: 12px; -} - -.system-page .token-scopes { - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px 14px; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 8px; - background: rgba(15, 23, 42, 0.85); - color: #e2e8f0; -} - -.system-page .token-scopes__head { - display: flex; - flex-direction: column; - gap: 4px; -} - -.system-page .token-scopes__presets { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.system-page .token-scopes__inherit { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 13px; - color: #cbd5e1; -} - -.system-page .token-scopes__grid { - display: flex; - flex-wrap: wrap; - gap: 8px 16px; -} - -.system-page .token-scopes__grid.is-disabled { - opacity: 0.45; -} - -.system-page .token-scopes__grid label { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: #cbd5e1; - white-space: nowrap; -} - -.system-page .token-scopes-cell { - max-width: 280px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - color: #94a3b8; -} - -.token-page__title { - display: inline-flex; - align-items: center; - gap: 8px; - margin: 0; -} - -.help-q { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 999px; - border: 1px solid #94a3b8; - color: #64748b; - font-size: 12px; - font-weight: 600; - line-height: 1; - cursor: help; - user-select: none; - flex: 0 0 auto; -} - -.help-q:hover, -.help-q:focus-visible { - border-color: #64748b; - color: #334155; - outline: none; -} - -.help-q__tip { - display: none; - position: absolute; - left: 0; - top: calc(100% + 8px); - z-index: 90; - width: max-content; - max-width: min(360px, 70vw); - padding: 8px 10px; - border-radius: 6px; - background: #0f172a; - color: #f8fafc; - font-size: 12px; - font-weight: 400; - line-height: 1.45; - white-space: normal; - text-align: left; - box-shadow: 0 6px 16px rgba(15, 23, 42, 0.22); - pointer-events: none; -} - -.help-q__tip::after { - content: ""; - position: absolute; - bottom: 100%; - left: 8px; - border: 5px solid transparent; - border-bottom-color: #0f172a; -} - -.help-q:hover .help-q__tip, -.help-q:focus-visible .help-q__tip { - display: block; -} - -.token-scopes-modal { - position: fixed; - inset: 0; - z-index: 80; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; -} - -.token-scopes-modal__backdrop { - position: absolute; - inset: 0; - background: rgba(2, 8, 23, 0.72); -} - -.token-scopes-modal__panel { - position: relative; - z-index: 1; - width: min(480px, 100%); - max-height: min(80vh, 640px); - display: flex; - flex-direction: column; - gap: 10px; - padding: 16px 18px; - border-radius: 10px; - border: 1px solid rgba(148, 163, 184, 0.22); - background: #0f172a; - color: #e2e8f0; - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); -} - -.token-scopes-modal__head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.token-scopes-modal__head h3 { - margin: 0; - font-size: 16px; - font-weight: 650; - color: #f1f5f9; -} - -.token-scopes-modal__list { - list-style: none; - margin: 0; - padding: 0; - overflow: auto; - border: 1px solid rgba(148, 163, 184, 0.2); - border-radius: 8px; - background: rgba(2, 8, 23, 0.45); -} - -.token-scopes-modal__list > li + li { - border-top: 1px solid rgba(148, 163, 184, 0.14); -} - -.token-scopes-modal__row { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 12px; - margin: 0; - cursor: pointer; - font-size: 13px; - color: #cbd5e1; -} - -.token-scopes-modal__row:hover { - background: rgba(30, 41, 59, 0.65); -} - -.token-scopes-modal__row input { - margin-top: 2px; - flex: 0 0 auto; -} - -.token-scopes-modal__label { - line-height: 1.4; -} - -.token-scopes-modal__foot { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: flex-end; - padding-top: 4px; -} - - -.ume-page .pt-list-actions, -.ume-page .pt-list-table .btn-row { - gap: 6px; -} - -.ume-page .actions-row--inline { - margin-top: 10px; -} - -.ume-page .panel__toolbar + .actions-row--inline, -.ume-page .panel__toolbar + .pt-list { - margin-top: 0; -} - -.mini-chart__bar-wrap { - height: 10px; - border-radius: 999px; - background: #10213a; - border: 1px solid #223651; - overflow: hidden; -} - -.mini-chart__bar { - height: 100%; - background: linear-gradient(90deg, #3973d2, #5aa1ff); -} - -.pager { - margin-top: 12px; - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.pager__meta { - color: #9db2cf; - font-size: 12px; -} - -.pager__controls { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: nowrap; -} - -.pager__btn, -.pager__num { - min-width: 34px; - padding: 6px 10px; -} - -.pager__num.active { - border-color: #4f7ec7; - background: rgba(61, 130, 247, 0.24); - color: #dce8ff; -} - -.pager__size { - width: auto; - min-width: 88px; -} - -.pager__jump { - position: relative; - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 12px; -} - -.pager__jump-input { - width: 3.2rem; - min-width: 2.5rem; - max-width: 4.5rem; - padding: 4px 6px; - text-align: center; -} - -.visually-hidden { - position: absolute; - inset: 0; - width: 1px; - height: 1px; - padding: 0; - margin: 0; - overflow: hidden; - clip: rect(0, 0, 0, 0); - clip-path: inset(50%); - white-space: nowrap; - border: 0; -} - -@keyframes shimmer { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } -} - -.page-stack { - display: flex; - flex-direction: column; - gap: 12px; -} - -.page-stack > .cards { - margin-bottom: 0; -} - -.panel--warn { - border-color: rgba(251, 191, 36, 0.4); - background: rgba(120, 53, 15, 0.35); - color: #fde68a; -} - -.panel__toolbar { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 12px; -} - -.panel__toolbar h2, -.panel__toolbar h3 { - margin: 0; -} - -.panel__actions { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.btn--danger { - color: #fca5a5; - border-color: rgba(248, 113, 113, 0.35); - background: rgba(127, 29, 29, 0.35); -} - -.btn--danger:hover:not(:disabled) { - background: rgba(153, 27, 27, 0.5); - color: #fecaca; -} - -.btn--danger:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -.table-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.modal-backdrop { - position: fixed; - inset: 0; - z-index: 50; - background: rgba(2, 8, 23, 0.72); - display: flex; - align-items: center; - justify-content: center; - padding: 16px; -} - -.modal { - width: min(720px, 100%); - max-height: 90vh; - overflow: auto; - background: #0f172a; - color: #e2e8f0; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 10px; - padding: 20px; - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); -} - -.modal--wide { - width: min(920px, 100%); -} - -.connect-detail-summary { - margin-left: 8px; - color: #94a3b8; - font-size: 13px; -} - -.connect-log { - margin: 12px 0 0; - padding: 12px; - max-height: min(52vh, 480px); - overflow: auto; - background: #0f172a; - color: #e2e8f0; - border-radius: 8px; - font-size: 12px; - line-height: 1.45; - white-space: pre-wrap; - word-break: break-word; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - -.modal h3 { - margin: 0 0 16px; -} - -.modal__actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 16px; -} - -.cli-connect-panel { - width: 100%; - text-align: left; -} - -.cli-connect-panel .form-grid, -.cli-connect-panel .form-fieldset { - width: 100%; - max-width: none; -} - -.cli-connect-panel .form-grid label:not(.form-check) { - align-items: stretch; - text-align: left; -} - -.cli-connect-panel .form-label { - display: block; - margin: 0; - text-align: left; -} - -.cli-connect-panel .form-grid input, -.cli-connect-panel .form-grid select, -.cli-connect-panel .form-fieldset input, -.cli-connect-panel .form-fieldset select { - width: 100%; - margin: 0; - text-align: left; -} - -.cli-connect-panel .form-fieldset { - text-align: left; - width: 100%; - min-width: 0; - min-inline-size: 0; -} - -.cli-connect-panel .hop-proxy-fields, -.cli-connect-panel .hop-proxy-fields .form-grid { - width: 100%; -} - -.cli-connect-panel .hop-proxy-fields .form-grid label { - align-items: flex-start; - justify-content: flex-start; - text-align: left; - width: 100%; -} - -.cli-connect-panel .hop-proxy-fields .form-grid select, -.cli-connect-panel .hop-proxy-fields .form-grid input { - display: block; - width: 100%; - max-width: 100%; - margin: 0; - box-sizing: border-box; - text-align: left; - text-align-last: left; -} - -.cli-connect-panel .hop-proxy-fields .form-field-hint { - text-align: left; -} - -.cli-connect-panel .form-fieldset legend { - margin: 0 0 8px; - padding: 0 6px; - text-align: left; -} - -.cli-connect-panel .form-check { - display: flex; - flex-direction: row; - align-items: center; - width: fit-content; - max-width: 100%; -} - -.card .cli-connect-panel input, -.card .cli-connect-panel select, -.card .cli-connect-panel textarea { - margin-bottom: 0; -} - -.cli-connect-test { - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - min-width: 0; -} - -.cli-connect-test-row { - display: flex; - flex-wrap: nowrap; - gap: 8px; - align-items: center; - width: 100%; -} - -.cli-connect-test-pick { - position: relative; - flex: 1 1 auto; - min-width: 0; -} - -.cli-connect-test-pick > input, -.cli-connect-test-pick [data-slot="input"], -.cli-connect-test-pick .input { - width: 100%; - margin-bottom: 0; -} - -.cli-connect-test-suggest { - width: 100%; - margin: 0; - padding: 4px; - border: 1px solid rgba(56, 189, 248, 0.28); - border-radius: 6px; - background: rgba(2, 8, 23, 0.92); - box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.55); - overflow: visible; -} - -.cli-connect-test-suggest__item { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - width: 100%; - margin: 0; - padding: 8px 10px; - border: 0; - border-radius: 4px; - background: transparent; - text-align: left; - cursor: pointer; - color: inherit; -} - -.cli-connect-test-suggest__item + .cli-connect-test-suggest__item { - margin-top: 2px; -} - -.cli-connect-test-suggest__item:hover, -.cli-connect-test-suggest__item.is-active { - background: rgba(56, 189, 248, 0.12); -} - -.cli-connect-test-suggest__name { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 13px; - font-weight: 650; - color: #e2e8f0; -} - -.cli-connect-test-suggest__meta { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 11px; - color: #94a3b8; - font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; -} - -.cli-connect-test-suggest__empty { - padding: 10px 12px; - font-size: 12px; - color: #64748b; -} - -.form-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.form-hint { - margin: 0 0 12px; - font-size: 12px; - color: #64748b; -} - -.form-label__required { - color: #c62828; - font-weight: 600; -} - -.form-label__optional { - color: #94a3b8; - font-weight: 400; -} - -.form-field-hint { - font-size: 11px; - color: #94a3b8; - line-height: 1.35; -} - -.form-label { - display: block; - margin: 0; - font-size: 12px; - color: #94a3b8; - text-align: left; -} - -.form-grid label { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 12px; - color: #94a3b8; -} - -.form-grid input, -.form-grid select { - font-size: 14px; -} - -.form-grid__full { - grid-column: 1 / -1; -} - -.form-fieldset { - margin-top: 16px; - padding: 12px; - border: 1px solid var(--nm-panel-border, #1e293b); - border-radius: 8px; -} - -.form-fieldset legend, -.form-fieldset__title { - padding: 0 6px; - font-size: 13px; - font-weight: 600; - color: var(--nm-text, #e2e8f0); -} - -.form-fieldset__title { - display: block; - margin: 0 0 10px; - padding: 0; - text-align: left; -} - -.form-check { - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; - margin-bottom: 12px; - font-size: 13px; - color: #cbd5e1; - width: fit-content; - max-width: 100%; -} - -.form-check__text { - white-space: nowrap; - line-height: 1.4; -} - -/* form-grid labels are column layout; checkbox rows must stay horizontal */ -.form-grid label.form-check { - flex-direction: row; - align-items: center; - width: fit-content; - max-width: 100%; -} - -.table-tag { - display: inline-block; - margin-left: 6px; - padding: 1px 6px; - border-radius: 4px; - font-size: 11px; - background: rgba(56, 189, 248, 0.14); - color: #7dd3fc; - border: 1px solid rgba(56, 189, 248, 0.22); -} - -.form-grid--single { - grid-template-columns: 1fr; -} - -/* Status / empty-state copy only — conceptual docs go in HelpHint on the title. */ -.panel__hint { - margin: 0 0 10px; - font-size: 12px; - color: var(--nm-ops-muted, #9aa9bc); - line-height: 1.4; -} - -.panel__hint--live { - color: var(--nm-ops-accent, #6ba9ec); - border: 1px solid var(--nm-ops-line, #2b3748); - border-radius: 4px; - padding: 8px 10px; - background: rgba(107, 169, 236, 0.06); -} - -.collect-commands { - width: 100%; - min-height: 160px; - font-family: ui-monospace, Consolas, monospace; - font-size: 13px; - line-height: 1.4; - padding: 10px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - color: #e2e8f0; - resize: vertical; -} - -.collect-meta { - font-size: 13px; - color: #94a3b8; - margin-right: auto; -} - -.collect-create-panel__head { - margin-bottom: 0; -} - -.collect-create-panel--collapsed { - padding-bottom: 16px; -} - -.collect-create-panel h3 { - margin: 0; - font-size: 15px; - font-weight: 600; - color: #f1f5f9; -} - -.collect-selected-block, -.collect-pick-block { - margin-top: 20px; - padding-top: 16px; - border-top: 1px solid rgba(148, 163, 184, 0.18); -} - -.collect-selected-block__head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px 16px; - margin-bottom: 10px; -} - -.collect-selected-list { - display: flex; - flex-direction: column; - gap: 8px; - max-height: 220px; - overflow: auto; - padding: 4px 0; -} - -.collect-selected-chip { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 12px; - background: rgba(15, 23, 42, 0.85); - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 8px; - color: #e2e8f0; -} - -.collect-selected-chip__main { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.collect-selected-chip__meta { - font-size: 12px; - color: #94a3b8; -} - -.collect-pick-filters { - margin-bottom: 12px; -} - -.collect-cmd-preview { - font-size: 12px; - background: rgba(2, 8, 23, 0.55); - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; - padding: 10px; - max-height: 120px; - overflow: auto; - white-space: pre-wrap; - color: #cbd5e1; - font-family: ui-monospace, Consolas, monospace; -} - -.collect-cmd-preview--modal { - flex: 0 0 auto; - margin: 0 0 10px; - max-height: 88px; -} - -.collect-create-modal .collect-selected-block, -.collect-create-modal .collect-pick-block { - margin-top: 16px; -} - -.collect-create-modal .panel__toolbar { - margin-bottom: 10px; - padding-bottom: 8px; -} - -.collect-runs-panel { - display: flex; - flex-direction: column; - gap: 10px; -} - -.collect-runs-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px; -} - -.collect-runs-filter { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - color: #94a3b8; -} - -.collect-runs-filter select { - min-width: 140px; - padding: 6px 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - color: #e2e8f0; -} - -.collect-runs-filter input { - min-width: 180px; - padding: 6px 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - color: #e2e8f0; -} - -.collect-run-message { - max-width: 420px; - max-height: 160px; - overflow: auto; - white-space: pre-wrap; - word-break: break-word; - font-size: 12px; - line-height: 1.45; - color: #cbd5e1; - padding: 6px 8px; - background: rgba(2, 8, 23, 0.55); - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; -} - -.webcrt-page { - min-height: calc(100vh - 120px); -} - -.webcrt-session-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 8px; - font-size: 13px; - color: #334155; -} - -.webcrt-term-panel { - padding: 0; - overflow: hidden; - min-height: 420px; - flex: 1; -} - -.webcrt-term { - width: 100%; - height: 100%; - min-height: 240px; - background: #0b1220; - padding: 8px; - box-sizing: border-box; -} - -.webcrt-term .xterm { - height: 100%; -} - -.webcrt-term .xterm-viewport { - overflow-y: auto !important; -} - -/* WebCRT — light sidebar + main (shared chrome tokens) */ -.webcrt-shell { - display: grid; - grid-template-columns: 280px 1fr; - height: calc(100vh - 48px); - min-height: 480px; - margin: -20px -28px -32px; - background: var(--nm-chrome); - border: 0; - overflow: hidden; -} - -.webcrt-sidebar { - display: flex; - flex-direction: column; - background: #0a121c; - border-right: 1px solid var(--nm-ops-line, var(--nm-nav-border)); - min-width: 0; - min-height: 0; - overflow: hidden; - height: 100%; - color: var(--nm-text); - box-shadow: none; -} - -.webcrt-sidebar__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 10px 12px; - background: transparent; - border-bottom: 1px solid rgba(148, 163, 184, 0.18); - flex-shrink: 0; -} - -.webcrt-sidebar__title { - font-size: 13px; - font-weight: 650; - color: var(--nm-brand); - line-height: 1.2; - letter-spacing: -0.01em; -} - -.webcrt-sidebar__chrome { - display: flex; - flex-direction: column; - gap: 8px; - padding: 8px 10px; - background: rgba(15, 23, 42, 0.55); - border-bottom: 1px solid rgba(148, 163, 184, 0.18); - flex-shrink: 0; -} - -.webcrt-sidebar__actions { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-height: 28px; - user-select: none; -} - -.webcrt-sidebar__new-btn { - display: inline-flex; - align-items: center; - justify-content: center; - height: 28px; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - color: var(--nm-brand); - font-size: 12px; - font-weight: 650; - line-height: 1; - cursor: pointer; - box-shadow: none; -} - -.webcrt-sidebar__new-btn:hover { - background: transparent; - color: var(--nm-accent); - text-decoration: underline; -} - -.app-main .webcrt-sidebar__new-btn { - border: 0; - box-shadow: none; - height: 28px; - border-radius: 0; - background: transparent; -} - -.webcrt-menubar__item { - position: relative; - flex-shrink: 0; -} - -.webcrt-menubar__btn { - display: inline-flex; - align-items: center; - height: 28px; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - color: #475569; - font-size: 12px; - font-weight: 600; - line-height: 1; - cursor: pointer; - box-shadow: none; -} - -.webcrt-menubar__btn:hover, -.webcrt-menubar__btn.is-open { - background: transparent; - color: var(--nm-brand); - text-decoration: underline; -} - -.app-main .webcrt-menubar__btn { - border: 0; - box-shadow: none; - height: 28px; - border-radius: 0; - background: transparent; -} - -.webcrt-menubar__menu { - position: absolute; - top: calc(100% + 2px); - left: 0; - z-index: 40; - min-width: 180px; - padding: 4px 0; - background: #0f172a; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 4px; - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4); - color: #e2e8f0; -} - -.webcrt-menubar__menu--end { - left: auto; - right: 0; -} - -.webcrt-menubar__menu-item { - display: block; - width: 100%; - padding: 7px 14px; - border: 0; - background: transparent; - color: #e2e8f0; - font-size: 12px; - text-align: left; - cursor: pointer; - box-shadow: none; - border-radius: 0; -} - -.webcrt-menubar__menu-item:hover { - background: rgba(37, 99, 235, 0.2); - color: #f1f5f9; -} - -.app-main .webcrt-menubar__menu-item { - border: 0; - box-shadow: none; - border-radius: 0; - height: auto; -} - -.webcrt-session-opts { - display: grid; - gap: 14px; -} - -.webcrt-session-opts__field { - display: grid; - gap: 6px; - font-size: 13px; - color: #334155; - font-weight: 600; -} - -.webcrt-session-opts__field--check { - grid-template-columns: 1fr auto; - align-items: center; - gap: 12px; -} - -.webcrt-session-opts__field--check input[type="checkbox"] { - width: 16px; - height: 16px; - margin: 0; -} - -.webcrt-session-opts__field select { - height: 34px; - padding: 0 10px; - border: 1px solid #b8c4d2; - border-radius: 6px; - background: #fff; - font-size: 13px; - font-weight: 500; - color: #0f172a; -} - -.webcrt-session-opts__hint { - margin: 12px 0 0; -} - -.webcrt-session-opts__colors { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - align-items: end; -} - -.webcrt-session-opts__color input[type="color"] { - width: 100%; - height: 34px; - padding: 2px; - border: 1px solid #b8c4d2; - border-radius: 6px; - background: #fff; - cursor: pointer; -} - -.webcrt-session-opts__preview { - grid-column: 1 / -1; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - border: 1px solid #c5d0de; - border-radius: 6px; - font-family: Consolas, "Courier New", monospace; - font-size: 13px; - font-weight: 600; -} - -.webcrt-session-opts-modal { - width: min(460px, 100%); -} - -.webcrt-keyword-modal { - width: min(520px, 100%); -} - -.webcrt-keyword-add { - display: grid; - grid-template-columns: 1fr auto auto; - gap: 8px; - align-items: end; - margin: 12px 0; -} - -.webcrt-keyword-add__regex { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: #94a3b8; - padding-bottom: 8px; - white-space: nowrap; -} - -.webcrt-keyword-list-wrap { - display: grid; - grid-template-columns: 1fr auto; - grid-template-rows: auto 1fr; - gap: 6px 8px; - margin-top: 4px; -} - -.webcrt-keyword-list-head { - grid-column: 1; - display: grid; - grid-template-columns: 1fr 48px; - gap: 8px; - padding: 0 8px; - font-size: 11px; - font-weight: 650; - color: #94a3b8; -} - -.webcrt-keyword-list { - grid-column: 1; - list-style: none; - margin: 0; - padding: 0; - min-height: 160px; - max-height: 220px; - overflow: auto; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; - background: rgba(2, 8, 23, 0.55); -} - -.webcrt-keyword-list__empty { - padding: 16px; - font-size: 12px; - color: #94a3b8; - text-align: center; -} - -.webcrt-keyword-list__row { - display: grid; - grid-template-columns: 1fr 48px; - gap: 8px; - width: 100%; - padding: 7px 8px; - border: 0; - border-bottom: 1px solid rgba(148, 163, 184, 0.12); - background: transparent; - color: #e2e8f0; - font-size: 12px; - text-align: left; - cursor: pointer; - border-radius: 0; - box-shadow: none; -} - -.webcrt-keyword-list__row:hover { - background: rgba(30, 41, 59, 0.75); -} - -.webcrt-keyword-list__row.is-selected { - background: rgba(37, 99, 235, 0.22); - color: #93c5fd; -} - -.app-main .webcrt-keyword-list__row { - border-radius: 0; - box-shadow: none; - height: auto; -} - -.webcrt-keyword-list__pattern { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.webcrt-keyword-list-actions { - grid-column: 2; - grid-row: 2; - display: flex; - flex-direction: column; - gap: 4px; -} - -.webcrt-keyword-list-actions button { - width: 32px; - height: 28px; - padding: 0; - font-size: 14px; - line-height: 1; -} - -.webcrt-keyword-footer { - display: grid; - grid-template-columns: 1fr auto; - gap: 16px; - align-items: end; - margin-top: 14px; -} - -.webcrt-keyword-color input[type="color"] { - width: 48px; - height: 32px; - padding: 2px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - cursor: pointer; -} - -.webcrt-sidebar__toolbar { - display: none; -} - -.webcrt-sidebar__chrome .webcrt-sidebar__search { - padding: 0; - border-bottom: 0; - flex-shrink: 0; -} - -.webcrt-sidebar__search input { - width: 100%; - box-sizing: border-box; - height: 30px; - padding: 0 10px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - color: #e2e8f0; - font-size: 12px; -} - -.webcrt-sidebar__search input::placeholder { - color: var(--nm-muted); -} - -.webcrt-sidebar__chrome .webcrt-source-tabs { - margin: 0; -} - -.webcrt-source-tabs { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 2px; - margin: 0; - padding: 2px; - background: rgba(2, 8, 23, 0.55); - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; - flex-shrink: 0; -} - -.webcrt-source-tabs--4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); -} - -.webcrt-new-session-modal, -.webcrt-auth-modal { - width: min(420px, calc(100vw - 32px)); - max-height: calc(100vh - 48px); - overflow: auto; -} - -.webcrt-auth-error { - color: #b91c1c; - margin-top: -4px; -} - -.webcrt-source-tabs__btn { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 0; - height: 26px; - padding: 0 2px; - border: 0; - border-radius: 4px; - background: transparent; - color: var(--nm-muted); - font-size: 11px; - font-weight: 600; - line-height: 1; - cursor: pointer; - box-shadow: none; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.app-main .webcrt-source-tabs__btn, -.app-main .webcrt-sidebar__footer button, -.app-main .webcrt-tree__item, -.app-main .webcrt-action-btn { - border-radius: 6px; -} - -.app-main .webcrt-source-tabs__btn, -.app-main .webcrt-sidebar__footer button { - border: 0; - background: transparent; - box-shadow: none; - height: 28px; - padding: 0 6px; -} - -.app-main .webcrt-source-tabs__btn.is-active, -.app-main .webcrt-sidebar__footer button.is-active { - background: rgba(30, 41, 59, 0.95); - color: #93c5fd; - box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.4); -} - -.app-main .webcrt-tree__item { - border: 0; - border-left: 2px solid transparent; - border-radius: 6px; - background: transparent; - box-shadow: none; - padding: 7px 10px; -} - -.app-main .webcrt-tree__item.is-active, -.app-main .webcrt-tree__item.is-selected { - background: var(--nm-nav-active); - border-left-color: var(--nm-accent); -} - -.app-main .webcrt-action-btn { - border: 1px solid rgba(148, 163, 184, 0.28); - background: #0b1220; - color: #e2e8f0; - height: 28px; - padding: 0 10px; -} - -.app-main .webcrt-action-btn:hover { - background: rgba(51, 65, 85, 0.75); - border-color: rgba(148, 163, 184, 0.4); - color: #f1f5f9; -} - -.app-main .webcrt-action-btn.is-active { - background: rgba(37, 99, 235, 0.22); - border-color: rgba(59, 130, 246, 0.45); - color: #93c5fd; -} - -.app-main .webcrt-action-btn.is-warn { - background: rgba(127, 29, 29, 0.35); - border-color: rgba(248, 113, 113, 0.4); - color: #fecaca; -} - -.webcrt-source-tabs__btn:last-child { - border-right: 0; -} - -.webcrt-source-tabs__btn.is-active { - background: rgba(30, 41, 59, 0.95); - color: #93c5fd; - box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.4); - font-weight: 650; -} - -.webcrt-tree { - flex: 1 1 auto; - overflow-x: hidden; - overflow-y: auto; - background: transparent; - min-height: 0; - overscroll-behavior: contain; - padding: 8px 10px; -} - -.webcrt-tree__folder { - padding: 6px 8px; - font-size: 12px; - font-weight: 650; - color: var(--nm-brand); -} - -.webcrt-tree__empty { - padding: 10px 12px; - font-size: 12px; - color: var(--nm-muted); -} - -.webcrt-tree__list { - list-style: none; - margin: 0; - padding: 0 0 8px; -} - -.webcrt-tree__item { - width: 100%; - display: flex; - align-items: flex-start; - gap: 6px; - padding: 7px 10px 7px 10px; - margin-bottom: 2px; - border: 0; - border-radius: 6px; - border-left: 2px solid transparent; - background: transparent; - text-align: left; - cursor: pointer; - font-size: 12px; -} - -.webcrt-tree__item:hover { - background: var(--nm-nav-hover); -} - -.webcrt-tree__item.is-selected, -.webcrt-tree__item.is-active { - background: var(--nm-nav-active); - border-left-color: var(--nm-accent); -} - -.webcrt-tree__icon { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - margin-top: 1px; - color: var(--nm-accent); - line-height: 1; -} - -.webcrt-tree__label { - display: flex; - flex-direction: column; - min-width: 0; -} - -.webcrt-tree__name { - color: var(--nm-brand); - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.webcrt-tree__item.is-active .webcrt-tree__name { - color: var(--nm-accent); -} - -.webcrt-tree__meta { - color: var(--nm-muted); - font-size: 11px; -} - -.webcrt-sidebar__pager { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - padding: 8px 10px; - border-top: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(15, 23, 42, 0.55); - font-size: 11px; - color: var(--nm-muted); - flex-shrink: 0; -} - -.webcrt-sidebar__pager-btns { - display: flex; - gap: 4px; -} - -.app-main .webcrt-sidebar__pager-btns button, -.webcrt-sidebar__pager-btns button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 24px; - padding: 0; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - color: #e2e8f0; - cursor: pointer; - line-height: 1; - font-size: 14px; -} - -.app-main .webcrt-sidebar__pager-btns button:disabled, -.webcrt-sidebar__pager-btns button:disabled { - opacity: 0.35; - cursor: not-allowed; -} - -.webcrt-sidebar__footer { - display: flex; - gap: 2px; - margin: 0 10px 10px; - padding: 2px; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; - background: rgba(2, 8, 23, 0.55); - flex-shrink: 0; -} - -.webcrt-sidebar__footer button { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 1; - height: 28px; - padding: 0 6px; - border: 0; - border-radius: 4px; - background: transparent; - color: var(--nm-muted); - font-size: 12px; - font-weight: 600; - line-height: 1; -} - -.webcrt-sidebar__footer button.is-active { - background: rgba(30, 41, 59, 0.95); - color: #93c5fd; - box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.4); - font-weight: 650; -} - -.webcrt-main { - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; - height: 100%; - overflow: hidden; - background: #0b1220; - border-left: 1px solid rgba(148, 163, 184, 0.18); - color: #e2e8f0; -} - -.webcrt-tabs { - display: flex; - gap: 4px; - overflow-x: auto; - padding: 6px 8px 0; - background: rgba(15, 23, 42, 0.92); - border-bottom: 1px solid rgba(148, 163, 184, 0.18); - min-height: 34px; - flex-shrink: 0; -} - -.webcrt-tabs__item { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border: 1px solid transparent; - border-bottom: 0; - border-radius: 6px 6px 0 0; - background: transparent; - color: #94a3b8; - font-size: 12px; - cursor: pointer; - white-space: nowrap; -} - -.webcrt-tabs__item:hover { - background: rgba(51, 65, 85, 0.55); - color: #e2e8f0; -} - -.webcrt-tabs__item.is-active { - background: #0b1220; - border-color: rgba(148, 163, 184, 0.22); - color: #93c5fd; - font-weight: 650; - box-shadow: 0 1px 0 #0b1220; -} - -.webcrt-tabs__item.is-dead { - color: #f87171; -} - -.webcrt-tabs__item.is-dead.is-active { - background: rgba(127, 29, 29, 0.35); - border-color: rgba(248, 113, 113, 0.35); - color: #fecaca; -} - -.webcrt-tabs__item.is-connecting { - color: #fbbf24; -} - -/* span close mark — avoid global `button` border/padding chrome */ -.webcrt-tabs__close { - display: inline; - margin: 0; - padding: 0; - border: none; - background: none; - box-shadow: none; - outline: none; - cursor: pointer; - font: inherit; - font-weight: inherit; - line-height: inherit; - color: inherit; - opacity: 0.4; -} - -.webcrt-tabs__close::before { - content: " "; -} - -.webcrt-tabs__item:hover .webcrt-tabs__close, -.webcrt-tabs__close:hover { - opacity: 0.75; -} - -.webcrt-tabs__close:focus-visible { - opacity: 1; - text-decoration: underline; -} - -.webcrt-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 8px 10px; - background: rgba(15, 23, 42, 0.92); - border-bottom: 1px solid rgba(148, 163, 184, 0.18); - flex-shrink: 0; -} - -.webcrt-action-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - height: 28px; - padding: 0 10px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - color: #e2e8f0; - font-size: 12px; - font-weight: 600; - line-height: 1; - cursor: pointer; -} - -.webcrt-action-btn:hover { - background: rgba(51, 65, 85, 0.75); - border-color: rgba(148, 163, 184, 0.4); - color: #f1f5f9; -} - -.webcrt-action-btn.is-active { - background: rgba(37, 99, 235, 0.22); - border-color: rgba(59, 130, 246, 0.45); - color: #93c5fd; -} - -.webcrt-action-btn.is-warn { - background: rgba(127, 29, 29, 0.35); - border-color: rgba(248, 113, 113, 0.4); - color: #fecaca; -} - -.webcrt-action-btn.is-warn:hover { - background: rgba(153, 27, 27, 0.5); - border-color: rgba(248, 113, 113, 0.55); -} - -.webcrt-connect-banner { - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; - padding: 8px 12px; - background: #1e3a5f; - border-bottom: 1px solid #274a73; - color: #dbeafe; - z-index: 2; -} - -.webcrt-connect-banner__text { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - font-size: 12px; -} - -.webcrt-connect-banner__text strong { - font-size: 13px; -} - -.webcrt-connect-banner__text span { - opacity: 0.85; -} - -.webcrt-disconnect-banner { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-shrink: 0; - padding: 8px 12px; - background: #7f1d1d; - border-bottom: 1px solid #991b1b; - color: #fee2e2; - z-index: 2; -} - -.webcrt-disconnect-banner__text { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - font-size: 12px; -} - -.webcrt-disconnect-banner__text strong { - font-size: 13px; -} - -.webcrt-disconnect-banner__text span { - opacity: 0.85; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.webcrt-disconnect-banner .webcrt-action-btn.is-warn { - background: rgba(15, 23, 42, 0.85); - border-color: rgba(248, 113, 113, 0.45); - color: #fecaca; - flex-shrink: 0; -} - -.webcrt-main__body { - flex: 1; - min-height: 0; - position: relative; - background: #0b1220; - display: flex; - flex-direction: row; - align-items: stretch; -} - -.webcrt-main__terms { - flex: 1; - min-width: 0; - min-height: 0; - position: relative; -} - -.webcrt-main__pane { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; -} - -.webcrt-main__pane[hidden] { - display: none !important; -} - -.webcrt-main__pane .webcrt-term, -.webcrt-main__pane .webcrt-term-wrap, -.webcrt-main__pane .webcrt-ssh-login { - flex: 1; - width: 100%; - height: auto !important; - min-height: 240px !important; -} - -.webcrt-ssh-login { - display: flex; - flex-direction: column; - min-height: 0; -} - -.webcrt-ssh-login__save { - flex: 0 0 auto; - padding: 6px 12px; - border-bottom: 1px solid rgba(148, 163, 184, 0.25); - color: #cbd5e1; - background: #0b1220; -} - -.webcrt-ssh-login .ssh-login-term { - flex: 1; - min-height: 200px !important; -} - -.webcrt-main__empty, -.webcrt-main__placeholder { - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 8px; - color: #cbd5e1; - background: #0b1220; - text-align: center; - padding: 24px; -} - -.webcrt-main__empty .panel__hint { - color: #94a3b8; -} - -.webcrt-main__placeholder--error { - color: #fecaca; -} - -.webcrt-error-detail { - margin: 8px 0 0; - max-width: 80%; - white-space: pre-wrap; - word-break: break-word; - font-size: 12px; - color: #fee2e2; - text-align: left; -} - -.webcrt-statusline { - display: none; -} - -.webcrt-term-wrap { - position: relative; - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - height: 100%; -} - -.webcrt-term-wrap .webcrt-term { - flex: 1; - min-height: 0; -} - -.webcrt-paste-status { - position: absolute; - top: 8px; - right: 10px; - z-index: 4; - padding: 4px 10px; - font-size: 12px; - color: #e2e8f0; - background: rgba(15, 23, 42, 0.92); - border: 1px solid #475569; - border-radius: 2px; - pointer-events: none; -} - -.webcrt-paste-bridge { - position: absolute; - inset: 0; - z-index: 8; - display: flex; - align-items: center; - justify-content: center; - background: rgba(2, 6, 23, 0.55); - padding: 16px; -} - -.webcrt-paste-bridge__card { - width: min(420px, 100%); - display: flex; - flex-direction: column; - gap: 10px; - padding: 14px; - border-radius: 10px; - background: #0f172a; - border: 1px solid #334155; - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); -} - -.webcrt-paste-bridge__hint { - margin: 0; - font-size: 13px; - line-height: 1.45; - color: #cbd5e1; -} - -.webcrt-paste-bridge__input { - width: 100%; - resize: vertical; - min-height: 88px; - padding: 8px 10px; - border-radius: 6px; - border: 1px solid #475569; - background: #020617; - color: #e2e8f0; - font: inherit; -} - -.webcrt-paste-bridge__actions { - display: flex; - gap: 8px; - justify-content: flex-end; -} - -.webcrt-paste-bridge__actions button { - padding: 6px 12px; - border-radius: 6px; - border: 1px solid #475569; - background: #1e293b; - color: #e2e8f0; - cursor: pointer; -} - -.webcrt-paste-bridge__actions button:hover { - background: #334155; -} - -.webcrt-find { - display: flex; - gap: 4px; - align-items: center; - padding: 4px 8px; - background: #1e293b; - border-bottom: 1px solid #334155; - z-index: 2; -} - -.webcrt-find input { - flex: 1; - min-width: 0; - background: #0f172a; - border: 1px solid #475569; - color: #e2e8f0; - padding: 2px 8px; - font-size: 12px; -} - -.webcrt-find button { - background: #334155; - border: 1px solid #475569; - color: #e2e8f0; - padding: 2px 8px; - cursor: pointer; -} - -.webcrt-ctx { - position: fixed; - z-index: 50; - min-width: 120px; - background: #1e293b; - border: 1px solid #475569; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); - display: flex; - flex-direction: column; - padding: 4px 0; -} - -.webcrt-ctx button { - background: transparent; - border: 0; - color: #e2e8f0; - text-align: left; - padding: 6px 14px; - cursor: pointer; - font-size: 12px; -} - -.webcrt-ctx button:hover { - background: #334155; -} - -.webcrt-ctx button:disabled { - opacity: 0.4; - cursor: default; -} - -.webcrt-ctx button:disabled:hover { - background: transparent; -} - -.webcrt-tab-ctx { - border-radius: 6px; - min-width: 140px; -} - -.webcrt-tree__row { - display: flex; - align-items: stretch; - gap: 0; -} - -.webcrt-tree__fav { - flex: 0 0 22px; - display: flex; - align-items: center; - justify-content: center; - background: transparent; - border: 0; - color: #94a3b8; - cursor: pointer; - padding: 0; -} - -.webcrt-tree__fav:hover { - color: #fbbf24; -} - -.webcrt-tree__row .webcrt-tree__item { - flex: 1; - min-width: 0; -} - -.webcrt-sidebar__pins { - max-height: 28%; - overflow: auto; - border-bottom: 1px solid #334155; - padding-bottom: 4px; -} - -.webcrt-sidebar__pin-title { - font-size: 11px; - color: #94a3b8; - padding: 6px 10px 2px; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.webcrt-sidebar__prefs { - display: flex; - gap: 8px; - padding: 8px 10px; - border-top: 1px solid #334155; - font-size: 12px; - color: #cbd5e1; -} - -.webcrt-sidebar__prefs label { - display: flex; - flex-direction: column; - gap: 2px; - flex: 1; -} - -.webcrt-sidebar__prefs select, -.webcrt-sidebar__prefs input { - background: #0f172a; - border: 1px solid #475569; - color: #e2e8f0; - padding: 2px 6px; - font-size: 12px; -} - -.webcrt-buttonbar { - display: flex; - flex-wrap: wrap; - gap: 4px; - align-items: center; - padding: 4px 8px; - background: #1e293b; - border-bottom: 1px solid #334155; -} - -.webcrt-buttonbar__btn { - background: #334155; - border: 1px solid #475569; - color: #e2e8f0; - padding: 2px 8px; - font-size: 11px; - cursor: pointer; - border-radius: 2px; -} - -.webcrt-buttonbar__btn:hover { - background: #475569; -} - -.webcrt-broadcast { - display: flex; - gap: 4px; - margin-left: auto; - align-items: center; -} - -.webcrt-broadcast input { - width: 160px; - background: #0f172a; - border: 1px solid #475569; - color: #e2e8f0; - padding: 2px 6px; - font-size: 11px; -} - -.webcrt-broadcast button { - background: #0ea5e9; - border: 0; - color: #0f172a; - padding: 2px 8px; - font-size: 11px; - cursor: pointer; -} - -.webcrt-buttonbar-edit { - padding: 6px 8px; - background: #0f172a; - border-bottom: 1px solid #334155; -} - -.webcrt-buttonbar-edit textarea { - width: 100%; - background: #1e293b; - border: 1px solid #475569; - color: #e2e8f0; - font-family: Consolas, monospace; - font-size: 12px; - padding: 6px; -} - -.webcrt-sftp-split { - flex: 0 0 5px; - width: 5px; - cursor: col-resize; - background: #1e293b; - border-left: 1px solid #334155; - border-right: 1px solid #334155; - z-index: 4; - position: relative; -} - -.webcrt-sftp-split:hover, -.webcrt-sftp-split.is-dragging { - background: #0ea5e9; -} - -.webcrt-sftp-split::after { - content: ""; - position: absolute; - inset: 0 -3px; -} - -.webcrt-sftp { - position: relative; - flex: 0 0 auto; - width: 420px; - max-width: 75%; - min-width: 280px; - background: #0f172a; - border-left: 0; - z-index: 3; - display: flex; - flex-direction: column; - min-height: 0; -} - -.webcrt-sftp.is-busy { - cursor: progress; -} - -.webcrt-sftp.is-drop-target { - outline: 1px solid #0ea5e9; - outline-offset: -1px; -} - -.webcrt-sftp__drop-overlay { - position: absolute; - inset: 0; - z-index: 5; - display: flex; - align-items: center; - justify-content: center; - padding: 16px; - text-align: center; - font-size: 12px; - font-weight: 600; - color: #e0f2fe; - background: rgba(14, 165, 233, 0.18); - border: 1px dashed #38bdf8; - pointer-events: none; -} - -.webcrt-sftp__bar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px; - padding: 5px 6px; - border-bottom: 1px solid #334155; - flex-shrink: 0; - background: #0f172a; -} - -.webcrt-sftp__bar input { - flex: 1; - min-width: 0; - height: 24px; - background: #111827; - border: 1px solid #334155; - border-radius: 4px; - color: #cbd5e1; - padding: 0 6px; - font-size: 11px; - line-height: 1; -} - -.webcrt-sftp__bar button, -.webcrt-sftp__upload { - display: inline-flex; - align-items: center; - justify-content: center; - height: 24px; - background: #1e293b; - border: 1px solid #334155; - border-radius: 4px; - color: #cbd5e1; - padding: 0 8px; - font-size: 11px; - font-weight: 500; - line-height: 1; - cursor: pointer; -} - -.webcrt-sftp__bar button:hover:not(:disabled), -.webcrt-sftp__upload:hover:not(.is-disabled) { - background: #243447; - border-color: #475569; - color: #e2e8f0; -} - -.webcrt-sftp__bar button:disabled, -.webcrt-sftp__upload.is-disabled { - opacity: 0.55; - cursor: not-allowed; -} - -.webcrt-sftp__status { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 3px 8px; - font-size: 10px; - color: #64748b; - border-bottom: 1px solid #1e293b; - flex-shrink: 0; -} - -.webcrt-sftp__status--warn { - color: #fbbf24; - background: rgba(251, 191, 36, 0.08); -} - -.webcrt-sftp__cancel { - flex-shrink: 0; - height: 20px; - padding: 0 8px; - border: 1px solid #475569; - border-radius: 4px; - background: #1e293b; - color: #f87171; - font-size: 10px; - font-weight: 600; - cursor: pointer; -} - -.webcrt-sftp__cancel:hover { - background: #7f1d1d; - border-color: #ef4444; - color: #fecaca; -} - -.webcrt-sftp__table-wrap { - flex: 1; - min-height: 0; - overflow: auto; -} - -.webcrt-sftp__table { - border-collapse: collapse; - table-layout: fixed; - font-size: 11px; - line-height: 1.35; - color: #cbd5e1; -} - -.webcrt-sftp__table thead th { - position: sticky; - top: 0; - z-index: 1; - background: #0f172a; - color: #64748b; - font-size: 10px; - font-weight: 500; - text-align: left; - padding: 3px 6px 3px 4px; - border-bottom: 1px solid #1e293b; - white-space: nowrap; - user-select: none; - overflow: hidden; -} - -/* Beat .app-main button { white pill } — plain header text like the file rows. */ -.app-main .webcrt-sftp__th-label, -.webcrt-sftp__th-label { - display: block; - width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin: 0; - padding: 0; - border: none; - border-radius: 0; - background: transparent; - box-shadow: none; - color: #64748b; - font-family: inherit; - font-size: 10px; - font-weight: 500; - line-height: 1.3; - letter-spacing: normal; - text-align: inherit; - text-transform: none; - cursor: pointer; -} - -.app-main .webcrt-sftp__th-label:hover, -.webcrt-sftp__th-label:hover { - color: #94a3b8; - background: transparent; - border: none; -} - -.app-main .webcrt-sftp__table thead th.is-sorted .webcrt-sftp__th-label, -.webcrt-sftp__table thead th.is-sorted .webcrt-sftp__th-label { - color: #94a3b8; -} - -.webcrt-sftp__col-resizer { - position: absolute; - top: 0; - right: 0; - width: 5px; - height: 100%; - cursor: col-resize; - z-index: 2; -} - -.webcrt-sftp__col-resizer::after { - content: ""; - position: absolute; - top: 2px; - bottom: 2px; - right: 1px; - width: 1px; - background: transparent; -} - -.webcrt-sftp__col-resizer:hover::after, -.webcrt-sftp__col-resizer:active::after { - background: #0ea5e9; -} - -.webcrt-sftp__table tbody tr { - cursor: pointer; - user-select: none; -} - -.webcrt-sftp__table tbody tr:hover { - background: #111827; -} - -.webcrt-sftp__table tbody tr.is-selected { - background: #1e3a5f; -} - -.webcrt-sftp__table tbody tr.is-selected:hover { - background: #234872; -} - -.webcrt-sftp__table tbody tr.is-busy { - opacity: 0.7; - cursor: progress; -} - -.webcrt-sftp__empty-row td { - color: #64748b; - text-align: center; - padding: 18px 8px; - cursor: default; -} - -.webcrt-sftp__table td { - padding: 3px 6px; - border-bottom: 1px solid #1e293b; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - vertical-align: middle; -} - -.webcrt-sftp__col-name { - color: #e2e8f0; -} - -.webcrt-sftp__col-size { - text-align: right; - font-variant-numeric: tabular-nums; - color: #64748b; -} - -.webcrt-sftp__table thead th.webcrt-sftp__col-size, -.webcrt-sftp__table thead th.webcrt-sftp__col-size .webcrt-sftp__th-label { - text-align: right; -} - -.webcrt-sftp__col-mtime { - font-variant-numeric: tabular-nums; - color: #64748b; -} - -.webcrt-sftp__col-owner, -.webcrt-sftp__col-group { - color: #64748b; -} - -.webcrt-sftp__col-mode { - font-family: Consolas, "Courier New", monospace; - font-size: 10px; - color: #475569; -} - -.webcrt-sftp__name-cell { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; -} - -.webcrt-sftp__icon { - display: inline-flex; - flex-shrink: 0; - color: #64748b; - line-height: 1; -} - -.webcrt-sftp__icon.is-dir { - color: #d97706; -} - -.webcrt-sftp__name { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 500; -} - -@media (max-width: 900px) { - .webcrt-shell { - grid-template-columns: 1fr; - height: auto; - min-height: calc(100vh - 64px); - } - - .webcrt-sidebar { - max-height: 42vh; - } - - .webcrt-main { - min-height: 50vh; - } -} - -@media (max-width: 1200px) { - .cards { - grid-template-columns: 1fr; - } - .filter-inline { - grid-template-columns: 1fr; - } - .actions-row--inline { - flex-wrap: wrap; - } - .split { - grid-template-columns: 1fr; - } - .form-grid { - grid-template-columns: 1fr; - } -} - -/* ---- Topology — dark sidebar + canvas (shared chrome tokens) ---- */ -.topo-page { - --topo-sidebar-w: 260px; - display: grid; - grid-template-columns: var(--topo-sidebar-w) 1fr; - gap: 12px; - height: calc(100vh - 48px); - min-height: 520px; - margin: -20px -28px -32px; - padding: 12px 14px 14px; - background: var(--nm-chrome); - transition: grid-template-columns 0.18s ease; -} - -.topo-page.is-sidebar-collapsed { - grid-template-columns: 36px 1fr; -} - -.topo-page.is-sidebar-resizing { - transition: none; - cursor: col-resize; -} - -.topo-sidebar { - position: relative; - display: flex; - flex-direction: column; - gap: 10px; - min-height: 0; - background: #0a121c; - border: 1px solid var(--nm-ops-line, var(--nm-nav-border)); - border-radius: 0; - border-left: 0; - border-top: 0; - border-bottom: 0; - padding: 10px; - overflow: hidden; - color: var(--nm-text); - box-shadow: none; -} - -.topo-sidebar__resizer { - position: absolute; - top: 0; - right: -8px; - z-index: 6; - width: 12px; - height: 100%; - cursor: col-resize; - touch-action: none; -} - -.topo-sidebar__resizer::after { - content: ""; - position: absolute; - top: 12%; - bottom: 12%; - left: 50%; - width: 2px; - transform: translateX(-50%); - border-radius: 1px; - background: transparent; - transition: background 0.12s ease; -} - -.topo-sidebar__resizer:hover::after, -.topo-sidebar__resizer.is-dragging::after { - background: rgba(59, 130, 246, 0.75); -} - -.topo-page.is-sidebar-collapsed .topo-sidebar { - padding: 4px; - align-items: stretch; -} - -.topo-sidebar__rail-wrap { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - gap: 4px; -} - -.topo-sidebar__rail { - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; - gap: 8px; - width: 100%; - border: 0; - background: transparent; - padding: 8px 0; - cursor: pointer; - color: var(--nm-muted); - border-radius: 6px; -} - -.topo-sidebar__rail-wrap > .topo-sidebar__rail:first-child { - flex: 1; -} - -.topo-sidebar__rail .topo-sidebar__rail-icon { - width: 28px; - height: 28px; - border-radius: 6px; - border: 1px solid transparent; -} - -.topo-sidebar__rail:hover .topo-sidebar__rail-icon { - border-color: rgba(148, 163, 184, 0.28); - background: rgba(30, 41, 59, 0.85); -} - -.topo-sidebar__rail:hover { - background: var(--nm-nav-hover); - color: var(--nm-brand); -} - -.topo-sidebar__rail-icon { - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 14px; - line-height: 1; - font-weight: 600; -} - -.topo-sidebar__rail-label { - writing-mode: vertical-rl; - text-orientation: mixed; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.topo-sidebar__section { - display: flex; - flex-direction: column; - gap: 6px; - min-height: 0; - min-width: 0; -} - -.topo-sidebar__section--grow { - flex: 1; -} - -.app-main .topo-sidebar__icon-btn, -.topo-sidebar__icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - min-width: 24px; - padding: 0 !important; - margin: 0; - border: 0 !important; - border-radius: 4px !important; - background: transparent !important; - box-shadow: none !important; - color: #64748b; - cursor: pointer; - flex: 0 0 auto; -} - -.app-main .topo-sidebar__icon-btn:hover:not(:disabled), -.topo-sidebar__icon-btn:hover:not(:disabled) { - color: var(--nm-brand, #f1f5f9); - background: var(--nm-nav-hover, #152033) !important; -} - -.app-main .topo-sidebar__icon-btn:disabled, -.topo-sidebar__icon-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.topo-tree-search { - position: relative; - margin: 0 0 4px; -} - -.topo-tree-search__bar { - display: flex; - align-items: center; - gap: 2px; - min-width: 0; -} - -.topo-tree-search__bar > .input { - flex: 1 1 auto; - min-width: 0; -} - -.topo-tree-search__panel { - position: absolute; - z-index: 40; - top: calc(100% + 4px); - left: 0; - right: 0; - max-height: min(360px, 50vh); - overflow: auto; - padding: 4px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - background: #0b1220; - color: #e2e8f0; - box-shadow: 0 8px 24px rgba(2, 8, 23, 0.55); -} - -.topo-tree-search__item { - display: flex; - flex-direction: column; - gap: 2px; - padding: 2px; - border-radius: 6px; -} - -.topo-tree-search__item + .topo-tree-search__item { - margin-top: 2px; - border-top: 1px solid rgba(148, 163, 184, 0.16); - padding-top: 4px; -} - -.topo-tree-search__ne { - display: grid; - grid-template-columns: 1fr auto; - grid-template-rows: auto auto; - gap: 0 8px; - width: 100%; - padding: 6px 8px; - border: 0; - border-radius: 6px; - background: transparent; - text-align: left; - cursor: pointer; - color: inherit; -} - -.topo-tree-search__ne:hover { - background: rgba(148, 163, 184, 0.12); -} - -.topo-tree-search__name { - grid-column: 1; - font-size: 12px; - font-weight: 600; - color: #e2e8f0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.topo-tree-search__ip { - grid-column: 1; - font-size: 11px; -} - -.topo-tree-search__meta { - grid-column: 2; - grid-row: 1 / span 2; - align-self: center; - font-size: 11px; - white-space: nowrap; -} - -.topo-tree-search__views { - display: flex; - flex-wrap: wrap; - gap: 4px; - padding: 0 6px 4px; -} - -.topo-tree-search__view { - max-width: 100%; - padding: 2px 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: rgba(30, 41, 59, 0.9); - font-size: 11px; - color: #93c5fd; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.topo-tree-search__view:hover { - background: rgba(51, 65, 85, 0.95); - border-color: rgba(96, 165, 250, 0.45); -} - -.topo-tree-search__hint { - margin: 0; - padding: 8px; - font-size: 12px; -} - -.topo-palette-source { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2px; - padding: 2px; - background: rgba(15, 23, 42, 0.85); - border: 1px solid var(--nm-nav-border, rgba(148, 163, 184, 0.22)); - border-radius: 6px; -} - -.topo-palette-source__btn { - border: 0; - background: transparent; - border-radius: 4px; - padding: 5px 6px; - font-size: 11px; - font-weight: 600; - color: var(--nm-muted); - cursor: pointer; -} - -.topo-palette-source__btn.is-active { - background: rgba(30, 41, 59, 0.95); - color: #93c5fd; - box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.4); -} - -.topo-page .topo-sidebar__section { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - gap: 8px; -} -.topo-dir { - flex: 1; - min-height: 0; - overflow: auto; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; - background: rgba(2, 8, 23, 0.45); -} -.topo-dir__list { - list-style: none; - margin: 0; - padding: 4px 0; -} -.topo-dir__list--nested { - margin: 0 0 2px 10px; - padding: 2px 0 4px; - border-left: 1px solid rgba(148, 163, 184, 0.18); -} -.topo-dir__block { - list-style: none; -} -.topo-dir__block--sep { - margin-top: 4px; - padding-top: 4px; - border-top: 1px solid rgba(148, 163, 184, 0.18); -} -.topo-dir__row { - display: flex; - align-items: center; - min-height: 28px; - gap: 2px; - margin: 0 4px; - padding: 0 4px 0 2px; - border-radius: 4px; -} -.topo-dir__row:hover { - background: rgba(30, 41, 59, 0.65); -} -.topo-dir__row:last-child { - border-bottom: 0; -} -.topo-dir__row--root { - min-height: 32px; - background: rgba(30, 41, 59, 0.55); - border-bottom: 1px solid rgba(148, 163, 184, 0.18); - position: sticky; - top: 0; - z-index: 1; -} -.topo-dir__row--file { - min-height: 26px; -} -.topo-dir__row.is-active { - background: rgba(37, 99, 235, 0.22); - box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.4); -} -.topo-dir__row.is-active .topo-dir__name { - font-weight: 600; - color: var(--nm-accent); -} -.topo-dir__twist { - border: 0; - background: transparent; - width: 18px; - height: 24px; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: #64748b; - flex: 0 0 auto; -} -.topo-dir__indent { - width: 10px; - flex: 0 0 auto; -} -.topo-dir__item { - flex: 1; - min-width: 0; - border: 0; - background: transparent; - display: flex; - align-items: center; - gap: 6px; - padding: 2px 2px; - cursor: pointer; - color: inherit; - text-align: left; -} -.topo-dir__icon { - width: 16px; - height: 16px; - border-radius: 3px; - display: grid; - place-items: center; - flex: 0 0 auto; - color: #475569; -} -.topo-dir__icon--root { - color: #1d4ed8; -} -.topo-dir__icon--region { - color: #0f766e; -} -.topo-dir__icon--core { - color: #b45309; -} -.topo-dir__icon--aggregation { - color: #1d4ed8; -} -.topo-dir__icon--access { - color: var(--nm-accent, #3b82f6); -} -.topo-dir__name { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12.5px; - color: var(--nm-text); -} -.topo-dir__text { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 0; -} -.topo-dir__meta { - font-size: 11px; - color: var(--nm-muted, #94a3b8); -} -.topo-dir__action { - border: 0; - background: transparent; - width: 20px; - height: 20px; - border-radius: 4px; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: #94a3b8; - opacity: 0; - flex: 0 0 auto; -} -.topo-dir__row:hover .topo-dir__action, -.topo-dir__row.is-active .topo-dir__action { - opacity: 1; -} -.topo-dir__action:hover { - color: var(--nm-brand, #f1f5f9); - background: var(--nm-nav-hover, #152033); -} -.topo-dir__toolbar { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 2px 6px 6px 20px; -} -.topo-dir__toolbar button { - border: 0; - background: transparent; - border-radius: 4px; - font-size: 11px; - padding: 2px 6px; - cursor: pointer; - color: var(--nm-accent); - display: inline-flex; - align-items: center; - gap: 4px; -} -.topo-dir__toolbar button:hover { - background: rgba(59, 130, 246, 0.08); -} -.topo-dir__hint { - margin: 0; - font-size: 11px; - line-height: 1.4; - color: #94a3b8; -} -.topo-dir__icon svg, -.topo-tree__glyph-svg { - display: block; -} -.topo-browser { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - margin: 0; - border: 0; - border-radius: 0; - background: rgba(7, 11, 20, 0.92); - overflow: hidden; - color: #e2e8f0; -} -.topo-browser__head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - padding: 14px 18px 10px; - border-bottom: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(15, 23, 42, 0.85); -} -.topo-browser__head strong { - font-size: 15px; - font-weight: 650; - color: #f1f5f9; -} -.topo-browser__sub { - margin: 4px 0 0; - font-size: 12px; - color: #94a3b8; -} -.topo-browser__actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - flex: 0 0 auto; -} -.topo-browser__actions .btn { - display: inline-flex; - align-items: center; - gap: 4px; -} -.topo-browser__empty { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - padding: 48px 20px; - color: #94a3b8; - font-size: 13px; -} -.topo-browser__empty p { - margin: 0; -} -.topo-browser__empty-actions { - display: inline-flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: 8px; -} -.topo-browser__empty-icon { - display: grid; - place-items: center; - width: 64px; - height: 64px; - border-radius: 14px; - background: rgba(15, 23, 42, 0.85); - border: 1px solid rgba(148, 163, 184, 0.22); - color: #5eead4; - box-shadow: none; -} -.topo-browser__grid { - flex: 1; - min-height: 0; - overflow: auto; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 14px; - align-content: start; - padding: 16px 18px 20px; - background: rgba(2, 8, 23, 0.35); -} -.topo-browser__grid--regions { - grid-template-columns: repeat(auto-fill, minmax(148px, 160px)); - gap: 20px 22px; - padding: 24px 22px 32px; - background: rgba(2, 8, 23, 0.45); - justify-content: start; -} -/* Beat .app-main button { background:#fff; color:#1f2937; border } */ -.app-main .topo-region-hex, -.topo-region-hex { - width: 140px; - height: 156px; - box-sizing: border-box; - border: 0 !important; - border-radius: 0; - box-shadow: none; - padding: 34px 18px 30px; - margin: 0; - clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - text-align: center; - cursor: pointer; - color: #fff !important; - transition: transform 0.15s ease, filter 0.15s ease; -} -.app-main .topo-region-hex:hover, -.topo-region-hex:hover, -.app-main .topo-region-hex.is-hot, -.topo-region-hex.is-hot { - transform: translateY(-2px) scale(1.03); - filter: brightness(1.06); -} -.app-main .topo-region-hex.is-selected, -.topo-region-hex.is-selected, -.app-main .topo-region-hex.is-hot, -.topo-region-hex.is-hot { - box-shadow: - 0 0 0 3px rgba(255, 255, 255, 0.92), - 0 0 0 6px rgba(26, 86, 168, 0.55); - z-index: 1; -} -.app-main .topo-region-hex.is-selected, -.topo-region-hex.is-selected { - filter: brightness(1.08); - transform: translateY(-2px) scale(1.04); -} -.app-main .topo-region-hex--tone-0, -.topo-region-hex--tone-0 { - background: linear-gradient(160deg, #3b82f6 0%, #1e3a5f 55%, #0f172a 100%) !important; -} -.app-main .topo-region-hex--tone-1, -.topo-region-hex--tone-1 { - background: linear-gradient(160deg, #38bdf8 0%, #1e40af 52%, #0f172a 100%) !important; -} -.app-main .topo-region-hex--tone-2, -.topo-region-hex--tone-2 { - background: linear-gradient(160deg, #2dd4bf 0%, #0f766e 50%, #0f172a 100%) !important; -} -.app-main .topo-region-hex--tone-3, -.topo-region-hex--tone-3 { - background: linear-gradient(160deg, #64748b 0%, #334155 48%, #0f172a 100%) !important; -} -.app-main .topo-region-hex--tone-4, -.topo-region-hex--tone-4 { - background: linear-gradient(160deg, #94a3b8 0%, #475569 48%, #1e293b 100%) !important; -} -.app-main .topo-region-hex--tone-physical, -.topo-region-hex--tone-physical { - background: linear-gradient(160deg, #f59e0b 0%, #92400e 46%, #0f172a 100%) !important; -} -.app-main .topo-region-hex--add, -.topo-region-hex--add { - background: linear-gradient(160deg, #1e293b 0%, #0f172a 100%) !important; - color: #93c5fd !important; -} -.app-main .topo-region-hex--add:hover, -.topo-region-hex--add:hover { - background: linear-gradient(160deg, #334155 0%, #1e293b 100%) !important; - color: #bfdbfe !important; -} -.app-main .topo-region-hex--add:disabled, -.topo-region-hex--add:disabled { - opacity: 0.55; - cursor: not-allowed; -} -.topo-region-hex__icon { - display: grid; - place-items: center; - width: 32px; - height: 32px; - border-radius: 8px; - background: rgba(255, 255, 255, 0.22); - color: #fff; - flex: 0 0 auto; -} -.topo-region-hex--add .topo-region-hex__icon { - background: rgba(59, 130, 246, 0.2); - color: #93c5fd; -} -.topo-region-hex__name { - max-width: 100%; - font-size: 14px; - font-weight: 700; - line-height: 1.25; - color: inherit; - white-space: normal; - overflow-wrap: anywhere; - text-align: center; -} -.topo-region-hex__title { - display: flex; - align-items: baseline; - justify-content: center; - flex-wrap: wrap; - gap: 4px 6px; - max-width: 100%; - min-width: 0; -} -.topo-region-hex__title .topo-region-hex__name { - max-width: 100%; -} -.topo-region-hex__meta { - display: inline; - flex: 0 0 auto; - font-size: 11px; - font-weight: 600; - line-height: 1.25; - color: rgba(255, 255, 255, 0.95); - white-space: nowrap; -} -.topo-region-hex--add .topo-region-hex__meta { - color: #93c5fd; -} -.topo-region-hex__plus { - display: grid; - place-items: center; - width: 32px; - height: 32px; - border-radius: 8px; - font-size: 22px; - line-height: 1; - background: rgba(59, 130, 246, 0.2); - color: #93c5fd; - flex: 0 0 auto; -} -.topo-map-card { - display: flex; - flex-direction: column; - min-height: 168px; - border: 1px solid var(--nm-panel-border, rgba(148, 163, 184, 0.22)); - border-radius: 8px; - background: var(--nm-panel-solid, #0f172a); - box-shadow: none; - overflow: hidden; - transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; - color: var(--nm-text, #e2e8f0); -} -.topo-map-card:hover { - border-color: rgba(59, 130, 246, 0.45); - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); - transform: translateY(-1px); -} -.topo-map-card.is-physical { - border-color: rgba(245, 158, 11, 0.4); - background: linear-gradient(180deg, rgba(69, 26, 3, 0.35) 0%, rgba(15, 23, 42, 0.9) 42%); -} -.topo-map-card__body { - flex: 1; - min-height: 0; - border: 0; - background: transparent; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 8px; - padding: 16px 14px 10px; - cursor: pointer; - text-align: left; - color: inherit; -} -.topo-map-card__icon { - width: 44px; - height: 44px; - border-radius: 10px; - display: grid; - place-items: center; - background: rgba(37, 99, 235, 0.18); - color: #93c5fd; - border: 1px solid rgba(59, 130, 246, 0.3); -} -.topo-map-card.is-physical .topo-map-card__icon { - background: rgba(245, 158, 11, 0.16); - color: #fbbf24; - border-color: rgba(245, 158, 11, 0.35); -} -.topo-map-card__kind { - font-size: 11px; - font-weight: 650; - letter-spacing: 0.02em; - color: #93c5fd; - background: rgba(37, 99, 235, 0.18); - border-radius: 6px; - padding: 2px 8px; -} -.topo-map-card__kind.is-physical { - color: #fdba74; - background: rgba(245, 158, 11, 0.16); -} -.topo-map-card__name { - font-size: 14px; - font-weight: 650; - color: #f1f5f9; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - width: 100%; -} -.topo-map-card__meta { - font-size: 11px; - line-height: 1.4; - color: #64748b; - width: 100%; -} -.topo-map-card__actions { - display: flex; - gap: 6px; - padding: 0 12px 12px; -} -.topo-map-card__actions .btn { - flex: 1; -} -.topo-map-card--add { - align-items: center; - justify-content: center; - gap: 8px; - min-height: 168px; - border-style: dashed; - border-color: rgba(148, 163, 184, 0.35); - background: rgba(15, 23, 42, 0.45); - color: var(--nm-accent); - font-size: 13px; - font-weight: 600; - cursor: pointer; - box-shadow: none; -} -.topo-map-card--add:hover { - background: rgba(37, 99, 235, 0.14); - border-color: var(--nm-accent); - color: #93c5fd; - box-shadow: none; -} -.topo-map-card__add-plus { - width: 36px; - height: 36px; - border-radius: 8px; - display: grid; - place-items: center; - font-size: 22px; - line-height: 1; - border: 1px dashed currentColor; - opacity: 0.85; -} -.topo-breadcrumb__crumb { - display: inline-flex; - align-items: center; - gap: 4px; -} -.topo-breadcrumb { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 2px 6px; - min-width: 0; - font-size: 13px; - font-weight: 550; - letter-spacing: -0.01em; - line-height: 1.3; - color: var(--nm-brand, #f1f5f9); -} -.app-main .topo-breadcrumb__link, -.topo-breadcrumb__link { - border: 0 !important; - background: transparent !important; - box-shadow: none !important; - color: #94a3b8 !important; - cursor: pointer; - padding: 0 !important; - margin: 0; - border-radius: 0 !important; - font: inherit; - font-weight: 550; - line-height: inherit; - width: auto; -} -.app-main .topo-breadcrumb__link:hover, -.topo-breadcrumb__link:hover { - color: var(--nm-accent, #3b82f6) !important; - background: transparent !important; - text-decoration: underline; - text-underline-offset: 2px; -} -.topo-breadcrumb__sep { - color: #94a3b8; - opacity: 1; - font-weight: 400; - user-select: none; -} -.topo-breadcrumb__current { - display: inline; - padding: 0; - border-radius: 0; - font: inherit; - font-weight: 700; - color: var(--nm-accent, #3b82f6); - background: transparent; - box-shadow: none; -} -.topo-toolbar__level { - margin-left: 8px; -} -.topo-nav-node { - min-width: 180px; - max-width: 220px; - padding: 12px 14px; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - background: linear-gradient(165deg, #1e293b 0%, #0f172a 100%); - box-shadow: none; - cursor: pointer; - display: flex; - align-items: flex-start; - gap: 12px; - color: #e2e8f0; -} -.topo-nav-node.is-selected, -.topo-nav-node:hover { - border-color: var(--nm-accent, #2563eb); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--nm-accent, #2563eb) 22%, transparent), - 0 8px 18px rgba(37, 99, 235, 0.12); -} -.topo-nav-node__icon { - width: 44px; - height: 44px; - border-radius: 8px; - display: grid; - place-items: center; - flex: 0 0 auto; - color: #93c5fd; - background: rgba(37, 99, 235, 0.18); -} -.topo-nav-node--region .topo-nav-node__icon { - color: #5eead4; - background: rgba(13, 148, 136, 0.22); -} -.topo-nav-node--core .topo-nav-node__icon { - color: #fbbf24; - background: rgba(245, 158, 11, 0.2); -} -.topo-nav-node--aggregation .topo-nav-node__icon { - color: #93c5fd; - background: rgba(59, 130, 246, 0.2); -} -.topo-nav-node--access .topo-nav-node__icon { - color: #93c5fd; - background: rgba(37, 99, 235, 0.22); -} -.topo-nav-node__body { - min-width: 0; - flex: 1; -} -.topo-nav-node__badge { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.55; - margin-bottom: 2px; -} -.topo-nav-node__title { - font-weight: 650; - font-size: 14px; - line-height: 1.25; -} -.topo-nav-node__sub, -.topo-nav-node__meta { - margin-top: 4px; - font-size: 12px; - opacity: 0.68; - line-height: 1.3; -} -.topo-modal { - position: fixed; - inset: 0; - z-index: 80; - display: grid; - place-items: center; -} -.topo-modal__backdrop { - position: absolute; - inset: 0; - background: rgba(2, 8, 23, 0.72); -} -.topo-modal__panel { - position: relative; - width: min(480px, calc(100vw - 32px)); - max-height: min(70vh, 640px); - overflow: hidden; - display: flex; - flex-direction: column; - gap: 10px; - padding: 14px; - border-radius: 12px; - background: #0f172a; - color: #e2e8f0; - border: 1px solid rgba(148, 163, 184, 0.22); - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); -} -.topo-modal__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} -.topo-modal__hint { - margin: 0; -} -.topo-modal__selectbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} -.topo-modal__selectall { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--nm-muted, #5b6b7c); - cursor: pointer; - user-select: none; -} -.topo-modal__list { - max-height: 360px; - overflow: auto; -} -.topo-modal__foot { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - padding-top: 6px; - border-top: 1px solid rgba(148, 163, 184, 0.18); -} -.topo-palette__check { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - padding: 0 8px; - border-right: 1px solid rgba(148, 163, 184, 0.22); - cursor: pointer; -} -.topo-palette__row.is-selected { - border-color: rgba(59, 130, 246, 0.5); - background: rgba(37, 99, 235, 0.2); - box-shadow: inset 2px 0 0 #3b82f6; -} -.topo-map-list, -.topo-palette { - list-style: none; - margin: 0; - padding: 0; - overflow: auto; - flex: 1; - min-width: 0; -} - -.topo-classify .topo-classify__head-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; -} -.topo-classify__section { - margin-top: 18px; - padding-top: 14px; - border-top: 1px solid rgba(148, 163, 184, 0.18); -} -.topo-classify__section--first { - margin-top: 8px; - padding-top: 0; - border-top: 0; -} -.topo-classify__regex-bar, -.topo-classify__assign-bar { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - margin: 0 0 12px; - padding: 10px; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; - background: rgba(15, 23, 42, 0.65); -} -.topo-classify__regex-input { - min-width: 220px; - flex: 1 1 220px; -} -.topo-classify__table tr.is-match { - background: rgba(37, 99, 235, 0.18); -} -.topo-classify__row-actions { - display: inline-flex; - gap: 4px; -} -.topo-classify__help { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 50%; - border: 1px solid rgba(148, 163, 184, 0.45); - color: #94a3b8; - font-size: 12px; - font-weight: 600; - line-height: 1; - cursor: help; - user-select: none; - flex: 0 0 auto; -} -.topo-classify__help:hover, -.topo-classify__help:focus-visible { - border-color: #93c5fd; - color: #e2e8f0; - outline: none; -} -.topo-classify__help-tip { - display: none; - position: absolute; - left: 0; - top: calc(100% + 8px); - z-index: 40; - width: max-content; - max-width: min(280px, 70vw); - padding: 8px 10px; - border-radius: 6px; - background: #0f172a; - color: #f8fafc; - font-size: 12px; - font-weight: 400; - line-height: 1.45; - white-space: normal; - text-align: left; - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); - pointer-events: none; -} -.topo-classify__help-tip::after { - content: ""; - position: absolute; - bottom: 100%; - left: 8px; - border: 5px solid transparent; - border-bottom-color: #0f172a; -} -.topo-classify__help:hover .topo-classify__help-tip, -.topo-classify__help:focus-visible .topo-classify__help-tip { - display: block; -} -.topo-classify__section h3 { - margin: 0 0 10px; - font-size: 14px; - color: var(--nm-brand, #f1f5f9); -} -.topo-classify__section-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 10px; -} -.topo-classify__section-head h3 { - margin: 0; -} -.topo-classify__draft { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 12px; - align-items: center; -} -.topo-classify__draft .input { - min-width: 140px; - max-width: 280px; -} -.topo-classify__preview { - margin: 10px 0 14px; - padding: 10px 12px; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; - background: rgba(15, 23, 42, 0.65); -} -.topo-classify__pager { - display: flex; - align-items: center; - gap: 10px; - margin-top: 8px; -} -.topo-classify__check { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: #94a3b8; -} -.topo-classify__slice-list, -.topo-classify__view-links { - margin: 6px 0 0; - padding-left: 18px; -} -.topo-classify__view-links { - list-style: none; - padding-left: 0; -} -.topo-classify__view-links a { - color: var(--nm-accent, #3b82f6); -} - -.topo-region-list { - display: flex; - flex-direction: column; - gap: 2px; - padding: 2px 0; - /* Allow deep indent + actions to extend; parent scroll shows a horizontal bar. */ - width: max-content; - min-width: 100%; - max-width: none; - box-sizing: border-box; -} - -.topo-region-list-scroll { - flex: 1 1 auto; - min-height: 0; - min-width: 0; - overflow-x: auto; - overflow-y: auto; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; - background: rgba(2, 8, 23, 0.45); - padding: 2px 0; -} - -.topo-region-list__block { - margin: 0; - width: auto; - max-width: none; - min-width: 0; - padding: 2px 0 4px; - box-sizing: border-box; -} - -.topo-region-list__row { - align-items: center; -} - -/* Flat list rows — no boxed cards */ -.topo-region-list .topo-map-list__row { - border: 0; - border-radius: 8px; - background: transparent; - box-shadow: none; - overflow: visible; - gap: 2px; - padding-right: 2px; - width: max-content; - min-width: 100%; - transition: background 0.12s ease; -} - -.topo-region-list .topo-map-list__row:hover, -.topo-region-list__block.is-hot > .topo-region-list__row, -.topo-region-list__maps > li.is-hot > .topo-map-list__row, -.topo-region-list__view.is-hot > .topo-map-list__row { - background: var(--nm-nav-hover, #152033); -} - -/* Selected = deep wash + left accent */ -.topo-region-list__block.is-active > .topo-region-list__row, -.topo-region-list__maps > li.is-active > .topo-map-list__row, -.topo-region-list__view.is-active > .topo-map-list__row { - border: 0; - background: var(--nm-nav-active, #1a2740); - box-shadow: inset 3px 0 0 var(--nm-accent, #3b82f6); -} - -.topo-region-list__block.is-branch-active > .topo-region-list__row { - border: 0; - background: rgba(37, 99, 235, 0.16); - box-shadow: none; -} - -.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__actions, -.topo-region-list__maps > li.is-active > .topo-map-list__row .topo-map-list__actions { - border-left: 0; -} - -.topo-region-list__glyph { - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - margin-right: 2px; - border-radius: 6px; - background: rgba(45, 212, 191, 0.12); - color: #5eead4; - flex: 0 0 auto; -} - -.topo-region-list__maps .topo-region-list__glyph { - background: rgba(59, 130, 246, 0.18); - color: #93c5fd; -} - -.topo-region-list__maps { - position: relative; - margin: 2px 0 0; - /* One nest level = one indent; do not also set marginLeft on child blocks. */ - padding: 2px 0 2px 14px; - overflow: visible; - flex: none; -} - -.topo-region-list__maps::before { - content: ""; - position: absolute; - left: 6px; - top: 0; - bottom: 6px; - width: 1px; - background: rgba(148, 163, 184, 0.55); -} - -.topo-region-list__maps > li { - margin: 0; - width: auto; - max-width: 100%; - position: relative; - box-sizing: border-box; -} - -.topo-region-list__maps > li + li { - margin-top: 1px; -} - -.topo-region-list__maps .topo-map-list__row { - width: 100%; -} - -.app-main .topo-region-list .topo-map-list__item, -.topo-region-list .topo-map-list__item { - padding: 8px 6px 8px 8px; - border-radius: 8px; -} - -/* View rows use a chevron spacer — same padding as folder rows (no extra 36px). */ -.app-main .topo-region-list__maps .topo-map-list__item, -.topo-region-list__maps .topo-map-list__item { - padding: 8px 6px 8px 8px; -} - -.topo-region-list .topo-map-list__item:hover, -.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__item, -.topo-region-list__block.is-branch-active > .topo-region-list__row .topo-map-list__item, -.topo-region-list__maps > li.is-active .topo-map-list__item, -.topo-region-list__maps > li.is-active .topo-map-list__item:hover { - background: transparent !important; -} - -.topo-region-list .topo-map-list__actions { - opacity: 0; - pointer-events: none; - flex: 0 0 auto; - border-left: 0; - gap: 4px; - padding-right: 4px; - align-items: center; - transition: opacity 0.12s ease; -} - -/* Edit/delete only while the row itself is hovered */ -.topo-region-list .topo-map-list__row:hover .topo-map-list__actions { - opacity: 1; - pointer-events: auto; -} - -.app-main .topo-region-list .topo-map-list__icon, -.topo-region-list .topo-map-list__icon { - width: 24px; - height: 24px; - min-height: 24px; - border-radius: 5px; - color: #64748b; - background: transparent !important; - border: 0 !important; -} - -.app-main .topo-region-list .topo-map-list__icon:hover:not(:disabled), -.topo-region-list .topo-map-list__icon:hover:not(:disabled) { - background: rgba(148, 163, 184, 0.16) !important; - color: var(--nm-brand); -} - -.topo-region-list .topo-map-list__name { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - width: 100%; -} - -.topo-region-list .topo-map-list__title { - min-width: max-content; - flex: 0 1 auto; - overflow: visible; - text-overflow: clip; - white-space: nowrap; - font-size: 13px; - font-weight: 650; - color: var(--nm-brand, #f1f5f9); -} - -.topo-region-list__maps .topo-map-list__title { - font-size: 12px; - font-weight: 600; -} - -.topo-region-list .topo-map-list__count { - flex: 0 0 auto; - margin-left: 4px; - padding: 0; - border-radius: 0; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; - color: var(--nm-muted); - background: transparent; - box-shadow: none; - font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; -} - -.topo-region-list__maps .topo-map-list__count { - color: var(--nm-muted); - background: transparent; -} - -.topo-region-list__block.is-active .topo-map-list__count, -.topo-region-list__maps > li.is-active .topo-map-list__count { - background: transparent; - color: #cbd5e1; -} - -.topo-region-list__block.is-hot:not(.is-active) .topo-map-list__count, -.topo-region-list__maps > li.is-hot:not(.is-active) .topo-map-list__count { - background: transparent; - color: var(--nm-accent); -} - -.topo-region-list__block.is-active .topo-map-list__title, -.topo-region-list__maps > li.is-active .topo-map-list__title { - color: #f1f5f9; - font-weight: 700; -} - -.topo-region-list__maps > li.is-active .topo-region-list__glyph { - background: rgba(37, 99, 235, 0.35); - color: #dbeafe; -} - -.topo-map-list li, -.topo-palette > li { - margin: 0 0 4px; - width: 100%; - min-width: 0; -} - -.topo-map-list li:last-child, -.topo-palette > li:last-child { - margin-bottom: 0; -} - -.topo-map-list__row, -.topo-palette__row { - display: flex; - align-items: stretch; - width: 100%; - box-sizing: border-box; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; - background: rgba(15, 23, 42, 0.75); - overflow: hidden; - min-width: 0; -} - -.topo-map-list li.is-active .topo-map-list__row { - border-color: var(--nm-accent); - background: var(--nm-nav-active); - box-shadow: inset 2px 0 0 var(--nm-accent); -} - -.topo-region-list__block.is-active > .topo-map-list__row, -.topo-region-list__maps > li.is-active > .topo-map-list__row { - border: 0 !important; - background: var(--nm-nav-active, #1a2740) !important; - box-shadow: inset 3px 0 0 var(--nm-accent, #3b82f6) !important; -} - -.topo-region-list__block.is-active > .topo-map-list__row:hover, -.topo-region-list__maps > li.is-active > .topo-map-list__row:hover { - background: #243552 !important; -} - -.topo-region-list__block.is-branch-active > .topo-map-list__row { - border: 0 !important; - background: rgba(37, 99, 235, 0.16) !important; - box-shadow: none !important; -} - -.topo-region-list__block.is-hot:not(.is-active):not(.is-branch-active) > .topo-map-list__row, -.topo-region-list__maps > li.is-hot:not(.is-active) > .topo-map-list__row { - background: rgba(59, 130, 246, 0.14) !important; -} - -.topo-palette > li.is-on-canvas .topo-palette__row { - background: rgba(30, 41, 59, 0.55); - border-color: rgba(148, 163, 184, 0.18); -} - -.topo-map-list__item, -.topo-palette__item { - display: block; - flex: 1 1 auto; - width: 100%; - max-width: 100%; - text-align: left; - border: 0; - background: transparent; - padding: 6px 8px; - cursor: pointer; - border-radius: 0; - min-width: 0; - box-sizing: border-box; -} - -.app-main .topo-map-list__item, -.app-main .topo-map-list__icon, -.app-main .topo-palette__item { - border: none; - background: transparent; - border-radius: 0; - box-shadow: none; - width: auto; -} - -.app-main .topo-map-list__item, -.app-main .topo-palette__item { - display: block; - width: 100%; - padding: 6px 8px; -} - -.app-main .topo-map-list__icon { - width: 28px; - height: auto; - padding: 0; - color: #64748b; - font-size: 14px; - line-height: 1; -} - -.topo-map-list__item:hover, -.topo-palette__item:hover:not(:disabled) { - background: var(--nm-nav-hover); -} - -.topo-map-list li.is-active .topo-map-list__item:hover { - background: rgba(37, 99, 235, 0.12); -} - -.topo-map-list__actions { - display: flex; - align-items: stretch; - flex: 0 0 auto; - border-left: 1px solid rgba(148, 163, 184, 0.22); -} - -.topo-map-list li.is-active .topo-map-list__actions { - border-left-color: rgba(59, 130, 246, 0.35); -} - -.topo-map-list__icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - min-height: 100%; - border: 0; - background: transparent; - color: var(--nm-muted); - cursor: pointer; -} - -.topo-map-list__icon:hover:not(:disabled) { - background: #e8eef8; - color: var(--nm-brand); -} - -.topo-map-list__icon:disabled { - opacity: 0.45; - cursor: default; -} - -.topo-palette__item:disabled { - opacity: 0.55; - cursor: default; -} - -.topo-palette__empty { - padding: 8px; - box-sizing: border-box; - width: 100%; -} - -.topo-map-list__name, -.topo-palette__name { - display: block; - font-weight: 650; - font-size: 12px; - color: var(--nm-brand, #f1f5f9); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.topo-map-list__meta, -.topo-palette__meta { - display: block; - font-size: 11px; - color: var(--nm-muted, #94a3b8); - font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.topo-main { - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; - background: var(--nm-chrome, #070b14); - border: 0; - border-radius: 0; - overflow: hidden; - box-shadow: none; - color: var(--nm-text, #e2e8f0); -} - -.topo-toolbar { - display: flex; - flex-direction: column; - gap: 6px; - padding: 8px 10px; - border-bottom: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(15, 23, 42, 0.9); -} - -.topo-toolbar__row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-width: 0; -} - -.topo-toolbar__row--tools { - justify-content: flex-start; - flex-wrap: wrap; - gap: 8px; - padding-top: 4px; - border-top: 1px solid rgba(148, 163, 184, 0.12); -} - -.topo-toolbar__group + .topo-toolbar__group, -.topo-toolbar__cluster + .topo-toolbar__group, -.topo-toolbar__group + .topo-toolbar__group--find, -.topo-toolbar__cluster + .topo-toolbar__group--find { - margin-left: 2px; - padding-left: 10px; - border-left: 1px solid rgba(148, 163, 184, 0.2); -} - -/* Flat cluster: no nested chrome — only a separator from tool modes. */ -.topo-toolbar__cluster { - display: inline-flex; - align-items: center; - gap: 8px; - flex-shrink: 0; - min-width: 0; - margin-left: 2px; - padding-left: 10px; - border-left: 1px solid rgba(148, 163, 184, 0.2); -} - -.topo-toolbar__group--find { - position: relative; - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - margin-left: auto; -} - -.topo-find-suggest { - position: absolute; - top: calc(100% + 4px); - right: 0; - left: auto; - z-index: 30; - width: min(340px, 72vw); - max-height: 280px; - overflow: auto; - background: var(--nm-panel-solid, #0f172a); - border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.18)); - border-radius: 8px; - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4); - color: var(--nm-text, #e2e8f0); -} - -.topo-find-suggest__item { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - width: 100%; - padding: 8px 10px; - border: 0; - background: transparent; - text-align: left; - cursor: pointer; - color: inherit; -} - -.topo-find-suggest__item:hover, -.topo-find-suggest__item.is-active { - background: rgba(37, 99, 235, 0.22); -} - -.topo-find-suggest__name { - font-size: 12px; - font-weight: 650; - color: #f1f5f9; -} - -.topo-find-suggest__meta { - font-size: 11px; - color: #94a3b8; - font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; -} - -.topo-find-suggest__empty, -.topo-find-suggest__more { - padding: 8px 10px; - font-size: 12px; - color: #94a3b8; -} - -.app-main .topo-toolbar__find, -.topo-toolbar__find { - width: 190px; - max-width: 36vw; - height: 30px; - box-sizing: border-box; - padding: 0 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - font-size: 12px; - line-height: 1.2; - background: #0b1220; - color: var(--nm-text, #e2e8f0); -} - -.topo-toolbar__find::placeholder { - color: var(--nm-muted, #94a3b8); -} - -.topo-toolbar__find:disabled { - opacity: 0.55; -} - -.topo-toolbar__title { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.topo-toolbar__title strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 14px; - font-weight: 650; - color: var(--nm-brand, #f1f5f9); - letter-spacing: -0.01em; -} - -.topo-toolbar__meta { - font-size: 12px; - color: var(--nm-muted, #94a3b8); - white-space: nowrap; -} - -.topo-toolbar__actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - justify-content: flex-end; - flex-shrink: 0; -} - -.topo-toolbar__primary { - gap: 6px; -} - -.app-main .topo-toolbar__save--dirty, -.topo-toolbar__save--dirty { - background: var(--nm-accent, #3b82f6) !important; - border-color: transparent !important; - color: #f8fafc !important; -} - -.topo-toolbar__actions > .btn, -.topo-toolbar__primary > button, -.topo-toolbar__group > .btn { - width: auto; - flex: 0 0 auto; - height: 30px; - padding: 0 10px; - font-size: 12px; - line-height: 1; - border-radius: 6px; -} - -.topo-toolbar__group > select.topo-toolbar__find { - width: auto; - min-width: 140px; -} - -.topo-toolbar__group { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.topo-tools { - display: inline-flex; - align-items: center; - gap: 2px; - flex-shrink: 0; - padding: 2px; - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 8px; - background: rgba(11, 18, 32, 0.65); -} - -.topo-tools__btn { - display: inline-flex; - align-items: center; - gap: 4px; - min-height: 30px; - border: 0; - background: transparent; - color: var(--nm-muted, #94a3b8); - font-size: 12px; - font-weight: 600; - padding: 5px 9px; - border-radius: 6px; - cursor: pointer; - line-height: 1.2; -} - -.topo-tools__btn:hover { - background: rgba(30, 41, 59, 0.9); - color: var(--nm-text, #f1f5f9); -} - -.topo-tools__btn.is-active, -.app-main .topo-tools__btn.is-active { - background: var(--nm-accent, #3b82f6) !important; - border-color: transparent !important; - color: #f8fafc !important; -} - -.topo-tools__key { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 14px; - height: 14px; - padding: 0 3px; - border: 1px solid currentColor; - border-radius: 2px; - font-size: 10px; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-weight: 700; - opacity: 0.75; - line-height: 1; -} - -.app-main .topo-toolbar__select, -.topo-toolbar__select { - min-width: 88px; - height: 30px; - box-sizing: border-box; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - color: #e2e8f0; - font-size: 12px; - line-height: 1.2; - padding: 0 8px; - vertical-align: middle; -} - -.app-main .topo-toolbar__display > summary, -.topo-toolbar__display > summary { - list-style: none; - cursor: pointer; - user-select: none; - height: 30px; - padding: 0 10px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - color: #e2e8f0; - font-size: 12px; - font-weight: 600; - line-height: 1.2; - display: inline-flex; - align-items: center; -} - -.topo-toolbar__display { - position: relative; -} - -.topo-toolbar__display > summary::-webkit-details-marker { - display: none; -} - -.topo-toolbar__display[open] > summary { - border-color: rgba(59, 130, 246, 0.55); - background: rgba(37, 99, 235, 0.22); - color: #93c5fd; -} - -.topo-toolbar__display .topo-display-toggles { - position: absolute; - top: calc(100% + 4px); - left: 0; - z-index: 20; - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 8px 14px; - align-content: start; - width: min(560px, calc(100vw - 32px)); - max-height: min(70vh, 520px); - overflow: auto; - padding: 10px 12px; - border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.18)); - border-radius: 8px; - background: var(--nm-panel-solid, #0f172a); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4); - color: var(--nm-text, #e2e8f0); -} - -.topo-toolbar__more-popover { - min-width: 168px; - padding: 4px; - border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.22)); - border-radius: 8px; - background: var(--nm-panel-solid, #0f172a); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); - color: var(--nm-text, #e2e8f0); -} - -.topo-toolbar__more-popover [role="menuitem"] { - border-radius: 6px; - font-size: 12px; - color: var(--nm-text, #e2e8f0); -} - -.topo-toolbar__more-popover [role="menuitem"][data-disabled], -.topo-toolbar__more-popover [role="menuitem"][aria-disabled="true"] { - opacity: 0.45; -} - -.topo-mode-hint { - position: absolute; - top: 10px; - left: 50%; - transform: translateX(-50%); - z-index: 6; - padding: 6px 12px; - border-radius: 3px; - border: 1px solid #0ea5e9; - background: rgba(15, 23, 42, 0.9); - color: #e0f2fe; - font-size: 12px; - font-weight: 600; - pointer-events: none; - white-space: nowrap; - max-width: calc(100% - 24px); - overflow: hidden; - text-overflow: ellipsis; -} - -.topo-svg-icon { - display: block; -} - -.topo-icon-btn { - display: inline-flex !important; - align-items: center; - justify-content: center; - width: 28px; - min-width: 28px; - padding: 0 !important; -} - -.topo-display-defaults { - grid-column: 1 / -1; - display: grid; - gap: 8px; - margin-top: 2px; - padding-top: 8px; - border-top: 1px solid rgba(148, 163, 184, 0.18); -} - -.topo-display-defaults__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - font-size: 12px; - color: #cbd5e1; - grid-column: 1 / -1; -} - -.topo-display-defaults__row { - display: grid; - grid-template-columns: 48px 28px minmax(72px, 1fr) 64px; - align-items: center; - gap: 6px; -} - -.topo-display-defaults--canvas-bg .topo-display-defaults__row { - grid-template-columns: minmax(72px, auto) 28px minmax(88px, 1fr); -} - -/* Color pickers / vendor list: two columns inside the wide card */ -.topo-display-defaults--colors { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - column-gap: 14px; -} - -.topo-display-defaults--colors .topo-display-defaults__row { - grid-template-columns: minmax(64px, auto) 28px minmax(72px, 1fr); -} - -.topo-canvas-bg-hex { - min-width: 88px !important; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; -} - -.topo-display-defaults__name { - font-size: 12px; - color: #94a3b8; -} - -.topo-display-defaults__row input[type="color"] { - width: 28px; - height: 28px; - padding: 0; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - cursor: pointer; -} - -.topo-display-defaults__row .topo-toolbar__select { - min-width: 0; - width: 100%; -} - -.topo-canvas.is-pan-mode .react-flow__pane { - cursor: grab; -} - -.topo-legend { - position: absolute; - left: 10px; - bottom: 10px; - z-index: 5; - display: flex; - flex-wrap: wrap; - gap: 8px 12px; - padding: 6px 10px; - border-radius: 6px; - background: rgba(15, 39, 68, 0.92); - border: 1px solid rgba(255, 255, 255, 0.12); - font-size: 11px; - color: #e2e8f0; - pointer-events: none; -} - -.topo-legend__item { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.topo-legend__swatch { - display: inline-block; - width: 18px; - height: 0; - border-top: 2px solid #94a3b8; - vertical-align: middle; -} - -.topo-node.is-connect-mode .topo-node__handle { - width: 14px !important; - height: 14px !important; - min-width: 14px !important; - min-height: 14px !important; - opacity: 0.9 !important; - background: rgba(14, 165, 233, 0.35) !important; - border: 1px solid #38bdf8 !important; - border-radius: 50% !important; - z-index: 5; -} - -.react-flow__selection { - background: rgba(51, 65, 85, 0.08); - border: 1px solid rgba(51, 65, 85, 0.7); -} - -.topo-display-toggles { - display: grid; - gap: 6px; -} - -.topo-display-toggles__item { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--nm-text, #e2e8f0); - white-space: nowrap; - cursor: pointer; - user-select: none; - min-width: 0; -} - -.topo-ctx { - position: fixed; - z-index: 40; - min-width: 168px; - padding: 4px 0; - margin: 0; - list-style: none; - background: var(--nm-panel-solid, #0f172a); - color: var(--nm-text, #e2e8f0); - border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.18)); - border-radius: 8px; - box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); - font-size: 12px; - line-height: 1.3; -} - -.topo-ctx--edge { - min-width: 196px; - max-width: min(320px, calc(100vw - 16px)); - max-height: min(72vh, calc(100vh - 16px)); - overflow-x: hidden; - overflow-y: auto; - overscroll-behavior: contain; -} - -.topo-ctx__head { - margin: 0; - padding: 8px 12px 4px; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; - color: #94a3b8; -} - -.topo-ctx__style { - padding: 4px 12px 6px; -} - -.topo-ctx__style-title { - margin: 0 0 6px; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; - color: #94a3b8; -} - -.topo-ctx__style-grid { - display: grid; - gap: 6px; -} - -.topo-ctx__style-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin: 0; - font-size: 12px; - font-weight: 400; - color: #e2e8f0; - line-height: 1.2; -} - -.topo-ctx__style-row > span { - flex: 1 1 auto; - min-width: 2.5em; - max-width: 11.5em; - color: #cbd5e1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.topo-ctx__style-row input[type="color"] { - flex: 0 0 auto; - box-sizing: border-box; - width: 28px; - height: 22px; - padding: 0; - border: 1px solid #475569; - border-radius: 4px; - background: transparent; - cursor: pointer; -} - -.topo-ctx__style-row input[type="color"]::-webkit-color-swatch-wrapper { - padding: 1px; -} - -.topo-ctx__style-row input[type="color"]::-webkit-color-swatch { - border: 0; - border-radius: 2px; -} - -.topo-ctx__style-row select, -.topo-ctx__style-input { - flex: 1 1 auto; - max-width: 108px; - height: 24px; - border: 1px solid #475569; - border-radius: 4px; - background: #1e293b; - color: #e2e8f0; - font-size: 12px; - line-height: 1.2; - padding: 0 6px; -} - -.topo-ctx__style-input { - max-width: 120px; - min-width: 0; -} - -/* Beat .app-main button { color:#1f2937; background:#fff } so hover stays readable. */ -.app-main .topo-ctx__item, -.topo-ctx__item { - display: block; - width: 100%; - box-sizing: border-box; - padding: 7px 12px; - border: 0; - border-radius: 0; - background: transparent; - color: #e2e8f0; - text-align: left; - font-size: 12px; - font-weight: 400; - line-height: 1.3; - cursor: pointer; -} - -.app-main .topo-ctx__item:hover:not(:disabled), -.app-main .topo-ctx__item:focus-visible:not(:disabled), -.topo-ctx__item:hover:not(:disabled), -.topo-ctx__item:focus-visible:not(:disabled) { - background: #1e293b; - color: #f8fafc; -} - -.app-main .topo-ctx__item:disabled, -.topo-ctx__item:disabled { - opacity: 0.45; - cursor: not-allowed; - color: #94a3b8; -} - -.app-main .topo-ctx__item--danger, -.topo-ctx__item--danger { - color: #fca5a5; -} - -.app-main .topo-ctx__item--danger:hover:not(:disabled), -.app-main .topo-ctx__item--danger:focus-visible:not(:disabled), -.topo-ctx__item--danger:hover:not(:disabled), -.topo-ctx__item--danger:focus-visible:not(:disabled) { - background: #7f1d1d; - color: #fecaca; -} - -.topo-ctx__sep { - height: 1px; - margin: 4px 0; - background: var(--nm-glass-border, rgba(148, 163, 184, 0.18)); -} - -.topo-discover { - display: flex; - flex-direction: column; - gap: 8px; - max-height: none; - padding: 10px 12px; - background: rgba(15, 23, 42, 0.96); - color: #e2e8f0; - border-bottom: 1px solid rgba(148, 163, 184, 0.18); - overflow: hidden; -} - -.topo-discover__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - color: #f1f5f9; -} - -.topo-discover__head strong { - color: #f8fafc; -} - -.topo-discover .panel__hint, -.topo-discover .panel__hint--live { - color: #94a3b8; -} - -.topo-discover__progress { - display: flex; - flex-direction: column; - gap: 4px; -} - -.topo-discover__bar { - height: 6px; - border-radius: 6px; - background: rgba(51, 65, 85, 0.9); - overflow: hidden; -} - -.topo-discover__bar-fill { - height: 100%; - background: #38bdf8; - transition: width 0.2s ease; -} - -.topo-discover__progress-meta { - display: flex; - justify-content: space-between; - gap: 8px; - font-size: 12px; - color: #94a3b8; -} - -.topo-discover__summary { - margin: 0; - font-size: 13px; - color: #cbd5e1; -} - -.topo-discover__error { - margin: 0; - font-size: 13px; - color: #fca5a5; -} - -.topo-discover__item-meta, -.topo-discover__item-cmd, -.topo-discover__item-warn { - margin-top: 2px; - font-size: 12px; - color: #64748b; -} - -.topo-discover__item-warn { - color: #b45309; -} - -.topo-discover__unmatched { - margin: 6px 0 0; - padding-left: 1.1rem; - font-size: 11px; - color: #92400e; -} - -.topo-discover__unmatched-more { - list-style: none; - margin-left: -1.1rem; - color: #a16207; -} - -.topo-discover__row-actions { - display: inline-flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.topo-discover__detail-btn { - font-size: 11px; - color: var(--nm-accent, #3b82f6); - white-space: nowrap; -} - -.topo-discover__badge--lg { - font-size: 12px; - padding: 2px 8px; - border-radius: 6px; - border: 1px solid rgba(34, 197, 94, 0.35); - background: rgba(20, 83, 45, 0.45); - color: #bbf7d0; -} - -.topo-discover__badge--lg.is-fail { - border-color: rgba(248, 113, 113, 0.4); - background: rgba(127, 29, 29, 0.45); - color: #fecaca; -} - -.topo-discover__badge--lg.is-warn { - border-color: rgba(245, 158, 11, 0.4); - background: rgba(120, 53, 15, 0.45); - color: #fde68a; -} - -.topo-discover-modal { - display: block; - width: min(960px, 100%); - max-height: min(88vh, 900px); - overflow: auto; - color: var(--nm-text, #e2e8f0); -} - -.topo-discover-modal__head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.topo-discover-modal__head h3 { - margin: 0; - color: var(--nm-brand, #f1f5f9); -} - -.topo-discover-modal__sub { - margin: 4px 0 0; - font-size: 12px; - color: var(--nm-muted, #94a3b8); - font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; -} - -.topo-discover-modal__stats { - margin-top: 8px; - font-size: 13px; - color: var(--nm-muted, #94a3b8); -} - -.topo-discover-modal__section { - margin: 14px 0 8px; - font-size: 13px; - font-weight: 700; - color: var(--nm-brand, #f1f5f9); -} - -.topo-discover-modal__table-wrap { - overflow-x: auto; - overflow-y: visible; - max-height: none; - border: 1px solid var(--nm-panel-border, rgba(148, 163, 184, 0.22)); - border-radius: 8px; - background: var(--nm-panel-solid, #0f172a); -} - -.topo-discover-modal__table { - width: 100%; - border-collapse: collapse; - font-size: 12px; - table-layout: auto; -} - -.topo-discover-modal__table th, -.topo-discover-modal__table td { - padding: 8px 10px; - border-bottom: 1px solid rgba(148, 163, 184, 0.16); - text-align: left; - vertical-align: top; - color: var(--nm-text, #e2e8f0); - background: transparent; -} - -.topo-discover-modal__table th { - position: static; - background: rgba(15, 23, 42, 0.95); - color: var(--nm-muted, #94a3b8); - font-weight: 600; - white-space: nowrap; -} - -.topo-discover-modal__table tbody tr:hover td { - background: rgba(30, 41, 59, 0.55); -} - -.topo-discover-modal__table tbody tr:last-child td { - border-bottom: 0; -} - -.topo-discover-modal__peer { - display: flex; - flex-direction: column; - gap: 2px; -} - -.topo-discover-modal__peer span { - color: var(--nm-muted, #94a3b8); -} - -.topo-discover-action.is-added { - color: #86efac; - font-weight: 600; -} - -.topo-discover-action.is-updated { - color: #93c5fd; - font-weight: 600; -} - -.topo-discover-action.is-kept_manual { - color: #fbbf24; - font-weight: 600; -} - -.topo-discover-modal__raw { - margin: 0; - padding: 10px 12px; - max-height: min(36vh, 360px); - overflow: auto; - background: #0b1220; - color: var(--nm-text, #e2e8f0); - border: 1px solid rgba(148, 163, 184, 0.2); - border-radius: 8px; - font-size: 11px; - line-height: 1.45; - white-space: pre-wrap; - word-break: break-word; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - -.topo-canvas { - position: relative; - flex: 1; - min-height: 0; - background: var(--topo-canvas-bg, #0f172a); - --xy-edge-label-background-color: transparent; - --xy-edge-label-color: var(--topo-edge-label-color, #e2e8f0); - /* Defaults = dark canvas; TopologyPage overrides via canvasFloatChromeVars. */ - --topo-float-bg: rgba(15, 23, 42, 0.92); - --topo-float-border: rgba(148, 163, 184, 0.22); - --topo-float-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); - --topo-float-fg: #e2e8f0; - --topo-float-muted: #94a3b8; - --topo-float-btn-bg: #0b1220; - --topo-float-btn-border: rgba(148, 163, 184, 0.28); - --topo-float-btn-hover: #152033; - --topo-float-active-bg: rgba(37, 99, 235, 0.28); - --topo-float-active-fg: #93c5fd; - --topo-float-active-border: #3b82f6; - --topo-float-control-bg: #0f172a; - --topo-float-control-hover: #1e293b; - --topo-float-control-fill: #cbd5e1; - --topo-float-input-bg: #0b1220; - --topo-float-input-fg: #e2e8f0; - --topo-float-input-border: rgba(148, 163, 184, 0.28); -} - -.topo-canvas:fullscreen, -.topo-canvas.is-fullscreen:fullscreen { - width: 100vw; - height: 100vh; - background: var(--topo-canvas-bg, #0f172a); -} - -/* Place fullscreen above zoom-in (+); Controls renders custom children last. */ -.topo-canvas .react-flow__controls { - display: flex; - flex-direction: column; - box-shadow: var(--topo-float-shadow); - border: 0; - border-radius: 6px; - overflow: hidden; - background: var(--topo-float-bg); -} - -.topo-canvas .react-flow__controls-button { - background: transparent; - border: 0; - border-bottom: 1px solid var(--topo-float-border); - color: var(--topo-float-control-fill); - fill: var(--topo-float-control-fill); -} - -.topo-canvas .react-flow__controls-button:last-child { - border-bottom: 0; -} - -.topo-canvas .react-flow__controls-button:hover { - background: var(--topo-float-control-hover); -} - -.topo-canvas .react-flow__controls-button svg, -.topo-canvas .react-flow__controls-button svg path { - fill: currentColor; -} - -.topo-canvas .react-flow__minimap { - border: 0; - border-radius: 8px; - overflow: hidden; - box-shadow: var(--topo-float-shadow); -} - -.topo-canvas .react-flow__controls-button.topo-fs-control { - order: -1; -} - -.topo-canvas .react-flow__controls-button.topo-fs-control svg { - max-width: 14px; - max-height: 14px; - width: 14px; - height: 14px; - display: block; - color: inherit; -} - -.topo-toolbar__cluster .topo-toolbar__group--find { - margin-left: 0; - padding-left: 0; - border-left: 0; -} - -.topo-fs-toolbar { - position: absolute; - top: 12px; - left: 12px; - z-index: 20; - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 8px; - border-radius: 10px; - background: var(--topo-float-bg); - border: 1px solid var(--topo-float-border); - box-shadow: var(--topo-float-shadow); - color: var(--topo-float-fg); - pointer-events: auto; -} - -.topo-view-tools { - position: absolute; - top: 12px; - right: 12px; - z-index: 20; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - padding: 6px 8px; - border-radius: 10px; - background: var(--topo-float-bg); - border: 1px solid var(--topo-float-border); - box-shadow: var(--topo-float-shadow); - color: var(--topo-float-fg); - pointer-events: auto; - max-width: min(520px, calc(100% - 24px)); -} - -.topo-view-tools .topo-toolbar__display { - position: relative; -} - -.topo-view-tools .topo-toolbar__display .topo-display-toggles { - right: 0; - left: auto; - max-height: min(70vh, 520px); - overflow: auto; -} - -.topo-view-tools .topo-toolbar__group--find { - margin-left: 0; - padding-left: 0; - border-left: 0; - position: relative; -} - -.topo-view-tools .topo-toolbar__find { - width: 140px; - min-width: 120px; -} - -.topo-canvas.is-fullscreen .topo-view-tools, -.topo-canvas:fullscreen .topo-view-tools { - top: 12px; - right: 12px; -} - -/* Beat `.app-main button { background:#0b1220 }` so inactive chips stay ghost. */ -.app-main .topo-fs-toolbar__btn, -.topo-canvas .topo-fs-toolbar__btn, -.topo-fs-toolbar__btn { - display: inline-flex; - align-items: center; - gap: 6px; - height: auto; - padding: 6px 10px !important; - border-radius: 8px !important; - border: 1px solid transparent !important; - background: transparent !important; - color: var(--topo-float-muted) !important; - box-shadow: none !important; - font-size: 12px; - font-weight: 600; - line-height: 1.2; - cursor: pointer; -} - -.app-main .topo-fs-toolbar__btn:hover, -.topo-canvas .topo-fs-toolbar__btn:hover, -.topo-fs-toolbar__btn:hover { - background: var(--topo-float-btn-hover) !important; - color: var(--topo-float-fg) !important; -} - -.app-main .topo-fs-toolbar__btn.is-active, -.topo-canvas .topo-fs-toolbar__btn.is-active, -.topo-fs-toolbar__btn.is-active { - border-color: var(--topo-float-active-border) !important; - background: var(--topo-float-active-bg) !important; - color: var(--topo-float-active-fg) !important; -} - -.app-main .topo-fs-toolbar__btn kbd, -.topo-canvas .topo-fs-toolbar__btn kbd, -.topo-fs-toolbar__btn kbd { - font-size: 10px; - opacity: 0.7; - border-color: currentColor; - background: transparent; - color: inherit; - padding: 0 3px; -} - -/* Beat global dark toolbar overrides when canvas bg is light. */ -.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__find, -.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__select, -.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__display > summary { - background: var(--topo-float-input-bg) !important; - border-color: var(--topo-float-input-border) !important; - color: var(--topo-float-input-fg) !important; -} - -.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__find::placeholder { - color: var(--topo-float-muted); -} - -.topo-canvas .topo-view-tools .topo-toolbar__find, -.topo-canvas .topo-view-tools .topo-toolbar__select, -.topo-canvas .topo-view-tools .topo-toolbar__display > summary { - background: var(--topo-float-input-bg); - border-color: var(--topo-float-input-border); - color: var(--topo-float-input-fg); -} - -.topo-canvas__empty { - display: grid; - place-items: center; - height: 100%; - color: var(--nm-muted, #5b6b7c); -} - -.topo-canvas__status { - gap: 12px; - justify-items: center; - text-align: center; -} - -.topo-canvas__status p { - margin: 0; - font-size: 13px; -} - -.topo-loading-spinner { - display: inline-block; - width: 18px; - height: 18px; - border: 2px solid rgba(148, 163, 184, 0.28); - border-top-color: #60a5fa; - border-radius: 50%; - animation: topo-spin 0.7s linear infinite; -} - -.topo-loading-spinner--lg { - width: 36px; - height: 36px; - border-width: 3px; -} - -@keyframes topo-spin { - to { - transform: rotate(360deg); - } -} - -.topo-canvas__overlay { - position: absolute; - inset: 0; - z-index: 15; - display: grid; - place-content: center; - justify-items: center; - gap: 8px; - padding: 24px; - text-align: center; - pointer-events: none; -} - -.topo-canvas__overlay--loading { - background: rgba(11, 18, 32, 0.55); - backdrop-filter: blur(2px); - pointer-events: auto; -} - -.topo-canvas__overlay--empty { - background: transparent; - pointer-events: none; -} - -.topo-canvas__overlay-title { - margin: 0; - font-size: 15px; - font-weight: 600; - color: #e2e8f0; -} - -.topo-canvas__overlay--empty .topo-canvas__overlay-title { - color: #94a3b8; -} - -.topo-canvas__overlay-hint { - margin: 0; - max-width: 320px; - font-size: 12px; - line-height: 1.45; - color: #94a3b8; -} - -.topo-canvas__refresh { - position: absolute; - top: 12px; - left: 50%; - z-index: 21; - display: inline-flex; - align-items: center; - gap: 8px; - transform: translateX(-50%); - padding: 6px 12px; - border-radius: 999px; - border: 1px solid rgba(148, 163, 184, 0.28); - background: rgba(15, 23, 42, 0.92); - color: #cbd5e1; - font-size: 12px; - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28); - pointer-events: none; -} - -.topo-browser__empty--loading { - gap: 12px; -} - -.topo-browser__empty--loading p { - margin: 0; -} - -.topo-sidebar .panel__hint { - margin: 0; - font-size: 12px; - color: var(--nm-muted); -} - -.topo-tree-status { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.topo-sidebar .input { - width: 100%; - height: 32px; - margin: 0; - padding: 0 10px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: #0b1220; - color: #e2e8f0; - font-size: 12px; -} - -.topo-sidebar .input::placeholder { - color: var(--nm-muted); -} - -.topo-sidebar .btn.btn--sm { - display: inline-flex; - align-items: center; - justify-content: center; - height: 28px; - padding: 0 10px; - line-height: 1; - border-radius: 6px; - border-color: rgba(148, 163, 184, 0.28); - background: #0b1220; - color: #e2e8f0; - font-size: 12px; -} - -.topo-node { - position: relative; - width: 88px; - min-width: 0; - max-width: none; - height: 28px; - padding: 0; - border: 0; - background: transparent; - box-shadow: none; - font-size: 8px; - text-align: center; - --topo-icon-color: var(--topo-vendor-gray, #94a3b8); -} - -/* Flat-world LOD: pixel / pin overview (Google-Earth style). */ -.topo-node--dot, -.topo-node--pin { - min-width: 0; - max-width: none; - width: 100%; - height: 100%; - display: grid; - place-items: center; - color: var(--topo-icon-color, #94a3b8); - pointer-events: auto; -} - -.topo-world-hud { - position: absolute; - z-index: 6; - top: 10px; - left: 12px; - max-width: min(520px, calc(100% - 24px)); - padding: 4px 8px; - border-radius: 6px; - background: rgba(15, 23, 42, 0.72); - color: #cbd5e1; - font-size: 11px; - line-height: 1.35; - pointer-events: none; - backdrop-filter: blur(2px); -} - -.topo-world-scatter { - position: absolute; - inset: 0; - z-index: 1; - width: 100%; - height: 100%; - pointer-events: none; -} - -.topo-world-scatter-panel.react-flow__panel { - margin: 0 !important; -} - -.topo-canvas__overlay--empty.is-interactive { - pointer-events: auto; - background: rgba(11, 18, 32, 0.35); -} - -.topo-node__pixel { - display: block; - width: 4px; - height: 4px; - border-radius: 50%; - background: currentColor; - box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.35); -} - -.topo-node--dot.is-selected .topo-node__pixel { - width: 6px; - height: 6px; - box-shadow: 0 0 0 2px #38bdf8; -} - -.topo-node__pin { - display: block; - width: 10px; - height: 10px; - border-radius: 50%; - background: currentColor; - border: 1px solid rgba(15, 23, 42, 0.45); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); -} - -.topo-node--pin.is-selected .topo-node__pin { - box-shadow: 0 0 0 2px #38bdf8; -} - -.topo-node__handle--dot { - opacity: 0 !important; - width: 1px !important; - height: 1px !important; - min-width: 0 !important; - min-height: 0 !important; - border: 0 !important; - pointer-events: none !important; -} - -.topo-node.is-placeholder .topo-node__glyph { - outline: none; - opacity: 0.88; -} -.topo-node.is-placeholder .topo-node__icon { - filter: drop-shadow(0 0 1px rgba(148, 163, 184, 0.85)); - opacity: 0.92; -} - -.topo-node__badge { - position: absolute; - top: -4px; - right: -6px; - z-index: 2; - min-width: 16px; - padding: 1px 4px; - border-radius: 999px; - background: color-mix(in srgb, #0f172a 88%, #fbbf24 12%); - border: 1px solid rgba(251, 191, 36, 0.45); - color: #fde68a; - font-size: 7px; - font-weight: 700; - line-height: 1.25; - text-transform: uppercase; - letter-spacing: 0.04em; - pointer-events: none; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); -} - -.topo-node__conn { - position: absolute; - left: -2px; - bottom: -2px; - z-index: 2; - width: 8px; - height: 8px; - border-radius: 50%; - border: 1.5px solid rgba(15, 23, 42, 0.9); - pointer-events: none; - box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.25); -} -.topo-node__conn--pass { - background: #22c55e; -} -.topo-node__conn--fail { - background: #ef4444; -} -.topo-node__conn--testing { - background: #f59e0b; -} -.topo-node__conn--unknown { - background: #64748b; -} - -.topo-node__alarm { - position: absolute; - top: -5px; - right: -6px; - z-index: 3; - min-width: 16px; - padding: 1px 4px; - border-radius: 999px; - font-size: 8px; - font-weight: 700; - line-height: 1.35; - color: #fff; - pointer-events: none; - border: 1px solid rgba(15, 23, 42, 0.75); - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.4); -} -.topo-node__alarm--critical { - background: #dc2626; -} -.topo-node__alarm--major { - background: #ea580c; -} -.topo-node__alarm--minor { - background: #ca8a04; -} -.topo-node__alarm--warning, -.topo-node__alarm--indeterminate, -.topo-node__alarm--unknown { - background: #d97706; -} - -.topo-node__unmatched { - position: absolute; - top: -3px; - right: -4px; - z-index: 2; - width: 12px; - height: 12px; - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - background: rgba(51, 65, 85, 0.95); - border: 1px solid rgba(148, 163, 184, 0.55); - color: #e2e8f0; - font-size: 8px; - font-weight: 700; - line-height: 1; - pointer-events: none; -} - -.topo-node.is-level-external .topo-node__glyph, -.topo-node.is-level-core .topo-node__glyph, -.topo-node.is-level-aggregation .topo-node__glyph, -.topo-node.is-level-access .topo-node__glyph, -.topo-node.is-level-edge .topo-node__glyph, -.topo-node.is-level-unmatched .topo-node__glyph { - box-shadow: none; - background: transparent; -} -/* Level tint on the icon art only — no rounded plate behind the glyph. */ -.topo-node.is-level-external { - --topo-level-ring: #38bdf8; -} -.topo-node.is-level-core { - --topo-level-ring: #60a5fa; -} -.topo-node.is-level-aggregation { - --topo-level-ring: #34d399; -} -.topo-node.is-level-access { - --topo-level-ring: #fbbf24; -} -.topo-node.is-level-edge { - --topo-level-ring: #fb7185; -} -.topo-node.is-level-unmatched { - --topo-level-ring: rgba(148, 163, 184, 0.7); -} -.topo-node.is-level-external .topo-node__icon, -.topo-node.is-level-core .topo-node__icon, -.topo-node.is-level-aggregation .topo-node__icon, -.topo-node.is-level-access .topo-node__icon, -.topo-node.is-level-edge .topo-node__icon, -.topo-node.is-level-unmatched .topo-node__icon { - filter: drop-shadow(0 0 1.5px var(--topo-level-ring)); -} - -.topo-node.has-alarm--critical .topo-node__glyph { - outline: 2px solid rgba(220, 38, 38, 0.85); - outline-offset: 1px; -} -.topo-node.has-alarm--major .topo-node__glyph { - outline: 2px solid rgba(234, 88, 12, 0.85); - outline-offset: 1px; -} - -.topo-truncate-banner { - position: absolute; - top: 8px; - left: 50%; - z-index: 5; - transform: translateX(-50%); - max-width: min(720px, calc(100% - 24px)); - padding: 8px 12px; - border-radius: 8px; - border: 1px solid rgba(251, 191, 36, 0.45); - background: rgba(15, 23, 42, 0.92); - color: #fde68a; - font-size: 12px; - line-height: 1.4; - text-align: center; - pointer-events: none; -} - -.topo-readonly-banner { - position: absolute; - top: 8px; - right: 12px; - z-index: 5; - max-width: min(360px, calc(100% - 24px)); - padding: 6px 10px; - border-radius: 8px; - border: 1px solid rgba(56, 189, 248, 0.45); - background: rgba(15, 23, 42, 0.92); - color: #bae6fd; - font-size: 12px; - line-height: 1.4; - pointer-events: none; -} - -.topo-canvas__overlay--error { - background: rgba(15, 23, 42, 0.88); -} - -.app-main .topo-toolbar__outside-peers, -.topo-toolbar__outside-peers { - margin-left: auto; - flex-shrink: 0; - white-space: nowrap; - width: auto; - height: 30px; - padding: 0 10px; - font-size: 12px; - font-weight: 600; - line-height: 1; - border-radius: 6px; - border: 1px solid rgba(148, 163, 184, 0.28); - background: #0b1220; - color: #94a3b8; -} - -.app-main .topo-toolbar__outside-peers:hover, -.topo-toolbar__outside-peers:hover { - background: #152033; - color: #e2e8f0; -} - -.topo-modal__panel--wide { - width: min(560px, calc(100vw - 32px)); -} - -.topo-modal__count { - font-weight: 500; - color: #94a3b8; -} - -.topo-node--cisco { - --topo-icon-color: var(--topo-vendor-cisco, #049fd9); -} -.topo-node--huawei { - --topo-icon-color: var(--topo-vendor-huawei, #cf0a2c); -} -.topo-node--zte { - --topo-icon-color: var(--topo-vendor-zte, #0091da); -} -.topo-node--juniper { - --topo-icon-color: var(--topo-vendor-juniper, #84b135); -} -.topo-node--nokia { - --topo-icon-color: var(--topo-vendor-nokia, #124191); -} -.topo-node--ericsson { - --topo-icon-color: var(--topo-vendor-ericsson, #1e3a5f); -} -.topo-node--h3c { - --topo-icon-color: var(--topo-vendor-h3c, #7ac143); -} -.topo-node--ruijie { - --topo-icon-color: var(--topo-vendor-ruijie, #7c3aed); -} -.topo-node--mikrotik { - --topo-icon-color: var(--topo-vendor-mikrotik, #ea580c); -} -.topo-node--gray, -.topo-node--other { - --topo-icon-color: var(--topo-vendor-gray, #94a3b8); -} -.topo-node--region { - --topo-icon-color: #38bdf8; -} -.topo-region-list__chevron { - flex: 0 0 auto; - width: 24px; - height: 24px; - min-width: 24px; - opacity: 0.75; -} -.topo-region-list__chevron:disabled { - opacity: 0.25; - cursor: default; -} -.topo-region-list__chevron-spacer { - flex: 0 0 auto; - width: 24px; - min-width: 24px; - height: 24px; -} -.topo-region-list .topo-map-list__row { - display: flex; - align-items: center; - gap: 2px; -} - -.topo-node.is-region { - height: 17px; -} - -.topo-node__glyph { - position: relative; - width: 28px; - height: 28px; - margin: 0 auto; - border-radius: 0; - background: transparent; - box-shadow: none; - transition: filter 0.15s ease; -} - -.topo-node.is-region .topo-node__glyph { - width: 28px; - height: 19px; -} - -.topo-node__icon--region { - width: 28px; - height: 19px; -} - -.react-flow__node.is-search-hit { - z-index: 1000 !important; -} - -.react-flow__node.is-search-hit .topo-node { - --topo-icon-color: #fbbf24; - filter: drop-shadow(0 0 8px rgba(251, 191, 36, 0.95)); - animation: topo-search-flash 0.7s ease-in-out infinite; -} - -.react-flow__node.is-search-hit .topo-node__glyph, -.react-flow__node.is-search-hit .topo-node__pixel, -.react-flow__node.is-search-hit .topo-node__pin { - outline: none; - border-radius: 0; -} -.react-flow__node.is-search-hit .topo-node__icon { - filter: drop-shadow(0 0 6px rgba(251, 191, 36, 0.95)); -} - -.react-flow__node.is-search-hit .topo-node__pixel, -.react-flow__node.is-search-hit .topo-node__pin { - background: #fbbf24 !important; - color: #fbbf24; - box-shadow: 0 0 10px rgba(251, 191, 36, 0.95); -} - -.react-flow__node.is-search-hit .topo-node__icon-tint { - background-color: #fbbf24 !important; -} - -.react-flow__node.is-search-hit .topo-node__caption { - opacity: 1; - z-index: 6; -} - -.react-flow__node.is-search-hit .topo-node__caption-name { - color: #fde68a; - font-weight: 700; - font-size: 10px; - max-width: 140px; - text-shadow: - 0 0 4px rgba(0, 0, 0, 1), - 0 0 8px rgba(251, 191, 36, 0.85); -} - -@keyframes topo-search-flash { - 0%, - 100% { - filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.7)); - opacity: 1; - transform: scale(1); - } - 50% { - filter: drop-shadow(0 0 14px rgba(251, 191, 36, 1)); - opacity: 0.72; - transform: scale(1.18); - } -} - -.topo-node.is-selected .topo-node__glyph { - outline: none; - box-shadow: none; - background: transparent; -} - -.topo-node.is-selected .topo-node__icon { - transform: none; - filter: drop-shadow(0 0 3px rgba(56, 189, 248, 0.95)); -} - -.topo-node__handle { - width: 10px !important; - height: 10px !important; - min-width: 10px !important; - min-height: 10px !important; - background: transparent !important; - border: 0 !important; - opacity: 0 !important; - pointer-events: none; -} - -.topo-node.is-connect-mode .topo-node__handle { - pointer-events: all; -} - -.topo-node__handle--center { - left: 50% !important; - top: 50% !important; - right: auto !important; - bottom: auto !important; - transform: translate(-50%, -50%) !important; -} - -.topo-node.is-selected .topo-node__caption { - font-weight: 700; -} - -.topo-node.is-selected .topo-node__caption-name { - color: var(--topo-label-color, #f1f5f9); - filter: brightness(1.12); -} - -/* Selected link: dark green so it stands out from UME blue / LLDP sky. */ -.topo-canvas .react-flow__edge.selected .react-flow__edge-path { - stroke: #166534 !important; - stroke-width: 3px !important; -} - -.topo-node__icon { - position: relative; - display: block; - width: 28px; - height: 28px; - margin: 0; - pointer-events: none; - user-select: none; - isolation: isolate; - transition: transform 0.12s ease; -} - -.topo-node__icon-art { - display: block; - width: 100%; - height: 100%; - object-fit: contain; -} - -/* Recolor hue/sat from --topo-icon-color; keep luminosity so white textures remain. */ -.topo-node__icon-tint { - position: absolute; - inset: 0; - background-color: var(--topo-icon-color, #3b82f6); - mix-blend-mode: color; - -webkit-mask-image: url(/topo/ne-router.png); - mask-image: url(/topo/ne-router.png); - -webkit-mask-size: contain; - mask-size: contain; - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; - -webkit-mask-position: center; - mask-position: center; - -webkit-mask-mode: alpha; - mask-mode: alpha; - pointer-events: none; - transition: background-color 0.12s ease; -} - -.topo-node__caption { - position: absolute; - left: 50%; - top: calc(100% + 4px); - transform: translateX(-50%); - z-index: 2; - display: flex; - flex-direction: column; - align-items: center; - gap: 1px; - margin-top: 0; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - backdrop-filter: none; - -webkit-backdrop-filter: none; - box-shadow: none; - color: var(--topo-label-color, #f1f5f9); - line-height: 1.2; - width: max-content; - max-width: 148px; - pointer-events: none; -} - -.topo-node__caption-name { - display: block; - max-width: 134px; - font-size: 10px; - font-weight: 650; - letter-spacing: 0.01em; - color: var(--topo-label-color, #f1f5f9); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-shadow: - 0 1px 2px rgba(0, 0, 0, 0.9), - 0 0 6px rgba(0, 0, 0, 0.65); -} - -.topo-node__caption-meta { - display: block; - max-width: 134px; - font-size: 8px; - color: color-mix(in srgb, var(--topo-label-color, #f1f5f9) 55%, #94a3b8); - font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; - text-shadow: - 0 1px 2px rgba(0, 0, 0, 0.9), - 0 0 6px rgba(0, 0, 0, 0.55); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.topo-canvas .react-flow__edge-textbg { - fill: none !important; - display: none !important; -} - -.topo-canvas .react-flow__edge-text { - fill: var(--topo-edge-label-color, #e2e8f0); - font-size: 11px; - paint-order: stroke; - stroke: rgba(15, 23, 42, 0.85); - stroke-width: 3px; -} - -.topo-edge-label { - font-size: 11px; - color: var(--topo-edge-label-color, #e2e8f0); - background: transparent !important; - border: 0 !important; - border-radius: 0; - padding: 0 !important; - box-shadow: none !important; - white-space: nowrap; - text-shadow: - 0 0 3px rgba(15, 23, 42, 0.95), - 0 1px 2px rgba(15, 23, 42, 0.85); -} - -.topo-edge-label.is-selected { - color: var(--topo-edge-label-color, #e2e8f0); - font-weight: 600; - filter: brightness(1.15); -} - -.topo-ctx__member-list { - margin: 0 0 8px; - padding: 0 0 0 16px; - font-size: 12px; - color: #cbd5e1; - max-height: 96px; - overflow: auto; - overscroll-behavior: contain; -} - -.topo-ctx__style-members { - grid-column: 1 / -1; - display: grid; - gap: 6px; -} - -@media (max-width: 960px) { - .topo-page, - .topo-page.is-sidebar-collapsed { - grid-template-columns: 1fr; - height: auto; - } - - .topo-sidebar__resizer { - display: none; - } - - .topo-sidebar { - max-height: 40vh; - } - - .topo-page.is-sidebar-collapsed .topo-sidebar { - max-height: none; - min-height: 40px; - } - - .topo-sidebar__rail { - flex-direction: row; - justify-content: center; - padding: 6px 8px; - } - - .topo-sidebar__rail-label { - writing-mode: horizontal-tb; - } - - .topo-canvas { - min-height: 420px; - } -} - -.login-page { - --login-accent: #3b82f6; - --login-accent-deep: #2563eb; - --login-ink: rgba(255, 255, 255, 0.92); - --login-muted: rgba(255, 255, 255, 0.55); - --login-field: rgba(255, 255, 255, 0.08); - --login-field-border: rgba(255, 255, 255, 0.14); - --login-danger: #fda29b; - --login-rail-w: clamp(360px, 30vw, 420px); - position: relative; - isolation: isolate; - min-height: 100vh; - overflow: hidden; - display: grid; - grid-template-columns: minmax(0, 1fr) var(--login-rail-w); - align-items: stretch; - background: #00050a; - font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; - color: var(--login-ink); -} - -.login-page__space { - position: absolute; - inset: 0; - z-index: 1; - display: flex; - align-items: center; - /* Center earth within the left stage (not under the form rail). */ - justify-content: center; - padding: 3vh calc(var(--login-rail-w) + 3vw) 3vh 5vw; - background: url("/login-space.webp") center / cover no-repeat; -} - -.login-page__space::before { - content: ""; - position: absolute; - inset: 0; - z-index: 2; - pointer-events: none; - background: - linear-gradient( - 90deg, - transparent 0%, - transparent 52%, - rgba(0, 5, 12, 0.18) 70%, - rgba(0, 5, 12, 0.48) 88%, - rgba(0, 5, 12, 0.72) 100% - ), - linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, transparent 30%, transparent 72%, rgba(0, 0, 0, 0.38) 100%); -} - -/* Tiny sun flare — upper-left void. */ -.login-page__flares { - position: absolute; - inset: 0; - z-index: 4; - pointer-events: none; - overflow: hidden; -} - -.login-page__flare { - position: absolute; - border-radius: 50%; - pointer-events: none; - opacity: 0.85; -} - -.login-page__flare--a { - width: 44px; - height: 44px; - left: 7%; - top: 11%; - background: - radial-gradient( - circle at 50% 50%, - rgba(245, 250, 255, 0.92) 0%, - rgba(170, 210, 255, 0.48) 28%, - rgba(90, 150, 245, 0.14) 55%, - transparent 78% - ); - box-shadow: 0 0 16px 3px rgba(120, 180, 255, 0.2); - animation: login-flare-pulse-a 10s ease-in-out infinite alternate; -} - -.login-planet { - position: relative; - z-index: 1; - width: min(500px, 38vw, 62vmin); - height: min(500px, 38vw, 62vmin); - border-radius: 50%; - transform: rotate(5deg); - animation: login-planet-enter 1.8s cubic-bezier(0.76, -0.46, 0.37, 0.99) both; -} - -.login-planet__earth { - position: relative; - z-index: 1; - width: 100%; - height: 100%; - border-radius: 50%; - overflow: hidden; - /* Soft dissolve into space — no outer glow (Earth does not emit light). */ - -webkit-mask-image: radial-gradient(circle at 50% 50%, #000 87%, transparent 100%); - mask-image: radial-gradient(circle at 50% 50%, #000 87%, transparent 100%); - transform-origin: center center; -} - -/* Gentle volume shade — cooler blue midtones. */ -.login-planet__earth::before { - content: ""; - position: absolute; - z-index: 1; - inset: 0; - border-radius: 50%; - pointer-events: none; - background: linear-gradient( - 148deg, - rgba(70, 130, 210, 0.16) 0%, - rgba(45, 100, 185, 0.08) 20%, - transparent 42%, - rgba(0, 12, 36, 0.22) 72%, - rgba(0, 8, 24, 0.42) 100% - ); -} - -/* - * Blue atmospheric limb (scattered sunlight) — stays on the sphere only. - */ -.login-planet__earth::after { - content: ""; - position: absolute; - z-index: 2; - inset: 0; - border-radius: 50%; - pointer-events: none; - background: - radial-gradient( - 120% 95% at 10% 12%, - rgba(55, 130, 230, 0.42) 0%, - rgba(40, 110, 210, 0.22) 28%, - rgba(30, 90, 185, 0.08) 48%, - transparent 66% - ), - radial-gradient( - circle at 50% 50%, - transparent 74%, - rgba(40, 105, 200, 0.14) 88%, - rgba(50, 120, 215, 0.22) 94%, - transparent 100% - ); - box-shadow: - inset 0 0 20px 4px rgba(45, 110, 200, 0.28), - inset 22px 26px 32px 4px rgba(60, 130, 220, 0.12), - inset -20px -24px 40px 10px rgba(0, 6, 20, 0.3); - animation: login-earth-inner 2.2s ease-in-out 0.7s both; -} - -.login-planet__textures { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - background-color: rgba(8, 28, 72, 0.2); - /* Pre-brightened asset — no live CSS filter (that was the GPU thrash). */ - background-image: url("/login-earth-night-bright.webp"); - background-repeat: repeat-x; - background-size: 200%; - animation: login-earth-spin 80s linear infinite; -} - -.login-page__rail { - position: relative; - z-index: 5; - grid-column: 2; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - min-width: 0; - padding: 48px clamp(32px, 3.5vw, 52px) 48px 8px; -} - -.login-card { - width: 100%; - max-width: 340px; - display: flex; - flex-direction: column; - gap: 15px; - padding: 26px 24px 28px; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.1); - background: rgba(8, 16, 32, 0.82); - /* Lighter blur — full blur over the spinning globe was a steady GPU cost. */ - backdrop-filter: blur(8px) saturate(1.08); - -webkit-backdrop-filter: blur(8px) saturate(1.08); - box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.32), - 0 0 0 1px rgba(120, 170, 255, 0.07), - inset 0 1px 0 rgba(255, 255, 255, 0.07); - animation: login-card-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both; -} - -.login-card__head { - display: flex; - align-items: baseline; - justify-content: flex-start; - gap: 10px; - flex-wrap: wrap; - margin-bottom: 2px; -} - -.login-card__brand, -.login-card__title { - margin: 0; - font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; - font-size: clamp(1.55rem, 4vw, 1.85rem); - font-weight: 700; - line-height: 1.15; - color: rgba(226, 232, 240, 0.88); -} - -.login-card__brand { - letter-spacing: 0.1em; - text-shadow: 0 0 22px rgba(59, 130, 246, 0.28); - animation: login-brand-in 700ms cubic-bezier(0.22, 1, 0.36, 1) both; -} - -.login-card__title { - font-weight: 650; - letter-spacing: 0.02em; - color: rgba(203, 213, 225, 0.9); -} - -.login-card__hint { - margin: -4px 0 4px; - color: var(--login-muted); - font-size: 13px; - line-height: 1.45; -} - -.login-card__sr { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.login-card__label { - position: relative; - display: block; -} - -.login-card__label input, -.login-card__password input { - width: 100%; - height: 44px; - padding: 0 14px; - border: 1px solid var(--login-field-border); - border-radius: 10px; - background: var(--login-field); - color: #fff; - font: inherit; - font-size: 15px; - box-sizing: border-box; - transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; -} - -.login-card__label input::placeholder, -.login-card__password input::placeholder { - color: rgba(255, 255, 255, 0.42); -} - -.login-card__password { - position: relative; - display: block; - width: 100%; -} - -.login-card__password input { - padding-right: 44px !important; -} - -.login-card__eye { - position: absolute; - top: 50%; - right: 6px; - z-index: 2; - transform: translateY(-50%); - width: 34px; - height: 34px; - padding: 0; - border: 0; - border-radius: 8px; - background: transparent; - color: rgba(255, 255, 255, 0.55); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; -} - -.login-card__eye:hover { - color: #93c5fd; -} - -.login-card__eye:focus-visible { - outline: none; - color: #93c5fd; - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35); -} - -.login-card__eye svg { - display: block; - pointer-events: none; -} - -.login-card__label input:focus, -.login-card__password input:focus { - outline: none; - border-color: rgba(59, 130, 246, 0.7); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.22); - background: rgba(255, 255, 255, 0.1); -} - -.login-card__label input:disabled, -.login-card__password input:disabled { - opacity: 0.65; - cursor: not-allowed; -} - -.login-card__error { - color: var(--login-danger); - font-size: 13px; - line-height: 1.4; - padding: 8px 10px; - border-radius: 8px; - background: rgba(180, 35, 24, 0.22); - border: 1px solid rgba(253, 162, 155, 0.28); - animation: login-error-in 220ms ease both; -} - -.login-card__submit { - margin-top: 8px; - height: 44px; - border: 0; - border-radius: 9999px; - background: linear-gradient(to right, #3b82f6, #2563eb, #2563eb); - color: #fff; - font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; - font-size: 15px; - font-weight: 600; - letter-spacing: 0.04em; - box-shadow: - 0 8px 15px -3px rgba(59, 130, 246, 0.5), - 0 4px 6px -4px rgba(59, 130, 246, 0.5); - cursor: pointer; - transition: transform 140ms ease, filter 140ms ease, opacity 140ms ease; -} - -.login-card__submit:hover:not(:disabled) { - filter: brightness(1.06); - transform: translateY(-1px); -} - -.login-card__submit:active:not(:disabled) { - transform: translateY(0); - filter: brightness(0.98); -} - -.login-card__submit:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -.login-card__submit--ghost, -.login-card__submit + .login-card__submit { - margin-top: 0; - background: transparent; - border: 1px solid rgba(59, 130, 246, 0.7); - color: #93c5fd; - box-shadow: none; -} - -.login-card__submit--ghost:hover:not(:disabled), -.login-card__submit + .login-card__submit:hover:not(:disabled) { - background: rgba(59, 130, 246, 0.12); - filter: none; -} - -@keyframes login-planet-enter { - 0% { - transform: scale(0.92) rotate(-5deg); - opacity: 0.65; - } - 100% { - transform: rotate(5deg); - opacity: 1; - } -} - -@keyframes login-earth-spin { - from { - background-position: calc(120% + 120px) 0; - } - to { - background-position: calc(-80% + 120px) 0; - } -} - -@keyframes login-earth-inner { - 0% { - opacity: 0.55; - } - 100% { - opacity: 1; - } -} - -@keyframes login-card-in { - from { - opacity: 0; - transform: translateY(12px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes login-brand-in { - from { - opacity: 0; - letter-spacing: 0.24em; - transform: translateY(8px); - } - to { - opacity: 1; - letter-spacing: 0.12em; - transform: translateY(0); - } -} - -@keyframes login-error-in { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes login-flare-pulse-a { - from { opacity: 0.7; transform: scale(0.96); } - to { opacity: 1; transform: scale(1.08); } -} - -/* Pause infinite loops when the login tab is backgrounded (class set by LoginShell). */ -html.login-page--paused .login-planet__textures, -html.login-page--paused .login-page__flare { - animation-play-state: paused !important; -} - -@media (prefers-reduced-motion: reduce) { - /* Keep ambient earth spin; only drop entrance flourishes. */ - .login-planet, - .login-planet__earth::after, - .login-card, - .login-card__brand, - .login-card__error, - .login-page__flare { - animation: none !important; - } - - .login-page__flare { - opacity: 0.85; - } - - .login-planet { - transform: rotate(5deg); - } - - .login-card__submit:hover:not(:disabled) { - transform: none; - } -} - -@media (max-width: 1200px) { - .login-page { - --login-rail-w: clamp(340px, 34vw, 400px); - } - - .login-planet { - width: min(440px, 40vw, 58vmin); - height: min(440px, 40vw, 58vmin); - } -} - -@media (max-width: 820px) { - .login-page { - grid-template-columns: 1fr; - } - - .login-page__space { - justify-content: center; - padding: 0; - } - - .login-page__space::before { - background: - linear-gradient(180deg, rgba(0, 0, 0, 0.18) 0%, transparent 30%, transparent 48%, rgba(0, 0, 0, 0.72) 100%); - } - - .login-planet { - width: min(420px, 78vmin); - height: min(420px, 78vmin); - opacity: 0.9; - } - - .login-page__rail { - grid-column: 1; - min-height: 100vh; - padding: 28px 18px 36px; - } - - .login-card { - max-width: 380px; - padding: 20px 18px 22px; - } - - .login-card__brand, - .login-card__title { - font-size: 1.4rem; - } -} - - -/* —— Network Management shell (left nav + main) —— - Fill the app chrome without a second document scrollbar. - Negative margins + 100vh used to fight app-main padding and over-expand the page. */ -.app--shell:has(.network-shell), -.app--shell:has(.audit-shell) { - height: 100vh; - max-height: 100vh; - overflow: hidden; -} - -.app-main:has(> .network-shell), -.app-main:has(> .audit-shell) { - flex: 1 1 auto; - min-height: 0; - padding: 0; - overflow: hidden; - display: flex; - flex-direction: column; -} - -.network-shell { - display: flex; - flex: 1 1 auto; - width: 100%; - min-height: 0; - height: auto; - margin: 0; - background: var(--nm-chrome); - overflow: hidden; -} - -.network-nav { - width: 232px; - flex-shrink: 0; - display: flex; - flex-direction: column; - background: #0a121c; - border-right: 1px solid var(--nm-ops-line); - min-height: 0; - color: var(--nm-text); - box-shadow: none; -} - -.network-nav__scroll { - flex: 1; - min-height: 0; - overflow: auto; - padding: 8px 8px 16px; -} - -.network-nav__accordion.accordion { - width: 100%; - background: transparent; - border: 0; - border-radius: 0; - contain: none; -} - -.network-nav__group { - margin: 0 0 2px; - border-radius: 0; - background: transparent; -} - -.network-nav__group.is-active-group { - background: transparent; -} - -.app-main .network-nav__group-toggle, -.network-nav__group-toggle, -.network-nav .accordion__trigger.network-nav__group-toggle { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - padding: 8px 10px; - border: 0; - border-radius: 4px; - background: transparent; - color: var(--nm-ops-muted); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; - text-align: left; - cursor: pointer; -} - -.app-main .network-nav__group-toggle:hover, -.network-nav__group-toggle:hover, -.network-nav .accordion__trigger.network-nav__group-toggle:hover { - background: rgba(17, 28, 42, 0.8); - color: #d7e6f8; -} - -.network-nav__group.is-active-group .network-nav__group-toggle, -.network-nav__group.is-active-group .accordion__trigger.network-nav__group-toggle { - color: var(--nm-ops-accent); - background: transparent; -} - -.network-nav__chevron, -.network-nav .accordion__indicator.network-nav__chevron { - display: inline-flex; - width: 12px; - height: 12px; - color: #64748b; - flex-shrink: 0; - opacity: 0.85; -} - -.network-nav__group.is-active-group .network-nav__chevron, -.network-nav__group.is-active-group .accordion__indicator.network-nav__chevron { - color: var(--nm-ops-accent); -} - -.network-nav__group-label { - flex: 1; - min-width: 0; -} - -.network-nav .accordion__body-inner.network-nav__panel-body, -.network-nav .network-nav__panel-body { - padding: 0 0 6px !important; - color: inherit !important; -} - -.network-nav__list { - list-style: none; - margin: 0; - padding: 0 0 0 6px; - display: flex; - flex-direction: column; - gap: 1px; - border-left: 1px solid var(--nm-ops-line); - margin-left: 14px; -} - -.network-nav__list > li { - width: 100%; -} - -.network-nav__link { - display: block; - box-sizing: border-box; - width: 100%; - padding: 7px 10px; - border-radius: 4px; - color: var(--nm-ops-muted); - text-decoration: none; - font-size: 13px; - line-height: 1.35; - border: 0; - box-shadow: none; - transition: - background 120ms ease, - color 120ms ease; -} - -.network-nav__link:hover { - background: var(--nm-ops-surface); - color: #e8f0fa; -} - -.network-nav__link.is-active { - background: rgba(107, 169, 236, 0.1); - color: #edf5ff; - font-weight: 600; - box-shadow: inset 2px 0 0 var(--nm-ops-accent); -} - -.network-nav__brand { - padding: 14px 14px 12px; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #edf5ff; - border-bottom: 1px solid var(--nm-ops-line); - background: #0c1521; -} - -.network-nav__list--flat { - padding: 0; - margin-left: 0; - border-left: 0; - gap: 1px; -} - -.audit-shell .network-main { - overflow: auto; -} - -.network-main { - flex: 1; - min-width: 0; - min-height: 0; - overflow: auto; - padding: 20px 22px 28px; - background: - radial-gradient(1200px 480px at 0% 0%, rgba(59, 130, 246, 0.07), transparent 55%), - var(--nm-main, var(--nm-chrome)); -} - -/* Compact ops chrome (aligned with biz-monitor / biz-compare). */ -.network-main > .panel, -.network-main .page-stack > .panel, -.network-main > .nm-page-panel { - padding: 18px 20px 20px; - border: 1px solid var(--nm-ops-line); - border-radius: 6px; - background: #0c1521; - box-shadow: none; -} - -.network-main .panel__toolbar { - margin-bottom: 16px; - padding-bottom: 14px; - border-bottom: 1px solid var(--nm-ops-line); - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.network-main .panel__toolbar h2 { - margin: 0; - font-size: 18px; - font-weight: 650; - color: var(--nm-brand); - letter-spacing: -0.02em; -} - -.network-main .button, -.network-main .input, -.network-main .ui-field__select { - border-radius: 4px; - box-shadow: none; -} - -.network-main .filter-inline { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; - margin-bottom: 0; - padding: 12px 14px; - border: 1px solid var(--nm-ops-line); - border-radius: 4px; - background: rgba(8, 13, 24, 0.45); - grid-template-columns: none; -} - -.network-main .filter-inline input, -.network-main .filter-inline > select { - width: auto; - min-width: 140px; - flex: 1 1 160px; - max-width: 280px; - height: 34px; - margin: 0; - border: 1px solid var(--nm-ops-line); - border-radius: 4px; - background: #0b1220; - color: #e2e8f0; - padding: 0 12px; - font-size: 13px; -} - -/* FieldSelect wrapper is the flex item; nested select must NOT inherit flex-basis as height */ -.network-main .filter-inline > .ui-field { - flex: 1 1 160px; - max-width: 280px; - min-width: 140px; - align-self: center; - gap: 0; -} - -.network-main .filter-inline > .ui-field > .ui-field__select { - width: 100%; - height: 34px; - min-height: 34px; - max-height: 34px; - flex: none; - margin: 0; - border: 1px solid var(--nm-ops-line); - border-radius: 4px; - background: #0b1220; - color: #e2e8f0; - padding: 0 12px; - font-size: 13px; -} - -.network-main .filter-inline button { - height: 34px; - flex: 0 0 auto; -} - -.network-main .pt-list { - gap: 14px; -} - -.network-main .pt-list-kpis { - gap: 0; - border: 1px solid var(--nm-ops-line); - border-radius: 4px; - overflow: hidden; - background: var(--nm-ops-surface); -} - -.network-main .pt-list-kpi { - border: 0; - border-right: 1px solid var(--nm-ops-line); - border-radius: 0; - background: transparent; -} - -.network-main .pt-list-kpi:last-child { - border-right: 0; -} - -.network-main .pt-list-kpi__value { - font-family: ui-monospace, Consolas, monospace; -} - -.network-main .pt-list-kpi__label { - font-size: 11px; - color: var(--nm-ops-muted); -} - -.network-main .pt-list-table-wrap { - border: 1px solid var(--nm-ops-line); - border-radius: 4px; - overflow: hidden; - background: rgba(8, 13, 24, 0.28); -} - -.network-main .pt-list-table { - margin: 0; -} - -.network-main .pt-list-table thead th { - background: #162232; - color: #94a3b8; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; - text-transform: uppercase; - padding-top: 12px; - padding-bottom: 12px; -} - -.network-main .pt-list-table tbody td { - padding-top: 11px; - padding-bottom: 11px; - font-size: 12px; - border-bottom-color: var(--nm-ops-line); -} - -.network-main .pt-list-table tbody tr:last-child td { - border-bottom: 0; -} - -.network-main .pt-list-actions .button, -.network-main .table-actions .button { - min-height: 28px; -} - -@media (max-width: 800px) { - .network-main .pt-list-kpis { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .network-main .pt-list-kpi:nth-child(2) { - border-right: 0; - } - - .network-main .pt-list-kpi:nth-child(-n+2) { - border-bottom: 1px solid var(--nm-ops-line); - } -} - -.network-main .pager.pt-list-pager, -.network-main .pt-list-pager { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-top: 4px; - padding-top: 4px; -} - -.pt-port-updown { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 4px; -} - -.pt-port-updown .conn-pill { - min-width: 2.4em; - justify-content: center; - text-transform: lowercase; - font-variant-numeric: tabular-nums; -} - -.pt-wizard__confirm-port { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; -} - -.pt-list-status--ok, -.pt-list-status--up, -.pt-list-status--connected { - background: rgba(34, 197, 94, 0.16); - color: #86efac; -} - -.pt-list-status--down, -.pt-list-status--failed, -.pt-list-status--critical { - background: rgba(239, 68, 68, 0.16); - color: #fca5a5; -} - -.pt-list-status--major { - background: rgba(249, 115, 22, 0.16); - color: #fdba74; -} - -.pt-list-status--minor, -.pt-list-status--warning { - background: rgba(245, 158, 11, 0.16); - color: #fde68a; -} - -.pt-list-status--info { - background: rgba(56, 189, 248, 0.14); - color: #7dd3fc; -} - -.pt-list-status--unknown { - background: rgba(148, 163, 184, 0.14); - color: #94a3b8; -} - -/* Shared detail dialog for config view / LLDP jobs / config-sync cycles */ -.ops-detail-modal, -.nm-config-modal { - display: flex; - flex-direction: column; - width: min(960px, 100%); - max-height: min(90vh, 840px); - overflow: hidden; -} - -.ops-detail-modal__head, -.nm-config-modal__head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 12px; - flex: 0 0 auto; -} - -.ops-detail-modal__title h3, -.nm-config-modal__title h3 { - margin: 0 0 4px; -} - -.ops-detail-modal__title .muted, -.nm-config-modal__title .muted { - margin: 0; - font-size: 13px; -} - -.ops-detail-modal__actions, -.nm-config-modal__actions { - flex: 0 0 auto; -} - -.ops-detail-modal__toolbar { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - margin-bottom: 10px; - flex: 0 0 auto; -} - -.ops-detail-modal__scroll { - flex: 1 1 auto; - min-height: 0; - overflow: auto; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 8px; - background: rgba(2, 8, 23, 0.35); -} - -.ops-detail-modal__scroll .pt-list-table-wrap, -.ops-detail-modal__scroll .data-table { - border: 0; -} - -.ops-detail-modal__foot { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-top: 12px; - flex: 0 0 auto; -} - -.ops-detail-modal__error { - margin: 0 0 10px; - color: #fca5a5; - font-size: 13px; -} - -.ops-detail-modal--xl { - width: min(1100px, 100%); -} - -.ops-detail-modal__scroll--pad { - padding: 12px 14px; -} - -/* Form + list share one scroll body (no cramped nested table viewport). */ -.ops-detail-modal__scroll--flow { - border: 0; - background: transparent; - border-radius: 0; -} - -.ops-detail-modal__scroll--flow .pt-list-table-wrap { - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 8px; - overflow: visible; -} - -.ume-entry-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 16px; -} - -.ume-page > .ume-entry-grid > .panel { - padding: 16px 18px 18px; - border: 1px solid var(--nm-ops-line); - border-radius: 6px; - background: #0c1521; - box-shadow: none; - margin: 0; - display: flex; - flex-direction: column; - gap: 10px; - min-height: 118px; -} - -.ume-entry .panel__toolbar { - margin-bottom: 0; - padding-bottom: 10px; -} - -.ume-entry__summary { - margin: 0; - font-size: 13px; - line-height: 1.45; - flex: 1 1 auto; -} - -.ume-entry__pills { - margin: 0; - flex-wrap: wrap; - gap: 6px; -} - -.ume-entry__link { - color: #93c5fd; - text-decoration: none; - font-weight: 600; -} - -.ume-entry__link:hover { - color: #bfdbfe; - text-decoration: underline; -} - -.ume-modal-form { - flex: 0 0 auto; - margin-bottom: 10px; -} - -.ume-modal-form__grow { - flex: 1 1 160px; - min-width: 140px; -} - -.ume-modal-form__label { - margin: 0 0 6px; - font-size: 13px; -} - -.ume-modal-form__hint { - margin: 6px 0 0; - font-size: 12px; -} - -.ume-modal-form__opts { - margin-top: 8px; - margin-bottom: 0; -} - -.ume-ne-types { - display: flex; - flex-wrap: wrap; - gap: 8px 16px; - max-height: 120px; - overflow: auto; - padding: 8px 10px; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 8px; - background: rgba(2, 8, 23, 0.35); -} - -.ume-ne-types--tall { - max-height: 240px; -} - -.ume-ne-types__item { - display: inline-flex; - align-items: center; - gap: 6px; - white-space: nowrap; - font-size: 13px; -} - -.ume-ne-detail-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px 18px; - font-size: 13px; -} - -.ume-ne-detail-grid > div > .muted { - display: block; - font-size: 11px; - margin-bottom: 2px; -} - -.nowrap { - white-space: nowrap; -} - -.nm-config-modal__tabs { - margin-bottom: 10px; -} - -.nm-page-panel .nm-config-modal__tabs button.is-active, -.bs-cmp-job-body .nm-config-modal__tabs button.is-active, -.app-heroui-modal .nm-config-modal__tabs button.is-active, -.nm-config-modal__tabs button.is-active { - outline: 2px solid rgba(37, 99, 235, 0.55); - outline-offset: 1px; - box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.25); - font-weight: 600; -} - -.bs-cmp-metric-chips { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.bs-cmp-metric-chip { - display: inline-block; - padding: 2px 6px; - border-radius: 4px; - background: rgba(15, 23, 42, 0.72); - border: 1px solid rgba(148, 163, 184, 0.22); - font-size: 12px; - color: #cbd5e1; -} - -/* biz_state batch workbook (Excel-like sheets) */ -.bs-workbook-modal .bs-workbook-body { - min-height: min(62vh, 640px); -} - -.bs-workbook-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; -} - -.bs-alias-link { - appearance: none; - border: 0; - background: transparent; - padding: 0; - margin: 0; - color: #93c5fd; - cursor: pointer; - text-align: left; - max-width: 160px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.bs-alias-link:hover { - text-decoration: underline; -} - -.bs-alias-link:disabled { - cursor: default; - opacity: 0.6; - text-decoration: none; -} - -.bs-id-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - width: 100%; - min-width: 0; - padding: 6px 8px; - border-radius: 6px; - border: 1px solid rgba(148, 163, 184, 0.2); - background: rgba(2, 6, 23, 0.45); -} - -.bs-id-row--inline { - width: auto; - flex: 1 1 220px; -} - -.bs-id-row__label { - flex: 0 0 auto; - font-size: 11px; - letter-spacing: 0.04em; - text-transform: uppercase; - color: #94a3b8; -} - -.bs-id-row__value { - flex: 1 1 auto; - min-width: 0; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - line-height: 1.4; - color: #e2e8f0; - white-space: nowrap; - overflow-x: auto; - overflow-y: hidden; - word-break: normal; -} - -.bs-sheet-cmd-bar { - display: flex; - flex-direction: column; - gap: 6px; - padding: 8px 10px; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.22); - background: rgba(15, 23, 42, 0.45); -} - -.bs-sheet-cmd-bar__label { - font-size: 11px; - letter-spacing: 0.04em; - text-transform: uppercase; - color: #94a3b8; -} - -.bs-sheet-cmd-list { - display: flex; - flex-direction: column; - gap: 6px; -} - -.bs-sheet-cmd-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - justify-content: space-between; -} - -.bs-sheet-cmd-code { - flex: 1 1 240px; - min-width: 0; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - line-height: 1.45; - color: #e2e8f0; - background: rgba(2, 6, 23, 0.55); - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 6px; - padding: 6px 8px; - overflow: auto; - white-space: nowrap; -} - -.bs-sheet-cmd-code--block { - white-space: pre-wrap; - word-break: break-all; -} - -.bs-sheet-cmd-actions { - display: flex; - align-items: center; - gap: 8px; - flex: 0 1 auto; - flex-wrap: wrap; - max-width: 100%; -} - -.bs-commands-card-list { - display: flex; - flex-direction: column; - gap: 8px; - max-height: min(48vh, 520px); - overflow: auto; - padding: 2px; -} - -.bs-commands-card { - padding: 8px 10px; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.22); - background: rgba(15, 23, 42, 0.45); -} - -.bs-commands-card__msg { - flex: 1 0 100%; - margin-top: 2px; - font-size: 12px; - color: #94a3b8; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.bs-cmd-metric { - font-size: 11px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - -.bs-inline-cmd { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - color: #cbd5e1; -} - -.bs-rawlog-body { - min-height: min(50vh, 480px); -} - -.bs-rawlog-stats { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; -} - -.bs-batch-message { - display: flex; - flex-direction: column; - gap: 4px; - width: 100%; - padding: 8px 10px; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - background: rgba(15, 23, 42, 0.55); -} - -.bs-batch-message__label { - font-size: 11px; - letter-spacing: 0.04em; - text-transform: uppercase; - color: #94a3b8; -} - -.bs-batch-message__body { - margin: 0; - white-space: pre-wrap; - word-break: break-word; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - line-height: 1.45; - color: #e2e8f0; -} - -.bs-batch-message--alert { - border-color: rgba(248, 113, 113, 0.28); - background: rgba(127, 29, 29, 0.22); -} - -.bs-batch-message--alert .bs-batch-message__label { - color: #fca5a5; -} - -.bs-batch-message--alert .bs-batch-message__body { - color: #fecaca; -} - -.bs-msg-cell { - max-width: 220px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; -} - -.bs-cmd-stat { - font-size: 11px; - white-space: nowrap; -} - -.bs-collect-detail-modal .bs-sheet-table { - max-height: min(48vh, 480px); - overflow: auto; -} - -.bs-collect-detail-modal .bs-collect-detail-table table { - border-collapse: separate; - border-spacing: 0; -} - -.bs-collect-detail-modal .bs-collect-detail-table thead th { - position: sticky; - top: 0; - z-index: 3; - background: rgba(15, 23, 42, 0.97); - box-shadow: 0 1px 0 rgba(148, 163, 184, 0.25); -} - -.bs-rawlog-pre { - flex: 1 1 auto; - margin: 0; - padding: 12px 14px; - max-height: min(56vh, 560px); - overflow: auto; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.22); - background: rgba(2, 6, 23, 0.72); - color: #e2e8f0; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; -} - -.bs-sheet-filter { - align-items: center; -} - -.bs-sheet-count { - white-space: nowrap; - font-size: 12px; -} - -.bs-sheet-table { - flex: 1 1 auto; - min-height: 240px; - max-height: min(48vh, 520px); - overflow: auto; - border-collapse: separate; -} - -.bs-workbook-modal .bs-sheet-table { - overflow: auto; -} - -.bs-workbook-modal .bs-sheet-table table { - border-collapse: separate; - border-spacing: 0; -} - -.bs-workbook-modal .bs-sheet-table thead th { - position: sticky; - top: 0; - z-index: 2; - background: rgba(15, 23, 42, 0.98) !important; - box-shadow: 0 1px 0 rgba(148, 163, 184, 0.22); -} - -.bs-cmp-result-table.bs-sheet-table { - max-height: none; -} - -.bs-sheet-tabs { - display: flex; - flex-wrap: wrap; - gap: 4px; - align-items: flex-end; - margin-top: 4px; - padding: 6px 4px 0; - border-top: 1px solid rgba(148, 163, 184, 0.2); -} - -.bs-sheet-tabs--top { - align-items: center; - margin-top: 0; - margin-bottom: 2px; - padding: 0 0 8px; - border-top: none; - border-bottom: 1px solid rgba(148, 163, 184, 0.22); - max-height: 88px; - overflow: auto; -} - -.bs-sheet-tabs--top .bs-sheet-tab { - border-radius: 6px; - border-bottom: 1px solid rgba(148, 163, 184, 0.28); -} - -.bs-sheet-tabs--top .bs-sheet-tab.is-active { - box-shadow: none; - border-color: rgba(59, 130, 246, 0.55); - background: rgba(37, 99, 235, 0.2); - color: #93c5fd; -} - -.bs-sheet-tab { - appearance: none; - border: 1px solid rgba(148, 163, 184, 0.28); - border-bottom: none; - border-radius: 6px 6px 0 0; - background: rgba(15, 23, 42, 0.55); - color: #cbd5e1; - padding: 6px 12px; - font-size: 12px; - cursor: pointer; - line-height: 1.2; -} - -.bs-sheet-tab:hover { - background: rgba(30, 41, 59, 0.9); - color: #e2e8f0; -} - -.bs-sheet-tab.is-active { - background: rgba(30, 41, 59, 0.98); - border-color: rgba(59, 130, 246, 0.55); - color: #f1f5f9; - font-weight: 600; - box-shadow: 0 -1px 0 rgba(59, 130, 246, 0.45) inset; -} - -.bs-sheet-tab__count { - margin-left: 6px; - opacity: 0.65; - font-variant-numeric: tabular-nums; -} - -/* Monitor template visual rule editor */ -.mt-editor__basics { - display: grid; - grid-template-columns: 1.2fr 1.2fr 1fr; - gap: 12px; - align-items: start; -} -@media (max-width: 900px) { - .mt-editor__basics { - grid-template-columns: 1fr; - } -} -.mt-editor__main { - display: grid; - grid-template-columns: 200px 1fr; - gap: 12px; - min-height: 360px; - align-items: stretch; -} -@media (max-width: 900px) { - .mt-editor__main { - grid-template-columns: 1fr; - } -} -.mt-editor__nav { - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - padding: 8px; - background: rgba(15, 23, 42, 0.35); - display: flex; - flex-direction: column; - gap: 6px; - max-height: min(70vh, 720px); - overflow: auto; - min-height: 0; - align-self: stretch; -} -.mt-editor__nav-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - font-size: 12px; - font-weight: 600; - padding: 2px 4px 6px; - border-bottom: 1px solid rgba(148, 163, 184, 0.2); - flex-shrink: 0; -} -.mt-editor__nav-list { - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; - min-height: 0; - overflow: auto; -} -.mt-editor__nav-item { - appearance: none; - text-align: left; - border: 1px solid transparent; - border-radius: 6px; - background: transparent; - color: inherit; - padding: 8px 10px; - cursor: pointer; - display: flex; - flex-direction: column; - gap: 2px; -} -.mt-editor__nav-item:hover { - background: rgba(30, 41, 59, 0.65); -} -.mt-editor__nav-item.is-active { - border-color: rgba(59, 130, 246, 0.55); - background: rgba(37, 99, 235, 0.18); -} -.mt-editor__nav-name { - font-size: 13px; - font-weight: 600; -} -.mt-editor__nav-tag { - font-size: 11px; - opacity: 0.7; -} -.mt-editor__pane { - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - padding: 12px 14px; - display: flex; - flex-direction: column; - gap: 12px; - min-width: 0; - min-height: 0; - max-height: min(70vh, 720px); - overflow: auto; - align-self: stretch; -} -.mt-editor__pane-head { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: flex-start; - flex-wrap: wrap; -} -.mt-editor__metric { - font-size: 15px; - font-weight: 700; -} -.mt-editor__meta { - display: flex; - flex-wrap: wrap; - gap: 6px 12px; - font-size: 12px; - margin-top: 4px; -} -.mt-editor__uv { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; - margin-top: 8px; -} -.mt-block__title { - font-size: 13px; - font-weight: 650; - margin-bottom: 6px; -} -.mt-block__hint { - margin: 0 0 10px; - font-size: 12px; - line-height: 1.4; -} -.mt-chip-row { - display: flex; - flex-wrap: wrap; - gap: 6px; -} -.mt-chip { - appearance: none; - border: 1px solid rgba(148, 163, 184, 0.4); - border-radius: 999px; - background: transparent; - color: inherit; - padding: 4px 10px; - font-size: 12px; - cursor: pointer; - line-height: 1.3; -} -.mt-chip:hover { - border-color: rgba(59, 130, 246, 0.55); -} -.mt-chip.is-on { - border-color: rgba(37, 99, 235, 0.75); - background: rgba(37, 99, 235, 0.22); - font-weight: 600; -} -.mt-skip-check { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - margin: 0; - cursor: pointer; -} -.mt-rule-tabs { - display: flex; - gap: 4px; - border-bottom: 1px solid rgba(148, 163, 184, 0.28); - padding-bottom: 0; -} -.mt-rule-tab { - appearance: none; - border: 1px solid transparent; - border-bottom: none; - border-radius: 6px 6px 0 0; - background: transparent; - color: inherit; - padding: 7px 12px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 6px; - opacity: 0.72; -} -.mt-rule-tab:hover { - opacity: 1; - background: rgba(30, 41, 59, 0.45); -} -.mt-rule-tab.is-active { - opacity: 1; - border-color: rgba(148, 163, 184, 0.35); - background: rgba(15, 23, 42, 0.55); - color: rgba(74, 222, 128, 0.95); -} -.mt-rule-tab--anomaly.is-active { - color: rgba(248, 113, 113, 0.95); -} -.mt-rule-tab__n { - font-size: 11px; - font-weight: 600; - opacity: 0.7; - min-width: 1.2em; - text-align: center; - padding: 0 5px; - border-radius: 999px; - background: rgba(148, 163, 184, 0.2); -} -.mt-rule-card { - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - padding: 10px 12px; - margin-bottom: 10px; - background: rgba(15, 23, 42, 0.28); -} -.mt-rule-card--anomaly { - border-color: rgba(239, 68, 68, 0.4); - background: rgba(239, 68, 68, 0.05); -} -.mt-rule-card__head { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; - font-size: 12px; - font-weight: 650; -} -.mt-rule-card__grid { - display: grid; - grid-template-columns: 1fr auto 1fr; - gap: 10px; - align-items: stretch; -} -@media (max-width: 900px) { - .mt-rule-card__grid { - grid-template-columns: 1fr; - } - .mt-rule-arrow { - display: none; - } -} -.mt-rule-arrow { - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - font-weight: 700; - opacity: 0.55; - padding: 0 2px; - align-self: stretch; -} -.mt-side-col { - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; -} -.mt-side-col__label { - font-size: 12px; - font-weight: 650; - margin-bottom: 6px; - flex-shrink: 0; -} -.mt-side { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; - flex: 1 1 auto; - min-height: 0; -} -.mt-side-empty-box { - flex: 1 1 auto; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - border: 1px dashed rgba(148, 163, 184, 0.35); - border-radius: 6px; - padding: 14px 12px; - min-height: 88px; - background: rgba(2, 6, 23, 0.2); -} -.mt-side-empty { - margin: 0; - font-size: 12px; - line-height: 1.45; -} -.mt-or-wrap { - display: flex; - flex-direction: column; - gap: 4px; -} -.mt-and-box { - border: 1px solid rgba(148, 163, 184, 0.3); - border-radius: 6px; - padding: 8px; - background: rgba(2, 6, 23, 0.25); -} -.mt-and-box__head { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 11px; - margin-bottom: 4px; -} -.mt-logic-badge { - align-self: center; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - padding: 1px 8px; - border-radius: 999px; - opacity: 0.85; -} -.mt-logic-badge--or { - background: rgba(59, 130, 246, 0.2); - color: rgba(147, 197, 253, 0.95); -} -.mt-logic-badge--and { - background: rgba(148, 163, 184, 0.18); - color: rgba(203, 213, 225, 0.95); - margin: 2px auto; - width: fit-content; -} -.mt-select { - appearance: auto; - border: 1px solid rgba(148, 163, 184, 0.4); - border-radius: 6px; - background: rgba(15, 23, 42, 0.45); - color: inherit; - padding: 4px 8px; - font-size: 12px; - min-width: 0; -} -.mt-select--grow { - flex: 1 1 120px; -} -.mt-cond-row { - display: grid; - grid-template-columns: 88px minmax(0, 1fr) auto; - gap: 6px; - align-items: center; - margin: 4px 0; -} -.mt-cond-row.is-value { - grid-template-columns: 88px minmax(72px, 1fr) minmax(88px, 1.1fr) minmax(64px, 1fr) auto; -} -.mt-cond-row .mt-cond-value, -.mt-cond-row input { - min-width: 0; - width: 100%; -} -.mt-icon-btn { - appearance: none; - border: none; - background: transparent; - color: inherit; - opacity: 0.55; - cursor: pointer; - font-size: 16px; - line-height: 1; - padding: 2px 6px; - border-radius: 4px; -} -.mt-icon-btn:hover { - opacity: 1; - background: rgba(148, 163, 184, 0.15); -} -.mt-link-btn { - appearance: none; - border: none; - background: transparent; - color: rgba(147, 197, 253, 0.95); - font-size: 12px; - cursor: pointer; - padding: 4px 0 0; - text-align: left; - width: fit-content; -} -.mt-link-btn:hover { - text-decoration: underline; -} -.mt-dontcare { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - margin: 0 0 4px; - cursor: pointer; -} -/* legacy aliases kept for safety */ -.mt-success-card { - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - padding: 10px; - margin-bottom: 8px; -} -.mt-anomaly-card { - border-color: rgba(239, 68, 68, 0.45); - background: rgba(239, 68, 68, 0.06); -} -.mt-block--success .mt-block__title { - color: rgba(74, 222, 128, 0.95); -} -.mt-block--anomaly .mt-block__title { - color: rgba(248, 113, 113, 0.95); -} -.mt-rule-sections { - display: flex; - flex-direction: column; - gap: 16px; -} -.mt-success-card__head { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; - font-size: 12px; - font-weight: 600; -} -.mt-success-card__grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} -@media (max-width: 800px) { - .mt-success-card__grid { - grid-template-columns: 1fr; - } -} -.mt-success-side__label { - font-size: 12px; - font-weight: 600; - margin-bottom: 6px; -} -.mt-group { - border: 1px dashed rgba(148, 163, 184, 0.35); - border-radius: 8px; - padding: 8px; - margin-bottom: 6px; -} -.mt-group__head { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 11px; - opacity: 0.75; -} -.mt-group__or, -.mt-group__and { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.04em; - opacity: 0.7; - margin: 2px 0; -} -.mt-side-hint { - margin: 0 0 6px; - font-size: 11px; -} -.mt-chip-group { - margin-bottom: 8px; -} -.mt-chip-group__label { - display: block; - font-size: 11px; - opacity: 0.65; - margin-bottom: 4px; -} -.mt-success-card__formula { - margin-top: 8px; - font-size: 12px; - padding-top: 8px; - border-top: 1px dashed rgba(148, 163, 184, 0.35); -} -.mt-arrow { - font-weight: 700; - opacity: 0.85; -} -.mt-editor__more { - border-top: 1px solid rgba(148, 163, 184, 0.22); - padding-top: 8px; -} - -/* Compare template editor (aligned with monitor template editor) */ -.ct-editor__basics { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} -@media (max-width: 720px) { - .ct-editor__basics { - grid-template-columns: 1fr; - } -} -.ct-editor__pick { - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - padding: 8px 10px; - background: rgba(15, 23, 42, 0.28); -} -.ct-editor__pick-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} -.ct-editor__pick-title { - font-size: 13px; - font-weight: 650; -} -.ct-editor__pick-hint { - margin: 6px 0 8px; - font-size: 12px; -} -.ct-metric-chips { - display: flex; - flex-wrap: wrap; - gap: 6px; - max-height: 140px; - overflow: auto; -} -.ct-metric-chip__n { - margin-left: 6px; - opacity: 0.65; - font-size: 11px; -} -.ct-editor__main { - display: grid; - grid-template-columns: 200px 1fr; - gap: 12px; - min-height: 360px; - align-items: stretch; -} -@media (max-width: 900px) { - .ct-editor__main { - grid-template-columns: 1fr; - } -} -.ct-editor__nav { - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - padding: 8px; - background: rgba(15, 23, 42, 0.35); - display: flex; - flex-direction: column; - gap: 6px; - max-height: 70vh; - overflow: auto; -} -.ct-editor__nav-head { - font-size: 12px; - font-weight: 600; - padding: 2px 4px 6px; - border-bottom: 1px solid rgba(148, 163, 184, 0.2); -} -.ct-editor__nav-list { - display: flex; - flex-direction: column; - gap: 4px; -} -.ct-editor__nav-item { - appearance: none; - text-align: left; - border: 1px solid transparent; - border-radius: 6px; - background: transparent; - color: inherit; - padding: 8px 10px; - cursor: pointer; - display: flex; - flex-direction: column; - gap: 2px; -} -.ct-editor__nav-item:hover { - background: rgba(30, 41, 59, 0.65); -} -.ct-editor__nav-item.is-active { - border-color: rgba(59, 130, 246, 0.55); - background: rgba(37, 99, 235, 0.18); -} -.ct-editor__nav-name { - font-size: 13px; - font-weight: 600; -} -.ct-editor__nav-tag { - font-size: 11px; - opacity: 0.7; -} -.ct-editor__pane { - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - padding: 12px 14px; - display: flex; - flex-direction: column; - gap: 12px; - min-width: 0; -} -.ct-editor__pane-head { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: flex-start; - flex-wrap: wrap; -} -.ct-editor__metric { - font-size: 15px; - font-weight: 700; -} -.ct-editor__meta { - font-size: 12px; - margin-top: 4px; -} -.ct-filters__hint, -.ct-filters__empty, -.ct-field-hint { - margin: 0 0 8px; - font-size: 12px; -} -.ct-filters__actions { - display: flex; - gap: 12px; - margin-bottom: 8px; -} -.ct-filter-row { - display: grid; - grid-template-columns: minmax(90px, 1.1fr) minmax(72px, 0.8fr) minmax(80px, 1.2fr) auto; - gap: 6px; - align-items: center; - margin: 4px 0; -} -.ct-filter-row input { - min-width: 0; - width: 100%; -} -.ct-field-table-wrap { - max-height: min(55vh, 520px); - overflow: auto; -} -.ct-field-table { - border-collapse: separate; - border-spacing: 0; -} -.ct-field-table thead th { - position: sticky; - top: 0; - z-index: 2; - background: rgba(15, 23, 42, 0.98) !important; - box-shadow: 0 1px 0 rgba(148, 163, 184, 0.22); -} -.ct-field-table th, -.ct-field-table td { - vertical-align: middle; -} -.ct-field-table th:nth-child(n + 2), -.ct-field-table td:nth-child(n + 2) { - text-align: center; - white-space: nowrap; -} -.ct-field-table td:first-child { - text-align: left; -} -.ct-field-name { - font-size: 12px; -} - -/* Cutover monitor (biz migration) */ -.bm-hint { - margin: 0; - font-size: 12px; -} -.bm-pair-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - align-items: start; -} -@media (max-width: 720px) { - .bm-pair-grid { - grid-template-columns: 1fr; - } -} -.bm-pair-card { - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - padding: 10px 12px; - background: rgba(15, 23, 42, 0.28); -} -.bm-pair-card__label { - font-size: 11px; - opacity: 0.7; - margin-bottom: 4px; -} -.bm-mapping { - display: flex; - flex-direction: column; - gap: 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - padding: 10px 12px; - background: rgba(15, 23, 42, 0.22); -} -.bm-mapping__label { - font-size: 13px; - font-weight: 650; -} -.bm-mapping__row { - display: grid; - grid-template-columns: minmax(140px, 1.2fr) minmax(120px, 1fr) auto; - gap: 8px; - align-items: end; -} -@media (max-width: 720px) { - .bm-mapping__row { - grid-template-columns: 1fr; - } -} -.bm-mapping__text { - width: 100%; - min-height: 110px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - border: 1px solid rgba(148, 163, 184, 0.4); - border-radius: 6px; - background: rgba(2, 6, 23, 0.35); - color: inherit; - padding: 8px 10px; - resize: vertical; -} -.bm-detail__chrome { - display: flex; - flex-wrap: wrap; - gap: 8px 16px; - align-items: center; - justify-content: space-between; -} -.bm-detail__meta { - display: flex; - flex-wrap: wrap; - gap: 6px 10px; - align-items: center; - font-size: 12px; -} -.bm-detail__arrow { - opacity: 0.55; - font-weight: 700; -} -.bm-detail__actions { - flex-wrap: wrap; - gap: 6px; -} -.bm-detail__link { - font-size: 12px; - align-self: center; -} -.bm-setup__meta { - display: flex; - flex-wrap: wrap; - gap: 4px 8px; - align-items: center; - font-size: 12px; - font-variant-numeric: tabular-nums; - padding: 6px 0 2px; -} -.bm-setup__sep { - opacity: 0.4; -} -.bm-setup__binds { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 11px; -} -.bm-setup__cmp { - display: flex; - align-items: end; - font-size: 12px; - padding-bottom: 6px; -} -.bm-batches__toolbar { - display: flex; - flex-wrap: wrap; - gap: 8px 12px; - justify-content: space-between; - align-items: center; -} -.bm-batches__toolbar-main, -.bm-batches__toolbar-side { - flex-wrap: wrap; - gap: 6px; - align-items: center; -} -.bm-board__status { - display: flex; - flex-wrap: wrap; - gap: 10px 16px; - align-items: baseline; - padding: 8px 10px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: rgba(15, 23, 42, 0.35); - font-variant-numeric: tabular-nums; - font-size: 13px; -} -.bm-board__verdict { - font-weight: 700; - letter-spacing: 0.02em; -} -.bm-board__verdict.is-ok { - color: #34d399; -} -.bm-board__verdict.is-bad, -.bm-board__num.is-bad { - color: #f87171; -} -.bm-board__num { - opacity: 0.9; -} -.bm-batches { - display: flex; - flex-direction: column; - gap: 10px; - min-height: 0; - flex: 1; -} -.bm-batches__main { - min-height: 320px; - flex: 1; -} -.bm-expect-table { - max-height: min(48vh, 420px); - overflow: auto; -} -.bm-expect-desc { - max-width: 240px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.bm-board { - flex: 1; - min-height: 0; -} -.bm-board__accept { - display: flex; - flex-wrap: wrap; - gap: 10px 16px; - font-size: 12px; - font-variant-numeric: tabular-nums; - align-items: center; -} -.bm-board__table { - max-height: min(58vh, 560px); - overflow: auto; -} - -/* Cutover operations console: readable device samples, continuity and scope. */ -.bm-live-header, .bm-live-header > div, .bm-live-filters { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 10px; -} -.bm-live-header { justify-content: space-between; border-bottom: 1px solid rgba(148,163,184,.25); padding-bottom: 10px; } -.bm-live-state { font-size: 11px; padding: 3px 8px; border: 1px solid rgba(148,163,184,.35); border-radius: 3px; } -.bm-live-state.is-live { color: #34d399; border-color: rgba(52,211,153,.45); } -.bm-live-state.is-live::before { content: ""; display: inline-block; width: 6px; height: 6px; background: currentColor; margin-right: 6px; border-radius: 50%; } -.bm-live-sources { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; } -.bm-live-sources > div { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; padding: 10px 12px; border: 1px solid rgba(148,163,184,.24); border-left: 3px solid #64748b; border-radius: 4px; min-width: 0; } -.bm-live-sources > div:last-child { border-left-color: #38bdf8; } -.bm-live-sources small { flex-basis: 100%; font-variant-numeric: tabular-nums; } -.bm-live-sources strong { overflow-wrap: anywhere; } -.bm-live-kpis { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; } -.bm-live-kpis > div { display: flex; flex-direction: column; gap: 5px; padding: 12px 14px; border: 1px solid rgba(148,163,184,.25); border-radius: 4px; } -.bm-live-kpis span { font-size: 12px; opacity: .8; } -.bm-live-kpis strong { font: 600 26px/1.2 ui-monospace,monospace; font-variant-numeric: tabular-nums; } -.bm-live-kpis strong small { font-size: 13px; opacity: .7; } -.bm-live-kpis .is-ok strong { color: #34d399; } -.bm-live-kpis .is-bad strong { color: #f87171; } -.bm-live-warning { display: flex; flex-wrap: wrap; gap: 6px 14px; padding: 10px 12px; border: 1px solid rgba(251,191,36,.3); border-left: 3px solid #fbbf24; border-radius: 3px; background: rgba(251,191,36,.06); font-size: 12px; overflow-wrap: anywhere; } -.bm-live-warning strong { flex-basis: 100%; } -.bm-live-filters > input { flex: 1 1 240px; min-width: 0; } -.bm-live-filters > .ui-field-select { max-width: 230px; } -.bm-diff-row.is-red > td:first-child { box-shadow: inset 3px 0 #f87171; } -.bm-diff-row.is-yellow > td:first-child { box-shadow: inset 3px 0 #fbbf24; } -.bm-scope-label { display: block; font-size: 10px; opacity: .65; padding-top: 3px; } -.bm-field-changes { font-size: 11px; margin-top: 5px; } -.bm-field-changes summary { cursor: pointer; color: #fbbf24; } -.bm-field-changes div { padding-top: 5px; overflow-wrap: anywhere; } -.bm-field-changes ins { color: #f87171; text-decoration: none; } -.bm-board .bs-cmp-main { min-width: 0; } -.bm-sampling { border: 1px solid rgba(148,163,184,.25); border-radius: 4px; min-width: 0; } -.bm-sampling > summary { padding: 8px 12px; cursor: pointer; font-size: 12px; } -.app-heroui-modal .bm-sampling .pt-list-table-wrap { max-width: 100%; min-height: 0; } -.bm-live-header__actions { display: flex; gap: 8px; align-items: center; } -.bm-board-controls > summary { display: none; } -.bm-board-controls > .bm-live-filters { margin-top: 8px; } -@media (max-width: 720px) { - .bm-live-sources { grid-template-columns: 1fr; } - .bm-live-kpis { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 6px; } - .bm-live-header > .muted { flex: 1 1 100%; font-size: 11px; } - .bm-board .bs-cmp-board__toolbar { display: grid; grid-template-columns: minmax(0,1fr); } - .bm-board .bs-id-row { min-width: 0; } - .bm-board .bs-cmp-board__body { display: flex; flex-direction: column; min-width: 0; } - .bm-board .bs-cmp-nav { width: 100%; max-width: none; } - .bm-board .bs-cmp-nav__list { max-height: 180px; } - .bm-live-filters > .ui-field-select { max-width: none; width: 100%; } - .bm-board__table { max-width: 100%; } -} - -/* Cutover create wizard */ -.bm-create { - min-height: min(52vh, 520px); -} -.bm-create__pane { - display: flex; - flex-direction: column; - gap: 12px; -} -.bm-steps { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 6px; - margin-bottom: 4px; -} -.bm-steps__item { - display: flex; - align-items: center; - gap: 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - background: rgba(15, 23, 42, 0.22); - color: inherit; - padding: 8px 10px; - cursor: pointer; - text-align: left; - font: inherit; - opacity: 0.72; - transition: border-color 0.15s ease, opacity 0.15s ease, background 0.15s ease; -} -.bm-steps__item:hover { - opacity: 0.92; -} -.bm-steps__item.is-active { - opacity: 1; - border-color: rgba(148, 163, 184, 0.55); - background: rgba(51, 65, 85, 0.35); -} -.bm-steps__item.is-done { - opacity: 0.9; -} -.bm-steps__num { - flex: 0 0 auto; - width: 22px; - height: 22px; - border-radius: 999px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 11px; - font-weight: 700; - background: rgba(148, 163, 184, 0.22); -} -.bm-steps__item.is-active .bm-steps__num, -.bm-steps__item.is-done .bm-steps__num { - background: rgba(148, 163, 184, 0.4); -} -.bm-steps__label { - font-size: 12px; - font-weight: 600; - line-height: 1.2; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -@media (max-width: 720px) { - .bm-steps { - grid-template-columns: 1fr 1fr; - } -} -.bm-ne-slot { - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - padding: 12px 14px; - background: rgba(15, 23, 42, 0.28); - color: inherit; - text-align: left; - cursor: pointer; - font: inherit; - transition: border-color 0.15s ease, box-shadow 0.15s ease; - min-height: 78px; -} -.bm-ne-slot:hover { - border-color: rgba(148, 163, 184, 0.5); -} -.bm-ne-slot.is-picking { - border-color: rgba(148, 163, 184, 0.65); - box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.2); -} -.bm-ne-slot.is-filled { - background: rgba(15, 23, 42, 0.4); -} -.bm-ne-slot__name { - font-weight: 650; - font-size: 14px; -} -.bm-ne-slot__meta, -.bm-ne-slot__empty { - font-size: 12px; - margin-top: 4px; -} -.bm-ne-slot__empty { - opacity: 0.55; -} -.bm-ne-picker { - display: flex; - flex-direction: column; - gap: 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - padding: 10px 12px; - background: rgba(2, 6, 23, 0.28); -} -.bm-ne-picker__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} -.bm-ne-picker__table { - max-height: min(36vh, 320px); - overflow: auto; -} -.bm-ne-picker .pt-list-pager, -.bm-ne-picker .pager { - flex-shrink: 0; - margin-top: 4px; -} -.bm-ne-picker__table tr.is-selected td { - background: rgba(51, 65, 85, 0.45); -} -.bm-metric-chips { - display: flex; - flex-wrap: wrap; - gap: 8px; -} -.bm-metric-chip { - border: 1px solid rgba(148, 163, 184, 0.35); - border-radius: 6px; - background: rgba(15, 23, 42, 0.35); - color: inherit; - font: inherit; - font-size: 12px; - padding: 6px 10px; - cursor: pointer; - opacity: 0.7; -} -.bm-metric-chip.is-on { - opacity: 1; - border-color: rgba(148, 163, 184, 0.65); - background: rgba(51, 65, 85, 0.45); - font-weight: 600; -} -.bm-sched-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; -} -@media (max-width: 720px) { - .bm-sched-grid { - grid-template-columns: 1fr; - } -} -.bm-sched-field { - display: flex; - flex-direction: column; - gap: 4px; -} -.bm-interval-table { - max-height: min(28vh, 240px); - overflow: auto; -} -.bm-hf-bindings { - margin: 6px 0 0; - padding-left: 1.1em; - list-style: disc; -} -.bm-create__summary { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px 14px; - padding: 10px 12px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 8px; - background: rgba(15, 23, 42, 0.28); -} -@media (max-width: 720px) { - .bm-create__summary { - grid-template-columns: 1fr; - } -} -.bm-create-modal .modal-footer, -.bm-create-modal [data-slot="footer"], -.bm-create-modal .app-heroui-modal__dialog [data-slot="footer"] { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} -.bm-create__footer-spacer { - flex: 1 1 auto; - min-width: 8px; -} -.bm-list-hf { - display: flex; - flex-direction: column; - gap: 2px; - align-items: flex-start; -} -.bm-list-hf__iv { - font-size: 11px; - font-variant-numeric: tabular-nums; -} - -.bs-bind-panel { - padding: 10px 12px; - border: 1px solid rgba(148, 163, 184, 0.25); - border-radius: 8px; -} - -.bs-bind-list { - max-height: 200px; - overflow: auto; -} - -.bs-bind-list--modal { - max-height: min(48vh, 360px); - padding: 4px 2px; - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 8px; -} - -.bs-bind-item { - display: block; - margin-bottom: 4px; -} - -.bs-profiles-panel { - display: flex; - flex-direction: column; - gap: 8px; - min-height: 0; - flex: 1 1 auto; - max-height: min(62vh, 620px); -} - -.bs-profiles-toolbar { - flex: 0 0 auto; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.bs-profiles-stats { - font-size: 12px; - white-space: nowrap; -} - -.bs-profiles-table-wrap { - flex: 1 1 auto; - min-height: 0; - overflow: auto !important; - max-height: none; - max-width: 100%; - /* Always reserve room for both scrollbars so H-bar stays in the visible viewport. */ - scrollbar-gutter: stable both-edges; - scrollbar-width: thin; - scrollbar-color: rgba(148, 163, 184, 0.65) rgba(15, 23, 42, 0.9); -} - -.bs-profiles-table-wrap::-webkit-scrollbar { - height: 12px; - width: 10px; -} - -.bs-profiles-table-wrap::-webkit-scrollbar-track { - background: rgba(15, 23, 42, 0.9); - border-radius: 6px; -} - -.bs-profiles-table-wrap::-webkit-scrollbar-thumb { - background: rgba(148, 163, 184, 0.55); - border-radius: 6px; - border: 2px solid rgba(15, 23, 42, 0.9); -} - -.bs-profiles-table-wrap::-webkit-scrollbar-thumb:hover { - background: rgba(148, 163, 184, 0.75); -} - -.bs-profiles-table { - width: max-content; - min-width: 100%; - table-layout: fixed; - border-collapse: separate; - border-spacing: 0; -} - -.bs-profiles-table thead th { - position: sticky; - top: 0; - z-index: 3; - background: rgba(15, 23, 42, 0.97); - box-shadow: 0 1px 0 rgba(148, 163, 184, 0.25); -} - -/* enable | title | lane | actions | params | command | aux */ -.bs-profiles-table th:nth-child(1), -.bs-profiles-table td:nth-child(1) { - width: 52px; -} - -.bs-profiles-table th:nth-child(2), -.bs-profiles-table td:nth-child(2) { - width: 200px; -} - -.bs-profiles-table th:nth-child(3), -.bs-profiles-table td:nth-child(3) { - width: 88px; -} - -.bs-profiles-table th:nth-child(4), -.bs-profiles-table td:nth-child(4) { - width: 120px; -} - -.bs-profiles-table th:nth-child(5), -.bs-profiles-table td:nth-child(5) { - width: 200px; -} - -.bs-profiles-table th:nth-child(6), -.bs-profiles-table td:nth-child(6) { - width: 360px; -} - -.bs-profiles-table th:nth-child(7), -.bs-profiles-table td:nth-child(7) { - width: 280px; -} - -.bs-profiles-sticky-col { - position: sticky; - z-index: 2; - background: rgba(15, 23, 42, 0.97); -} - -.bs-profiles-table thead .bs-profiles-sticky-col, -.bs-profiles-table thead th:nth-child(1), -.bs-profiles-table thead th:nth-child(2) { - z-index: 4; -} - -.bs-profiles-sticky-col--1, -.bs-profiles-table th:nth-child(1), -.bs-profiles-table td.bs-profiles-sticky-col--1 { - left: 0; - box-shadow: 1px 0 0 rgba(148, 163, 184, 0.2); -} - -.bs-profiles-sticky-col--2, -.bs-profiles-table th:nth-child(2), -.bs-profiles-table td.bs-profiles-sticky-col--2 { - left: 52px; - box-shadow: 1px 0 0 rgba(148, 163, 184, 0.2); -} - -.bs-profiles-table .bs-cmd-cell { - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: 12px; - max-width: 100%; -} - -.bs-profiles-table .bs-aux-cell { - vertical-align: top; - font-size: 12px; -} - -.bs-profiles-table .bs-aux-list { - margin: 0; - padding: 0; - list-style: none; - display: flex; - flex-direction: column; - gap: 6px; -} - -.bs-profiles-table .bs-aux-list li { - min-width: 0; -} - -.bs-profiles-table .bs-aux-key { - font-weight: 600; - color: #cbd5e1; -} - -.bs-profiles-table .bs-aux-cmd { - display: block; - margin-top: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: 11px; - color: #94a3b8; - max-width: 100%; -} - -.bs-profiles-table .bs-params-cell { - vertical-align: top; -} - -.bs-profiles-table .bs-params-scroll { - max-width: 100%; - max-height: 3.2em; - overflow: hidden; - white-space: normal; - word-break: break-word; - line-height: 1.35; - font-size: 12px; -} - -.bs-profiles-table .bs-params-scroll--clickable { - cursor: pointer; - user-select: none; -} - -.bs-profiles-table .bs-params-scroll--clickable:hover { - text-decoration: underline; - text-underline-offset: 2px; -} - -.bs-params-detail-list { - max-height: min(50vh, 420px); -} - -.bs-params-detail-item { - padding: 4px 0; - border-bottom: 1px solid rgba(148, 163, 184, 0.2); - font-size: 12px; - word-break: break-all; -} - -.bs-params-detail-item:last-child { - border-bottom: none; -} - -.bs-profiles-table .bs-actions-cell { - white-space: nowrap; - vertical-align: middle; -} - -.bs-bind-hint { - margin-top: 4px; - font-size: 12px; -} - -.bs-params-warn { - color: #fbbf24; -} - -.bs-bind-modal.app-heroui-modal { - z-index: 80; -} - -/* biz_state compare result workbook / board — dark surfaces (match nm modal) */ -.bs-cmp-job-body { - min-height: min(64vh, 680px); -} - -.bs-cmp-board-modal .modal__dialog, -.bs-cmp-board-modal .modal__dialog--lg, -.bs-cmp-board-modal .modal__dialog--md, -.bs-cmp-board-modal .modal__dialog--cover { - width: min(96vw, 1600px) !important; - max-width: min(96vw, 1600px) !important; -} - -.bs-cmp-board-modal .app-heroui-modal__dialog [data-slot="modal-body"].bs-cmp-job-body, -.bs-cmp-board-modal .app-heroui-modal__dialog .modal__body.bs-cmp-job-body { - display: flex; - flex-direction: column; - overflow: hidden; - max-height: min(90vh, 980px); - min-height: min(64vh, 680px); -} - -.bs-cmp-board-modal .bs-cmp-job-body > .nm-config-modal__tabs { - flex: 0 0 auto; -} - -.bs-cmp-board-modal .app-heroui-modal__dialog [data-slot="modal-body"].bs-cmp-job-body > .bs-cmp-board, -.bs-cmp-board-modal .app-heroui-modal__dialog .modal__body.bs-cmp-job-body > .bs-cmp-board, -.bs-cmp-board-modal .bs-cmp-job-body > .bs-cmp-board { - flex: 1 1 auto !important; - flex-shrink: 1 !important; - min-height: 0 !important; - overflow: hidden; -} - -.bs-cmp-board { - --bs-cmp-surface: rgba(15, 23, 42, 0.78); - --bs-cmp-border: rgba(148, 163, 184, 0.2); - --bs-cmp-ink: #e2e8f0; - --bs-cmp-muted: #94a3b8; - --bs-cmp-row: rgba(15, 23, 42, 0.42); - --bs-cmp-row-alt: rgba(30, 41, 59, 0.28); - display: flex; - flex-direction: column; - gap: 8px; - flex: 1 1 auto; - min-height: min(62vh, 720px); - padding: 8px; - border-radius: 12px; - color: var(--bs-cmp-ink); - background: - radial-gradient(1100px 380px at 8% -12%, rgba(37, 99, 235, 0.09), transparent 58%), - rgba(8, 13, 24, 0.4); -} - -.bs-cmp-progress { - display: flex; - flex-direction: column; - gap: 6px; - padding: 10px 12px; - border-radius: 8px; - border: 1px solid rgba(59, 130, 246, 0.35); - background: rgba(37, 99, 235, 0.12); - color: var(--bs-cmp-ink, #e2e8f0); - font-size: 0.875rem; -} - -.bs-cmp-progress.is-failed { - border-color: rgba(239, 68, 68, 0.4); - background: rgba(239, 68, 68, 0.12); -} - -.bs-cmp-progress.is-cancelled { - border-color: rgba(245, 158, 11, 0.4); - background: rgba(245, 158, 11, 0.1); -} - -.bs-cmp-progress__head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; -} - -.bs-cmp-progress__actions { - margin-left: auto; - display: flex; - align-items: center; - gap: 6px; -} - -.bs-cmp-progress__engine, -.bs-cmp-progress__elapsed { - font-size: 0.8125rem; -} - -.bs-cmp-progress__track, -.bs-cmp-run-prog__track { - height: 6px; - border-radius: 999px; - background: rgba(148, 163, 184, 0.28); - overflow: hidden; -} - -.bs-cmp-progress__fill, -.bs-cmp-run-prog__fill { - height: 100%; - width: 0; - border-radius: inherit; - background: linear-gradient(90deg, #2563eb, #60a5fa); - transition: width 0.35s ease; -} - -.bs-cmp-progress__fill.is-indeterminate, -.bs-cmp-run-prog__fill.is-indeterminate { - width: 36%; - animation: bs-cmp-prog-slide 1.25s ease-in-out infinite; -} - -@keyframes bs-cmp-prog-slide { - 0% { - transform: translateX(-120%); - } - 100% { - transform: translateX(320%); - } -} - -.bs-cmp-progress__meta { - font-size: 0.8125rem; - color: var(--bs-cmp-muted, #94a3b8); - line-height: 1.4; - word-break: break-word; -} - -.bs-cmp-progress__msg { - flex: 1 1 100%; - font-size: 0.8125rem; -} - -/* Compare batch list (job detail → 对比批次) */ -.bs-cmp-runs { - display: flex; - flex-direction: column; - gap: 10px; - min-height: 260px; -} - -.bs-cmp-runs__toolbar { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - justify-content: space-between; - gap: 10px 14px; -} - -.bs-cmp-runs__hint { - margin: 0; - flex: 1 1 220px; - line-height: 1.45; -} - -.bs-cmp-runs__table tbody tr.is-selected td { - background: rgba(37, 99, 235, 0.08); -} - -.bs-cmp-runs__elapsed { - margin-top: 4px; - font-size: 0.75rem; - color: var(--muted, #64748b); - font-variant-numeric: tabular-nums; -} - -.bs-cmp-runs__sides { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 2px 6px; - font-size: 0.8125rem; - line-height: 1.35; - max-width: 22rem; -} - -.bs-cmp-runs__arrow { - color: var(--muted, #94a3b8); - flex: 0 0 auto; -} - -.bs-cmp-runs__summary { - font-size: 0.8125rem; - line-height: 1.4; - color: var(--ink, #334155); - max-width: 18rem; -} - -.bs-cmp-run-prog { - display: flex; - flex-direction: column; - gap: 5px; - min-width: 160px; - max-width: 22rem; -} - -.bs-cmp-run-prog__meta { - font-size: 0.75rem; - line-height: 1.35; - color: var(--muted, #64748b); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -/* Business compare workspace: compact, flat ops surfaces. - Panel / table / control chrome inherits from .network-main ops defaults. */ -.bs-cmp-workspace, -.bs-cmp-job-modal, -.bs-cmp-create { - --cmp-line: var(--nm-ops-line, #2b3748); - --cmp-muted: var(--nm-ops-muted, #9aa9bc); - --cmp-surface: var(--nm-ops-surface, #111c2a); - --cmp-accent: var(--nm-ops-accent, #6ba9ec); -} - -.bs-cmp-workspace .bs-cmp-runs__delete { - color: #fca5a5; -} - -.bs-cmp-job-modal .modal__dialog, -.bs-cmp-create .modal__dialog { - border-radius: 6px; - background: #0c1521; - border-color: var(--cmp-line); - max-height: min(92dvh, 1000px); -} - -.bs-cmp-job-modal:not(.bs-cmp-board-modal) .modal__dialog { - width: min(1180px, 96vw); - max-width: min(1180px, 96vw); - display: flex; - flex-direction: column; -} - -.bs-cmp-job-modal:not(.bs-cmp-board-modal) .modal__body.bs-cmp-job-body { - min-height: 0; - max-height: none; - flex: 1 1 auto; - overflow: auto; -} - -.bs-cmp-job-modal .button, -.bs-cmp-create .button, -.bs-cmp-job-modal .ui-field__select, -.bs-cmp-create .ui-field__select, -.bs-cmp-job-modal .input, -.bs-cmp-create .input { - border-radius: 4px; - box-shadow: none; -} - -.bs-cmp-job-heading { - display: flex; - flex-direction: column; - gap: 5px; - min-width: 0; - overflow-wrap: anywhere; - font-size: 16px; -} - -.bs-cmp-job-heading__id { - color: var(--cmp-muted); - font: 10px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; - letter-spacing: 0.08em; -} - -.bs-cmp-job-modal .bs-cmp-job-tabs { - margin: 0; - gap: 0; - flex-wrap: nowrap; - border-bottom: 1px solid var(--cmp-line); -} - -.bs-cmp-job-tabs .button { - flex: 0 1 auto; - min-width: 0; - height: 38px; - padding: 0 15px; - border: 0; - border-bottom: 2px solid transparent; - border-radius: 0; - background: transparent; - color: var(--cmp-muted); -} - -.bs-cmp-job-modal .bs-cmp-job-tabs .button.is-active { - outline: none; - box-shadow: none; - border-bottom-color: var(--cmp-accent); - color: #edf5ff; - background: rgba(107, 169, 236, 0.08); -} - -.bs-cmp-job-tabs__index { - font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; - opacity: 0.7; -} - -.bs-cmp-job-tabs__count { - padding: 1px 5px; - background: #263347; - border-radius: 3px; - font-size: 10px; -} - -.bs-cmp-config { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.bs-cmp-config__section { - min-width: 0; - padding: 14px; - border: 1px solid var(--cmp-line); - border-radius: 4px; - background: var(--cmp-surface); -} - -.bs-cmp-config .is-wide, -.bs-cmp-config__fields > .is-wide { - grid-column: 1 / -1; -} - -.bs-cmp-config__title { - display: flex; - align-items: center; - gap: 9px; - margin: 0 0 14px; - color: #e7eef7; - font-size: 12px; - font-weight: 650; -} - -.bs-cmp-config__title > span { - color: var(--cmp-accent); - font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; -} - -.bs-cmp-config__fields, -.bs-cmp-mapping__fields, -.bs-cmp-sources { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px 16px; - min-width: 0; -} - -.bs-cmp-config__fields > .ui-field--full, -.bs-cmp-mapping__fields > .ui-field--full { - grid-column: auto; -} - -.bs-cmp-config__hint { - color: var(--cmp-muted); - font-size: 11px; - line-height: 1.65; - margin: 0; -} - -.bs-cmp-source { - display: flex; - flex-direction: column; - gap: 12px; - min-width: 0; - padding: 12px; - border: 1px solid var(--cmp-line); - border-radius: 3px; - background: #0c1521; -} - -.bs-cmp-source__head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - font-size: 12px; -} - -.bs-cmp-source__head > span:last-child { - margin-left: auto; - color: var(--cmp-muted); - font-size: 10px; -} - -.bs-cmp-source__tag { - padding: 2px 5px; - border: 1px solid #465569; - color: #c6d3e3; - font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; -} - -.bs-cmp-source.is-after .bs-cmp-source__tag { - border-color: #3f6a97; - color: #93c5fd; -} - -.bs-cmp-source__meta { - display: flex; - justify-content: space-between; - flex-wrap: wrap; - gap: 5px; - margin-top: auto; - padding-top: 9px; - border-top: 1px solid var(--cmp-line); - color: var(--cmp-muted); - font: 11px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; -} - -.bs-cmp-source__auto { - min-height: 58px; -} - -.bs-cmp-scope__heading { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 12px; -} - -.bs-cmp-scope__count { - color: var(--cmp-accent); - font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; -} - -.bs-cmp-scope .bm-hint { - margin: 8px 0 12px; - font-size: 11px; - line-height: 1.55; -} - -.bs-cmp-scope__items { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); - gap: 6px; -} - -.bs-cmp-scope__item { - display: flex; - align-items: center; - gap: 9px; - min-width: 0; - padding: 9px 10px; - border: 1px solid var(--cmp-line); - border-radius: 3px; - background: #0c1521; - color: var(--cmp-muted); - text-align: left; - cursor: pointer; -} - -.bs-cmp-scope__item.is-on { - border-color: #3c5f89; - background: rgba(61, 108, 160, 0.1); - color: #dbeafe; -} - -.bs-cmp-scope__item:disabled { - cursor: default; -} - -.bs-cmp-scope__check { - display: grid; - place-items: center; - flex: 0 0 15px; - height: 15px; - border: 1px solid #53677f; - border-radius: 2px; - color: var(--cmp-accent); - font-size: 11px; -} - -.bs-cmp-scope__content { - display: flex; - flex-direction: column; - gap: 3px; - min-width: 0; - overflow-wrap: anywhere; -} - -.bs-cmp-scope__content strong { - font-size: 12px; - font-weight: 550; -} - -.bs-cmp-scope__content > span { - color: var(--cmp-muted); - font-size: 10px; -} - -.bs-cmp-mapping { - display: flex; - flex-direction: column; - gap: 12px; - min-width: 0; -} - -.bs-cmp-mapping__editor { - width: 100%; - min-height: 100px; - padding: 8px 10px; - border: 1px solid var(--cmp-line); - border-radius: 3px; - background: #0a121d; - color: #dce8f6; - resize: vertical; - font: 11px/1.7 ui-monospace, SFMono-Regular, Consolas, monospace; -} - -.bs-cmp-mapping__actions { - display: flex; - justify-content: flex-end; - flex-wrap: wrap; - gap: 6px; -} - -.bs-cmp-create .bm-create__pane, -.bs-cmp-create .bm-steps__item { - border-radius: 4px; - box-shadow: none; -} - -.bs-cmp-job-modal .bs-cmp-runs { - gap: 14px; -} - -.bs-cmp-runs__intro { - flex: 1 1 300px; -} - -.bs-cmp-runs__intro h3 { - margin: 0 0 5px; - color: #e7eef7; - font-size: 14px; -} - -.bs-cmp-job-modal .bs-cmp-runs__hint { - color: var(--cmp-muted); - font-size: 11px; - line-height: 1.55; -} - -.bs-cmp-run-stats { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - margin: 0; - border: 1px solid var(--cmp-line); - border-radius: 4px; - background: var(--cmp-surface); -} - -.bs-cmp-run-stats > div { - padding: 12px 15px; - border-right: 1px solid var(--cmp-line); -} - -.bs-cmp-run-stats > div:last-child { - border-right: 0; -} - -.bs-cmp-run-stats dt { - color: var(--cmp-muted); - font-size: 11px; -} - -.bs-cmp-run-stats dd { - margin: 5px 0 0; - color: #e7eef7; - font: 24px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; - font-variant-numeric: tabular-nums; -} - -.bs-cmp-run-stats .is-active dd { - color: #93c5fd; -} - -.bs-cmp-run-stats .is-stopped dd { - color: #fbbf24; -} - -.bs-cmp-job-modal .bs-cmp-runs .pt-list-table-wrap { - max-height: min(54vh, 580px); - overflow: auto; - border: 1px solid var(--cmp-line); - border-radius: 4px; -} - -.bs-cmp-runs__table { - min-width: 1000px; - table-layout: fixed; -} - -.bs-cmp-runs__table th:nth-child(1) { width: 12%; } -.bs-cmp-runs__table th:nth-child(2) { width: 18%; } -.bs-cmp-runs__table th:nth-child(3) { width: 27%; } -.bs-cmp-runs__table th:nth-child(4) { width: 25%; } -.bs-cmp-runs__table th:nth-child(5) { width: 18%; } - -.bs-cmp-job-modal .bs-cmp-runs__table th { - position: sticky; - top: 0; - z-index: 1; - background: #162232; - color: #a7b6ca; - font-size: 11px; - font-weight: 550; -} - -.bs-cmp-job-modal .bs-cmp-runs__table td { - height: 80px; - border-bottom: 1px solid var(--cmp-line); - background: #0c1521; - vertical-align: middle; - font-size: 12px; -} - -.bs-cmp-job-modal .bs-cmp-runs__table tr.is-selected td { - background: #142237; -} - -.bs-cmp-runs__table tr td:first-child { - border-left: 3px solid transparent; -} - -.bs-cmp-runs__table tr.is-selected td:first-child, -.bs-cmp-runs__table tr[data-status="running"] td:first-child, -.bs-cmp-runs__table tr[data-status="queued"] td:first-child { - border-left-color: var(--cmp-accent); -} - -.bs-cmp-runs__table tr[data-status="failed"] td:first-child { - border-left-color: #f87171; -} - -.bs-cmp-runs__id { - display: block; - margin-top: 5px; - color: var(--cmp-muted); - font-size: 10px; -} - -.bs-cmp-job-modal .bs-cmp-runs__sides { - display: grid; - gap: 8px; - max-width: none; - font-size: 11px; -} - -.bs-cmp-runs__sides > div { - display: grid; - grid-template-columns: 42px minmax(0, 1fr) auto; - align-items: center; - gap: 6px; -} - -.bs-cmp-runs__side-tag, -.bs-cmp-runs__sides time { - color: var(--cmp-muted); - font-size: 10px; - white-space: nowrap; -} - -.bs-cmp-runs__sides strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 550; -} - -.bs-cmp-run-verdict { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; -} - -.bs-cmp-run-verdict > span { - display: flex; - flex-direction: column; - gap: 4px; - color: var(--cmp-muted); - font-size: 10px; -} - -.bs-cmp-run-verdict strong { - color: #dbe7f5; - font: 16px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; - font-variant-numeric: tabular-nums; -} - -.bs-cmp-run-verdict .is-fail strong { color: #fca5a5; } -.bs-cmp-run-verdict .is-pass strong { color: #86efac; } - -.bs-cmp-job-modal .bs-cmp-runs__summary, -.bs-cmp-job-modal .bs-cmp-run-prog__meta, -.bs-cmp-job-modal .bs-cmp-runs__elapsed { - color: var(--cmp-muted); - font-size: 11px; - overflow-wrap: anywhere; -} - -.bs-cmp-runs__summary.is-stopped { - color: #fbbf24; -} - -.bs-cmp-job-modal .bs-cmp-runs__delete { - color: #fca5a5; -} - -.bs-cmp-runs__error { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 8px; - padding: 10px; - border: 1px solid #7f4343; - border-radius: 4px; - color: #fecaca; - background: rgba(127, 67, 67, 0.1); - font-size: 12px; - overflow-wrap: anywhere; -} - -.bs-cmp-job-modal .bs-cmp-board, -.bs-cmp-job-modal .bs-cmp-strip, -.bs-cmp-job-modal .bs-cmp-sides, -.bs-cmp-job-modal .bs-cmp-nav, -.bs-cmp-job-modal .bs-cmp-progress { - border-radius: 4px; - background-image: none; -} - -.bs-cmp-job-modal .bs-cmp-progress__track, -.bs-cmp-job-modal .bs-cmp-run-prog__track { - border-radius: 2px; -} - -.bs-cmp-job-modal .bs-cmp-progress__fill, -.bs-cmp-job-modal .bs-cmp-run-prog__fill { - background: #5795d9; -} - -.bs-cmp-scope__item:focus-visible, -.bs-cmp-job-modal .bs-cmp-job-tabs .button:focus-visible, -.bs-cmp-mapping__editor:focus-visible { - outline: 2px solid var(--cmp-accent); - outline-offset: 2px; -} - -@media (max-width: 800px) { - .bs-cmp-config { grid-template-columns: minmax(0, 1fr); } - .bs-cmp-job-tabs .button { padding: 0 10px; } -} - -@media (max-width: 600px) { - .bs-cmp-sources, - .bs-cmp-config__fields, - .bs-cmp-mapping__fields { grid-template-columns: minmax(0, 1fr); } - .bs-cmp-config__section { padding: 11px; } - .bs-cmp-run-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .bs-cmp-run-stats > div:nth-child(2) { border-right: 0; } - .bs-cmp-run-stats > div:nth-child(-n+2) { border-bottom: 1px solid var(--cmp-line); } - .bs-cmp-job-tabs__index { display: none; } - .bs-cmp-job-modal .bs-cmp-job-tabs .button { flex: 1 1 0; padding: 0 7px; font-size: 12px; } -} - -@media (prefers-reduced-motion: reduce) { - .bs-cmp-job-modal .bs-cmp-progress__fill, - .bs-cmp-job-modal .bs-cmp-run-prog__fill { transition: none; } - .bs-cmp-job-modal .is-indeterminate { animation: none; } -} - -/* Business monitoring: modal + task chrome; list panel inherits .network-main ops. */ -.bs-monitor-workspace, -.bs-monitor-modal { - --mon-line: var(--nm-ops-line, #2b3748); - --mon-muted: var(--nm-ops-muted, #9aa9bc); - --mon-accent: var(--nm-ops-accent, #6ba9ec); -} - -.bs-monitor-modal .modal__dialog { - border-color: var(--mon-line); - border-radius: 6px; - background: #0c1521; -} - -.bs-monitor-modal .button, -.bs-monitor-modal .input, -.bs-monitor-modal .ui-field__select { - border-radius: 4px; - box-shadow: none; -} - -.bs-monitor-modal .filter-inline { - flex-wrap: wrap; - border-radius: 4px; -} - -.bs-monitor-workspace > .pt-list > .pt-list-table-wrap, -.bs-monitor-modal .pt-list-table-wrap { - min-width: 0; -} - -.bs-monitor-workspace > .pt-list > .pt-list-table-wrap .pt-list-table { min-width: 1120px; } -.bs-monitor-batches .pt-list-table { min-width: 1100px; } -.bs-monitor-modal .pt-list-table th { background: #162232; font-size: 11px; } -.bs-monitor-modal .pt-list-table td { border-bottom-color: var(--mon-line); } -.bs-monitor-workspace .pt-list-task-name + .muted { font-size: 11px; } - -.bs-monitor-heading { display: flex; flex-direction: column; gap: 5px; min-width: 0; } -.bs-monitor-id { color: var(--mon-muted); font: 10px/1.4 ui-monospace, Consolas, monospace; } -.bs-monitor-heading > span:last-child { font-size: 16px; overflow-wrap: anywhere; } -.bs-monitor-task-content { display: flex; flex-direction: column; gap: 14px; min-width: 0; } -.bs-monitor-task-content[hidden] { display: none; } -.bs-monitor-task-content[inert] { opacity: 0.5; } - -.bs-monitor-task .bs-schedule-row { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - align-items: start; - gap: 12px; - padding: 14px; - border: 1px solid var(--mon-line); - border-radius: 4px; - background: #111c2a; -} - -.bs-monitor-task .bs-schedule-row > .muted { grid-column: 1 / -1; font-size: 11px; line-height: 1.6; } -.bs-monitor-task .config-sync-policy-field { min-width: 0; flex-wrap: wrap; } -.bs-monitor-task .config-sync-policy-field .input { min-width: 0; width: 100%; } -.bs-monitor-task .config-sync-policy-field > select { min-width: 0; border-radius: 4px; } -.bs-monitor-task .bs-monitor-interval-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; } -.bs-monitor-task .bs-monitor-interval-field > span { grid-column: 1 / -1; } -.bs-monitor-modal .modal__footer { min-width: 0; max-width: 100%; flex-wrap: wrap; } -.bs-monitor-task .bs-monitor-task-tabs { gap: 0; margin: 0; border-bottom: 1px solid var(--mon-line); } -.bs-monitor-task-tabs .button { border: 0; border-radius: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--mon-muted); } -.bs-monitor-task .bs-monitor-task-tabs .button.is-active { border-bottom-color: var(--mon-accent); color: #edf5ff; background: rgba(107,169,236,0.08); outline: none; box-shadow: none; } -.bs-monitor-task-tabs .button:focus-visible { outline: 2px solid var(--mon-accent); outline-offset: -2px; } -.bs-monitor-history-count { flex: 1 1 240px; font-size: 11px; } -.bs-monitor-batches > .btn-row { padding: 10px; } -.bs-monitor-workspace .bs-monitor-delete, -.bs-monitor-modal .bs-monitor-delete { color: #fca5a5; } - -.bs-monitor-loading { padding: 16px; color: var(--mon-muted); font-size: 12px; } -.bs-monitor-error { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 8px; - padding: 10px; - border: 1px solid #7f4343; - border-radius: 4px; - color: #fecaca; - background: rgba(127,67,67,0.1); - font-size: 12px; - overflow-wrap: anywhere; -} - -.bs-monitor-modal .bs-workbook-meta, -.bs-monitor-modal .bs-sheet-cmd-bar, -.bs-monitor-modal .bs-sheet-cmd-row, -.bs-monitor-modal .bs-sheet-tab { border-radius: 4px; background-image: none; } -.bs-monitor-modal .bs-sheet-tabs { min-width: 0; flex-wrap: nowrap; overflow-x: auto; } -.bs-monitor-modal .bs-sheet-tab { flex: 0 0 auto; } -.bs-monitor-modal .bs-sheet-filter { gap: 8px; } -.bs-monitor-modal .bs-sheet-filter .input { flex: 1 1 200px; min-width: 0; } -.bs-monitor-modal .bs-sheet-filter .ui-field { min-width: 0; max-width: 100%; } -.bs-monitor-modal .bs-sheet-filter .ui-field__select { max-width: 100%; } -.bs-monitor-modal .bs-sheet-count { margin-left: auto; } - -@media (max-width: 800px) { - .bs-monitor-task .bs-schedule-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } -} - -@media (max-width: 600px) { - .bs-monitor-task .bs-schedule-row { grid-template-columns: minmax(0, 1fr); } - .bs-monitor-modal .bs-sheet-filter > .input { flex-basis: 100%; } -} - -/* Compare templates: compact operations editor and safe nested-rule readout. */ -.bs-template-editor .modal__dialog { border-radius: 6px; border-color: #2b3748; background: #0c1521; } -.bs-template-editor .button, -.bs-template-editor .input, -.bs-template-editor textarea, -.bs-template-editor .mt-select, -.bs-template-editor .ui-field__select { border-radius: 4px; box-shadow: none; } -.bs-template-editor .modal__footer { flex-wrap: wrap; } -.bs-template-editor .ct-editor__norm, -.bs-template-editor .ct-editor__pick, -.bs-template-editor .ct-editor__nav, -.bs-template-editor .ct-editor__pane { border: 1px solid #2b3748; border-radius: 4px; background: #111c2a; min-width: 0; } -.bs-template-editor .ct-editor__norm { padding: 10px; } -.bs-template-editor .ct-editor__norm summary { cursor: pointer; color: #c4ddfa; } -.bs-template-editor .ct-editor__norm-area { width: 100%; max-width: 100%; resize: vertical; font: 12px/1.6 ui-monospace, Consolas, monospace; } -.bs-template-editor .ct-editor__main { grid-template-columns: 190px minmax(0, 1fr); min-width: 0; } -.bs-template-editor .ct-editor__nav { max-height: 560px; } -.bs-template-editor .ct-editor__nav-item { border-radius: 3px; font-size: 12px; } -.bs-template-editor .ct-editor__nav-item.is-active { background: #1b2c43; border-color: #526f94; } -.bs-template-editor .ct-editor__meta { font: 11px/1.6 ui-monospace, Consolas, monospace; overflow-wrap: anywhere; } -.bs-template-editor .mt-rule-tab.is-active { color: #9cc7f7; border-color: #526f94; background: #1b2c43; } -.bs-template-editor .ct-field-table-wrap { min-width: 0; overflow: auto; border: 1px solid #2b3748; border-radius: 4px; } -.bs-template-editor .ct-field-table { min-width: 800px; } -.bs-template-editor .ct-field-table td { font-size: 12px; } -.bs-template-editor .ct-field-table th { font-size: 11px; } -.bs-template-editor .ct-field-table .input { max-width: 100px; } -.bs-template-editor .ct-filter-row { min-width: 0; } -.bs-template-editor .ct-filter-row .mt-select { min-width: 0; max-width: 100%; } -.bs-template-editor .ct-complex-filters pre { max-height: 360px; overflow: auto; background: #0c1521; border: 1px solid #2b3748; border-radius: 4px; padding: 10px; font: 11px/1.6 ui-monospace, Consolas, monospace; } -.bs-template-editor button:focus-visible { outline: 2px solid #6ba9ec; outline-offset: 2px; } -.bs-template-workspace .pt-list-table { min-width: 720px; } -.bs-template-workspace .bs-cmp-metric-chips { max-height: 84px; overflow: auto; } -@media (max-width: 900px) { - .bs-template-editor .ct-editor__main { grid-template-columns: minmax(0, 1fr); } - .bs-template-editor .ct-editor__nav-list { flex-direction: row; overflow-x: auto; } - .bs-template-editor .ct-editor__nav-item { flex: 0 0 160px; } - .bs-template-editor .ct-editor__nav { max-height: none; } -} -@media (max-width: 600px) { - .bs-template-editor .ct-editor__pane { padding: 10px; } - .bs-template-editor .ct-editor__pane-head { flex-direction: column; } - .bs-template-editor .ct-editor__pane-head > div:first-child { width: 100%; } - .bs-template-editor .ct-editor__pane-head > .btn-row { align-self: flex-end; } - .bs-template-editor .ct-filter-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; } - .bs-template-editor .ct-filter-row > .input { grid-column: 1 / 3; grid-row: 2; } - .bs-template-editor .ct-filter-row > button { grid-column: 3; grid-row: 1 / 3; } -} - -/* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */ -.bs-cmp-board.is-fullscreen, -.bs-cmp-board:fullscreen { - --bs-cmp-surface: rgba(15, 23, 42, 0.92); - background: #0b1220; - color: #e2e8f0; - padding: 12px 14px; - gap: 10px; - overflow: hidden; - box-sizing: border-box; -} - -.bs-cmp-board:fullscreen { - width: 100%; - height: 100%; - max-height: 100%; - min-height: 100%; - border-radius: 0; - margin: 0; -} - -/* CSS fallback only — used when requestFullscreen is blocked */ -.bs-cmp-board.is-fullscreen:not(:fullscreen) { - position: fixed; - inset: 0; - z-index: 2147483000; - width: 100vw; - height: 100vh; - max-height: 100vh; - min-height: 100vh; - margin: 0; - border-radius: 0; - box-shadow: none; -} - -body:has(.bs-cmp-board.is-fullscreen), -body:has(.bs-cmp-board:fullscreen) { - overflow: hidden; -} - -.bs-cmp-board__toolbar { - display: flex; - flex-wrap: wrap; - gap: 8px 10px; - align-items: center; - justify-content: flex-start; - flex: 0 0 auto; - min-height: 36px; - padding: 2px 0; -} - -.bs-cmp-board__meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - min-width: 0; - flex: 1 1 auto; -} - -.bs-cmp-board__run-select { - flex: 0 1 280px; - min-width: 140px; - max-width: 360px; - height: 32px; - margin: 0; -} - -.bs-cmp-board__run-count { - flex: 0 0 auto; - font-size: 11px; - white-space: nowrap; - opacity: 0.75; -} - -.bs-cmp-board__config { - flex: 0 1 auto; - min-width: 0; - padding: 4px 7px; - border: 1px solid var(--bs-cmp-border, rgba(148, 163, 184, 0.22)); - border-radius: 6px; - color: var(--nm-muted, #94a3b8); - font-size: 11px; - line-height: 1.4; - overflow-wrap: anywhere; -} - -.bs-cmp-board__config.is-legacy { - color: #fbbf24; -} - -.bs-cmp-board__actions { - margin-left: auto; - flex: 0 0 auto; - align-items: center; - gap: 6px; -} - -.bs-cmp-sides { - display: inline-flex; - flex-wrap: nowrap; - align-items: center; - gap: 6px 8px; - min-width: 0; - flex: 1 1 280px; - height: 32px; - padding: 0 8px; - border-radius: 8px; - border: 1px solid var(--bs-cmp-border, rgba(148, 163, 184, 0.22)); - background: rgba(15, 23, 42, 0.5); - overflow: hidden; -} - -.bs-cmp-sides__side { - display: inline-flex; - flex-wrap: nowrap; - align-items: center; - gap: 6px; - min-width: 0; - max-width: 100%; - padding: 0 8px; - border-radius: 6px; - height: 24px; - flex: 1 1 auto; -} - -.bs-cmp-sides__side.is-before { - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(248, 113, 113, 0.28); -} - -.bs-cmp-sides__side.is-after { - background: rgba(34, 197, 94, 0.1); - border: 1px solid rgba(74, 222, 128, 0.28); -} - -.bs-cmp-sides__tag { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.04em; - color: #94a3b8; - flex: 0 0 auto; -} - -.bs-cmp-sides__side.is-before .bs-cmp-sides__tag { - color: #fca5a5; -} - -.bs-cmp-sides__side.is-after .bs-cmp-sides__tag { - color: #86efac; -} - -.bs-cmp-sides__name { - font-size: 12px; - font-weight: 700; - color: #f1f5f9; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 12rem; - min-width: 0; - flex: 1 1 auto; - line-height: 1.2; -} - -.bs-cmp-sides__meta { - font-size: 11px; - font-variant-numeric: tabular-nums; - white-space: nowrap; - flex: 0 0 auto; - color: #94a3b8; -} - -.bs-cmp-sides__arrow { - flex: 0 0 auto; - color: #93c5fd; - font-weight: 700; - opacity: 0.85; -} - -.bs-cmp-sides__vs { - display: none; -} - -@media (max-width: 1100px) { - .bs-cmp-board__actions { - margin-left: 0; - } - - .bs-cmp-board__meta { - flex: 1 1 100%; - } - - .bs-cmp-sides { - flex: 1 1 100%; - height: auto; - min-height: 32px; - padding: 4px 8px; - overflow: visible; - } - - .bs-cmp-sides__name { - max-width: none; - } - - .bs-cmp-board__run-select { - flex: 1 1 200px; - max-width: none; - } -} - -.bs-cmp-board__body { - display: grid; - grid-template-columns: minmax(220px, 268px) minmax(0, 1fr); - gap: 10px; - flex: 1 1 auto; - min-height: 0; - align-items: stretch; - overflow: hidden; - transition: grid-template-columns 0.18s ease; -} - -.bs-cmp-board__body.is-nav-collapsed { - grid-template-columns: 44px minmax(0, 1fr); -} - -.bs-cmp-nav { - display: flex; - flex-direction: column; - min-height: 0; - border: 1px solid var(--bs-cmp-border); - border-radius: 10px; - background: var(--bs-cmp-surface); - overflow: hidden; -} - -.bs-cmp-nav__head { - display: grid; - grid-template-columns: 28px minmax(0, 1fr) auto; - align-items: center; - gap: 0 6px; - padding: 6px 8px; - border-bottom: 1px solid var(--bs-cmp-border); - flex: 0 0 auto; - min-height: 36px; -} - -.bs-cmp-nav.is-collapsed .bs-cmp-nav__head { - grid-template-columns: 1fr; - justify-items: center; - padding: 6px 4px; -} - -.bs-cmp-nav__toggle { - appearance: none; - width: 28px; - height: 28px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: rgba(15, 23, 42, 0.55); - color: #93c5fd; - font-size: 14px; - font-weight: 700; - line-height: 1; - cursor: pointer; - padding: 0; -} - -.bs-cmp-nav__toggle:hover { - background: rgba(37, 99, 235, 0.22); - border-color: rgba(59, 130, 246, 0.45); -} - -.bs-cmp-nav__dot--spacer { - visibility: hidden; -} - -.bs-cmp-nav__head-main { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; -} - -.bs-cmp-nav__title { - font-size: 12px; - font-weight: 700; - letter-spacing: 0.02em; - color: #f1f5f9; - white-space: nowrap; -} - -.bs-cmp-nav__count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.35rem; - height: 1.2rem; - padding: 0 5px; - border-radius: 999px; - font-size: 10px; - font-weight: 700; - font-variant-numeric: tabular-nums; - line-height: 1; - color: #93c5fd; - background: rgba(59, 130, 246, 0.18); - border: 1px solid rgba(96, 165, 250, 0.35); -} - -.bs-cmp-nav__hint { - display: none; -} - -.bs-cmp-nav__legend, -.bs-cmp-nav__stats { - display: inline-grid; - grid-template-columns: 2.6em 2.6em 2.8em; - gap: 2px; - justify-items: end; - align-items: center; - flex: 0 0 auto; - font-variant-numeric: tabular-nums; -} - -.bs-cmp-nav__legend { - margin: 0; - font-size: 10px; - font-weight: 700; -} - -.bs-cmp-nav__num { - font-size: 11px; - font-weight: 700; - line-height: 1; - color: #475569; - opacity: 0.55; - white-space: nowrap; -} - -.bs-cmp-nav__legend .bs-cmp-nav__num { - font-size: 10px; -} - -.bs-cmp-nav__num--fail.is-hot { - color: #f87171; - opacity: 1; -} - -.bs-cmp-nav__num--ok.is-hot { - color: #4ade80; - opacity: 1; -} - -.bs-cmp-nav__num--rate { - color: #93c5fd; - opacity: 0.95; -} - -.bs-cmp-nav__list { - flex: 1 1 auto; - min-height: 0; - overflow: auto; - padding: 6px; - display: flex; - flex-direction: column; - gap: 2px; -} - -.bs-cmp-nav__item { - appearance: none; - display: grid; - grid-template-columns: 8px minmax(0, 1fr) auto; - gap: 0 8px; - align-items: center; - width: 100%; - text-align: left; - border: 1px solid transparent; - border-radius: 8px; - background: transparent; - color: var(--bs-cmp-ink); - padding: 7px 8px; - cursor: pointer; - line-height: 1.25; - transition: background 0.12s ease, border-color 0.12s ease; -} - -.bs-cmp-nav.is-collapsed .bs-cmp-nav__item { - grid-template-columns: 1fr; - justify-items: center; - padding: 8px 4px; - border-radius: 6px; -} - -.bs-cmp-nav.is-collapsed .bs-cmp-nav__name, -.bs-cmp-nav.is-collapsed .bs-cmp-nav__stats { - display: none; -} - -.bs-cmp-nav.is-collapsed .bs-cmp-nav__list { - padding: 4px; - gap: 2px; - align-items: stretch; -} - -.bs-cmp-nav.is-collapsed .bs-cmp-nav__dot { - width: 8px; - height: 8px; -} - -.bs-cmp-nav.is-collapsed .bs-cmp-nav__item.is-active { - box-shadow: none; - background: rgba(37, 99, 235, 0.28); -} - -.bs-cmp-nav__item:hover { - background: rgba(30, 41, 59, 0.75); -} - -.bs-cmp-nav__item.is-active { - background: rgba(37, 99, 235, 0.22); - border-color: rgba(59, 130, 246, 0.5); - box-shadow: inset 2px 0 0 #3b82f6; -} - -.bs-cmp-nav__dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: #64748b; - justify-self: center; - align-self: center; - box-shadow: 0 0 0 2px rgba(100, 116, 139, 0.2); -} - -.bs-cmp-nav__item.is-clean .bs-cmp-nav__dot { - background: #22c55e; - box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); -} - -.bs-cmp-nav__item.has-diff .bs-cmp-nav__dot { - background: #f59e0b; - box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.22); -} - -.bs-cmp-nav__item.is-pending .bs-cmp-nav__dot { - background: #64748b; - box-shadow: 0 0 0 2px rgba(100, 116, 139, 0.25); -} - -.bs-cmp-nav__item.is-pending .bs-cmp-nav__name { - opacity: 0.75; -} - -.bs-cmp-nav__name { - font-size: 12px; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: #e2e8f0; - min-width: 0; -} - -.bs-cmp-nav__item.is-active .bs-cmp-nav__name { - color: #f8fafc; -} - -.bs-cmp-main { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.bs-cmp-main > .pt-list-pager, -.bs-cmp-main > .pager { - flex: 0 0 auto; -} - -/* Run-level overview: never changes when switching sheets */ -.bs-cmp-overview { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - min-height: 34px; - padding: 8px 12px; - border-radius: 8px; - border: 1px solid var(--bs-cmp-border); - background: rgba(15, 23, 42, 0.45); - font-size: 12px; - font-variant-numeric: tabular-nums; - color: #cbd5e1; - overflow: visible; - flex: 0 0 auto; -} - -.bs-cmp-overview__stat { - flex: 0 0 auto; - white-space: nowrap; - padding: 2px 8px; - border-radius: 6px; - background: rgba(148, 163, 184, 0.1); - border: 1px solid rgba(148, 163, 184, 0.18); -} - -.bs-cmp-overview.is-ok { - border-color: rgba(34, 197, 94, 0.28); -} - -.bs-cmp-overview.is-warn { - border-color: rgba(245, 158, 11, 0.32); -} - -.bs-cmp-overview__tag { - flex: 0 0 auto; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--bs-cmp-muted); - padding: 2px 6px; - border-radius: 4px; - background: rgba(148, 163, 184, 0.12); -} - -.bs-cmp-overview__pass b { - color: #f1f5f9; - font-weight: 800; - margin-left: 2px; -} - -.bs-cmp-overview.is-ok .bs-cmp-overview__pass b { - color: #86efac; -} - -.bs-cmp-overview.is-warn .bs-cmp-overview__pass b { - color: #fbbf24; -} - -.bs-cmp-overview__stat b { - color: #e2e8f0; - font-weight: 700; - margin-left: 1px; -} - -.bs-cmp-overview__time { - margin-left: auto; - white-space: nowrap; -} - -/* Sheet strip: sheet + pass rate + kind filters + search */ -.bs-cmp-strip { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - min-height: 44px; - padding: 8px 12px; - border-radius: 10px; - border: 1px solid var(--bs-cmp-border); - background: var(--bs-cmp-surface); - flex: 0 0 auto; -} - -.bs-cmp-strip.is-ok { - border-color: rgba(34, 197, 94, 0.32); - background: - linear-gradient(90deg, rgba(34, 197, 94, 0.06), transparent 28%), - var(--bs-cmp-surface); -} - -.bs-cmp-strip.is-warn { - border-color: rgba(245, 158, 11, 0.38); - background: - linear-gradient(90deg, rgba(245, 158, 11, 0.07), transparent 28%), - var(--bs-cmp-surface); -} - -.bs-cmp-strip__sheet { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - flex: 1 1 180px; -} - -.bs-cmp-strip__sheet-tag { - flex: 0 0 auto; - font-size: 10px; - font-weight: 700; - color: #93c5fd; - padding: 2px 6px; - border-radius: 4px; - background: rgba(37, 99, 235, 0.18); -} - -.bs-cmp-strip__sheet-name { - font-size: 13px; - color: #f8fafc; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.bs-cmp-strip__sheet-mode { - flex: 0 0 auto; - font-size: 11px; -} - -.bs-cmp-strip__pass { - display: flex; - flex-direction: column; - justify-content: center; - gap: 1px; - min-width: 0; - padding: 2px 10px; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.16); - background: rgba(8, 13, 24, 0.35); -} - -.bs-cmp-strip__pass-label { - font-size: 10px; - color: var(--bs-cmp-muted); - line-height: 1.2; - white-space: nowrap; -} - -.bs-cmp-strip__pass-value { - font-size: 18px; - font-weight: 800; - font-variant-numeric: tabular-nums; - letter-spacing: -0.02em; - line-height: 1.1; - min-width: 4.2ch; -} - -.bs-cmp-strip.is-ok .bs-cmp-strip__pass-value { - color: #86efac; -} - -.bs-cmp-strip.is-warn .bs-cmp-strip__pass-value { - color: #fbbf24; -} - -.bs-cmp-strip__kinds { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px; - flex: 1 1 100%; - min-width: 0; - padding: 2px; - border-radius: 8px; - background: rgba(8, 13, 24, 0.28); -} - -.bs-cmp-strip__kind { - appearance: none; - flex: 0 0 auto; - border: 1px solid transparent; - border-radius: 6px; - background: transparent; - color: #94a3b8; - padding: 5px 10px; - font-size: 12px; - cursor: pointer; - line-height: 1.35; - white-space: nowrap; - overflow: visible; - transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; -} - -.bs-cmp-strip__kind b { - font-variant-numeric: tabular-nums; - margin-left: 4px; - display: inline-block; - min-width: 1.5ch; -} - -.bs-cmp-strip__kind:hover { - background: rgba(30, 41, 59, 0.85); - color: #e2e8f0; -} - -.bs-cmp-strip__kind.is-active { - border-color: rgba(59, 130, 246, 0.45); - background: rgba(37, 99, 235, 0.22); - color: #93c5fd; - font-weight: 600; -} - -.bs-cmp-strip__kind--diff.is-active { - border-color: rgba(248, 113, 113, 0.4); - background: rgba(239, 68, 68, 0.14); - color: #fecaca; -} - -.bs-cmp-strip__kind--added b { - color: #86efac; -} - -.bs-cmp-strip__kind--removed b { - color: #fca5a5; -} - -.bs-cmp-strip__kind--changed b { - color: #fbbf24; -} - -.bs-cmp-strip__kind--unchanged b { - color: #4ade80; -} - -.bs-cmp-strip__kind--diff b { - color: #f87171; -} - -.bs-cmp-strip__range { - font-size: 11px; - font-variant-numeric: tabular-nums; - white-space: nowrap; - flex: 0 0 auto; - margin-left: auto; -} - -.bs-cmp-top-fields { - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: center; -} - -.bs-cmp-top-fields--inline { - justify-content: flex-end; -} - -.bs-cmp-top-fields__label { - font-size: 11px; - color: var(--bs-cmp-muted); - margin-right: 2px; -} - -.bs-cmp-top-fields__chip { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 2px 7px; - border-radius: 999px; - border: 1px solid rgba(245, 158, 11, 0.35); - font-size: 11px; - background: rgba(120, 53, 15, 0.28); - color: #fde68a; -} - -.bs-cmp-top-fields__chip em { - font-style: normal; - font-weight: 700; - font-variant-numeric: tabular-nums; - color: #fbbf24; -} - -.bs-cmp-result { - min-height: 0; -} - -.bs-cmp-filter-bar { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - flex: 1 1 100%; - min-width: 0; - max-width: none; - margin-left: 0; - min-height: 32px; -} - -.bs-cmp-filter-bar .input, -.bs-cmp-filter-bar [data-slot="input"], -.bs-cmp-filter-bar input { - min-width: 0; - width: 100%; - flex: 1 1 220px; - max-width: 440px; -} - -.bs-cmp-filter-bar .bs-sheet-count { - flex: 0 0 auto; - font-size: 11px; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.bs-cmp-key-filters { - display: flex; - flex-wrap: wrap; - gap: 8px 10px; - align-items: flex-end; - width: 100%; -} - -.bs-cmp-key-filter { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 110px; - max-width: 160px; - flex: 0 1 140px; -} - -.bs-cmp-key-filter > span { - font-size: 11px; - line-height: 1.2; -} - -.bs-cmp-key-filter .input, -.bs-cmp-key-filter [data-slot="input"], -.bs-cmp-key-filter input { - min-width: 0; - width: 100%; - font-size: 12px; -} - -.bs-cmp-kind-pills { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.bs-cmp-kind-pill { - appearance: none; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 999px; - background: rgba(15, 23, 42, 0.55); - color: #cbd5e1; - padding: 4px 10px; - font-size: 12px; - cursor: pointer; - line-height: 1.3; -} - -.bs-cmp-kind-pill:hover { - background: rgba(30, 41, 59, 0.9); - color: #e2e8f0; -} - -.bs-cmp-kind-pill.is-active { - background: rgba(37, 99, 235, 0.22); - border-color: rgba(59, 130, 246, 0.55); - font-weight: 600; - color: #93c5fd; -} - -.bs-cmp-result-table { - flex: 1 1 auto; - min-height: 0; - max-height: none; - overflow: auto; - border-color: var(--bs-cmp-border); - background: rgba(8, 13, 24, 0.4); - transition: opacity 0.15s ease; - border-radius: 10px; - -webkit-overflow-scrolling: touch; -} - -.bs-cmp-result-table.bs-sheet-table { - max-height: none; - min-height: 0; -} - -.bs-cmp-result-table.is-loading { - opacity: 1; - /* Keep header/filters clickable; only mute row hit-testing via opacity cue */ - pointer-events: auto; -} - -.bs-cmp-result-state { - min-height: 180px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - padding: 24px; - text-align: center; - white-space: normal; - overflow-wrap: anywhere; - color: var(--bs-cmp-muted); -} - -.bs-cmp-result-state strong { - color: var(--bs-cmp-ink); - font-size: 14px; -} - -.bs-cmp-result-state.is-error strong { - color: var(--danger, #fca5a5); -} - -.bs-cmp-strip__kind:focus-visible, -.bs-cmp-strip__toggle:focus-visible, -.bs-cmp-nav__item:focus-visible { - outline: 2px solid var(--accent, #60a5fa); - outline-offset: 2px; -} - -@media (max-width: 600px) { - .bs-cmp-strip__sheet { - flex-wrap: wrap; - } - - .bs-cmp-filter-bar input { - flex-basis: 100%; - max-width: none; - } - - .bs-cmp-key-filter { - flex: 1 1 120px; - max-width: none; - } -} - -.bs-cmp-board.is-fullscreen .bs-cmp-result-table { - max-height: none; - min-height: 0; - flex: 1 1 auto; -} - -@media (max-width: 1100px) { - .bs-cmp-strip__kinds { - flex: 1 1 100%; - } - - .bs-cmp-filter-bar { - flex: 1 1 100%; - max-width: none; - margin-left: 0; - } - - .bs-cmp-strip__range { - margin-left: 0; - } - - .bs-cmp-overview__time { - margin-left: 0; - flex-basis: 100%; - } -} - -@media (max-width: 960px) { - .bs-cmp-board__body { - grid-template-columns: 1fr; - grid-template-rows: auto minmax(0, 1fr); - } - - .bs-cmp-board__body.is-nav-collapsed { - grid-template-columns: 1fr; - } - - .bs-cmp-nav { - max-height: 180px; - align-self: start; - } - - .bs-cmp-nav.is-collapsed { - max-height: 52px; - } - - .bs-cmp-nav__list { - flex-direction: row; - flex-wrap: nowrap; - flex: 0 0 auto; - align-items: flex-start; - overflow-x: auto; - overflow-y: hidden; - } - - .bs-cmp-nav.is-collapsed .bs-cmp-nav__list { - display: none; - } - - .bs-cmp-nav__item { - flex: 0 0 auto; - width: auto; - min-width: 240px; - grid-template-columns: 8px minmax(0, 1fr) auto; - } -} - -.bs-cmp-diff-table { - border-collapse: separate; - border-spacing: 0; - --bs-cmp-group-h: 30px; - --bs-cmp-line: rgba(148, 163, 184, 0.22); -} - -.bs-cmp-diff-table thead th { - position: sticky; - z-index: 2; - background: rgba(15, 23, 42, 0.98); - color: #94a3b8; - font-size: 12px; - white-space: nowrap; - vertical-align: middle; - padding: 7px 10px; - border-bottom: 1px solid var(--bs-cmp-line); - border-right: 1px solid var(--bs-cmp-line); -} - -.bs-cmp-diff-table thead tr.bs-cmp-group-row th { - top: 0; - z-index: 3; - text-align: center; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.04em; - padding: 6px 12px; - height: var(--bs-cmp-group-h); - color: #cbd5e1; - background: rgba(15, 23, 42, 0.98); - border-top: 1px solid var(--bs-cmp-line); -} - -.bs-cmp-diff-table thead tr.bs-cmp-field-row th { - top: var(--bs-cmp-group-h); - z-index: 2; - vertical-align: middle; - padding-top: 6px; - padding-bottom: 7px; - background: rgba(15, 23, 42, 0.98); - color: #e2e8f0; -} - -.bs-cmp-diff-table thead th.bs-cmp-zone-start, -.bs-cmp-diff-table tbody td.bs-cmp-zone-start { - border-left: 1px solid var(--bs-cmp-line); -} - -.bs-cmp-diff-table tbody td { - padding: 7px 10px; - border-bottom: 1px solid rgba(148, 163, 184, 0.08); -} - -.bs-cmp-diff-table tbody tr:nth-child(even) td { - background: var(--bs-cmp-row-alt, rgba(30, 41, 59, 0.28)); -} - -.bs-cmp-diff-table tbody tr:hover td { - background: rgba(37, 99, 235, 0.1); -} - -/* 结果 column sticky */ -.bs-cmp-sticky-kind { - position: sticky; - left: 0; - z-index: 1; - background: rgba(15, 23, 42, 0.96); -} - -.bs-cmp-diff-table thead th.bs-cmp-sticky-kind { - z-index: 4; - background: rgba(15, 23, 42, 0.98); - text-align: center; - box-shadow: 1px 0 0 var(--bs-cmp-line); -} - -.bs-cmp-diff-table thead th.bs-cmp-col-fail { - z-index: 3; - text-align: center; -} - -.bs-cmp-diff-table tbody tr:nth-child(even) td.bs-cmp-sticky-kind { - background: rgba(22, 32, 51, 0.98); -} - -.bs-cmp-diff-table tbody tr:hover td.bs-cmp-sticky-kind { - background: rgba(30, 48, 80, 0.98); -} - -.bs-cmp-th__name { - color: #e2e8f0; - font-weight: 600; -} - -.bs-cmp-col-kind { - width: 3.5rem; - min-width: 3.5rem; - max-width: 4.5rem; - text-align: center; - white-space: nowrap; - padding-left: 8px !important; - padding-right: 8px !important; -} - -.bs-cmp-verdict { - display: block; - font-size: 12px; - font-weight: 600; - line-height: 1.3; - letter-spacing: 0.02em; -} - -.bs-cmp-verdict--pass { - color: #4ade80; -} - -.bs-cmp-verdict--fail { - color: #f87171; -} - -.bs-cmp-verdict--added { - color: #86efac; -} - -.bs-cmp-verdict__sub { - display: block; - margin-top: 2px; - font-size: 10px; - font-weight: 500; - color: #94a3b8; - line-height: 1.2; -} - -.bs-cmp-col-fail, -.bs-cmp-fail-cell { - max-width: 11rem; - font-size: 0.85em; - color: var(--bs-cmp-muted, #94a3b8); -} -.bs-cmp-val-reason { - font-size: 0.75em; - margin-top: 2px; -} - -.bs-cmp-badge { - display: inline-block; - padding: 2px 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 600; - line-height: 1.4; -} - -.bs-cmp-badge--added { - background: rgba(34, 197, 94, 0.18); - color: #86efac; -} - -.bs-cmp-badge--removed { - background: rgba(239, 68, 68, 0.18); - color: #fca5a5; -} - -.bs-cmp-badge--changed { - background: rgba(245, 158, 11, 0.18); - color: #fbbf24; -} - -.bs-cmp-badge--unchanged { - background: rgba(148, 163, 184, 0.16); - color: #94a3b8; -} - -.bs-cmp-key-cell { - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 12px; - white-space: nowrap; - color: #e2e8f0; -} - -.bs-cmp-val-cell { - font-size: 12px; - vertical-align: middle; -} - -.bs-cmp-pair { - display: flex; - flex-direction: column; - gap: 3px; - min-width: 4.5rem; -} - -.bs-cmp-pair--same { - min-width: 3rem; -} - -.bs-cmp-pair__row { - display: grid; - grid-template-columns: 1.6em minmax(0, 1fr); - gap: 6px; - align-items: baseline; -} - -.bs-cmp-pair__tag { - flex: 0 0 auto; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.02em; - color: #64748b; - line-height: 1.3; -} - -.bs-cmp-val-cell--diff .bs-cmp-pair__row:first-child .bs-cmp-pair__tag { - color: #fca5a5; -} - -.bs-cmp-val-cell--diff .bs-cmp-pair__row:last-child .bs-cmp-pair__tag { - color: #86efac; -} - -.bs-cmp-val-cell--pair.is-same { - background: transparent; -} - -.bs-cmp-val--same { - color: #cbd5e1; - opacity: 0.92; -} - -.bs-cmp-val-cell--diff { - background: rgba(245, 158, 11, 0.1); - box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.22); -} - -.bs-cmp-val { - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - color: #e2e8f0; - word-break: break-all; - line-height: 1.35; -} - -.bs-cmp-progress.is-compact { - padding: 6px 10px; - gap: 4px; -} - -.bs-cmp-progress.is-compact .bs-cmp-progress__track { - height: 4px; -} - -.bs-cmp-progress.is-compact .bs-cmp-progress__meta { - font-size: 0.75rem; -} - -.bs-cmp-strip__pass-scope { - margin-left: 4px; - font-size: 9px; - font-weight: 600; - color: #64748b; - text-transform: none; -} - -.bs-cmp-strip__toggle { - flex: 0 0 auto; - height: 28px; - padding: 0 8px; - border-radius: 6px; - border: 1px solid rgba(148, 163, 184, 0.28); - background: rgba(15, 23, 42, 0.45); - color: #cbd5e1; - font-size: 11px; - cursor: pointer; - white-space: nowrap; -} - -.bs-cmp-strip__toggle:hover { - border-color: rgba(96, 165, 250, 0.45); - color: #f1f5f9; -} - -.bs-cmp-strip__toggle.is-active { - border-color: rgba(59, 130, 246, 0.5); - background: rgba(37, 99, 235, 0.18); - color: #93c5fd; -} - -.bs-cmp-strip__hint { - flex: 1 1 100%; - margin: 0; - font-size: 0.75rem; - line-height: 1.35; -} - -.bs-cmp-strip .bs-cmp-key-filters { - flex: 1 1 100%; - margin-top: 0; -} - -.bs-cmp-val.is-empty { - color: #64748b; - font-style: italic; -} - -.bs-cmp-val--pre { - color: #fca5a5; - text-decoration: line-through; - text-decoration-thickness: 1px; - opacity: 0.95; -} - -.bs-cmp-val--post { - color: #86efac; - font-weight: 600; -} - -/* Whole-row 缺失/多余: keep the present side readable (no strikethrough) */ -.bs-cmp-val--present-pre { - color: #fecaca; - font-weight: 600; -} - -.bs-cmp-val--present-post { - color: #86efac; - font-weight: 600; -} - -.bs-cmp-val-cell--pair.is-removed { - background: rgba(239, 68, 68, 0.08); -} - -.bs-cmp-val-cell--pair.is-added { - background: rgba(34, 197, 94, 0.08); -} - -.bs-cmp-val-arrow { - margin: 0 6px; - opacity: 0.45; - font-weight: 600; - color: #94a3b8; -} - -.bs-cmp-row--added { - background: rgba(34, 197, 94, 0.06); -} - -.bs-cmp-row--removed { - background: rgba(239, 68, 68, 0.06); -} - -.bs-cmp-row--changed { - background: transparent; -} - -.bs-cmp-row--unchanged { - opacity: 0.78; -} - -.bs-cmp-cell--pre { - background: rgba(239, 68, 68, 0.14); - font-weight: 600; -} - -.bs-cmp-cell--post { - background: rgba(34, 197, 94, 0.14); - font-weight: 600; -} - -@media (max-width: 900px) { - .bs-cmp-board__hero { - grid-template-columns: 1fr; - } - - .bs-cmp-hero-metrics, - .bs-cmp-kpis { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -.nm-config-modal__body { - flex: 1 1 auto; - width: 100%; - min-height: min(52vh, 480px); - margin: 0; - padding: 12px 14px; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 8px; - background: #0b1220 !important; - color: #e2e8f0 !important; - font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; - font-size: 12px; - line-height: 1.45; - resize: vertical; - overflow: auto; -} - -.nm-config-modal__meta { - margin: 10px 0 0; - font-size: 12px; - word-break: break-all; -} - -.network-main:has(.topo-page), -.network-main:has(.webcrt-shell), -.network-main:has(.pt-wall-shell) { - padding: 0; - overflow: hidden; -} - -.app-main:has(> .pt-wall-shell) { - padding: 0; - overflow: hidden; - display: flex; - flex-direction: column; - min-height: 0; -} - -.network-main .topo-page, -.network-main .topo-page.is-sidebar-collapsed { - height: 100%; - margin: 0; - min-height: 0; -} - -.network-main .webcrt-shell { - height: 100%; - margin: 0; - min-height: 0; - border: 0; -} - -.network-main .pt-wall-shell, -.app-main > .pt-wall-shell { - flex: 1; - height: 100%; - margin: 0; - min-height: 0; -} - -.pt-board-toolbar__title { - font-size: 15px; - font-weight: 600; - color: #e2e8f0; - line-height: 30px; - max-width: min(420px, 50vw); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.network-placeholder h2 { - margin: 0 0 8px; -} - -.network-placeholder__list { - margin: 12px 0 0; - padding-left: 1.25rem; - color: #94a3b8; - line-height: 1.6; -} - -/* Port traffic monitoring */ -.pt-wizard__steps { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 16px; -} - -.pt-wizard__step { - border: 1px solid rgba(148, 163, 184, 0.28); - background: rgba(15, 23, 42, 0.75); - color: #cbd5e1; - border-radius: 6px; - padding: 6px 10px; - font-size: 13px; - cursor: pointer; -} - -.pt-wizard__step.is-active { - border-color: rgba(45, 212, 191, 0.45); - background: rgba(15, 118, 110, 0.28); - color: #5eead4; -} - -.pt-wizard__ne-block { - margin-bottom: 16px; - padding-bottom: 12px; - border-bottom: 1px solid rgba(148, 163, 184, 0.18); -} - -.pt-wizard__confirm-list { - margin: 8px 0 16px; - padding-left: 1.2rem; - max-height: 220px; - overflow: auto; - color: #cbd5e1; -} - -.pt-list { - display: flex; - flex-direction: column; - gap: 14px; -} - -.pt-list-kpis { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; -} - -.pt-list-kpi { - padding: 12px 14px; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 8px; - background: rgba(15, 23, 42, 0.72); -} - -.pt-list-kpi--live { - background: rgba(37, 99, 235, 0.18); - border-color: rgba(59, 130, 246, 0.4); -} - -.pt-list-kpi__label { - font-size: 12px; - color: #94a3b8; - margin-bottom: 4px; -} - -.pt-list-kpi__value { - font-size: 22px; - font-weight: 650; - font-variant-numeric: tabular-nums; - color: #f1f5f9; - line-height: 1.2; -} - -.pt-list-kpi--live .pt-list-kpi__value { - color: #93c5fd; -} - -.pt-list-table-wrap { - border: 1px solid rgba(148, 163, 184, 0.2); - border-radius: 8px; - overflow: auto; -} - -.pt-list-table { - margin: 0; -} - -.pt-list-table thead th { - background: rgba(15, 23, 42, 0.9); - font-size: 12px; - font-weight: 600; - color: #94a3b8; - white-space: nowrap; -} - -.pt-list-table tbody td { - vertical-align: middle; -} - -.pt-list-table tbody tr:hover td { - background: rgba(30, 41, 59, 0.55); -} - -.pt-list-task-name { - font-weight: 600; - color: #e2e8f0; -} - -.pt-list-task-err { - margin-top: 2px; - max-width: 280px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - color: #fca5a5; -} - -/* Beat .app-main button { color/background } so error state shows without hover. */ -.app-main .pt-list-log-btn--error, -.pt-list-actions .pt-list-log-btn--error { - color: #fca5a5; - border-color: rgba(248, 113, 113, 0.35); - background: rgba(127, 29, 29, 0.35); - font-weight: 600; -} - -.app-main .pt-list-log-btn--error:hover, -.pt-list-actions .pt-list-log-btn--error:hover { - color: #fecaca; - background: rgba(153, 27, 27, 0.5); -} - -.pt-log-modal__head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} - -.pt-log-modal__head h3 { - margin: 0; -} - -.pt-log-modal__sub { - margin: 4px 0 0; - font-size: 13px; -} - -.pt-log-modal__list { - display: flex; - flex-direction: column; - gap: 10px; - max-height: min(60vh, 520px); - overflow: auto; -} - -.pt-log-item { - border: 1px solid rgba(148, 163, 184, 0.2); - border-radius: 8px; - padding: 10px 12px; - background: rgba(15, 23, 42, 0.72); - color: #e2e8f0; -} - -.pt-log-item--error { - border-color: rgba(248, 113, 113, 0.35); - background: rgba(239, 68, 68, 0.12); -} - -.pt-log-item--warn { - border-color: rgba(251, 191, 36, 0.35); - background: rgba(245, 158, 11, 0.12); -} - -.pt-log-item__meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - margin-bottom: 6px; - font-size: 12px; - color: #94a3b8; -} - -.pt-log-item__level { - text-transform: uppercase; - letter-spacing: 0.04em; - font-weight: 700; - color: #fca5a5; -} - -.pt-log-item--warn .pt-log-item__level { - color: #fde68a; -} - -.pt-log-item--info .pt-log-item__level { - color: #7dd3fc; -} - -.pt-log-item__if { - font-family: ui-monospace, SFMono-Regular, Consolas, "Cascadia Mono", Consolas, monospace; - color: #cbd5e1; -} - -.pt-log-item__msg { - margin: 0; - background: rgba(2, 8, 23, 0.55); - border: 1px solid rgba(148, 163, 184, 0.2); - border-radius: 6px; - padding: 8px 10px; - white-space: pre-wrap; - word-break: break-word; - font-size: 12px; - line-height: 1.45; - color: #e2e8f0; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - -.pt-rebind-modal__current { - display: flex; - flex-direction: column; - gap: 4px; - padding: 10px 12px; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 8px; - background: rgba(15, 23, 42, 0.72); - color: #e2e8f0; -} - -.pt-rebind-modal__current strong { - margin-left: 8px; - font-weight: 600; -} - -.pt-rebind-modal__table-wrap { - max-height: min(40vh, 360px); - overflow: auto; -} - -.pt-rebind-row--same-ip { - background: rgba(120, 53, 15, 0.35); -} - -.pt-rebind-same-ip { - margin-left: 8px; - font-size: 11px; - font-weight: 600; - color: #fbbf24; -} - -.pt-list-status { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 8px; - border-radius: 999px; - font-size: 12px; - font-weight: 600; - line-height: 1.5; - white-space: nowrap; -} - -.pt-list-status + .pt-list-status { - margin-left: 6px; -} - -.pt-list-status--running { - background: rgba(34, 197, 94, 0.16); - color: #86efac; -} - -.pt-list-status--running::before, -.pt-list-status--collect::before { - content: ""; - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; -} - -.pt-list-status--paused { - background: rgba(245, 158, 11, 0.16); - color: #fde68a; -} - -.pt-list-status--stopped, -.pt-list-status--other { - background: rgba(148, 163, 184, 0.14); - color: #94a3b8; -} - -.pt-list-status--collect { - background: rgba(99, 102, 241, 0.18); - color: #a5b4fc; -} - -.pt-list-num, -.pt-list-time { - font-variant-numeric: tabular-nums; - white-space: nowrap; - color: #94a3b8; -} - -.pt-list-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: center; -} - -.pt-list-actions .btn-primary, -.pt-list-actions .btn--danger, -.pt-list-actions button { - padding: 4px 10px; - font-size: 12px; - line-height: 1.4; -} - -.pt-list-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - min-height: 200px; - padding: 32px 16px; - border: 1px dashed rgba(148, 163, 184, 0.28); - border-radius: 8px; - background: rgba(15, 23, 42, 0.55); - color: #94a3b8; - text-align: center; -} - -.pt-list-empty p { - margin: 0; -} - -.pt-list-pager { - justify-content: center; -} - -@media (max-width: 900px) { - .pt-list-kpis { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -.pt-wall-shell { - display: flex; - flex-direction: column; - min-height: 0; - color: #e2e8f0; - background: - radial-gradient(1200px 480px at 12% -10%, rgba(45, 212, 191, 0.08), transparent 55%), - radial-gradient(900px 420px at 88% 0%, rgba(56, 189, 248, 0.06), transparent 50%), - linear-gradient(180deg, #0a1220 0%, #070d18 100%); -} - -.pt-board-toolbar { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - justify-content: space-between; - gap: 10px 16px; - flex: 0 0 auto; - padding: 10px 14px; - border-bottom: 1px solid rgba(148, 163, 184, 0.16); - background: rgba(15, 23, 42, 0.88); -} - -.pt-board-toolbar__left { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 8px 12px; - min-width: 0; -} - -.pt-board-toolbar__field { - display: flex; - flex-direction: column; - gap: 3px; - font-size: 11px; - color: #94a3b8; - min-width: 0; -} - -.pt-board-toolbar__field select, -.pt-board-toolbar__field input { - height: 30px; - min-width: 160px; - padding: 0 9px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: rgba(15, 23, 42, 0.7); - color: #e2e8f0; - font-size: 13px; -} - -.pt-board-toolbar__field--sm select { - min-width: 72px; -} - -.pt-board-toolbar__actions { - flex-wrap: wrap; -} - -.app-main .pt-board-toolbar button { - height: 30px; - font-size: 12px; -} - -.pt-board-page { - padding-top: 10px; -} - -.pt-board-add { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 10px 12px; - margin-bottom: 12px; - padding: 10px 12px; - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: 8px; - background: rgba(15, 23, 42, 0.45); -} - -.pt-board-grid { - display: grid; - gap: 12px; - flex: 1; - min-height: 0; - align-content: start; - overflow: auto; - padding-bottom: 8px; -} - -.pt-board-panel { - min-height: 320px; - display: flex; - flex-direction: column; - min-width: 0; - border: 1px solid rgba(148, 163, 184, 0.12); - border-radius: 10px; - background: rgba(8, 14, 26, 0.35); - overflow: hidden; -} - -.pt-board-panel.is-stale { - border-color: rgba(248, 113, 113, 0.45); -} - -.pt-board-panel .pt-wall { - flex: 1; - min-height: 280px; - padding: 12px 12px 8px; -} - -.pt-board-panel__editbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - flex: 0 0 auto; - padding: 6px 10px; - border-bottom: 1px solid rgba(148, 163, 184, 0.14); - background: rgba(15, 23, 42, 0.72); -} - -.pt-board-panel__edit-title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - color: #cbd5e1; -} - -.pt-board-panel__stale { - flex: 1; - display: grid; - place-content: center; - gap: 10px; - padding: 24px; - text-align: center; - color: #fca5a5; -} - -.pt-wall-shell__empty { - flex: 1; - display: grid; - place-content: center; - gap: 12px; - color: #94a3b8; -} - -.pt-wall-page { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - padding: 10px 14px 12px; - gap: 0; -} - -.pt-wall-page__hint { - margin: 0 0 8px; - flex: 0 0 auto; - font-size: 12px; - color: #94a3b8; -} - -.pt-wall-page__stage { - position: relative; - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; -} - -.pt-wall-page__stage:fullscreen, -.pt-wall-page__stage.is-fullscreen:fullscreen { - width: 100vw; - height: 100vh; - padding: 12px 14px; - box-sizing: border-box; - display: flex; - flex-direction: column; - overflow: hidden; - background: - radial-gradient(1200px 480px at 12% -10%, rgba(45, 212, 191, 0.08), transparent 55%), - linear-gradient(180deg, #0a1220 0%, #070d18 100%); -} - -.pt-wall-page__stage:fullscreen .pt-board-grid, -.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-grid { - flex: 1 1 auto; - width: 100%; - height: auto; - min-height: 0; - overflow: hidden; - align-content: stretch; - /* Equal row heights that fit the viewport — no fixed min that forces scroll. */ - grid-auto-rows: minmax(0, 1fr); - gap: 6px; - padding-bottom: 0; -} - -.pt-board-fs-legend { - display: none; -} - -.pt-wall-page__stage:fullscreen .pt-board-fs-legend, -.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-fs-legend { - display: flex; - flex: 0 0 auto; - flex-wrap: wrap; - align-items: center; - justify-content: center; - gap: 8px 18px; - margin-top: 6px; - padding: 4px 8px 0; - color: #94a3b8; - font-size: 11px; -} - -.pt-wall-page__stage:fullscreen .pt-board-panel, -.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-panel { - min-height: 0; - height: auto; - max-height: 100%; - overflow: hidden; - border-radius: 8px; -} - -.pt-wall-page__stage:fullscreen .pt-board-panel .pt-wall, -.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-panel .pt-wall { - flex: 1 1 auto; - min-height: 0; - height: 100%; - padding: 6px 8px 2px; -} - -.pt-wall--dense .pt-wall__ne { - font-size: 11px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.pt-wall--dense .pt-wall__ne-base { - color: #64748b; - font-weight: 400; -} - -.pt-wall--dense .pt-wall__if { - font-size: clamp(13px, 1.5vh, 18px); - max-width: min(560px, 55vw); -} - -.pt-wall--dense .pt-wall__range { - font-size: 11px; -} - -.pt-wall--dense .pt-wall__head { - margin-bottom: 4px; - align-items: flex-start; -} - -.pt-wall--dense .pt-wall__kpis { - margin-bottom: 4px; -} - -.pt-wall--dense .pt-wall__kpi { - padding: 4px 8px; -} - -.pt-wall--dense .pt-wall__kpi-label { - font-size: 9px; -} - -.pt-wall--dense .pt-wall__kpi-value { - font-size: clamp(12px, 1.6vh, 16px); -} - -.pt-wall--dense .pt-wall__kpi-unit { - font-size: 10px; -} - -.pt-wall-page__stage:fullscreen .pt-wall__if, -.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__if { - font-size: clamp(13px, 1.5vh, 18px); -} - -.pt-wall-page__stage:fullscreen .pt-wall__head, -.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__head { - margin-bottom: 4px; -} - -.pt-wall-page__stage:fullscreen .pt-wall__kpis, -.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__kpis { - margin-bottom: 4px; -} - -.pt-wall-page__stage:fullscreen .pt-wall__kpi, -.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__kpi { - padding: 4px 8px; -} - -.pt-wall-page__stage:fullscreen .pt-wall__kpi-value, -.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__kpi-value { - font-size: clamp(12px, 1.6vh, 16px); -} - -.pt-wall-page__stage:fullscreen .pt-wall__chart-wrap, -.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__chart-wrap { - min-height: 0; - flex: 1 1 auto; -} - -.pt-wall-page__stage .pt-wall { - flex: 1; - min-height: 0; - height: 100%; -} - -.pt-wall-page__fs-btn { - display: inline-flex; - align-items: center; - gap: 6px; - height: 30px; - padding: 0 11px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 6px; - background: transparent; - color: #cbd5e1; - font-size: 12px; - cursor: pointer; -} - -.pt-wall-page__fs-btn--toolbar { - flex: 0 0 auto; - align-self: flex-end; -} - -.pt-wall-page__fs-btn:hover { - background: rgba(148, 163, 184, 0.12); - border-color: rgba(148, 163, 184, 0.45); - color: #f8fafc; -} - -.app-main .pt-wall-page__fs-btn { - background: transparent; - color: #cbd5e1; -} - -.app-main .pt-wall-page__fs-btn:hover { - background: rgba(148, 163, 184, 0.12); - color: #f8fafc; -} - -.pt-wall-page__toolbar { - display: flex; - flex-direction: column; - gap: 8px; - flex: 0 0 auto; - margin-bottom: 10px; - padding: 0 0 10px; - background: transparent; - border: 0; - border-bottom: 1px solid rgba(148, 163, 184, 0.14); - border-radius: 0; -} - -.pt-wall-page__row { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 8px 10px; -} - -.pt-wall-page__row--primary { - padding-bottom: 0; - border-bottom: 0; -} - -.pt-wall-page__row--secondary { - align-items: flex-end; -} - -.pt-wall-page__field { - display: flex; - flex-direction: column; - gap: 3px; - font-size: 11px; - color: #64748b; - min-width: 0; -} - -.pt-wall-page__field select, -.pt-wall-page__field input { - height: 30px; - padding: 0 9px; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 6px; - background: rgba(15, 23, 42, 0.55); - color: #e2e8f0; - font-size: 12px; -} - -.pt-wall-page__field select:hover, -.pt-wall-page__field input:hover, -.pt-wall-page__field select:focus, -.pt-wall-page__field input:focus { - border-color: rgba(45, 212, 191, 0.45); - outline: none; -} - -.app-main .pt-wall-page__field select, -.app-main .pt-wall-page__field input { - background: rgba(15, 23, 42, 0.55); - color: #e2e8f0; - border-color: rgba(148, 163, 184, 0.22); -} - -.pt-wall-page__field--task { - flex: 0 1 160px; - min-width: 128px; -} - -.pt-wall-page__field--port { - flex: 1 1 220px; - min-width: 180px; -} - -.pt-wall-page__field--sm { - flex: 0 0 78px; -} - -.pt-wall-page__field--md { - flex: 0 1 148px; - min-width: 120px; -} - -.pt-wall-page__field--port select { - width: 100%; -} - -.pt-wall-page__collect { - flex: 0 0 auto; - height: 30px; - margin-left: auto; - align-self: flex-end; - white-space: nowrap; - border-radius: 6px; - border: 1px solid rgba(45, 212, 191, 0.35); - background: rgba(45, 212, 191, 0.16); - color: #99f6e4; - font-size: 12px; -} - -.app-main .pt-wall-page__collect.btn-primary { - background: rgba(45, 212, 191, 0.16); - border-color: rgba(45, 212, 191, 0.35); - color: #99f6e4; -} - -.app-main .pt-wall-page__collect.btn-primary:hover:not(:disabled) { - background: rgba(45, 212, 191, 0.28); - color: #ecfdf5; -} - -.pt-wall-page__cluster { - display: flex; - flex-direction: row; - align-items: flex-end; - gap: 8px 10px; - min-width: 0; -} - -.pt-wall-page__cluster--map { - flex: 1 1 280px; -} - -.pt-wall-page__cluster-label { - flex: 0 0 auto; - padding-bottom: 7px; - font-size: 10px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #475569; - white-space: nowrap; -} - -.pt-wall-page__cluster-fields { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 8px 10px; -} - -.pt-wall-page__cluster-sep { - width: 1px; - align-self: stretch; - margin: 4px 6px 0; - background: rgba(148, 163, 184, 0.14); - flex: 0 0 1px; -} - -@media (max-width: 900px) { - .pt-wall-page__cluster { - flex-direction: column; - align-items: stretch; - } - - .pt-wall-page__cluster-label { - padding-bottom: 0; - } - - .pt-wall-page__cluster-sep { - display: none; - } - - .pt-wall-page__collect { - margin-left: 0; - } - - .pt-wall-page__field--port { - flex-basis: 100%; - } -} - -.pt-wall { - --pt-bg: #0a1220; - --pt-panel: transparent; - --pt-line: rgba(148, 163, 184, 0.16); - --pt-text: #e2e8f0; - --pt-muted: #8b9bb0; - --pt-in: #2dd4bf; - --pt-out: #fbbf24; - display: flex; - flex-direction: column; - min-height: 0; - background: transparent; - border: 0; - border-radius: 0; - padding: 4px 2px 0; - color: var(--pt-text); -} - -.pt-wall__head { - display: flex; - justify-content: space-between; - align-items: flex-end; - gap: 16px; - margin-bottom: 12px; - flex: 0 0 auto; -} - -.pt-wall__head-title { - font-size: 14px; - font-weight: 600; - letter-spacing: 0.04em; - color: #cbd5e1; -} - -.pt-wall__title { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.pt-wall__ne { - font-size: 12px; - font-weight: 500; - letter-spacing: 0.02em; - color: var(--pt-muted); -} - -.pt-wall__if { - font-size: 22px; - font-weight: 600; - letter-spacing: 0.01em; - font-family: ui-monospace, SFMono-Regular, Consolas, "Cascadia Mono", Consolas, monospace; - color: #f8fafc; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: min(720px, 72vw); -} - -.pt-wall__range { - font-size: 12px; - color: var(--pt-muted); - white-space: nowrap; - padding-bottom: 3px; - font-variant-numeric: tabular-nums; -} - -.pt-wall__kpis { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0; - margin-bottom: 14px; - flex: 0 0 auto; - border: 1px solid rgba(148, 163, 184, 0.12); - border-radius: 8px; - background: rgba(15, 23, 42, 0.35); - overflow: hidden; -} - -.pt-wall__kpi { - background: transparent; - border: 0; - border-radius: 0; - border-right: 1px solid rgba(148, 163, 184, 0.1); - padding: 12px 16px; -} - -.pt-wall__kpi:last-child { - border-right: 0; -} - -.pt-wall__kpi-label { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: #64748b; - margin-bottom: 6px; -} - -.pt-wall__kpi-value { - font-size: 20px; - font-weight: 600; - font-variant-numeric: tabular-nums; - font-family: ui-monospace, SFMono-Regular, Consolas, "Cascadia Mono", Consolas, monospace; - line-height: 1.15; - color: #f1f5f9; -} - -.pt-wall__kpi--in .pt-wall__kpi-value { - color: var(--pt-in); -} - -.pt-wall__kpi--out .pt-wall__kpi-value { - color: var(--pt-out); -} - -.pt-wall__kpi--util .pt-wall__kpi-value { - color: #7dd3fc; -} - -.pt-wall__kpi-unit, -.pt-wall__kpi-sep { - margin-left: 4px; - font-size: 11px; - color: var(--pt-muted); - font-weight: 500; -} - -.pt-wall__chart-wrap { - position: relative; - flex: 1; - min-height: 180px; - width: 100%; - border-radius: 8px; - background: rgba(8, 14, 26, 0.45); - border: 1px solid rgba(148, 163, 184, 0.1); - padding: 8px 8px 4px; - box-sizing: border-box; -} - -.pt-wall__chart { - min-height: 0; - height: 100%; - width: 100%; -} - -.pt-wall__chart .uplot, -.pt-wall__chart .u-wrap { - font-family: ui-monospace, SFMono-Regular, Consolas, "Cascadia Mono", Consolas, monospace; -} - -.pt-wall__tip { - position: absolute; - top: 0; - left: 0; - z-index: 5; - min-width: 150px; - padding: 8px 10px; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.28); - background: rgba(8, 14, 26, 0.94); - color: #e2e8f0; - font-size: 12px; - line-height: 1.45; - pointer-events: none; - box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4); - backdrop-filter: blur(6px); -} - -.pt-wall__tip-time { - margin-bottom: 6px; - color: #94a3b8; - font-variant-numeric: tabular-nums; -} - -.pt-wall__tip-row { - display: flex; - justify-content: space-between; - gap: 12px; - font-variant-numeric: tabular-nums; -} - -.pt-wall__tip-row--in strong { - color: #2dd4bf; -} - -.pt-wall__tip-row--out strong { - color: #fbbf24; -} - -.pt-wall__chart .u-axis { - border: 0 !important; -} - -.pt-wall__chart .u-legend { - display: none !important; -} - -.pt-wall__foot { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 8px 20px; - margin-top: 8px; - padding-top: 0; - border-top: 0; - flex: 0 0 auto; - min-height: 28px; -} - -.pt-wall__legend { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 16px; - color: var(--pt-muted); - font-size: 12px; - line-height: 1.2; -} - -.pt-wall__legend-item { - display: inline-flex; - align-items: center; - gap: 6px; - white-space: nowrap; -} - -.pt-wall__legend-swatch { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 2px; - flex: 0 0 10px; -} - -.pt-wall__legend-item--in .pt-wall__legend-swatch { - background: #2dd4bf; -} - -.pt-wall__legend-item--out .pt-wall__legend-swatch { - background: #fbbf24; -} - -.pt-wall__legend-item--base-in .pt-wall__legend-swatch { - background: rgba(45, 212, 191, 0.45); - box-shadow: inset 0 0 0 1px rgba(45, 212, 191, 0.7); -} - -.pt-wall__legend-item--base-out .pt-wall__legend-swatch { - background: rgba(251, 191, 36, 0.4); - box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.65); -} - -.pt-wall__foot-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 20px; - margin-left: auto; -} - -.pt-wall__foot-ne, -.pt-wall__foot-if { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 10px; - min-width: 0; -} - -.pt-wall__foot-label { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: #64748b; -} - -.pt-wall__foot-value { - font-size: 13px; - font-weight: 600; - color: #e2e8f0; -} - -.pt-wall__foot-value--mono { - font-family: ui-monospace, SFMono-Regular, Consolas, "Cascadia Mono", Consolas, monospace; - letter-spacing: 0.02em; -} - -.pt-wall__foot-ip { - font-size: 12px; - color: var(--pt-muted); - font-variant-numeric: tabular-nums; -} - -.pt-wall__empty { - position: absolute; - inset: 8px; - display: flex; - align-items: center; - justify-content: center; - padding: 16px 24px; - text-align: center; - line-height: 1.5; - color: var(--pt-muted); - background: rgba(8, 14, 26, 0.55); - border: 1px dashed rgba(148, 163, 184, 0.22); - border-radius: 6px; - pointer-events: none; -} - -@media (max-width: 900px) { - .pt-wall__kpis { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .pt-wall__kpi:nth-child(2n) { - border-right: 0; - } - - .pt-wall__kpi:nth-child(-n + 2) { - border-bottom: 1px solid rgba(148, 163, 184, 0.1); - } -} - -/* ===== Unified dark content surfaces (forms / tables / status) ===== */ -.page-loading { - display: flex; - align-items: center; - justify-content: center; - min-height: 240px; - color: #94a3b8; - font-size: 13px; -} -.page-loading--soft { - min-height: 120px; - opacity: 0; - animation: page-loading-soft-in 160ms ease 180ms forwards; -} -@keyframes page-loading-soft-in { - to { - opacity: 1; - } -} - -.app-main option, -option { - background: #0f172a; - color: #e2e8f0; -} - -.app-main .filter-inline, -.ne-page .filter-inline, -.ume-page .filter-inline, -.system-page .filter-inline, -.network-main .filter-inline { - background: rgba(8, 13, 24, 0.45) !important; - border-color: var(--nm-ops-line) !important; -} - -.app-main .filter-inline input, -.app-main .filter-inline select, -.ne-page .filter-inline input, -.ne-page .filter-inline select, -.ume-page .filter-inline input, -.ume-page .filter-inline select, -.system-page .filter-inline input, -.system-page .filter-inline select, -.network-main .filter-inline input, -.network-main .filter-inline select { - background: #0b1220 !important; - border-color: var(--nm-ops-line) !important; - color: #e2e8f0 !important; -} - -.app-main .pt-list-table-wrap, -.ne-page .pt-list-table-wrap, -.ume-page .pt-list-table-wrap, -.system-page .pt-list-table-wrap, -.network-main .pt-list-table-wrap, -.pt-list-table-wrap { - border-color: var(--nm-ops-line) !important; - background: rgba(8, 13, 24, 0.28); -} - -.app-main .pt-list-table thead th, -.ne-page .pt-list-table thead th, -.ume-page .pt-list-table thead th, -.system-page .pt-list-table thead th, -.network-main .pt-list-table thead th, -.pt-list-table thead th, -.app-main .data-table thead th, -.data-table thead th { - background: #162232 !important; - color: #94a3b8 !important; - border-bottom-color: var(--nm-ops-line); -} - -.app-main .pt-list-table tbody tr:hover td, -.pt-list-table tbody tr:hover td, -.app-main .data-table tbody tr:hover td, -.data-table tbody tr:hover td { - background: rgba(30, 41, 59, 0.55) !important; -} - -.app-main .pt-list-status--unknown, -.pt-list-status--unknown, -.app-main .pt-list-status--other, -.pt-list-status--other, -.app-main .pt-list-status--stopped, -.pt-list-status--stopped { - background: rgba(148, 163, 184, 0.14) !important; - color: #94a3b8 !important; -} - -.app-main .pt-list-status--ok, -.pt-list-status--ok, -.app-main .pt-list-status--pass, -.pt-list-status--pass, -.app-main .pt-list-status--up, -.pt-list-status--up { - background: rgba(34, 197, 94, 0.16) !important; - color: #86efac !important; -} - -.app-main table, -.app-main .data-table { - color: #e2e8f0; -} - -.app-main .muted, -.muted { - color: #94a3b8; -} - -/* ===== Dark modals + topology non-canvas ===== */ -.modal-backdrop, -.token-scopes-modal__backdrop, -.topo-modal__backdrop { - background: rgba(2, 8, 23, 0.72) !important; -} - -.modal, -.app-main .modal, -.token-scopes-modal__panel, -.topo-modal__panel, -.webcrt-auth-modal, -.webcrt-new-session-modal, -.webcrt-keyword-modal, -.webcrt-session-opts-modal { - background: #0f172a !important; - color: #e2e8f0 !important; - border: 1px solid rgba(148, 163, 184, 0.22) !important; - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45) !important; -} - -.modal h3, -.token-scopes-modal__head h3, -.topo-modal__head h3, -.pt-log-modal__head h3 { - color: #f1f5f9 !important; -} - -.modal .muted, -.modal .form-hint, -.modal label, -.modal .form-label, -.token-scopes-modal__row, -.topo-modal__hint, -.topo-modal__selectall { - color: #cbd5e1 !important; -} - -.modal input, -.modal select, -.modal textarea, -.topo-modal__panel input, -.topo-modal__panel select, -.token-scopes-modal__panel input, -.token-scopes-modal__panel select { - background: #0b1220 !important; - border-color: rgba(148, 163, 184, 0.28) !important; - color: #e2e8f0 !important; -} - -.topo-browser, -.topo-main { - background: var(--nm-chrome, #070b14); - color: var(--nm-text, #e2e8f0); - border-color: rgba(148, 163, 184, 0.2); -} -.topo-browser__head, -.topo-toolbar { - background: rgba(15, 23, 42, 0.9); - border-color: rgba(148, 163, 184, 0.18); -} -.topo-browser__grid, -.topo-browser__grid--regions { - background: rgba(2, 8, 23, 0.4); -} -.topo-map-list__row, -.topo-palette__row { - background: rgba(15, 23, 42, 0.75); - border-color: rgba(148, 163, 184, 0.22); -} - -.app-main .topo-toolbar__select, -.topo-toolbar__select, -.app-main .topo-toolbar__display > summary, -.topo-toolbar__display > summary, -.app-main .topo-toolbar__actions > .btn:not([data-variant="primary"]):not([data-variant="secondary"]), -.topo-toolbar__actions > .btn:not([data-variant="primary"]):not([data-variant="secondary"]), -.app-main .topo-toolbar__group > .btn, -.topo-toolbar__group > .btn { - background: #0b1220 !important; - border-color: rgba(148, 163, 184, 0.28) !important; - color: #e2e8f0 !important; -} - -.topo-nav-node { - background: linear-gradient(165deg, #1e293b 0%, #0f172a 100%); - border-color: rgba(148, 163, 184, 0.28); - color: #e2e8f0; -} -.topo-nav-node__icon { - background: rgba(37, 99, 235, 0.18); -} - -.webcrt-menubar__menu { - background: #0f172a !important; - border-color: rgba(148, 163, 184, 0.28) !important; - color: #e2e8f0 !important; -} -.webcrt-menubar__menu-item { - color: #e2e8f0 !important; -} -.webcrt-menubar__menu-item:hover { - background: rgba(37, 99, 235, 0.2) !important; -} - -.pt-log-modal__list { - background: rgba(2, 8, 23, 0.55) !important; - border-color: rgba(148, 163, 184, 0.2) !important; - color: #e2e8f0 !important; -} - -/* Operation audit logs */ -.system-page .audit-quick-filters button.is-active { - background: rgba(37, 99, 235, 0.18); - border-color: rgba(59, 130, 246, 0.55); - color: #1e3a8a; - font-weight: 600; -} - -.system-page .audit-summary-cell { - max-width: 420px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.system-page button.linkish { - background: transparent; - border: none; - color: #2563eb; - cursor: pointer; - padding: 0; - height: auto; - font: inherit; - text-decoration: underline; -} - -.system-page .audit-detail-row td { - background: rgba(15, 23, 42, 0.04); - padding: 10px 12px 14px; -} - -.system-page .audit-detail-pre { - margin: 0; - max-height: 240px; - overflow: auto; - white-space: pre-wrap; - word-break: break-word; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - line-height: 1.45; -} - -.system-page .audit-detail-meta { - margin: 8px 0 0; -} - -/* HeroUI Modal chrome used by NetX dialogs */ -.app-heroui-modal-backdrop { - z-index: 1200; -} - -.app-heroui-modal .modal__dialog, -.app-heroui-modal__dialog, -.topo-heroui-modal .modal__dialog, -.topo-heroui-modal__dialog { - background: #0f172a; - color: #e2e8f0; - border: 1px solid rgba(148, 163, 184, 0.22); - max-height: min(90vh, 920px); - overflow: hidden; -} - -/* - * HeroUI lg = max-w-lg (~32rem) — too narrow for NetX ops tables. - * Match legacy ops-detail-modal--xl (~1100px). - */ -.app-heroui-modal .modal__dialog--lg, -.topo-heroui-modal .modal__dialog--lg { - width: min(1100px, 96vw); - max-width: min(1100px, 96vw); -} - -.app-heroui-modal--xl .modal__dialog, -.app-heroui-modal--xl .modal__dialog--lg, -.app-heroui-modal--xl .modal__dialog--md { - width: min(1280px, 98vw); - max-width: min(1280px, 98vw); -} - -.app-heroui-modal__dialog [data-slot="modal-body"], -.app-heroui-modal__dialog .modal__body { - overflow: auto; - max-height: min(78vh, 780px); -} - -/* - * Modal.Body often uses flex column; default flex-shrink:1 crushes the rules - * table to thead-only while the pager still shows total > 0. - */ -.app-heroui-modal__dialog [data-slot="modal-body"] > *, -.app-heroui-modal__dialog .modal__body > * { - flex-shrink: 0; -} - -.app-heroui-modal--xl .app-heroui-modal__dialog [data-slot="modal-body"], -.app-heroui-modal--xl .app-heroui-modal__dialog .modal__body { - max-height: min(86vh, 900px); -} - -.app-heroui-modal .pt-list-table-wrap { - overflow-x: auto; - max-width: 100%; - width: 100%; - min-height: 10rem; -} - -.app-heroui-modal .pt-list-table { - width: 100%; - min-width: 100%; - table-layout: auto; -} - -.app-heroui-modal .pt-list-table th, -.app-heroui-modal .pt-list-table td { - white-space: nowrap; -} - -/* Biz-state profiles: keep wide table + H-scroll inside a fixed viewport. */ -.app-heroui-modal__dialog [data-slot="modal-body"] > .bs-profiles-panel, -.app-heroui-modal__dialog .modal__body > .bs-profiles-panel { - flex-shrink: 1; - min-height: 0; -} - -.app-heroui-modal .pt-list-table-wrap.bs-profiles-table-wrap { - overflow: auto !important; - max-width: 100%; - width: 100%; - max-height: none; -} - -.app-heroui-modal .pt-list-table.bs-profiles-table { - width: max-content !important; - min-width: 100% !important; - table-layout: fixed !important; -} - -.app-heroui-modal .pt-list-table.bs-profiles-table th, -.app-heroui-modal .pt-list-table.bs-profiles-table td { - white-space: normal; -} - -.app-heroui-modal .pt-list-table.bs-profiles-table .bs-cmd-cell { - white-space: nowrap; -} - -/* Last flexible column absorbs leftover width so the table fills the row */ -.app-heroui-modal .pt-list-table td.ume-col-error, -.app-heroui-modal .pt-list-table th.ume-col-error { - white-space: normal; - min-width: 12rem; - width: 100%; - max-width: none; - word-break: break-word; -} - -/* Keep dense filter bars compact when FieldSelect has no label */ -.filter-inline > .ui-field { - gap: 0; - align-self: center; -} - -.filter-inline > .ui-field > .ui-field__select { - height: 32px; - min-height: 32px; - max-height: 32px; - flex: none; -} - -.login-card--heroui { - gap: 0; -} - -.login-card__form { - display: flex; - flex-direction: column; - gap: 15px; - width: 100%; -} - -.ui-field { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; -} - -.ui-field--full { - grid-column: 1 / -1; -} - -.ui-field__label { - font-size: 12px; - color: var(--nm-muted, #94a3b8); -} - -.ui-field__select { - width: 100%; - min-height: 36px; - padding: 0 10px; - border-radius: 10px; - border: 1px solid rgba(148, 163, 184, 0.28); - background: color-mix(in oklab, var(--field-background, #0f172a) 92%, #020617); - color: inherit; - font: inherit; -} - -.ui-field__hint { - font-size: 11px; - color: var(--nm-muted, #94a3b8); - line-height: 1.35; -} - -.pager__size-field { - min-width: 7.5rem; -} - -.pager__jump .input, -.pager__jump-input { - width: 4.25rem; -} - -/* Cutover wall: keep one board/poller alive in native or window fullscreen. */ -.app-heroui-modal.bm-wall-modal { - width: 100vw !important; - max-width: 100vw !important; - height: 100dvh; - max-height: 100dvh; - padding: 0; - margin: 0; - transform: none; - box-sizing: border-box; - border: 0; -} -.app-heroui-modal.bm-wall-modal .app-heroui-modal__dialog { - width: 100% !important; - max-width: 100% !important; - height: 100%; - max-height: 100%; - margin: 0; - padding: 0; - border: 0; - border-radius: 0; - transform: none; - overflow: hidden; - box-sizing: border-box; -} -.bm-wall-modal .modal__header, -.bm-wall-modal .modal__footer, -.bm-wall-modal .bm-detail__chrome, -.bm-wall-modal .nm-config-modal__tabs { display: none; } -.app-heroui-modal.bm-wall-modal .bm-detail { - width: 100%; - min-width: 0; - height: 100%; - max-height: 100%; - margin: 0; - padding: 0; - gap: 0; - overflow: hidden; -} -.bm-board.is-wall, -.bm-board:fullscreen { - width: 100%; - min-width: 0; - height: 100dvh; - min-height: 0; - max-height: 100dvh; - margin: 0; - padding: clamp(12px, 1.25vw, 28px); - gap: 12px; - border-radius: 0; - background: #0b1220; - color: #e2e8f0; - box-sizing: border-box; - overflow: auto; -} -.bm-board.is-wall > * { flex-shrink: 0; } -.bm-wall-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; min-width: 0; } -.bm-wall-heading > div { min-width: 0; } -.bm-wall-heading__label { color: #94a3b8; font-size: 13px; letter-spacing: .08em; } -.bm-wall-heading h2 { margin: 4px 0 0; font-size: clamp(22px, 2vw, 36px); line-height: 1.2; overflow-wrap: anywhere; } -.bm-wall-heading__status { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; } -.bm-wall-heading time { color: #94a3b8; font: 14px ui-monospace, monospace; white-space: nowrap; } -.bm-wall-state { padding: 6px 12px; font-size: 15px; border: 1px solid currentColor; border-radius: 3px; } -.bm-wall-state.is-normal { color: #34d399; } -.bm-wall-state.is-anomaly { color: #f87171; } -.bm-wall-state.is-waiting, .bm-wall-state.is-unknown { color: #fbbf24; } -.bm-board.is-wall .bm-live-header { position: sticky; top: 0; z-index: 5; background: #0b1220; padding: 8px 0; } -.bm-board.is-wall .bm-live-header strong { font-size: 17px; } -.bm-board.is-wall .bm-live-sources strong { font-size: 19px; } -.bm-board.is-wall .bm-live-kpis > div { padding: 16px 20px; } -.bm-board.is-wall .bm-live-kpis span { font-size: 16px; } -.bm-board.is-wall .bm-live-kpis strong { font-size: clamp(40px, 3.5vw, 68px); } -.bm-board.is-wall .bm-live-kpis strong small { font-size: 20px; } -.bm-board.is-wall .bm-live-warning { font-size: 14px; } -.bm-wall-progress { display: flex; align-items: center; gap: 20px; padding: 4px 0; color: #34d399; } -.bm-wall-progress > div:first-child { display: flex; align-items: baseline; gap: 16px; } -.bm-wall-progress span { font-size: 13px; } -.bm-wall-progress strong { font: 600 24px ui-monospace, monospace; } -.bm-wall-progress.is-unknown { color: #fbbf24; } -.bm-wall-progress__track { flex: 1; height: 8px; min-width: 60px; background: #243044; border-radius: 2px; overflow: hidden; } -.bm-wall-progress__track > span { display: block; height: 100%; background: currentColor; } -.bm-board.is-wall .bm-board-controls > summary { display: list-item; cursor: pointer; color: #94a3b8; font-size: 13px; padding: 6px 0; } -.bm-board.is-wall .bm-sampling { max-height: 240px; overflow: auto; } -.bm-board.is-wall .bm-sampling td, .bm-board.is-wall .bm-sampling th { font-size: 14px; } -.bm-board.is-wall .bs-cmp-board__body { flex: 1 0 260px; min-height: 260px; } -.bm-board.is-wall .bs-cmp-main { display: flex; min-height: 0; } -.bm-board.is-wall .bm-board__table { flex: 1; width: 100%; min-height: 0; max-height: none; } -.bm-board.is-wall .bm-board__table td { font-size: 15px; } -.bm-board.is-wall .bs-cmp-nav__list { max-height: none; } -@media (max-width: 720px) { - .bm-wall-heading { align-items: flex-start; gap: 12px; } - .bm-wall-heading__status { max-width: 48%; } - .bm-wall-heading time { font-size: 10px; white-space: normal; } - .bm-wall-state { font-size: 12px; padding: 4px 6px; } - .bm-board.is-wall .bm-live-kpis > div { padding: 12px; } - .bm-board.is-wall .bm-live-kpis strong { font-size: 36px; } - .bm-board.is-wall .bm-live-kpis span { font-size: 13px; } - .bm-wall-progress { flex-wrap: wrap; gap: 8px; } - .bm-wall-progress__track { flex-basis: 100%; } - .bm-board.is-wall .bs-cmp-board__body { flex-basis: auto; } - .bm-board.is-wall .bm-board__table { max-height: 50dvh; } -} +@import "./styles/theme/tokens.css"; +@import "./styles/base/base.css"; +@import "./styles/layout/app-shell.css"; +@import "./styles/pages/workbench.css"; +@import "./styles/components/primitives.css"; +@import "./styles/pages/ne-ume-system.css"; +@import "./styles/pages/collect.css"; +@import "./styles/pages/webcrt.css"; +@import "./styles/pages/topology.css"; +@import "./styles/pages/login.css"; +@import "./styles/layout/network-shell.css"; +@import "./styles/pages/biz-state.css"; +@import "./styles/pages/biz-monitor-templates.css"; +@import "./styles/pages/biz-compare-templates.css"; +@import "./styles/pages/biz-migration.css"; +@import "./styles/pages/biz-compare.css"; +@import "./styles/pages/port-traffic.css"; +@import "./styles/legacy/overrides-dark.css"; +@import "./styles/legacy/overrides-late.css"; + +/* Design language layer — must stay last. */ +@import "./styles/theme/theme.css"; diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx index 134a65e..c6ab531 100644 --- a/web/src/layout/AppLayout.tsx +++ b/web/src/layout/AppLayout.tsx @@ -2,15 +2,15 @@ import { Link, useLocation } from "react-router-dom"; import type { ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button, Chip } from "@heroui/react"; -import { AppsGridIcon } from "../components/AppsGridIcon"; -import { HeaderMenu } from "../components/HeaderMenu"; +import { AppsGridIcon } from "./AppsGridIcon"; +import { HeaderMenu } from "./HeaderMenu"; import { getPageTitleKey, isWorkbenchPath } from "../config/modules"; import { useAppWindowRegistration } from "../hooks/useAppWindowRegistration"; import { useI18n } from "../i18n"; import { fetchOpsTasks } from "../services/api"; import { openOrFocusModule } from "../utils/moduleWindows"; import { returnToWorkbench } from "../utils/workbench"; -import { useAuth } from "../auth/AuthContext"; +import { useAuth } from "../features/auth/AuthContext"; type Props = { children: ReactNode; diff --git a/web/src/components/AppsGridIcon.tsx b/web/src/layout/AppsGridIcon.tsx similarity index 100% rename from web/src/components/AppsGridIcon.tsx rename to web/src/layout/AppsGridIcon.tsx diff --git a/web/src/components/HeaderMenu.tsx b/web/src/layout/HeaderMenu.tsx similarity index 100% rename from web/src/components/HeaderMenu.tsx rename to web/src/layout/HeaderMenu.tsx diff --git a/web/src/main.tsx b/web/src/main.tsx index dca15d3..b3aad3d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -7,7 +7,7 @@ import App from "./App.tsx"; import { ErrorBoundary } from "./layout/ErrorBoundary"; import { I18nProvider } from "./i18n"; import { ToastProvider } from "./hooks/useToast"; -import { AuthProvider } from "./auth/AuthContext"; +import { AuthProvider } from "./features/auth/AuthContext"; const queryClient = new QueryClient({ defaultOptions: { diff --git a/web/src/pages/UmePage.tsx b/web/src/pages/UmePage.tsx deleted file mode 100644 index 053f960..0000000 --- a/web/src/pages/UmePage.tsx +++ /dev/null @@ -1,1771 +0,0 @@ -import { useMemo, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Button, Modal, Input } from "@heroui/react"; -import { - apiPost, - applyUmeTopologyToFabric, - disconnectUmeToken, - fetchUmeNe, - cancelUmeAlarmSubscription, - clearLocalUmeAlarmSubscription, - establishUmeAlarmSubscription, - fetchUmeAlarmSubscriptionStatus, - fetchUmeAlarmKeywords, - fetchUmeInventoryNeTypes, - fetchUmeKeyAlertMonitor, - fetchUmeNotificationIds, - fetchUmeSyncStatus, - fetchUmeTokenStatus, - fetchCliTargets, - refreshUmeToken, - upsertUmeKeyAlertRule, - deleteUmeKeyAlertRule, - patchUmeKeyAlertRule, - updateUmeKeyAlertMonitorConfig, -} from "../services/api"; -import { HelpHint } from "../components/HelpHint"; -import { UmeCliConnectPanel } from "../components/UmeCliConnectPanel"; -import { AppModalShell } from "../components/ui/AppModalShell"; -import { queryKeys } from "../constants/queryKeys"; -import { useI18n } from "../i18n"; -import { useToast } from "../hooks/useToast"; -import type { UmeAlarmSubscriptionStatus, UmeKeyAlertRuleItem } from "../types"; -import { pageCount, runtimeIntervalLabel } from "../utils/display"; -import { runtimeLastError, wsConnectionLabel } from "../utils/runtimeMessages"; -import { formatSystemTime } from "../utils/time"; -import { openOrFocusModule } from "../utils/moduleWindows"; -import { jobChipColor, NmStatusChip, type NmChipColor } from "./network/nmChips"; - -function tokenChipColor(opts: { - hasToken: boolean; - needsRenewal: boolean; -}): NmChipColor { - if (!opts.hasToken) return "danger"; - if (opts.needsRenewal) return "warning"; - return "success"; -} - -function pillLevelToChip(level: string | undefined): NmChipColor { - const s = String(level || "").toLowerCase(); - if (s === "up" || s === "ok" || s === "pass") return "success"; - if (s === "down" || s === "fail" || s === "error") return "danger"; - if (s === "warn" || s === "warning" || s === "medium") return "warning"; - return "default"; -} - -export function UmePage() { - const { t } = useI18n(); - const { showOk, showError } = useToast(); - const queryClient = useQueryClient(); - const [tokenOpError, setTokenOpError] = useState(""); - const [runtimeTaskError, setRuntimeTaskError] = useState(""); - const [subscriptionOpError, setSubscriptionOpError] = useState(""); - const [syncPage, setSyncPage] = useState(1); - const [syncPageSize, setSyncPageSize] = useState(10); - const [neKeyword, setNeKeyword] = useState(""); - const [nePage, setNePage] = useState(1); - const [nePageSize, setNePageSize] = useState(10); - const [expandedNeId, setExpandedNeId] = useState(""); - const [nePanelOpen, setNePanelOpen] = useState(false); - const [syncStatusPanelOpen, setSyncStatusPanelOpen] = useState(false); - const [keyAlertPanelOpen, setKeyAlertPanelOpen] = useState(false); - const [keyAlertMatchType, setKeyAlertMatchType] = useState<"notification_id" | "keyword">("keyword"); - const [keyAlertMatchValue, setKeyAlertMatchValue] = useState(""); - const [keyAlertLabel, setKeyAlertLabel] = useState(""); - const [keyAlertOpError, setKeyAlertOpError] = useState(""); - const [keyAlertKeywordHints, setKeyAlertKeywordHints] = useState([]); - const [keyAlertIdHints, setKeyAlertIdHints] = useState< - Array<{ notification_id: string; native_probable_cause_sample: string }> - >([]); - const [keyAlertPage, setKeyAlertPage] = useState(1); - const [keyAlertPageSize, setKeyAlertPageSize] = useState(10); - const [keyAlertFilterKeyword, setKeyAlertFilterKeyword] = useState(""); - const [keyAlertFilterEnabled, setKeyAlertFilterEnabled] = useState<"" | "true" | "false">(""); - const [keyAlertFilterMatchType, setKeyAlertFilterMatchType] = useState<"" | "notification_id" | "keyword">(""); - const [keyAlertNeTypes, setKeyAlertNeTypes] = useState([]); - const [keyAlertEditRule, setKeyAlertEditRule] = useState(null); - const [keyAlertEditNeTypes, setKeyAlertEditNeTypes] = useState([]); - const [cliPanelOpen, setCliPanelOpen] = useState(false); - - const syncStatusQuery = useQuery({ - queryKey: queryKeys.umeSyncStatus(syncPage, syncPageSize), - queryFn: () => fetchUmeSyncStatus({ page: syncPage, pageSize: syncPageSize }), - staleTime: 10_000, - // Panel open or active sync → keep snappy; otherwise idle cards only need a slow heartbeat. - refetchInterval: (q) => { - const items = q.state.data?.items || []; - const running = items.some((x) => String(x.status || "").toLowerCase() === "running"); - if (syncStatusPanelOpen || running) return 5000; - return 20_000; - }, - refetchIntervalInBackground: false, - }); - const tokenStatusQuery = useQuery({ - queryKey: queryKeys.umeTokenStatus, - queryFn: fetchUmeTokenStatus, - staleTime: 15_000, - refetchInterval: 20_000, - refetchIntervalInBackground: false, - }); - const tokenRefreshMutation = useMutation({ - mutationFn: refreshUmeToken, - onMutate: () => setTokenOpError(""), - onSuccess: async (res) => { - if (!res?.ok) { - const msg = String(res.error || res.error_kind || "token_refresh_failed"); - setTokenOpError(msg); - showError(msg); - } else { - setTokenOpError(""); - showOk(res.changed ? t("ume.token.renewOkChanged") : t("ume.token.renewOkSame")); - } - await queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus }); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll }); - }, - onError: (err) => { - const msg = String(err); - setTokenOpError(msg); - showError(msg); - }, - }); - const subscriptionStatusQuery = useQuery({ - queryKey: queryKeys.umeAlarmSubscription, - queryFn: fetchUmeAlarmSubscriptionStatus, - staleTime: 10_000, - refetchInterval: 15_000, - refetchIntervalInBackground: false, - }); - const keyAlertMonitorQuery = useQuery({ - queryKey: queryKeys.umeKeyAlertMonitor( - keyAlertPage, - keyAlertPageSize, - keyAlertFilterKeyword, - keyAlertFilterEnabled, - keyAlertFilterMatchType, - ), - queryFn: () => - fetchUmeKeyAlertMonitor({ - page: keyAlertPage, - pageSize: keyAlertPageSize, - keyword: keyAlertFilterKeyword, - enabled: keyAlertFilterEnabled, - matchType: keyAlertFilterMatchType, - }), - staleTime: keyAlertPanelOpen ? 3000 : 15_000, - refetchInterval: keyAlertPanelOpen ? 5000 : 20_000, - refetchIntervalInBackground: false, - }); - const keyAlertNeTypesQuery = useQuery({ - queryKey: queryKeys.umeInventoryNeTypes, - queryFn: () => fetchUmeInventoryNeTypes(), - staleTime: 60_000, - enabled: keyAlertPanelOpen || Boolean(keyAlertEditRule), - }); - - const confirmClearLocalSubscription = (hint?: string) => - window.confirm(hint || t("ume.subscription.confirmClearDefault")); - - const subscriptionEstablishMutation = useMutation({ - mutationFn: (opts?: { forceReestablish?: boolean }) => establishUmeAlarmSubscription(opts), - onMutate: () => setSubscriptionOpError(""), - onSuccess: async (res) => { - if (!res?.active) { - const msg = t("ume.subscription.establishInvalid"); - setSubscriptionOpError(msg); - showError(msg); - } else { - setSubscriptionOpError(""); - showOk(res.already_exists ? t("ume.subscription.establishExists") : t("ume.subscription.establishOk")); - } - await queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription }); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll }); - }, - onError: (err) => { - const msg = String(err); - setSubscriptionOpError(msg); - showError(msg); - }, - }); - const subscriptionClearLocalMutation = useMutation({ - mutationFn: clearLocalUmeAlarmSubscription, - onMutate: () => setSubscriptionOpError(""), - onSuccess: async () => { - setSubscriptionOpError(""); - showOk(t("ume.subscription.clearLocalOk")); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription }); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll }); - }, - onError: (err) => { - const msg = String(err); - setSubscriptionOpError(msg); - showError(msg); - }, - }); - const subscriptionCancelMutation = useMutation({ - mutationFn: (opts?: { forceClearLocal?: boolean }) => cancelUmeAlarmSubscription(opts), - onMutate: () => setSubscriptionOpError(""), - onSuccess: async (res) => { - if (res?.needs_local_cleanup) { - const msg = res.message || t("ume.subscription.serverLostMsg"); - if (confirmClearLocalSubscription(msg)) { - subscriptionCancelMutation.mutate({ forceClearLocal: true }); - return; - } - setSubscriptionOpError(msg); - showError(msg); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription }); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll }); - return; - } - if (res?.active) { - const msg = t("ume.subscription.cancelFailed"); - setSubscriptionOpError(msg); - showError(msg); - } else { - setSubscriptionOpError(""); - showOk(res?.ume_already_missing ? t("ume.subscription.cancelOkMissing") : t("ume.subscription.cancelOk")); - } - await queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription }); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll }); - }, - onError: (err) => { - const msg = String(err); - setSubscriptionOpError(msg); - showError(msg); - }, - }); - const tokenDisconnectMutation = useMutation({ - mutationFn: disconnectUmeToken, - onMutate: () => setTokenOpError(""), - onSuccess: async (res) => { - if (!res?.ok) { - const msg = String(res.error || res.error_kind || "token_disconnect_failed"); - setTokenOpError(msg); - showError(msg); - } else { - setTokenOpError(""); - showOk(t("ume.token.disconnectOk")); - } - await queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus }); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll }); - }, - onError: (err) => { - const msg = String(err); - setTokenOpError(msg); - showError(msg); - }, - }); - - const tokenExpiresIn = Number(tokenStatusQuery.data?.expires_in_s || 0); - const hasToken = Boolean(tokenStatusQuery.data?.has_token); - const tokenNeedsRenewal = hasToken && tokenExpiresIn <= 0; - const tokenLevel = !hasToken - ? "down" - : tokenNeedsRenewal - ? "warn" - : tokenExpiresIn < 15 - ? "down" - : tokenExpiresIn < 60 - ? "unknown" - : "up"; - - const neQuery = useQuery({ - queryKey: queryKeys.umeNE(neKeyword, nePage, nePageSize), - queryFn: () => fetchUmeNe({ keyword: neKeyword, page: nePage, pageSize: nePageSize }), - staleTime: 5000, - }); - const cliTargetsQuery = useQuery({ - queryKey: queryKeys.cliTargets(neKeyword, nePage, nePageSize), - queryFn: () => - fetchCliTargets({ source: "ume", keyword: neKeyword, page: nePage, pageSize: nePageSize }), - enabled: nePanelOpen, - staleTime: 5000, - }); - const cliStatusByNeId = useMemo(() => { - const map = new Map(); - for (const row of cliTargetsQuery.data?.items || []) { - map.set(row.id, row.connect_status); - } - return map; - }, [cliTargetsQuery.data]); - - const runningTasks = (syncStatusQuery.data?.items || []).filter((x) => String(x.status || "").toLowerCase() === "running"); - const runtimeTasks = syncStatusQuery.data?.runtime_tasks || []; - const topologyFabric = syncStatusQuery.data?.topology_fabric; - const needsFabricApply = Boolean(topologyFabric?.needs_apply); - const applyFabricMut = useMutation({ - mutationFn: () => applyUmeTopologyToFabric(), - onSuccess: async () => { - showOk(t("ume.syncStatus.applyFabricOk")); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll }); - await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); - }, - onError: (err) => showError(String(err)), - }); - const alarmSub: UmeAlarmSubscriptionStatus = - subscriptionStatusQuery.data ?? - syncStatusQuery.data?.alarm_subscription ?? - { active: false }; - const wsConsumer = runtimeTasks.find((x) => x.task === "alarms_current_ws_consumer"); - const wsConn = subscriptionStatusQuery.data?.ws_connection; - const wsState = String(wsConn?.state || ""); - const wsPaused = Boolean(wsConsumer?.paused); - const wsLabel = - (wsPaused - ? t("ume.subscription.wssPaused") - : wsConnectionLabel(wsState, wsConn?.label, t)) || - runtimeLastError(wsConsumer?.last_error, t) || - wsConsumer?.status || - t("common.empty"); - const subscriptionActive = Boolean(alarmSub.active); - const serverSubLost = Boolean( - subscriptionStatusQuery.data?.server_subscription_lost ?? alarmSub.server_subscription_lost, - ); - const serverSubLostReason = String( - subscriptionStatusQuery.data?.server_subscription_lost_reason ?? alarmSub.server_subscription_lost_reason ?? "", - ); - const currentAlarmsMode = - subscriptionStatusQuery.data?.current_alarms_mode ?? alarmSub.current_alarms_mode ?? "rest"; - const scheduledSyncSkipped = Boolean( - subscriptionStatusQuery.data?.scheduled_sync_skipped ?? alarmSub.scheduled_sync_skipped, - ); - const wsPillLevel = - serverSubLost || wsState === "subscription_lost" - ? "warn" - : wsPaused || wsState === "paused" - ? "warn" - : wsState === "connected" - ? "up" - : wsState === "connecting" || wsState === "reconnecting" || wsState === "waiting_token" - ? "warn" - : wsState === "no_subscription" || wsState === "disconnected" || wsState === "error" || wsState === "init" - ? "down" - : String(wsConsumer?.last_error || "").includes("connected") || - String(wsConsumer?.last_error || "").includes("ws:connected") - ? "up" - : "unknown"; - const wsLogs = [...(subscriptionStatusQuery.data?.ws_logs ?? alarmSub.ws_logs ?? [])].reverse(); - const subPending = - subscriptionEstablishMutation.isPending || - subscriptionCancelMutation.isPending || - subscriptionClearLocalMutation.isPending; - - const runtimeTaskMutation = useMutation({ - mutationFn: async (vars: { task: string; action: "pause" | "resume" }) => - apiPost<{ ok: boolean }>(`/v1/ume/runtime/tasks/${encodeURIComponent(vars.task)}/${vars.action}`, {}), - onMutate: () => setRuntimeTaskError(""), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll }); - }, - onError: (err) => setRuntimeTaskError(String(err)), - }); - - const keyAlertAddMutation = useMutation({ - mutationFn: () => - upsertUmeKeyAlertRule({ - match_type: keyAlertMatchType, - match_value: keyAlertMatchValue.trim(), - label: keyAlertLabel.trim(), - enabled: true, - ne_types: keyAlertNeTypes, - }), - onMutate: () => setKeyAlertOpError(""), - onSuccess: async () => { - setKeyAlertMatchValue(""); - setKeyAlertLabel(""); - setKeyAlertNeTypes([]); - showOk(t("ume.keyAlert.addOk")); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll }); - await queryClient.invalidateQueries({ queryKey: ["opsTasks"] }); - }, - onError: (err) => { - const msg = String(err); - setKeyAlertOpError(msg); - showError(msg); - }, - }); - - const keyAlertDeleteMutation = useMutation({ - mutationFn: (notificationId: string) => deleteUmeKeyAlertRule(notificationId), - onMutate: () => setKeyAlertOpError(""), - onSuccess: async () => { - showOk(t("ume.keyAlert.deleteOk")); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll }); - await queryClient.invalidateQueries({ queryKey: ["opsTasks"] }); - }, - onError: (err) => { - const msg = String(err); - setKeyAlertOpError(msg); - showError(msg); - }, - }); - - const keyAlertToggleMutation = useMutation({ - mutationFn: (vars: { ruleKey: string; enabled: boolean }) => - patchUmeKeyAlertRule(vars.ruleKey, { enabled: vars.enabled }), - onMutate: () => setKeyAlertOpError(""), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll }); - }, - onError: (err) => { - const msg = String(err); - setKeyAlertOpError(msg); - showError(msg); - }, - }); - - const keyAlertEditMutation = useMutation({ - mutationFn: (vars: { ruleKey: string; ne_types: string[] }) => - patchUmeKeyAlertRule(vars.ruleKey, { ne_types: vars.ne_types }), - onMutate: () => setKeyAlertOpError(""), - onSuccess: async () => { - setKeyAlertEditRule(null); - showOk(t("ume.keyAlert.editOk")); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll }); - }, - onError: (err) => { - const msg = String(err); - setKeyAlertOpError(msg); - showError(msg); - }, - }); - - const keyAlertConfigMutation = useMutation({ - mutationFn: (forwardOnClear: boolean) => updateUmeKeyAlertMonitorConfig({ forward_on_clear: forwardOnClear }), - onMutate: () => setKeyAlertOpError(""), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll }); - }, - onError: (err) => { - const msg = String(err); - setKeyAlertOpError(msg); - showError(msg); - }, - }); - - 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 expandedNe = useMemo( - () => (neQuery.data?.items || []).find((x) => x.ne_id === expandedNeId) || null, - [neQuery.data, expandedNeId], - ); - - const keyAlertHub = keyAlertMonitorQuery.data?.dsh_alarm_hub; - const keyAlertHubConnections = keyAlertHub?.connections || []; - const keyAlertHubSubscribers = Number(keyAlertHub?.subscribers || keyAlertHubConnections.length || 0); - const keyAlertRules = keyAlertMonitorQuery.data?.rules || []; - const keyAlertTotal = Number(keyAlertMonitorQuery.data?.total || keyAlertRules.length); - const keyAlertPages = pageCount(keyAlertTotal, keyAlertPageSize); - const keyAlertForwardOnClear = Boolean(keyAlertMonitorQuery.data?.config?.forward_on_clear); - const hubWsPill = keyAlertHubSubscribers > 0 ? "up" : "down"; - - const runtimeTaskLabel = (task: string) => { - const key = `ume.tasks.runtimeTask.${task}`; - const label = t(key); - return label === key ? task : label; - }; - - const perPage = (n: number) => t("common.perPage", { n: String(n) }); - - return ( - <> -
-
-
-
-
-
-

{t("ume.token.title")}

-
-
- - - token:{" "} - {!hasToken - ? t("ume.token.disconnected") - : tokenNeedsRenewal - ? t("ume.token.connectedRenew") - : t("ume.token.connected")} - - - - {t("ume.token.expiresIn")}:{" "} - {typeof tokenStatusQuery.data?.expires_in_s === "number" - ? tokenNeedsRenewal - ? t("ume.token.needsRenew") - : `${tokenStatusQuery.data.expires_in_s}s` - : t("common.empty")} - - {tokenStatusQuery.data?.token_preview ? ( - - {t("ume.token.preview")}: {tokenStatusQuery.data.token_preview} - - ) : null} -
-
- - - -
- {(tokenOpError || tokenRefreshMutation.error || tokenDisconnectMutation.error) && ( -
- {t("common.opFailed")}: {tokenOpError || String(tokenRefreshMutation.error || tokenDisconnectMutation.error)} -
- )} -
- -
-
-

- {t("ume.subscription.title")} - -

-
-
- - {t("ume.subscription.sub")}:{" "} - {subscriptionActive ? t("ume.subscription.subActive") : t("ume.subscription.subInactive")} - - {subscriptionActive && alarmSub.subscription_id ? ( - - - id: {String(alarmSub.subscription_id).slice(0, 12)}… - - - ) : null} - {subscriptionActive || wsConsumer ? ( - - - WSS: {wsLabel} - - - ) : null} -
- {scheduledSyncSkipped && !serverSubLost ? ( -
- {t("ume.subscription.wssModeBanner", { mode: currentAlarmsMode })} -
- ) : null} - {serverSubLost ? ( -
- {t("ume.subscription.serverLostBanner")} - {serverSubLostReason ? ( -
- {serverSubLostReason.slice(0, 280)} -
- ) : null} -
- ) : null} -
- - {serverSubLost && subscriptionActive ? ( - - ) : null} - - -
- {subscriptionOpError ? ( -
- {t("ume.subscription.opFailed")}: {subscriptionOpError} -
- ) : null} -
-
- -
-
- {t("ume.subscription.wsLogs")} - - {wsConsumer?.last_run_at - ? t("ume.subscription.wsActivity", { time: formatSystemTime(String(wsConsumer.last_run_at)) }) - : t("ume.subscription.wsAutoRefresh")} - -
-
- {wsLogs.length === 0 ? ( -
{t("ume.subscription.wsLogsEmpty")}
- ) : ( - wsLogs.map((line, idx) => { - const lvl = String(line.level || "info").toLowerCase(); - const cls = - lvl === "error" ? "ws-log-line--error" : lvl === "warning" ? "ws-log-line--warning" : "ws-log-line--info"; - return ( -
- {formatSystemTime(line.ts)} - [{lvl}] - {line.message} - {line.subscription_id ? ( - · {String(line.subscription_id).slice(0, 8)}… - ) : null} -
- ); - }) - )} -
-
-
- -
-
-

{t("ume.tasks.currentTitle")}

-
- 0 ? "accent" : "success"}> - {t("ume.tasks.running")}: {runningTasks.length} - - -
-
- {needsFabricApply ? ( -
-
{t("ume.syncStatus.fabricGapTitle")}
-
- {t("ume.syncStatus.fabricGapHint") - .replace("{{dock}}", String(topologyFabric?.dock_me_count ?? 0)) - .replace("{{fabric}}", String(topologyFabric?.fabric_ume_count ?? 0)) - .replace( - "{{status}}", - String(topologyFabric?.latest_topology_status || topologyFabric?.latest_topology_error || "—"), - )} -
-
- -
-
- ) : null} -
-
- - - - - - - - - - - - - {runningTasks.map((x) => ( - - - - - - - - - ))} - {!syncStatusQuery.isLoading && runningTasks.length === 0 && ( - - - - )} - -
IDdomaintrigger_modestatusstarted_aterror
{x.id}{x.domain}{x.trigger_mode} - {x.status} - {formatSystemTime(x.started_at)} - {x.error_message || t("common.empty")} -
{t("ume.tasks.noRunning")}
-
- -

{t("ume.tasks.runtimeTitle")}

- {runtimeTaskError ? ( -
- {t("ume.tasks.runtimeOpFailed")}: {runtimeTaskError} -
- ) : null} -
- - - - - - - - - - - - - {runtimeTasks.map((x) => ( - - - - - - - - - ))} - {!syncStatusQuery.isLoading && runtimeTasks.length === 0 && ( - - - - )} - -
taskintervalstatuslast_run_at{t("ume.tasks.lastErrorCol")}actions
- {runtimeTaskLabel(x.task)} - - {runtimeIntervalLabel(x.interval_label)} - - {x.status} - {x.last_run_at ? formatSystemTime(x.last_run_at) : t("common.empty")} - {runtimeLastError(x.last_error, t) || t("common.empty")} - - {Boolean(x.paused) ? ( - - ) : ( - - )} -
{t("ume.tasks.noRuntime")}
-
-
-
- -
- -
-
-
-

- {t("ume.cli.title")} - -

- -
-
- -
-
-

- {t("ume.keyAlert.title")} - -

- -
-
- - {t("ume.keyAlert.hub")}:{" "} - {keyAlertHubSubscribers > 0 - ? t("ume.keyAlert.hubConnected") - : t("ume.keyAlert.hubEmpty")} - - - {t("ume.keyAlert.hubSubscribers")}: {keyAlertHubSubscribers} - - - {t("ume.keyAlert.hubPublished")}: {Number(keyAlertHub?.published || 0)} - - - {t("ume.keyAlert.hubDeliverOk")}: {Number(keyAlertHub?.deliver_ok || 0)} - -
-
- -
-
-

{t("ume.syncStatus.title")}

- -
-

- {syncTotal - ? t("ume.syncStatus.summary", { - total: String(syncTotal), - running: String(runningTasks.length), - }) - : t("ume.syncStatus.empty")} -

-
- -
-
-

{t("ume.ne.title")}

- -
-

- {t("ume.ne.summary", { total: String(neTotal) })} -

-
- - -
-
- - setCliPanelOpen(false)} - size="lg" - className="app-heroui-modal--xl" - > - - {t("ume.cli.title")} - - - - - - - - setKeyAlertPanelOpen(false)} - size="lg" - className="app-heroui-modal--xl" - > - - {t("ume.keyAlert.title")} - - - - -

- {t("ume.keyAlert.hub")}:{" "} - {keyAlertHubSubscribers > 0 - ? t("ume.keyAlert.hubConnected") - : t("ume.keyAlert.hubEmpty")} - {` · ${t("ume.keyAlert.hubSubscribers")} ${keyAlertHubSubscribers}`} - {` · ${t("ume.keyAlert.hubPublished")} ${Number(keyAlertHub?.published || 0)}`} - {` · ${t("ume.keyAlert.hubDeliverOk")} ${Number(keyAlertHub?.deliver_ok || 0)}`} - {keyAlertHub?.path ? ` · ${t("ume.keyAlert.hubPath")} ${keyAlertHub.path}` : ""} -

-
-
{t("ume.keyAlert.hubConnections")}
- {keyAlertHubConnections.length === 0 ? ( -

{t("ume.keyAlert.hubEmptyConnections")}

- ) : ( -
- - - - - - - - - - - - - {keyAlertHubConnections.map((conn) => ( - - - - - - - - - ))} - -
{t("ume.keyAlert.hubColId")}{t("ume.keyAlert.hubColUser")}{t("ume.keyAlert.hubColRemote")}{t("ume.keyAlert.hubColClient")}{t("ume.keyAlert.hubColConnected")}{t("ume.keyAlert.hubColLastSeen")}
- {conn.id} - {conn.user || "-"}{conn.remote || "-"}{conn.client || "-"} - {conn.connected_at - ? formatSystemTime(conn.connected_at) - : "-"} - - {conn.last_seen_at - ? formatSystemTime(conn.last_seen_at) - : "-"} -
-
- )} -
- -
-
- - - setKeyAlertMatchValue(e.target.value)} - list="ume-key-alert-match-options" - /> - setKeyAlertLabel(e.target.value)} - /> - - {keyAlertMatchType === "keyword" - ? keyAlertKeywordHints.map((kw) => - ))} - - - -
- -
{t("ume.keyAlert.neTypesLabel")}
- {keyAlertNeTypesQuery.isLoading ? ( - {t("common.refreshing")} - ) : (keyAlertNeTypesQuery.data?.items || []).length === 0 ? ( - {t("ume.keyAlert.neTypesEmpty")} - ) : ( -
- {(keyAlertNeTypesQuery.data?.items || []).map((item) => ( - - ))} -
- )} -

{t("ume.keyAlert.neTypesHint")}

- -
- -
- {keyAlertOpError ? ( -

- {t("common.opFailed")}: {keyAlertOpError} -

- ) : null} -
- -
- { - setKeyAlertFilterKeyword(e.target.value); - setKeyAlertPage(1); - }} - /> - - -
- -
- - - - - - - - - - - - - - - - {keyAlertRules.map((rule) => ( - - - - - - - - - - - - ))} - {!keyAlertMonitorQuery.isLoading && keyAlertRules.length === 0 ? ( - - - - ) : null} - -
{t("ume.keyAlert.colMonitor")}{t("ume.keyAlert.colType")}{t("ume.keyAlert.colMatch")}{t("ume.keyAlert.colLabel")}{t("ume.keyAlert.colNeTypes")}{t("ume.keyAlert.colPublished")}{t("ume.keyAlert.colAttempts")}{t("ume.keyAlert.colLast")}{t("ume.keyAlert.colActions")}
- - - {rule.match_type === "keyword" - ? t("ume.keyAlert.matchKeyword") - : t("ume.keyAlert.matchNotificationId")} - {rule.match_value || rule.notification_id}{rule.label || t("common.empty")} - {rule.ne_types?.length ? rule.ne_types.join(", ") : t("ume.keyAlert.neTypesAll")} - {Number(rule.forward_stats?.published_ok || 0)}{Number(rule.forward_stats?.attempts || 0)} - {rule.forward_stats?.last_forwarded_at - ? formatSystemTime(rule.forward_stats.last_forwarded_at) - : t("common.empty")} - -
- - -
-
- {t("ume.keyAlert.emptyRules")} -
-
- -
- - {t("common.pagerMeta", { - total: String(keyAlertTotal), - page: String(keyAlertPage), - pages: String(keyAlertPages), - })} - -
- - - -
-
-
-
- - setSyncStatusPanelOpen(false)} - size="lg" - className="app-heroui-modal--xl" - > - - {t("ume.syncStatus.title")} - - - - -

- {t("ume.syncStatus.summary", { - total: String(syncTotal), - running: String(runningTasks.length), - })} -

-
- - - - - - - - - - - - - - - - - {(syncStatusQuery.data?.items || []).map((x) => ( - - - - - - - - - - - - - ))} - {!syncStatusQuery.isLoading && (syncStatusQuery.data?.items || []).length === 0 ? ( - - - - ) : null} - -
IDdomainstatuspulledinsertedupdateddeletedstarted_atended_aterror
- {String(x.id).slice(0, 8)} - {x.domain} - {x.status} - {x.pulled_count}{x.inserted_count}{x.updated_count}{Number(x.deleted ?? 0)}{formatSystemTime(x.started_at)}{x.ended_at ? formatSystemTime(x.ended_at) : t("common.empty")} - {x.error_message || t("common.empty")} -
- {t("ume.syncStatus.empty")} -
-
- -
- - {t("common.pagerMeta", { - total: String(syncTotal), - page: String(syncPage), - pages: String(syncPages), - })} - -
- - - -
-
-
-
- - { - setNePanelOpen(false); - setExpandedNeId(""); - }} - size="lg" - className="app-heroui-modal--xl" - > - - {t("ume.ne.title")} - - - -

{t("ume.ne.summary", { total: String(neTotal) })}

- -
- { - setNeKeyword(e.target.value); - setNePage(1); - }} - /> - - -
- -
- - - - - - - - - - - - - - - - {(neQuery.data?.items || []).map((x) => ( - - - - - - - - - - - - - ))} - {!neQuery.isLoading && !(neQuery.data?.items || []).length ? ( - - - - ) : null} - -
ne_iduser_labeliptypedevice_levelhost_name{t("ume.ne.cliConnect")}hw_verlast_seen -
- {x.ne_id} - {x.user_label}{x.ip_address}{x.ne_type}{x.device_level || t("common.empty")}{x.host_name || t("common.empty")}{cliStatusByNeId.get(x.ne_id) || "unknown"}{x.hardware_version || t("common.empty")}{x.last_seen_at ? formatSystemTime(x.last_seen_at) : t("common.empty")} - -
- {t("common.empty")} -
-
- -
- - {t("common.pagerMeta", { - total: String(neTotal), - page: String(nePage), - pages: String(nePages), - })} - -
- - - -
-
-
-
- - setExpandedNeId("")} - size="lg" - > - - {expandedNe?.user_label || expandedNe?.ne_id || t("ume.ne.expand")} - - - - {expandedNe ? ( - <> -

- {expandedNe.ne_id} - {expandedNe.ip_address ? ` · ${expandedNe.ip_address}` : ""} - {expandedNe.ne_type ? ` · ${expandedNe.ne_type}` : ""} -

-
-
- consistent_state -
{expandedNe.consistent_state || t("common.empty")}
-
-
- admin_status -
{expandedNe.admin_status || t("common.empty")}
-
-
- connection_status -
{expandedNe.connection_status || t("common.empty")}
-
-
- maintain_status -
{expandedNe.maintain_status || t("common.empty")}
-
-
- address_type -
{expandedNe.address_type || t("common.empty")}
-
-
- location -
{expandedNe.location || t("common.empty")}
-
-
- loopback -
{expandedNe.loopback || t("common.empty")}
-
-
- net_mask -
{expandedNe.net_mask || t("common.empty")}
-
-
- mac -
{expandedNe.mac || t("common.empty")}
-
-
- interface_version -
{expandedNe.interface_version || t("common.empty")}
-
-
- create_time -
{expandedNe.create_time || t("common.empty")}
-
-
- creator -
{expandedNe.creator || t("common.empty")}
-
-
- host_name -
{expandedNe.host_name || t("common.empty")}
-
-
- hardware_version -
{expandedNe.hardware_version || t("common.empty")}
-
-
- {t("ume.ne.cliConnect")} -
{cliStatusByNeId.get(expandedNe.ne_id) || "unknown"}
-
-
- last_seen -
- {expandedNe.last_seen_at - ? formatSystemTime(expandedNe.last_seen_at) - : t("common.empty")} -
-
-
- - ) : null} -
-
- - { - if (!keyAlertEditMutation.isPending) setKeyAlertEditRule(null); - }} - dismissible={!keyAlertEditMutation.isPending} - size="lg" - > - - {t("ume.keyAlert.editTitle")} - - - - {keyAlertEditRule ? ( - <> -

- {keyAlertEditRule.match_type === "keyword" - ? t("ume.keyAlert.matchKeyword") - : t("ume.keyAlert.matchNotificationId")} - : {keyAlertEditRule.match_value || keyAlertEditRule.notification_id} - {keyAlertEditRule.label ? ` · ${keyAlertEditRule.label}` : ""} -

-
{t("ume.keyAlert.neTypesLabel")}
- {keyAlertNeTypesQuery.isLoading ? ( - {t("common.refreshing")} - ) : (keyAlertNeTypesQuery.data?.items || []).length === 0 ? ( - {t("ume.keyAlert.neTypesEmpty")} - ) : ( -
- {(keyAlertNeTypesQuery.data?.items || []).map((item) => ( - - ))} -
- )} -

{t("ume.keyAlert.neTypesHint")}

- - ) : null} -
- - - - -
- - ); -} diff --git a/web/src/services/api.ts b/web/src/services/api.ts deleted file mode 100644 index 9b3d08e..0000000 --- a/web/src/services/api.ts +++ /dev/null @@ -1,2421 +0,0 @@ -import type { - CollectionJobDetail, - CollectionJobItem, - CollectionDashboard, - CollectionPolicy, - CollectionRunList, - EligibleNeItem, - IntegrationStatus, - ManagedNeImportResult, - ManagedNeItem, - ManagedNeListResponse, - ManagedNeMeta, - UmeAlarmItem, - UmeAlarmSubscriptionStatus, - UmeKeyAlertMonitorResponse, - UmeNeItem, - UmeSyncStatusResponse, - UmeTokenStatus, - CliConnectProfileItem, - CliMeta, - CliTargetListResponse, - UmeCliOverrideItem, - FabricEdge, - FabricSummary, - TopologyDiscoverJob, - TopologyOutsidePeer, - TopologyTree, - TopologyViewGraph, - TopologyViewItem, - LldpCollectDashboard, - LldpCollectJobSummary, - LldpCollectPolicy, - ConfigSyncCycle, - ConfigSyncDashboard, - ConfigSyncPolicy, - ConfigSyncTask, - NeConfigSnapshotDetail, - NeConfigSnapshotMeta, - PortTrafficCompare, - PortTrafficDashboard, - PortTrafficDevice, - PortTrafficDiscoverResponse, - PortTrafficEvent, - PortTrafficBoard, - PortTrafficBoardPanelIn, - PortTrafficBoardSummary, - PortTrafficIfaceIn, - PortTrafficSamples, - PortTrafficTarget, -} from "../types"; - - -export const AUTH_TOKEN_KEY = "netx_access_token"; -export const AUTH_REFRESH_KEY = "netx_refresh_token"; - -/** Clear legacy localStorage tokens; browser auth now uses HttpOnly cookies. */ -export const clearAuthToken = (): void => { - try { - localStorage.removeItem(AUTH_TOKEN_KEY); - localStorage.removeItem(AUTH_REFRESH_KEY); - } catch { - // ignore - } -}; - -/** @deprecated Cookie session — always null for browser UI. */ -export const getAuthToken = (): string | null => null; - -/** No-op kept for call-site compatibility during cookie migration. */ -export const setAuthToken = (_token: string): void => { - clearAuthToken(); -}; - -export const setAuthTokens = (_access: string, _refresh?: string | null): void => { - clearAuthToken(); -}; - -const fetchCreds: RequestCredentials = "include"; - -const authHeaders = (extra?: Record): Record => { - // Browser JWT rides HttpOnly cookies (credentials: include). - // Authorization Bearer is only needed for non-browser API tokens if ever injected. - return { accept: "application/json", ...(extra || {}) }; -}; - -const handleUnauthorized = (path: string): void => { - if (path.startsWith("/v1/auth/login") || path.startsWith("/v1/auth/refresh")) return; - clearAuthToken(); - if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) { - const next = `${window.location.pathname}${window.location.search || ""}`; - window.location.assign(`/login?next=${encodeURIComponent(next)}`); - } -}; - -let refreshInFlight: Promise | null = null; - -const tryRefreshAccessToken = async (): Promise => { - if (!refreshInFlight) { - refreshInFlight = (async () => { - try { - // Refresh token comes from HttpOnly cookie when body is empty. - const res = await fetch("/v1/auth/refresh", { - method: "POST", - credentials: fetchCreds, - headers: { accept: "application/json", "content-type": "application/json" }, - body: JSON.stringify({}), - }); - if (!res.ok) { - clearAuthToken(); - return false; - } - return true; - } catch { - clearAuthToken(); - return false; - } finally { - refreshInFlight = null; - } - })(); - } - return refreshInFlight; -}; - -const shouldAttemptRefresh = (path: string): boolean => - !path.startsWith("/v1/auth/login") && !path.startsWith("/v1/auth/refresh"); - -const parseApiResponse = async (res: Response): Promise> => { - const text = await res.text(); - if (!text) return {}; - try { - return JSON.parse(text) as Record; - } catch { - const snippet = text.replace(/\s+/g, " ").trim().slice(0, 160); - throw new Error(res.ok ? "invalid_json_response" : `${res.status} ${snippet || res.statusText}`); - } -}; - -/** Preserve FastAPI structured ``detail`` objects (e.g. connect_failed + ne). */ -export class ApiRequestError extends Error { - status: number; - detail: unknown; - - constructor(status: number, detail: unknown) { - super(formatApiDetail(detail) || `${status}`); - this.name = "ApiRequestError"; - this.status = status; - this.detail = detail; - } -} - -/** User-facing message from API or thrown errors (prefers ApiRequestError.message). */ -export function formatErr(err: unknown): string { - if (err instanceof ApiRequestError) return err.message; - if (err instanceof Error) return err.message; - return String(err); -} - -export function formatApiDetail(detail: unknown): string { - if (typeof detail === "string") return detail; - if (Array.isArray(detail)) { - return detail - .map((item) => { - if (typeof item === "string") return item; - if (item && typeof item === "object" && "msg" in item) { - return String((item as { msg?: unknown }).msg || ""); - } - return ""; - }) - .filter(Boolean) - .join("; "); - } - if (detail && typeof detail === "object") { - const obj = detail as Record; - if (typeof obj.message === "string" && obj.message.trim()) return obj.message; - if (typeof obj.error === "string" && obj.error.trim()) return obj.error; - try { - return JSON.stringify(detail); - } catch { - /* fall through */ - } - } - return detail == null ? "" : String(detail); -} - -export const apiGet = async (path: string): Promise => { - const doFetch = () => fetch(path, { headers: authHeaders(), credentials: fetchCreds }); - let res = await doFetch(); - if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { - res = await doFetch(); - } - const data = await parseApiResponse(res); - if (res.status === 401) { - handleUnauthorized(path); - throw new ApiRequestError(401, data.detail || "unauthorized"); - } - if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); - return data as T; -}; - -export const apiPost = async (path: string, body: unknown): Promise => { - const doFetch = () => - fetch(path, { - method: "POST", - credentials: fetchCreds, - headers: authHeaders({ "content-type": "application/json" }), - body: JSON.stringify(body), - }); - let res = await doFetch(); - if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { - res = await doFetch(); - } - const data = await parseApiResponse(res); - if (res.status === 401) { - handleUnauthorized(path); - throw new ApiRequestError(401, data.detail || "unauthorized"); - } - if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); - return data as T; -}; - -export const apiPatch = async (path: string, body: unknown): Promise => { - const doFetch = () => - fetch(path, { - method: "PATCH", - credentials: fetchCreds, - headers: authHeaders({ "content-type": "application/json" }), - body: JSON.stringify(body), - }); - let res = await doFetch(); - if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { - res = await doFetch(); - } - const data = await parseApiResponse(res); - if (res.status === 401) { - handleUnauthorized(path); - throw new ApiRequestError(401, data.detail || "unauthorized"); - } - if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); - return data as T; -}; - -export const apiDelete = async (path: string): Promise => { - let res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds }); - if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { - res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds }); - } - const data = await parseApiResponse(res); - if (res.status === 401) { - handleUnauthorized(path); - throw new ApiRequestError(401, data.detail || "unauthorized"); - } - if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); - return data as T; -}; - -export const apiPut = async (path: string, body: unknown): Promise => { - const doFetch = () => - fetch(path, { - method: "PUT", - credentials: fetchCreds, - headers: authHeaders({ "content-type": "application/json" }), - body: JSON.stringify(body), - }); - let res = await doFetch(); - if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { - res = await doFetch(); - } - const data = await parseApiResponse(res); - if (res.status === 401) { - handleUnauthorized(path); - throw new ApiRequestError(401, data.detail || "unauthorized"); - } - if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); - return data as T; -}; - -export const fetchIntegrationStatus = () => apiGet("/v1/integrations/status"); - -export type OpsTaskItem = { - kind: string; - id: string; - title: string; - status: string; - trigger: string; - actor: string; - started_at: string | null; - updated_at: string | null; - progress: string; - inflight?: number; - detail: string; - href: string; -}; - -export type OpsTasksResponse = { - generated_at: string; - total: number; - active: number; - by_kind: Record; - by_status: Record; - items: OpsTaskItem[]; -}; - -export const fetchOpsTasks = () => apiGet("/v1/ops/tasks"); - -export type RuntimeMetrics = { - uptime_sec?: number; - pid?: number; - thread_count?: number; - host?: { - source?: string; - platform?: string; - cpu_percent?: number; - mem_percent?: number; - mem_used_bytes?: number; - mem_total_bytes?: number; - mem_available_bytes?: number; - error?: string; - }; - db_pool?: { - checked_out?: number; - checked_in?: number; - overflow?: number; - size?: number; - }; - db_storage?: { - db_name?: string; - used_bytes?: number; - source?: string; - error?: string; - }; - cli_budget?: { - limit?: number; - in_use?: number; - available?: number; - }; - webcrt?: { - active_sessions?: number; - max_sessions?: number; - }; - config_sync?: { running?: boolean }; - lldp_collect?: { running?: boolean }; - port_traffic?: { running?: boolean }; - device_schedulers?: { - stale?: boolean; - mode?: string; - source?: string; - age_sec?: number | null; - hint?: string | null; - config_sync?: { running?: boolean; last_tick_age_sec?: number | null }; - lldp_collect?: { running?: boolean; last_tick_age_sec?: number | null }; - ne_collect?: { running?: boolean; last_tick_age_sec?: number | null }; - port_traffic?: { running?: boolean; last_tick_age_sec?: number | null }; - fabric_reconcile?: { running?: boolean; last_run_age_sec?: number | null }; - }; - [key: string]: unknown; -}; - -export const fetchRuntimeMetrics = () => apiGet("/v1/metrics/json"); - -export type AuditLogItem = { - id: string; - ts: string | null; - actor_username: string; - action: string; - method: string; - path: string; - status_code: number; - client_ip: string; - detail: Record; -}; - -export const fetchAuditLogs = (params?: { - page?: number; - pageSize?: number; - username?: string; - action?: string; -}) => { - const p = new URLSearchParams(); - 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))))); - if (params?.username?.trim()) p.set("username", params.username.trim()); - if (params?.action?.trim()) p.set("action", params.action.trim()); - return apiGet<{ total: number; page: number; page_size: number; items: AuditLogItem[] }>( - `/v1/audit-logs?${p.toString()}`, - ); -}; - -export const fetchUmeAlarmSubscriptionStatus = () => - apiGet("/v1/ume/alarm-subscription/status"); - -export const establishUmeAlarmSubscription = (opts?: { forceReestablish?: boolean }) => - apiPost("/v1/ume/alarm-subscription/establish", { - force_reestablish: Boolean(opts?.forceReestablish), - }); - -export const cancelUmeAlarmSubscription = (opts?: { forceClearLocal?: boolean }) => - apiPost("/v1/ume/alarm-subscription/cancel", { - force_clear_local: Boolean(opts?.forceClearLocal), - }); - -export const clearLocalUmeAlarmSubscription = () => - apiPost("/v1/ume/alarm-subscription/clear-local", {}); - -export const fetchUmeKeyAlertMonitor = (params?: { - page?: number; - pageSize?: number; - keyword?: string; - enabled?: "" | "true" | "false"; - matchType?: "" | "notification_id" | "keyword"; -}) => { - const p = new URLSearchParams(); - 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 || 50))))); - const kw = String(params?.keyword || "").trim(); - if (kw) p.set("keyword", kw); - const en = String(params?.enabled || "").trim(); - if (en) p.set("enabled", en); - const mt = String(params?.matchType || "").trim(); - if (mt) p.set("match_type", mt); - return apiGet(`/v1/ume/key-alert-monitor?${p.toString()}`); -}; - -export const upsertUmeKeyAlertRule = (payload: { - match_type: "notification_id" | "keyword"; - match_value: string; - label: string; - enabled?: boolean; - ne_types?: string[]; -}) => apiPost<{ ok: boolean; item?: unknown }>("/v1/ume/key-alert-rules", payload); - -export const updateUmeKeyAlertMonitorConfig = (payload: { forward_on_clear: boolean }) => - apiPatch<{ ok: boolean; config: { forward_on_clear: boolean } }>("/v1/ume/key-alert-monitor/config", payload); - -export const deleteUmeKeyAlertRule = (ruleKey: string) => - apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`); - -export const patchUmeKeyAlertRule = ( - ruleKey: string, - payload: { enabled?: boolean; ne_types?: string[] }, -) => - apiPatch<{ ok: boolean; item?: unknown }>( - `/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`, - payload, - ); - -export const fetchUmeNotificationIds = (limit = 200) => - apiGet<{ items: Array<{ notification_id: string; native_probable_cause_sample: string }> }>( - `/v1/ume/notification-ids?limit=${encodeURIComponent(String(limit))}`, - ); - -export const fetchUmeAlarmKeywords = (limit = 200) => - apiGet<{ items: Array<{ keyword: string; alarm_count: number }> }>( - `/v1/ume/alarm-keywords?limit=${encodeURIComponent(String(limit))}`, - ); - -export const fetchUmeInventoryNeTypes = (limit = 500) => - apiGet<{ items: Array<{ ne_type: string; ne_count: number }>; total: number }>( - `/v1/ume/inventory/ne-types?limit=${encodeURIComponent(String(limit))}`, - ); - -export const fetchUmeSyncStatus = (params: { page: number; pageSize: number }) => { - const p = new URLSearchParams(); - 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 || 20))))); - return apiGet(`/v1/ume/sync/status?${p.toString()}`); -}; - -/** Dock tables → Fabric + UME World (does not pull from UME). */ -export const applyUmeTopologyToFabric = () => - apiPost<{ - ok: boolean; - fabric_apply?: Record; - world?: Record; - }>("/v1/topology/world/apply-ume", {}); - -export const fetchUmeTokenStatus = () => apiGet("/v1/ume/token/status"); -export const refreshUmeToken = () => apiPost("/v1/ume/token/refresh", {}); -export const disconnectUmeToken = () => apiPost("/v1/ume/token/disconnect", {}); - -export const fetchUmeNe = (params: { keyword: string; page: number; pageSize: number }) => { - const p = new URLSearchParams(); - if (params.keyword) p.set("keyword", params.keyword); - 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: UmeNeItem[] }>( - `/v1/ume/inventory/ne?${p.toString()}`, - ); -}; - -export const fetchManagedNeMeta = () => - Promise.all([ - apiGet("/v1/managed-ne/meta/device-types"), - apiGet<{ configured: boolean }>("/v1/managed-ne/meta/credentials-configured"), - ]).then(([types, creds]) => ({ - device_types: types.device_types, - vendors: types.vendors, - credentials_configured: creds.configured, - exec_policy_enabled: Boolean(types.exec_policy_enabled), - })); - -export type ManagedNeStats = { - total: number; - by_status: Record; - tags: string[]; - no_tag_count: number; - tag_counts: Record; - per_tag: Record< - string, - { - total: number; - by_status: Record; - } - >; -}; - -export const fetchManagedNeStats = () => apiGet("/v1/managed-ne/meta/stats"); - -export const fetchIdsByTag = (tag: string | null) => { - const url = tag ? `/v1/managed-ne/meta/ids-by-tag?tag=${encodeURIComponent(tag)}` : "/v1/managed-ne/meta/ids-by-tag"; - return apiGet<{ ids: string[] }>(url); -}; - -export const fetchManagedNe = (params: { - keyword: string; - vendor: string; - connectStatus: string; - page: number; - pageSize: number; -}) => { - const p = new URLSearchParams(); - if (params.keyword) p.set("keyword", params.keyword); - if (params.vendor) p.set("vendor", params.vendor); - if (params.connectStatus) p.set("connect_status", params.connectStatus); - p.set("page", String(Math.max(1, params.page))); - p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize)))); - return apiGet(`/v1/managed-ne?${p.toString()}`); -}; - -export const createManagedNe = (body: Record) => - apiPost("/v1/managed-ne", body); - -export const updateManagedNe = (id: string, body: Record) => - apiPatch(`/v1/managed-ne/${id}`, body); - -export const deleteManagedNe = (id: string) => apiDelete<{ ok: boolean }>(`/v1/managed-ne/${id}`); - -export const batchDeleteManagedNe = (ids: string[]) => - apiPost<{ ok: boolean; deleted: number }>("/v1/managed-ne/batch-delete", { ids }); - -export const connectTestManagedNe = (ids: string[]) => - apiPost<{ ok: boolean; submitted: number }>("/v1/managed-ne/connect-test", { ids }); - -export const batchApplyHopManagedNe = ( - ids: string[], - hop: { - hop_host: string; - hop_port: number; - hop_protocol: string; - hop_username: string; - hop_password: string; - hop_command_template: string; - hop_vrf: string; - hop_vendor?: string; - hop_target_auth_mode?: string; - hop_enter_system_view?: boolean; - }, -) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-hop", { ids, hop }); - -export const batchApplyAccountManagedNe = ( - ids: string[], - account: { - username?: string; - password?: string; - }, -) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-account", { ids, account }); - -export const syncUmeManagedNe = () => - apiPost<{ inserted: number; updated: number; deleted: number; total_inventory: number }>("/v1/managed-ne/ume-sync", {}); - -export const deleteUmeManagedNe = () => - apiDelete<{ deleted: number }>("/v1/managed-ne/ume-sync"); - -export const managedNeImportTemplateUrl = (format: "xlsx" | "csv" = "xlsx") => - `/v1/managed-ne/import/template?format=${format}`; - -export const downloadManagedNeImportTemplate = async (format: "xlsx" | "csv" = "xlsx"): Promise => { - const path = managedNeImportTemplateUrl(format); - const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds }); - if (res.status === 401) { - handleUnauthorized(path); - throw new Error("unauthorized"); - } - if (!res.ok) throw new Error(`${res.status} template`); - const blob = await res.blob(); - const cd = res.headers.get("content-disposition") || ""; - const matched = /filename\*?=(?:UTF-8''|")?([^\";]+)/i.exec(cd); - const filename = matched - ? decodeURIComponent(matched[1].replace(/"/g, "")) - : `managed_ne_import_template.${format}`; - const url = URL.createObjectURL(blob); - try { - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - } finally { - URL.revokeObjectURL(url); - } -}; - -export const importManagedNe = async (file: File): Promise => { - const form = new FormData(); - form.append("file", file); - const path = "/v1/managed-ne/import"; - // Do not set content-type — browser must add multipart boundary. - const res = await fetch(path, { method: "POST", headers: authHeaders(), body: form, credentials: fetchCreds }); - if (res.status === 401) { - handleUnauthorized(path); - throw new Error("unauthorized"); - } - const data = await parseApiResponse(res); - if (res.status === 401) { - handleUnauthorized(path); - throw new ApiRequestError(401, data.detail || "unauthorized"); - } - if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} import`); - return data as ManagedNeImportResult; -}; - -export const fetchEligibleNe = (params: { page: number; pageSize: number; keyword?: string }) => { - const p = new URLSearchParams(); - p.set("page", String(Math.max(1, params.page))); - p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize)))); - if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); - return apiGet<{ total: number; page: number; page_size: number; items: EligibleNeItem[] }>( - `/v1/ne-collections/eligible-ne?${p.toString()}`, - ); -}; - -export const createNeCollection = (body: { - title?: string; - commands: string; - ne_ids?: string[]; - ume_ne_ids?: string[]; -}) => - apiPost("/v1/ne-collections", body); - -export const fetchNeCollections = (params: { - page: number; - pageSize: number; - status?: string; - keyword?: string; -}) => { - const p = new URLSearchParams(); - p.set("page", String(Math.max(1, params.page))); - p.set("page_size", String(Math.max(1, Math.min(100, params.pageSize)))); - if (params.status?.trim()) p.set("status", params.status.trim()); - if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); - return apiGet<{ total: number; page: number; page_size: number; items: CollectionJobItem[] }>( - `/v1/ne-collections?${p.toString()}`, - ); -}; - -export const fetchCollectionDashboard = () => - apiGet("/v1/ne-collections/dashboard"); - -export const fetchCollectionPolicy = () => apiGet("/v1/ne-collections/policy"); - -export const updateCollectionPolicy = (body: Partial) => - apiPut("/v1/ne-collections/policy", body); - -export const startCollectionFromPolicy = () => - apiPost("/v1/ne-collections/start-from-policy", {}); - -export const fetchCollectionJob = (jobId: string) => apiGet(`/v1/ne-collections/${jobId}`); - -export const fetchCollectionRuns = (params: { - jobId: string; - page: number; - pageSize: number; - status?: string; - keyword?: string; -}) => { - const p = new URLSearchParams(); - p.set("page", String(Math.max(1, params.page))); - p.set("page_size", String(Math.max(1, Math.min(200, params.pageSize)))); - if (params.status) p.set("status", params.status); - if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); - return apiGet(`/v1/ne-collections/${params.jobId}/runs?${p.toString()}`); -}; - -export const pauseCollectionJob = (jobId: string) => - apiPost(`/v1/ne-collections/${jobId}/pause`, {}); - -export const startCollectionJob = (jobId: string) => - apiPost(`/v1/ne-collections/${jobId}/start`, {}); - -export const restartCollectionJob = (jobId: string) => - apiPost(`/v1/ne-collections/${jobId}/restart`, {}); - -export const retryFailedCollectionJob = (jobId: string) => - apiPost(`/v1/ne-collections/${jobId}/retry-failed`, {}); - -export const deleteCollectionJob = (jobId: string) => - apiDelete<{ ok: boolean }>(`/v1/ne-collections/${jobId}`); - -export const collectionRunDownloadUrl = (runId: string) => `/v1/ne-collections/runs/${runId}/download`; - -export const collectionJobDownloadUrl = (jobId: string) => `/v1/ne-collections/${jobId}/download`; - -export const fetchManagedNeById = (neId: string) => - apiGet(`/v1/managed-ne/${encodeURIComponent(neId)}`); - -export type WebcrtSessionCreateResult = { - session_id: string; - ne_id: string; - ne_name: string; - ne_ip: string; - protocol: string; - cols: number; - rows: number; - ws_path: string; - encoding?: string; - state?: string; - /** True when session used a vendor CLI hop (nested stelnet/telnet). */ - cli_hop?: boolean; - /** True when SFTP channel opened on the same SSH transport. */ - sftp_ready?: boolean; -}; - -export const createWebcrtSession = (body: { - ne_id?: string; - ume_ne_id?: string; - cols?: number; - rows?: number; - encoding?: string; - keepalive_sec?: number; - post_login_commands?: string[]; - async_connect?: boolean; - username?: string; - password?: string; -}) => apiPost("/v1/webcrt/sessions", body); - -export type WebcrtQuickConnectResult = WebcrtSessionCreateResult & { - ne: ManagedNeItem; - ne_action: "created" | "updated" | "reused" | string; - list_source: "webcrt" | "managed" | string; -}; - -export const quickConnectWebcrtSession = (body: { - name?: string; - ip_address: string; - port?: number; - protocol?: string; - username?: string; - password?: string; - save_password?: boolean; - /** Claim existing LLDP / incomplete ManagedNE → promote to webcrt. */ - ne_id?: string; - cols?: number; - rows?: number; - encoding?: string; - keepalive_sec?: number; - async_connect?: boolean; -}) => apiPost("/v1/webcrt/sessions/quick-connect", body); - -export const closeWebcrtSession = (sessionId: string) => - apiDelete<{ ok: boolean; session_id: string; closed: boolean }>( - `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`, - ); - -/** Best-effort close during tab/window unload (fetch keepalive survives navigation). */ -export function closeWebcrtSessionsKeepalive(sessionIds: string[]): void { - const ids = [...new Set(sessionIds.map((id) => String(id || "").trim()).filter(Boolean))]; - if (!ids.length) return; - const headers = authHeaders(); - for (const id of ids) { - const path = `/v1/webcrt/sessions/${encodeURIComponent(id)}`; - try { - void fetch(path, { method: "DELETE", headers, credentials: fetchCreds, keepalive: true }); - } catch { - /* ignore unload failures */ - } - } -} - -export type WebcrtSftpItem = { - name: string; - size: number; - mtime: number; - is_dir: boolean; - mode?: string; - owner?: string; - group?: string; - uid?: number; - gid?: number; -}; - -export type WebcrtSftpListResult = { - ne_id: string; - ne_name: string; - path: string; - items: WebcrtSftpItem[]; - truncated?: boolean; - max_entries?: number; -}; - -export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) => - apiPost("/v1/webcrt/sftp/list", body); - -export const webcrtSftpMkdir = (body: { ne_id?: string; ume_ne_id?: string; path: string }) => - apiPost<{ ok: boolean; path: string; ne_id: string }>("/v1/webcrt/sftp/mkdir", body); - -export const webcrtSftpRemove = (body: { - ne_id?: string; - ume_ne_id?: string; - path: string; - recursive?: boolean; -}) => apiPost<{ ok: boolean; path: string; ne_id: string }>("/v1/webcrt/sftp/remove", body); - -export const webcrtSftpRename = (body: { - ne_id?: string; - ume_ne_id?: string; - old_path: string; - new_path: string; -}) => apiPost<{ ok: boolean; old_path: string; new_path: string; ne_id: string }>("/v1/webcrt/sftp/rename", body); - -export const webcrtSftpChmod = (body: { - ne_id?: string; - ume_ne_id?: string; - path: string; - mode: string; -}) => apiPost<{ ok: boolean; path: string; mode: string; ne_id: string }>("/v1/webcrt/sftp/chmod", body); - -export type WebcrtSftpTransferProgress = { - loaded: number; - total: number; -}; - -export type WebcrtSftpTransferOpts = { - signal?: AbortSignal; - onProgress?: (p: WebcrtSftpTransferProgress) => void; - /** Extra attempts after the first failure. Default 2 (3 tries total). */ - retries?: number; - onRetry?: (attempt: number, err: unknown) => void; -}; - -const isAbortError = (err: unknown): boolean => { - const raw = String(err || "").toLowerCase(); - return raw.includes("aborted") || raw.includes("abort"); -}; - -async function withSftpRetries( - run: () => Promise, - opts?: Pick, -): Promise { - const retries = Math.max(0, Number(opts?.retries ?? 2)); - let lastErr: unknown; - for (let attempt = 0; attempt <= retries; attempt += 1) { - if (opts?.signal?.aborted) throw new Error("aborted"); - try { - return await run(); - } catch (err) { - lastErr = err; - if (isAbortError(err) || attempt >= retries) break; - opts?.onRetry?.(attempt + 1, err); - } - } - throw lastErr instanceof Error ? lastErr : new Error(String(lastErr || "transfer_failed")); -} - -async function webcrtSftpDownloadOnce( - body: { ne_id?: string; ume_ne_id?: string; path: string }, - opts?: Pick, -): Promise { - const path = "/v1/webcrt/sftp/download"; - const res = await fetch(path, { - method: "POST", - credentials: fetchCreds, - headers: authHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify(body), - signal: opts?.signal, - }); - if (res.status === 401) { - handleUnauthorized(path); - throw new Error("401 unauthorized"); - } - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `HTTP ${res.status}`); - } - const total = Number(res.headers.get("content-length") || 0); - if (!res.body) { - const blob = await res.blob(); - opts?.onProgress?.({ loaded: blob.size, total: total || blob.size }); - return blob; - } - const reader = res.body.getReader(); - const chunks: Uint8Array[] = []; - let loaded = 0; - try { - for (;;) { - if (opts?.signal?.aborted) { - await reader.cancel().catch(() => undefined); - throw new Error("aborted"); - } - const { done, value } = await reader.read(); - if (done) break; - if (value) { - chunks.push(value); - loaded += value.byteLength; - opts?.onProgress?.({ loaded, total }); - } - } - } catch (err) { - if (opts?.signal?.aborted || isAbortError(err)) throw new Error("aborted"); - throw err; - } - return new Blob(chunks as BlobPart[], { type: "application/octet-stream" }); -} - -export async function webcrtSftpDownload( - body: { - ne_id?: string; - ume_ne_id?: string; - path: string; - }, - optsOrProgress?: WebcrtSftpTransferOpts | ((p: WebcrtSftpTransferProgress) => void), -): Promise { - const opts: WebcrtSftpTransferOpts = - typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress || {}; - return withSftpRetries( - () => webcrtSftpDownloadOnce(body, opts), - opts, - ); -} - -function webcrtSftpUploadOnce( - body: { - ne_id?: string; - ume_ne_id?: string; - remote_path: string; - file: File; - }, - opts?: Pick, -): Promise<{ ok: boolean; path: string; size: number }> { - const path = "/v1/webcrt/sftp/upload"; - const fd = new FormData(); - if (body.ne_id) fd.append("ne_id", body.ne_id); - if (body.ume_ne_id) fd.append("ume_ne_id", body.ume_ne_id); - fd.append("remote_path", body.remote_path); - fd.append("file", body.file); - - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - const onAbort = () => { - try { - xhr.abort(); - } catch { - /* ignore */ - } - }; - if (opts?.signal) { - if (opts.signal.aborted) { - reject(new Error("aborted")); - return; - } - opts.signal.addEventListener("abort", onAbort, { once: true }); - } - xhr.open("POST", path); - xhr.withCredentials = true; - xhr.responseType = "text"; - xhr.upload.onprogress = (ev) => { - if (!opts?.onProgress) return; - opts.onProgress({ - loaded: Number(ev.loaded || 0), - total: ev.lengthComputable ? Number(ev.total || 0) : body.file.size || 0, - }); - }; - xhr.onload = () => { - opts?.signal?.removeEventListener("abort", onAbort); - if (xhr.status === 401) { - handleUnauthorized(path); - reject(new Error("401 unauthorized")); - return; - } - if (xhr.status < 200 || xhr.status >= 300) { - reject(new Error(xhr.responseText || `HTTP ${xhr.status}`)); - return; - } - try { - resolve(JSON.parse(xhr.responseText || "{}") as { ok: boolean; path: string; size: number }); - } catch { - reject(new Error("invalid_upload_response")); - } - }; - xhr.onerror = () => { - opts?.signal?.removeEventListener("abort", onAbort); - reject(new Error("network_error")); - }; - xhr.onabort = () => { - opts?.signal?.removeEventListener("abort", onAbort); - reject(new Error("aborted")); - }; - xhr.send(fd); - }); -} - -export function webcrtSftpUpload( - body: { - ne_id?: string; - ume_ne_id?: string; - remote_path: string; - file: File; - }, - optsOrProgress?: WebcrtSftpTransferOpts | ((p: WebcrtSftpTransferProgress) => void), -): Promise<{ ok: boolean; path: string; size: number }> { - const opts: WebcrtSftpTransferOpts = - typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress || {}; - return withSftpRetries(() => webcrtSftpUploadOnce(body, opts), opts); -} - -export async function webcrtWsUrl(sessionId: string): Promise { - const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; - const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`; - let qs = ""; - try { - const ticketResp = await apiPost<{ ticket: string; expires_in: number }>("/v1/webcrt/ws-ticket", {}); - if (ticketResp?.ticket) { - qs = `?ws_ticket=${encodeURIComponent(ticketResp.ticket)}`; - } - } catch { - qs = ""; - } - // Optional override, e.g. ws://127.0.0.1:8890 - const override = String((import.meta as ImportMeta & { env?: Record }).env?.VITE_NETX_WS_BASE || "").trim(); - if (override) { - return `${override.replace(/\/$/, "")}${path}${qs}`; - } - // Vite/preview: HTTP is proxied, but WS proxy is often flaky — hit API directly. - const port = window.location.port; - if (port === "5173" || port === "4173") { - const apiHost = window.location.hostname === "localhost" ? "127.0.0.1" : window.location.hostname; - return `${proto}//${apiHost}:8890${path}${qs}`; - } - return `${proto}//${window.location.host}${path}${qs}`; -} - -export const fetchCliMeta = () => apiGet("/v1/cli/meta"); - -export const fetchCliProfiles = () => - apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles"); - -export const fetchCliTargets = (params: { - source?: "managed" | "ume" | "webcrt" | "all"; - keyword?: string; - page?: number; - pageSize?: number; -}) => { - const p = new URLSearchParams(); - p.set("source", params.source || "all"); - if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); - 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(`/v1/cli/targets?${p.toString()}`); -}; - -export const postUmeConnectTest = (umeNeIds: string[]) => - apiPost<{ ok: boolean; submitted: number }>("/v1/cli/ume-connect-test", { ume_ne_ids: umeNeIds }); - -export const fetchUmeCliOverride = (umeNeId: string) => - apiGet(`/v1/cli/ume-overrides/${encodeURIComponent(umeNeId)}`); - -export const fetchUmeCurrentAlarms = (params: { - severity: string; - isCleared: string; - hostName: string; - keyword: string; - page: number; - pageSize: number; -}) => { - const p = new URLSearchParams(); - if (params.severity) p.set("severity", params.severity); - if (params.isCleared) p.set("is_cleared", params.isCleared); - if (params.hostName) p.set("host_name", params.hostName); - if (params.keyword) p.set("keyword", params.keyword); - 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[] }>( - `/v1/ume/alarms?${p.toString()}`, - ); -}; - -export const fetchTopologyViews = () => - apiGet<{ total: number; items: TopologyViewItem[] }>("/v1/topology/views"); - -export const fetchTopologyTree = () => apiGet("/v1/topology/tree"); - -export const createTopologyFolder = (body: { - name: string; - kind?: string; - parent_id?: string; - sort_order?: number; - /** UI locale so auto-spawned root map is「根图」or "Root map". */ - locale?: string; -}) => - apiPost<{ - id: string; - parent_id: string; - kind: string; - name: string; - sort_order: number; - is_system: boolean; - }>("/v1/topology/folders", body); - -export const updateTopologyFolder = ( - folderId: string, - body: { name?: string; parent_id?: string; sort_order?: number }, -) => - apiPatch<{ - id: string; - parent_id: string; - kind: string; - name: string; - sort_order: number; - is_system: boolean; - }>(`/v1/topology/folders/${encodeURIComponent(folderId)}`, body); - -export const deleteTopologyFolder = (folderId: string, force = false) => - apiDelete<{ ok: boolean; folder_id: string; deleted: boolean }>( - `/v1/topology/folders/${encodeURIComponent(folderId)}?force=${force ? "true" : "false"}`, - ); - -export const createTopologyView = (body: { - name: string; - remark?: string; - filter?: Record; - folder_id?: string; - kind?: string; - role?: string; - sort_order?: number; -}) => apiPost("/v1/topology/views", body); - -export const populateTopologyView = ( - viewId: string, - body?: { dry_run?: boolean; membership?: Record; freeze_after?: boolean }, -) => - apiPost<{ - view_id: string; - dry_run: boolean; - candidate_count: number; - would_add: number; - added: number; - max_nodes: number; - truncated: boolean; - outside_peers: TopologyOutsidePeer[]; - graph: TopologyViewGraph | null; - }>(`/v1/topology/views/${encodeURIComponent(viewId)}/populate`, body || {}); - -export const fetchTopologyViewGraph = ( - viewId: string, - opts?: { - min_x?: number; - max_x?: number; - min_y?: number; - max_y?: number; - sbn_id?: string; - folder_id?: string; - lod?: "auto" | "sbn" | "me" | "overview" | "detail"; - status?: string; - }, -) => { - const q = new URLSearchParams(); - if (opts?.min_x != null) q.set("min_x", String(opts.min_x)); - if (opts?.max_x != null) q.set("max_x", String(opts.max_x)); - if (opts?.min_y != null) q.set("min_y", String(opts.min_y)); - if (opts?.max_y != null) q.set("max_y", String(opts.max_y)); - if (opts?.sbn_id) q.set("sbn_id", opts.sbn_id); - if (opts?.folder_id) q.set("folder_id", opts.folder_id); - if (opts?.lod) q.set("lod", opts.lod); - if (opts?.status) q.set("status", opts.status); - const qs = q.toString(); - return apiGet( - `/v1/topology/views/${encodeURIComponent(viewId)}${qs ? `?${qs}` : ""}`, - ); -}; - -export const fetchTopologyWorld = () => - apiGet<{ view_id: string; folder_id: string; name: string }>("/v1/topology/world"); - -export const fetchTopologyFolderBBox = (folderId: string) => - apiGet<{ folder_id: string; min_x: number; max_x: number; min_y: number; max_y: number; n: number }>( - `/v1/topology/folders/${encodeURIComponent(folderId)}/bbox`, - ); - -export const updateTopologyView = ( - viewId: string, - body: { - name?: string; - remark?: string; - filter?: Record; - viewport?: Record; - folder_id?: string; - role?: string; - sort_order?: number; - }, -) => apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}`, body); - -export const deleteTopologyView = (viewId: string, force = false) => - apiDelete<{ ok: boolean; view_id: string; deleted: boolean }>( - `/v1/topology/views/${encodeURIComponent(viewId)}?force=${force ? "true" : "false"}`, - ); - -export const patchTopologyPositions = ( - viewId: string, - positions: Array<{ - fabric_node_id: string; - x?: number; - y?: number; - label?: string; - locked?: boolean; - }>, -) => - apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, { - positions, - }); - -export const addTopologyViewNodes = ( - viewId: string, - body: { - managed_ne_ids?: string[]; - ume_ne_ids?: string[]; - fabric_node_ids?: string[]; - layout?: string; - }, -) => apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body); - -export const createTopologyPlaceholder = ( - viewId: string, - body: { name: string; ip_address?: string; x?: number; y?: number }, -) => - apiPost( - `/v1/topology/views/${encodeURIComponent(viewId)}/nodes/create-placeholder`, - body, - ); - -export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) => - apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, { - fabric_node_ids: fabricNodeIds, - }); - -export const projectTopologyNeighbors = ( - viewId: string, - body?: { seed_fabric_node_ids?: string[]; managed_ne_ids?: string[]; dry_run?: boolean }, -) => - apiPost( - `/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, - body || {}, - ); - -export const patchTopologyEdgeStyle = ( - viewId: string, - body: { fabric_edge_id: string; stroke_color?: string; stroke_width?: number; line_style?: string }, -) => - apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}/edge-style`, body); - -export const fetchFabricSummary = () => apiGet("/v1/topology/fabric/summary"); - -export const fetchFabricNodes = (params?: { - keyword?: string; - role?: string; - level?: string; - levelMajor?: string; - regionFolderId?: string; - unmatched?: string; - linkStatus?: string; - page?: number; - pageSize?: number; -}) => { - const p = new URLSearchParams(); - if (params?.keyword) p.set("keyword", params.keyword); - if (params?.role) p.set("role", params.role); - if (params?.level) p.set("level", params.level); - if (params?.levelMajor) p.set("level_major", params.levelMajor); - if (params?.regionFolderId) p.set("region_folder_id", params.regionFolderId); - if (params?.unmatched) p.set("unmatched", params.unmatched); - if (params?.linkStatus) p.set("link_status", params.linkStatus); - 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: import("../types").FabricNodeSearchHit[]; - }>(`/v1/topology/fabric/nodes?${p.toString()}`); -}; - -export const matchFabricNodes = (body: { - pattern: string; - match_field?: string; - sample_limit?: number; -}) => - apiPost<{ - pattern: string; - match_field: string; - total_matched: number; - samples: Array>; - fabric_node_ids: string[]; - }>("/v1/topology/fabric/nodes/match", body); - -export const bulkTagFabricNodes = (body: { - fabric_node_ids?: string[]; - pattern?: string; - match_field?: string; - level?: number | null; - role?: string | null; - region_folder_id?: string | null; - dry_run?: boolean; -}) => - apiPost<{ - dry_run: boolean; - matched: number; - updated: number; - level?: number | null; - role?: string | null; - region_folder_id?: string | null; - samples: Array>; - }>("/v1/topology/fabric/nodes/tags/bulk", body); - -export const patchFabricNodeTags = ( - fabricNodeId: string, - body: { level?: number | null; role?: string | null; region_folder_id?: string | null }, -) => - apiPatch( - `/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}/tags`, - body, - ); - -export const deleteFabricNode = (fabricNodeId: string) => - apiDelete<{ deleted: number; edges_deleted: number; placements_deleted: number }>( - `/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}`, - ); - -export const deleteFabricNodes = (fabricNodeIds: string[]) => - apiPost<{ deleted: number; edges_deleted: number; placements_deleted: number }>( - "/v1/topology/fabric/nodes/delete", - { fabric_node_ids: fabricNodeIds }, - ); - -/** Hard-delete topology/LLDP placeholders (ManagedNE + fabric + edges). */ -export const purgePlaceholderFabricNodes = (fabricNodeIds: string[]) => - apiPost<{ - deleted: number; - edges_deleted: number; - placements_deleted: number; - managed_deleted: number; - membership_views: number; - }>("/v1/topology/fabric/nodes/purge-placeholders", { - fabric_node_ids: fabricNodeIds, - }); - -export const searchFabricNodes = (params?: { q?: string; page?: number; pageSize?: number }) => { - const p = new URLSearchParams(); - if (params?.q) p.set("q", params.q); - 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 || 50))))); - return apiGet<{ - total: number; - page: number; - page_size: number; - items: import("../types").FabricNodeSearchHit[]; - }>(`/v1/topology/fabric/nodes/search?${p.toString()}`); -}; - -export const fetchFabricEdges = (params?: { - keyword?: string; - status?: string; - source?: string; - nodeId?: string; - page?: number; - pageSize?: number; -}) => { - const p = new URLSearchParams(); - if (params?.keyword) p.set("keyword", params.keyword); - if (params?.status) p.set("status", params.status); - if (params?.source) p.set("source", params.source); - if (params?.nodeId) p.set("node_id", params.nodeId); - 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 || 20))))); - return apiGet<{ total: number; page: number; page_size: number; items: FabricEdge[] }>( - `/v1/topology/fabric/edges?${p.toString()}`, - ); -}; - -export const startLldpDiscover = (body?: { - scope?: "all_inventory" | "ne_ids"; - ne_ids?: string[]; - auto_add_unmatched?: boolean; - concurrency?: number; - trigger_mode?: "manual" | "schedule" | "topology"; -}) => apiPost("/v1/topology/fabric/discover", body || {}); - -export const fetchLldpDiscoverJob = ( - jobId: string, - params?: { page?: number; pageSize?: number }, -) => { - const p = new URLSearchParams(); - 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( - `/v1/topology/fabric/discover/${encodeURIComponent(jobId)}?${p.toString()}`, - ); -}; - -export const fetchLldpCollectDashboard = () => - apiGet("/v1/topology/lldp-collect/dashboard"); - -export const updateLldpCollectPolicy = (body: Partial) => - apiPut("/v1/topology/lldp-collect/policy", body); - -export const startLldpCollect = (body?: { mode?: "full" | "retry_failed"; job_id?: string }) => - apiPost<{ ok: boolean; job: TopologyDiscoverJob }>("/v1/topology/lldp-collect/start", { - mode: body?.mode || "full", - ...(body?.job_id ? { job_id: body.job_id } : {}), - }); - -export const pauseLldpCollectJob = (jobId: string) => - apiPost(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/pause`, {}); - -export const resumeLldpCollectJob = (jobId: string) => - apiPost(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/resume`, {}); - -export const stopLldpCollectJob = (jobId: string) => - apiPost(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/stop`, {}); - -export const fetchLldpCollectJobs = (params: { - page?: number; - pageSize?: number; - status?: string; - keyword?: string; -}) => { - const p = new URLSearchParams(); - 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))))); - if (params.status?.trim()) p.set("status", params.status.trim()); - if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); - return apiGet<{ total: number; page: number; page_size: number; items: LldpCollectJobSummary[] }>( - `/v1/topology/lldp-collect/jobs?${p.toString()}`, - ); -}; - -export const fetchLldpCollectJob = ( - jobId: string, - params?: { page?: number; pageSize?: number; status?: string; keyword?: string }, -) => { - const p = new URLSearchParams(); - 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))))); - if (params?.status?.trim()) p.set("status", params.status.trim()); - if (params?.keyword?.trim()) p.set("keyword", params.keyword.trim()); - return apiGet( - `/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}?${p.toString()}`, - ); -}; - -export const createFabricManualEdge = (body: { - a_node_id: string; - b_node_id: string; - a_port?: string; - b_port?: string; -}) => apiPost<{ ok: boolean; action: string; edge: Record }>("/v1/topology/fabric/edges", body); - -export const deleteFabricEdges = (edgeIds: string[]) => - apiPost<{ ok: boolean; deleted: number }>("/v1/topology/fabric/edges/delete", { - edge_ids: edgeIds, - }); - -export const deleteFabricEdge = (edgeId: string) => - apiDelete<{ ok: boolean; deleted: number }>( - `/v1/topology/fabric/edges/${encodeURIComponent(edgeId)}`, - ); - -/** Back-compat aliases used by older call sites during cutover. */ -export const fetchTopologyMaps = fetchTopologyViews; -export const createTopologyMap = createTopologyView; -export const fetchTopologyGraph = fetchTopologyViewGraph; -export const updateTopologyMap = updateTopologyView; -export const deleteTopologyMap = deleteTopologyView; - -export const fetchConfigSyncDashboard = () => - apiGet("/v1/config-sync/dashboard"); - -export const fetchConfigSyncPolicy = () => apiGet("/v1/config-sync/policy"); - -export const updateConfigSyncPolicy = (body: Partial) => - apiPut("/v1/config-sync/policy", body); - -export const fetchConfigSyncCycles = (params: { - page?: number; - pageSize?: number; - status?: string; - keyword?: string; -}) => { - const p = new URLSearchParams(); - 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))))); - if (params.status?.trim()) p.set("status", params.status.trim()); - if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); - return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncCycle[] }>( - `/v1/config-sync/cycles?${p.toString()}`, - ); -}; - -export const createConfigSyncCycle = (body: { mode: "full" | "retry_failed"; cycle_id?: string }) => - apiPost("/v1/config-sync/cycles", body); - -export const pauseConfigSyncCycle = (cycleId: string) => - apiPost(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/pause`, {}); - -export const resumeConfigSyncCycle = (cycleId: string) => - apiPost(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/resume`, {}); - -export const stopConfigSyncCycle = (cycleId: string) => - apiPost(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/stop`, {}); - -export const fetchConfigSyncCycleTasks = (params: { - cycleId: string; - page?: number; - pageSize?: number; - status?: string; - keyword?: string; -}) => { - const p = new URLSearchParams(); - 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 || 20))))); - if (params.status) p.set("status", params.status); - if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); - return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncTask[] }>( - `/v1/config-sync/cycles/${encodeURIComponent(params.cycleId)}/tasks?${p.toString()}`, - ); -}; - -export const fetchNeConfigSnapshots = (params: { - page?: number; - pageSize?: number; - keyword?: string; - source?: string; - vendor?: string; -}) => { - const p = new URLSearchParams(); - 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))))); - if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); - if (params.source) p.set("source", params.source); - if (params.vendor?.trim()) p.set("vendor", params.vendor.trim()); - return apiGet<{ total: number; page: number; page_size: number; items: NeConfigSnapshotMeta[] }>( - `/v1/config-sync/snapshots?${p.toString()}`, - ); -}; - -export const fetchNeConfigSnapshotDetail = ( - source: string, - targetId: string, - field: "primary" | "alt" | "both" = "both", -) => - apiGet( - `/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}?field=${field}`, - ); - -export const downloadNeConfigSnapshot = async ( - source: string, - targetId: string, - field: "primary" | "alt" | "both" = "primary", -): Promise => { - const path = - `/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}` + - `/download?field=${encodeURIComponent(field)}`; - const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds }); - if (res.status === 401) { - handleUnauthorized(path); - throw new Error("unauthorized"); - } - if (!res.ok) throw new Error(`${res.status} download`); - const blob = await res.blob(); - const cd = res.headers.get("content-disposition") || ""; - const star = /filename\*=UTF-8''([^;]+)/i.exec(cd); - const plain = /filename="?([^";]+)"?/i.exec(cd); - const filename = star - ? decodeURIComponent(star[1]) - : plain - ? plain[1] - : `ne-config-${source}-${targetId}.txt`; - const url = URL.createObjectURL(blob); - try { - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - } finally { - URL.revokeObjectURL(url); - } -}; - -export const fetchPortTrafficDashboard = () => - apiGet("/v1/port-traffic/dashboard"); - -export const fetchPortTrafficDevices = (params: { - page?: number; - pageSize?: number; - status?: string; - keyword?: string; -}) => { - const p = new URLSearchParams(); - 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))))); - if (params.status?.trim()) p.set("status", params.status.trim()); - if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); - return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficDevice[] }>( - `/v1/port-traffic/devices?${p.toString()}`, - ); -}; - -/** @deprecated */ -export const fetchPortTrafficTasks = fetchPortTrafficDevices; - -export const createPortTrafficDevice = (body: { - source: "managed" | "ume"; - ne_id: string; - ne_name?: string; - ne_ip?: string; - vendor?: string; - note?: string; - interval_sec?: number; - retention_days?: number; - concurrency?: number; - interfaces?: PortTrafficIfaceIn[]; - start_now?: boolean; -}) => apiPost("/v1/port-traffic/devices", body); - -export const updatePortTrafficDevice = ( - deviceId: string, - body: { - note?: string; - interval_sec?: number; - retention_days?: number; - concurrency?: number; - ne_name?: string; - ne_ip?: string; - vendor?: string; - }, -) => apiPatch(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`, body); - -export const startPortTrafficDevice = (deviceId: string) => - apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/start`, {}); - -export const pausePortTrafficDevice = (deviceId: string) => - apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/pause`, {}); - -export const stopPortTrafficDevice = (deviceId: string) => - apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/stop`, {}); - -export const collectPortTrafficNow = (deviceId: string) => - apiPost( - `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/collect-now`, - {}, - ); - -export const deletePortTrafficDevice = (deviceId: string) => - apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`); - -export const rebindPortTrafficDevice = (deviceId: string, body: { ne_id: string }) => - apiPost( - `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/rebind`, - body, - ); - -export const fetchPortTrafficTargets = (deviceId: string) => - apiGet<{ items: PortTrafficTarget[] }>( - `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/targets`, - ); - -export const fetchPortTrafficEvents = (deviceId: string, limit = 100) => - apiGet<{ items: PortTrafficEvent[]; total: number }>( - `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/events?limit=${limit}`, - ); - -export const putPortTrafficInterfaces = (deviceId: string, interfaces: PortTrafficIfaceIn[]) => - apiPut<{ items: PortTrafficTarget[] }>( - `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/interfaces`, - { interfaces }, - ); - -export const discoverPortTrafficPorts = (body: { source: "managed" | "ume"; id: string }) => - apiPost("/v1/port-traffic/discover/ports", body); - -export const fetchPortTrafficSamples = (params: { - targetId: string; - from?: string; - to?: string; -}) => { - const p = new URLSearchParams(); - p.set("target_id", params.targetId); - if (params.from) p.set("from", params.from); - if (params.to) p.set("to", params.to); - return apiGet(`/v1/port-traffic/samples?${p.toString()}`); -}; - -export const fetchPortTrafficCompare = (params: { - targetId: string; - rangeHours: number; - baseline: string; - offsetHours?: number; - aheadHours?: number; - baselineTargetId?: string; - to?: string; -}) => { - const p = new URLSearchParams(); - p.set("target_id", params.targetId); - p.set("range_hours", String(params.rangeHours)); - p.set("baseline", params.baseline); - if (params.offsetHours != null) p.set("offset_hours", String(params.offsetHours)); - if (params.aheadHours != null && params.aheadHours > 0) { - p.set("ahead_hours", String(params.aheadHours)); - } - if (params.baselineTargetId) p.set("baseline_target_id", params.baselineTargetId); - if (params.to) p.set("to", params.to); - return apiGet(`/v1/port-traffic/compare?${p.toString()}`); -}; - -export const fetchPortTrafficBoards = () => - apiGet<{ items: PortTrafficBoardSummary[] }>("/v1/port-traffic/boards"); - -export const fetchPortTrafficBoard = (boardId: string) => - apiGet(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`); - -export const createPortTrafficBoard = (body: { - name: string; - remark?: string; - cols?: number; - panels?: PortTrafficBoardPanelIn[]; -}) => apiPost("/v1/port-traffic/boards", body); - -export const updatePortTrafficBoard = ( - boardId: string, - body: { name?: string; remark?: string; cols?: number }, -) => apiPatch(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`, body); - -export const putPortTrafficBoardPanels = (boardId: string, panels: PortTrafficBoardPanelIn[]) => - apiPut(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}/panels`, { - panels, - }); - -export const deletePortTrafficBoard = (boardId: string) => - apiDelete<{ ok: boolean; id: string }>( - `/v1/port-traffic/boards/${encodeURIComponent(boardId)}`, - ); - -/* ---- biz_state (business state monitoring) ---- */ - -export const bizStateListProfiles = (params?: { - vendor?: string; - device_type?: string; - kind?: string; -}) => { - const q = new URLSearchParams(); - if (params?.vendor) q.set("vendor", params.vendor); - if (params?.device_type) q.set("device_type", params.device_type); - if (params?.kind) q.set("kind", params.kind); - const qs = q.toString(); - return apiGet<{ items: Record[] }>( - `/v1/biz-state/profiles${qs ? `?${qs}` : ""}`, - ); -}; - -export const bizStateDiscover = (body: Record) => - apiPost<{ - ok: boolean; - error?: string; - command?: string; - cache_hit?: boolean; - pair_mode?: boolean; - discover_profile_id?: string; - candidates?: Array<{ - value: string; - label: string; - rd?: string; - protocols?: string; - bindings?: Record; - }>; - raw_preview?: string; - }>("/v1/biz-state/discover", body); - -export const bizStateSetBindings = ( - taskId: string, - itemId: string, - bindings: Array<{ placeholder: string; value: string }>, -) => - apiPut>( - `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/items/${encodeURIComponent(itemId)}/bindings`, - { bindings }, - ); - -export const bizStateListTasks = (purpose = "") => { - const q = purpose ? `?purpose=${encodeURIComponent(purpose)}` : ""; - return apiGet<{ items: Record[] }>(`/v1/biz-state/tasks${q}`); -}; - -export const bizStateCreateTask = (body: Record) => - apiPost>("/v1/biz-state/tasks", body); - -export const bizStateGetTask = (taskId: string) => - apiGet>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`); - -export const bizStatePatchTask = (taskId: string, body: Record) => - apiPatch>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`, body); - -export const bizStatePauseTask = (taskId: string) => - apiPost>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/pause`, {}); - -export const bizStateStartTask = (taskId: string) => - apiPost>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/start`, {}); - -export const bizStateDeleteTask = (taskId: string) => - apiDelete<{ ok: boolean }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`); - -export const bizStateCollectNow = (taskId: string) => - apiPost<{ - ok: boolean; - started?: boolean; - queued?: boolean; - reason?: string; - batch_id?: string; - collect_running?: boolean; - }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect`, {}); - -export const bizStateCollectStop = (taskId: string) => - apiPost<{ - ok: boolean; - stopped?: boolean; - reason?: string; - cancelled_batches?: string[]; - signaled_batches?: string[]; - }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect/stop`, {}); - -type BizStateImportResult = { - ok: boolean; - started?: boolean; - queued?: boolean; - reason?: string; - batch_id?: string; - task_id?: string; - vendor_key?: string; - filename?: string; - ne_name?: string; - created_task?: boolean; - segments_preview?: Record; - collect_running?: boolean; -}; - -export const bizStateImportLog = async ( - taskId: string, - file: File, - opts?: { vendor_key?: string }, -): Promise => { - const form = new FormData(); - form.append("file", file); - if (opts?.vendor_key?.trim()) form.append("vendor_key", opts.vendor_key.trim()); - const path = `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/import`; - const res = await fetch(path, { - method: "POST", - headers: authHeaders(), - body: form, - credentials: fetchCreds, - }); - if (res.status === 401) { - handleUnauthorized(path); - throw new Error("unauthorized"); - } - const data = await parseApiResponse(res); - if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} import`); - return data as BizStateImportResult; -}; - -/** Standalone offline import: creates source=import task (no inventory NE). */ -export const bizStateImportLogStandalone = async ( - file: File, - opts?: { vendor_key?: string; ne_name?: string; note?: string }, -): Promise => { - const form = new FormData(); - form.append("file", file); - form.append("vendor_key", (opts?.vendor_key || "zte").trim() || "zte"); - if (opts?.ne_name?.trim()) form.append("ne_name", opts.ne_name.trim()); - if (opts?.note?.trim()) form.append("note", opts.note.trim()); - const path = "/v1/biz-state/import"; - const res = await fetch(path, { - method: "POST", - headers: authHeaders(), - body: form, - credentials: fetchCreds, - }); - if (res.status === 401) { - handleUnauthorized(path); - throw new Error("unauthorized"); - } - const data = await parseApiResponse(res); - if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} import`); - return data as BizStateImportResult; -}; - -export const bizStateListBatches = (taskId: string, limit = 50) => - apiGet<{ items: Record[] }>( - `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches?limit=${limit}`, - ); - -export const bizStateSetBatchBaseline = (batchId: string, marked: boolean) => - apiPost>( - `/v1/biz-state/batches/${encodeURIComponent(batchId)}/baseline`, - { marked }, - ); - -export const bizStateSetBatchAlias = (batchId: string, alias: string) => - apiPatch>( - `/v1/biz-state/batches/${encodeURIComponent(batchId)}/alias`, - { alias }, - ); - -export const bizStateDeleteBatch = (batchId: string) => - apiDelete<{ ok: boolean }>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`); - -export const bizStateBulkDeleteBatches = (batchIds: string[]) => - apiPost<{ - ok: boolean; - deleted: string[]; - skipped: { batch_id: string; reasons: string[] }[]; - deleted_count: number; - }>("/v1/biz-state/batches/bulk-delete", { batch_ids: batchIds }); - -export const bizStatePurgeTask = (taskId: string) => - apiPost>( - `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/purge`, - {}, - ); - -export const bizStateGetBatch = (batchId: string) => - apiGet>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`); - -export const bizStateGetTaskProgress = (taskId: string) => - apiGet>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/progress`); - -export const bizStateListBatchMetricRows = (params: { - batchId: string; - metricId: string; - page?: number; - pageSize?: number; - kw?: string; - column?: string; -}) => { - const p = new URLSearchParams(); - 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 || 50))))); - if (params.kw) p.set("kw", params.kw); - if (params.column) p.set("column", params.column); - return apiGet<{ - batch_id: string; - metric_id: string; - total: number; - page: number; - page_size: number; - pages: number; - columns: Array<{ key: string; header: string; role?: string; is_key?: boolean }>; - items: Record[]; - }>( - `/v1/biz-state/batches/${encodeURIComponent(params.batchId)}/metrics/${encodeURIComponent(params.metricId)}?${p.toString()}`, - ); -}; - -export const bizStateGetBatchCommand = (batchId: string, commandId: string) => - apiGet<{ - id: string; - batch_id: string; - raw_command: string; - metric_id: string; - parse_status: string; - row_count: number; - raw_line_count?: number; - declared_total?: number; - message: string; - raw_text: string; - collected_at?: string | null; - device?: { ne_id?: string; ne_name?: string; ne_ip?: string }; - }>( - `/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, - ); - -/** Download one command's raw CLI output as .txt */ -export const bizStateDownloadCommandRaw = async (batchId: string, commandId: string): Promise => { - const path = - `/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}/raw.txt`; - const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); - if (res.status === 401) { - handleUnauthorized(path); - throw new Error("unauthorized"); - } - if (!res.ok) throw new Error(`${res.status} download raw`); - const blob = await res.blob(); - const cd = res.headers.get("Content-Disposition") || ""; - const m = /filename="?([^";]+)"?/i.exec(cd); - const filename = m?.[1] || `biz_state_${commandId}.txt`; - const url = URL.createObjectURL(blob); - try { - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - } finally { - URL.revokeObjectURL(url); - } -}; - -export const bizStateDownloadExport = async (batchId: string): Promise => { - const path = `/v1/biz-state/batches/${encodeURIComponent(batchId)}/export`; - const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); - if (res.status === 401) { - handleUnauthorized(path); - throw new Error("unauthorized"); - } - if (!res.ok) throw new Error(`${res.status} export`); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - try { - const a = document.createElement("a"); - a.href = url; - a.download = `biz_state_${batchId}.zip`; - a.click(); - } finally { - URL.revokeObjectURL(url); - } -}; - -export const bizStateDownloadTaskCommands = async ( - taskId: string, - opts?: { includeAux?: boolean; enabledOnly?: boolean }, -): Promise => { - const q = new URLSearchParams(); - // Default includes aux (matches live collect); pass includeAux:false to omit. - q.set("include_aux", opts?.includeAux === false ? "false" : "true"); - if (opts?.enabledOnly === false) q.set("enabled_only", "false"); - const qs = q.toString(); - const path = - `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/commands/export` + - (qs ? `?${qs}` : ""); - const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); - if (res.status === 401) { - handleUnauthorized(path); - throw new Error("unauthorized"); - } - if (!res.ok) { - let detail = `${res.status} export commands`; - try { - const j = (await res.json()) as { detail?: unknown }; - if (j?.detail != null) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); - } catch { - /* ignore */ - } - throw new Error(detail); - } - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - try { - const a = document.createElement("a"); - a.href = url; - a.download = `biz_state_commands_${taskId}.txt`; - a.click(); - } finally { - URL.revokeObjectURL(url); - } -}; - -/* ---- biz_state compare (Phase2) ---- */ - -export const bizCompareListTemplates = () => - apiGet<{ items: Record[] }>("/v1/biz-state/compare/templates"); - -export const bizCompareListMetrics = () => - apiGet<{ - items: Array<{ - metric_id: string; - fields: Array<{ - name: string; - display_name: string; - dtype: string; - is_key: boolean; - is_interface: boolean; - role: string; - }>; - }>; - row_filter_presets?: Array<{ - id: string; - label: string; - row_filters: Array>; - }>; - }>("/v1/biz-state/compare/metrics"); - -export const bizCompareCreateTemplate = (body: Record) => - apiPost>("/v1/biz-state/compare/templates", body); - -export const bizCompareUpdateTemplate = (templateId: string, body: Record) => - apiPatch>( - `/v1/biz-state/compare/templates/${encodeURIComponent(templateId)}`, - body, - ); - -export const bizCompareDeleteTemplate = (templateId: string) => - apiDelete<{ ok: boolean }>(`/v1/biz-state/compare/templates/${encodeURIComponent(templateId)}`); - -export const bizCompareListMappings = () => - apiGet<{ items: Record[] }>("/v1/biz-state/compare/mappings"); - -export const bizCompareCreateMapping = (body: Record) => - apiPost>("/v1/biz-state/compare/mappings", body); - -export const bizCompareUpdateMapping = (mappingId: string, body: Record) => - apiPatch>( - `/v1/biz-state/compare/mappings/${encodeURIComponent(mappingId)}`, - body, - ); - -export const bizCompareValidateMapping = (body: Record) => - apiPost>("/v1/biz-state/compare/mappings/validate", body); - -export const bizCompareListJobs = () => - apiGet<{ items: Record[] }>("/v1/biz-state/compare/jobs"); - -export const bizCompareCreateJob = (body: Record) => - apiPost>("/v1/biz-state/compare/jobs", body); - -export const bizCompareUpdateJob = (jobId: string, body: Record) => - apiPatch>( - `/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}`, - body, - ); - -export const bizCompareDeleteJob = (jobId: string) => - apiDelete<{ ok: boolean }>(`/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}`); - -export const bizCompareRunJob = (jobId: string) => - apiPost>( - `/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}/run`, - {}, - ); - -export const bizCompareListRuns = (jobId: string, limit = 20) => - apiGet<{ items: Record[] }>( - `/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`, - ); - -export const bizCompareGetRun = (runId: string) => - apiGet>(`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`); - -export const bizCompareCancelRun = (runId: string) => - apiPost>( - `/v1/biz-state/compare/runs/${encodeURIComponent(runId)}/cancel`, - {}, - ); - -export const bizCompareDeleteRun = (runId: string) => - apiDelete<{ ok: boolean; job_id?: string; run_id?: string }>( - `/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`, - ); - -export const bizCompareListRunDiffs = (params: { - runId: string; - metricId?: string; - kind?: string; - kw?: string; - /** Field filters e.g. { direction: "out", network: "1.1.1.1" } */ - qf?: Record; - page?: number; - pageSize?: number; -}) => { - const p = new URLSearchParams(); - if (params.metricId) p.set("metric_id", params.metricId); - if (params.kind) p.set("kind", params.kind); - if (params.kw) p.set("kw", params.kw); - if (params.qf && Object.keys(params.qf).length) { - const cleaned: Record = {}; - for (const [k, v] of Object.entries(params.qf)) { - const key = String(k || "").trim(); - const val = String(v || "").trim(); - if (key && val) cleaned[key] = val; - } - if (Object.keys(cleaned).length) p.set("qf", JSON.stringify(cleaned)); - } - 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 || 100))))); - return apiGet<{ - total: number; - page: number; - page_size: number; - metric_id: string; - items: Record[]; - source?: string; - truncated?: boolean; - live_before_matched?: number; - live_after_matched?: number; - }>(`/v1/biz-state/compare/runs/${encodeURIComponent(params.runId)}/diffs?${p.toString()}`); -}; - -export const bizCompareDownloadRun = async (runId: string): Promise => { - const path = `/v1/biz-state/compare/runs/${encodeURIComponent(runId)}/export`; - const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); - if (res.status === 401) { - handleUnauthorized(path); - throw new Error("unauthorized"); - } - if (!res.ok) throw new Error(`${res.status} export`); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - try { - const a = document.createElement("a"); - a.href = url; - a.download = `biz_compare_${runId}.zip`; - a.click(); - } finally { - URL.revokeObjectURL(url); - } -}; - -// --- biz-migration (cutover monitor, separate from compare) --- - -export const bizMigrationListProjects = () => - apiGet<{ items: Record[] }>("/v1/biz-migration/projects"); - -export const bizMigrationNePortrait = (params: { source: string; ne_id: string; limit?: number }) => { - const q = new URLSearchParams(); - q.set("source", params.source || "managed"); - q.set("ne_id", params.ne_id); - if (params.limit != null) q.set("limit", String(params.limit)); - return apiGet<{ - source: string; - ne_id: string; - task: Record | null; - batches: Array<{ - id: string; - started_at?: string | null; - status?: string; - row_count?: number; - alias?: string; - }>; - hint?: string; - }>(`/v1/biz-migration/ne-portrait?${q.toString()}`); -}; - -export const bizMigrationCreateProject = (body: Record) => - apiPost>("/v1/biz-migration/projects", body); - -export const bizMigrationPatchProject = (projectId: string, body: Record) => - apiPatch>( - `/v1/biz-migration/projects/${encodeURIComponent(projectId)}`, - body, - ); - -export const bizMigrationDeleteProject = (projectId: string) => - apiDelete<{ ok?: boolean }>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}`); - -export const bizMigrationListBatches = (projectId: string) => - apiGet<{ items: Record[] }>( - `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, - ); - -export const bizMigrationListBaselinePorts = (projectId: string) => - apiGet<{ - batch_id: string; - metric_id: string; - ports: { - interface: string; - admin?: string; - phy?: string; - prot?: string; - description?: string; - mapped_to?: string; - }[]; - }>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/baseline-ports`); - -export const bizMigrationListBaselineExpect = (projectId: string) => - apiGet<{ - batch_id: string; - monitor_template_id?: string; - sheets: Array<{ - metric_id: string; - key_fields: string[]; - iface_fields: string[]; - items: Array<{ - key: string; - keys: string[]; - mapped_to?: string; - label?: string; - row?: Record; - }>; - }>; - mapped?: Record; - }>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/baseline-expect`); - -export const bizMigrationEnsureHighfreq = ( - projectId: string, - body: { - interval_sec?: number; - retention_days?: number; - collect_now?: boolean; - collect_metric_ids?: string[]; - hf_start_at?: string | null; - hf_end_at?: string | null; - old_ne?: Record; - new_ne?: Record; - } = {}, -) => - apiPost>( - `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/ensure-highfreq`, - body, - ); - -/** @deprecated use bizMigrationEnsureHighfreq */ -export const bizMigrationEnsurePortHighfreq = bizMigrationEnsureHighfreq; - -export const bizMigrationCollectNow = (projectId: string) => - apiPost>( - `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/collect-now`, - {}, - ); - -export const bizMigrationFinishBatch = (batchId: string, markDone = false) => - apiPost>( - `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/finish?mark_done=${markDone ? "true" : "false"}`, - {}, - ); - -export const bizMigrationListRedTickets = (projectId: string, status = "") => { - const q = status ? `?status=${encodeURIComponent(status)}` : ""; - return apiGet<{ open_count: number; items: Record[] }>( - `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets${q}`, - ); -}; - -export const bizMigrationResolveRedTicket = (ticketId: string, note = "") => - apiPost>( - `/v1/biz-migration/red-tickets/${encodeURIComponent(ticketId)}/resolve`, - { note }, - ); - -export const bizMigrationCreateBatch = (projectId: string, body: Record) => - apiPost>( - `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, - body, - ); - -export const bizMigrationPatchBatch = (batchId: string, body: Record) => - apiPatch>( - `/v1/biz-migration/batches/${encodeURIComponent(batchId)}`, - body, - ); - -export const bizMigrationEvaluate = (batchId: string, body: Record = {}) => - apiPost>( - `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/evaluate`, - body, - ); - -export const bizMigrationListRuns = (batchId: string, limit = 20) => - apiGet<{ items?: Array> }>( - `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs?limit=${limit}`, - ); - -export const bizMigrationGetBoard = (batchId: string, runId = "") => { - const q = runId ? `?run_id=${encodeURIComponent(runId)}` : ""; - return apiGet>( - `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board${q}`, - ); -}; - -export const bizMigrationListDiffs = (params: { - runId: string; - metricId?: string; - sheetId?: string; - onlyExpect?: boolean; - verdict?: string; - color?: string; - kw?: string; - offset?: number; - limit?: number; -}) => { - const p = new URLSearchParams(); - if (params.metricId) p.set("metric_id", params.metricId); - if (params.sheetId) p.set("sheet_id", params.sheetId); - if (params.onlyExpect) p.set("only_expect", "true"); - if (params.verdict) p.set("verdict", params.verdict); - if (params.color) p.set("color", params.color); - if (params.kw) p.set("kw", params.kw); - if (params.offset != null) p.set("offset", String(params.offset)); - if (params.limit != null) p.set("limit", String(params.limit)); - const qs = p.toString(); - return apiGet<{ items: Record[]; total: number }>( - `/v1/biz-migration/runs/${encodeURIComponent(params.runId)}/diffs${qs ? `?${qs}` : ""}`, - ); -}; - -export const bizMonitorListTemplates = () => - apiGet<{ items: Record[] }>("/v1/biz-migration/monitor-templates"); - -export const bizMonitorCreateTemplate = (body: Record) => - apiPost>("/v1/biz-migration/monitor-templates", body); - -export const bizMonitorUpdateTemplate = (templateId: string, body: Record) => - apiPatch>( - `/v1/biz-migration/monitor-templates/${encodeURIComponent(templateId)}`, - body, - ); - -export const bizMonitorDeleteTemplate = (templateId: string) => - apiDelete<{ ok: boolean }>( - `/v1/biz-migration/monitor-templates/${encodeURIComponent(templateId)}`, - ); diff --git a/web/src/services/api/bizCompare.ts b/web/src/services/api/bizCompare.ts new file mode 100644 index 0000000..c1c60f0 --- /dev/null +++ b/web/src/services/api/bizCompare.ts @@ -0,0 +1,151 @@ +import { apiDelete, apiGet, apiPatch, apiPost, authHeaders, fetchCreds, handleUnauthorized } from "./http"; + +/* ---- biz_state compare (Phase2) ---- */ + +export const bizCompareListTemplates = () => + apiGet<{ items: Record[] }>("/v1/biz-state/compare/templates"); + +export const bizCompareListMetrics = () => + apiGet<{ + items: Array<{ + metric_id: string; + fields: Array<{ + name: string; + display_name: string; + dtype: string; + is_key: boolean; + is_interface: boolean; + role: string; + }>; + }>; + row_filter_presets?: Array<{ + id: string; + label: string; + row_filters: Array>; + }>; + }>("/v1/biz-state/compare/metrics"); + +export const bizCompareCreateTemplate = (body: Record) => + apiPost>("/v1/biz-state/compare/templates", body); + +export const bizCompareUpdateTemplate = (templateId: string, body: Record) => + apiPatch>( + `/v1/biz-state/compare/templates/${encodeURIComponent(templateId)}`, + body, + ); + +export const bizCompareDeleteTemplate = (templateId: string) => + apiDelete<{ ok: boolean }>(`/v1/biz-state/compare/templates/${encodeURIComponent(templateId)}`); + +export const bizCompareListMappings = () => + apiGet<{ items: Record[] }>("/v1/biz-state/compare/mappings"); + +export const bizCompareCreateMapping = (body: Record) => + apiPost>("/v1/biz-state/compare/mappings", body); + +export const bizCompareUpdateMapping = (mappingId: string, body: Record) => + apiPatch>( + `/v1/biz-state/compare/mappings/${encodeURIComponent(mappingId)}`, + body, + ); + +export const bizCompareValidateMapping = (body: Record) => + apiPost>("/v1/biz-state/compare/mappings/validate", body); + +export const bizCompareListJobs = () => + apiGet<{ items: Record[] }>("/v1/biz-state/compare/jobs"); + +export const bizCompareCreateJob = (body: Record) => + apiPost>("/v1/biz-state/compare/jobs", body); + +export const bizCompareUpdateJob = (jobId: string, body: Record) => + apiPatch>( + `/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}`, + body, + ); + +export const bizCompareDeleteJob = (jobId: string) => + apiDelete<{ ok: boolean }>(`/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}`); + +export const bizCompareRunJob = (jobId: string) => + apiPost>( + `/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}/run`, + {}, + ); + +export const bizCompareListRuns = (jobId: string, limit = 20) => + apiGet<{ items: Record[] }>( + `/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`, + ); + +export const bizCompareGetRun = (runId: string) => + apiGet>(`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`); + +export const bizCompareCancelRun = (runId: string) => + apiPost>( + `/v1/biz-state/compare/runs/${encodeURIComponent(runId)}/cancel`, + {}, + ); + +export const bizCompareDeleteRun = (runId: string) => + apiDelete<{ ok: boolean; job_id?: string; run_id?: string }>( + `/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`, + ); + +export const bizCompareListRunDiffs = (params: { + runId: string; + metricId?: string; + kind?: string; + kw?: string; + /** Field filters e.g. { direction: "out", network: "1.1.1.1" } */ + qf?: Record; + page?: number; + pageSize?: number; +}) => { + const p = new URLSearchParams(); + if (params.metricId) p.set("metric_id", params.metricId); + if (params.kind) p.set("kind", params.kind); + if (params.kw) p.set("kw", params.kw); + if (params.qf && Object.keys(params.qf).length) { + const cleaned: Record = {}; + for (const [k, v] of Object.entries(params.qf)) { + const key = String(k || "").trim(); + const val = String(v || "").trim(); + if (key && val) cleaned[key] = val; + } + if (Object.keys(cleaned).length) p.set("qf", JSON.stringify(cleaned)); + } + 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 || 100))))); + return apiGet<{ + total: number; + page: number; + page_size: number; + metric_id: string; + items: Record[]; + source?: string; + truncated?: boolean; + live_before_matched?: number; + live_after_matched?: number; + }>(`/v1/biz-state/compare/runs/${encodeURIComponent(params.runId)}/diffs?${p.toString()}`); +}; + +export const bizCompareDownloadRun = async (runId: string): Promise => { + const path = `/v1/biz-state/compare/runs/${encodeURIComponent(runId)}/export`; + const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("unauthorized"); + } + if (!res.ok) throw new Error(`${res.status} export`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + try { + const a = document.createElement("a"); + a.href = url; + a.download = `biz_compare_${runId}.zip`; + a.click(); + } finally { + URL.revokeObjectURL(url); + } +}; diff --git a/web/src/services/api/bizMigration.ts b/web/src/services/api/bizMigration.ts new file mode 100644 index 0000000..d2e45a9 --- /dev/null +++ b/web/src/services/api/bizMigration.ts @@ -0,0 +1,178 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./http"; + +// --- biz-migration (cutover monitor, separate from compare) --- + +export const bizMigrationListProjects = () => + apiGet<{ items: Record[] }>("/v1/biz-migration/projects"); + +export const bizMigrationNePortrait = (params: { source: string; ne_id: string; limit?: number }) => { + const q = new URLSearchParams(); + q.set("source", params.source || "managed"); + q.set("ne_id", params.ne_id); + if (params.limit != null) q.set("limit", String(params.limit)); + return apiGet<{ + source: string; + ne_id: string; + task: Record | null; + batches: Array<{ + id: string; + started_at?: string | null; + status?: string; + row_count?: number; + alias?: string; + }>; + hint?: string; + }>(`/v1/biz-migration/ne-portrait?${q.toString()}`); +}; + +export const bizMigrationCreateProject = (body: Record) => + apiPost>("/v1/biz-migration/projects", body); + +export const bizMigrationPatchProject = (projectId: string, body: Record) => + apiPatch>( + `/v1/biz-migration/projects/${encodeURIComponent(projectId)}`, + body, + ); + +export const bizMigrationDeleteProject = (projectId: string) => + apiDelete<{ ok?: boolean }>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}`); + +export const bizMigrationListBatches = (projectId: string) => + apiGet<{ items: Record[] }>( + `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, + ); + +export const bizMigrationListBaselinePorts = (projectId: string) => + apiGet<{ + batch_id: string; + metric_id: string; + ports: { + interface: string; + admin?: string; + phy?: string; + prot?: string; + description?: string; + mapped_to?: string; + }[]; + }>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/baseline-ports`); + +export const bizMigrationListBaselineExpect = (projectId: string) => + apiGet<{ + batch_id: string; + monitor_template_id?: string; + sheets: Array<{ + metric_id: string; + key_fields: string[]; + iface_fields: string[]; + items: Array<{ + key: string; + keys: string[]; + mapped_to?: string; + label?: string; + row?: Record; + }>; + }>; + mapped?: Record; + }>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/baseline-expect`); + +export const bizMigrationEnsureHighfreq = ( + projectId: string, + body: { + interval_sec?: number; + retention_days?: number; + collect_now?: boolean; + collect_metric_ids?: string[]; + hf_start_at?: string | null; + hf_end_at?: string | null; + old_ne?: Record; + new_ne?: Record; + } = {}, +) => + apiPost>( + `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/ensure-highfreq`, + body, + ); + +/** @deprecated use bizMigrationEnsureHighfreq */ +export const bizMigrationEnsurePortHighfreq = bizMigrationEnsureHighfreq; + +export const bizMigrationCollectNow = (projectId: string) => + apiPost>( + `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/collect-now`, + {}, + ); + +export const bizMigrationFinishBatch = (batchId: string, markDone = false) => + apiPost>( + `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/finish?mark_done=${markDone ? "true" : "false"}`, + {}, + ); + +export const bizMigrationListRedTickets = (projectId: string, status = "") => { + const q = status ? `?status=${encodeURIComponent(status)}` : ""; + return apiGet<{ open_count: number; items: Record[] }>( + `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets${q}`, + ); +}; + +export const bizMigrationResolveRedTicket = (ticketId: string, note = "") => + apiPost>( + `/v1/biz-migration/red-tickets/${encodeURIComponent(ticketId)}/resolve`, + { note }, + ); + +export const bizMigrationCreateBatch = (projectId: string, body: Record) => + apiPost>( + `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, + body, + ); + +export const bizMigrationPatchBatch = (batchId: string, body: Record) => + apiPatch>( + `/v1/biz-migration/batches/${encodeURIComponent(batchId)}`, + body, + ); + +export const bizMigrationEvaluate = (batchId: string, body: Record = {}) => + apiPost>( + `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/evaluate`, + body, + ); + +export const bizMigrationListRuns = (batchId: string, limit = 20) => + apiGet<{ items?: Array> }>( + `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs?limit=${limit}`, + ); + +export const bizMigrationGetBoard = (batchId: string, runId = "") => { + const q = runId ? `?run_id=${encodeURIComponent(runId)}` : ""; + return apiGet>( + `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board${q}`, + ); +}; + +export const bizMigrationListDiffs = (params: { + runId: string; + metricId?: string; + sheetId?: string; + onlyExpect?: boolean; + verdict?: string; + color?: string; + kw?: string; + offset?: number; + limit?: number; +}) => { + const p = new URLSearchParams(); + if (params.metricId) p.set("metric_id", params.metricId); + if (params.sheetId) p.set("sheet_id", params.sheetId); + if (params.onlyExpect) p.set("only_expect", "true"); + if (params.verdict) p.set("verdict", params.verdict); + if (params.color) p.set("color", params.color); + if (params.kw) p.set("kw", params.kw); + if (params.offset != null) p.set("offset", String(params.offset)); + if (params.limit != null) p.set("limit", String(params.limit)); + const qs = p.toString(); + return apiGet<{ items: Record[]; total: number }>( + `/v1/biz-migration/runs/${encodeURIComponent(params.runId)}/diffs${qs ? `?${qs}` : ""}`, + ); +}; diff --git a/web/src/services/api/bizMonitor.ts b/web/src/services/api/bizMonitor.ts new file mode 100644 index 0000000..81bee4f --- /dev/null +++ b/web/src/services/api/bizMonitor.ts @@ -0,0 +1,18 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./http"; + +export const bizMonitorListTemplates = () => + apiGet<{ items: Record[] }>("/v1/biz-migration/monitor-templates"); + +export const bizMonitorCreateTemplate = (body: Record) => + apiPost>("/v1/biz-migration/monitor-templates", body); + +export const bizMonitorUpdateTemplate = (templateId: string, body: Record) => + apiPatch>( + `/v1/biz-migration/monitor-templates/${encodeURIComponent(templateId)}`, + body, + ); + +export const bizMonitorDeleteTemplate = (templateId: string) => + apiDelete<{ ok: boolean }>( + `/v1/biz-migration/monitor-templates/${encodeURIComponent(templateId)}`, + ); diff --git a/web/src/services/api/bizState.ts b/web/src/services/api/bizState.ts new file mode 100644 index 0000000..6a5bb09 --- /dev/null +++ b/web/src/services/api/bizState.ts @@ -0,0 +1,322 @@ +import { ApiRequestError, apiDelete, apiGet, apiPatch, apiPost, apiPut, authHeaders, fetchCreds, handleUnauthorized, parseApiResponse } from "./http"; + +/* ---- biz_state (business state monitoring) ---- */ + +export const bizStateListProfiles = (params?: { + vendor?: string; + device_type?: string; + kind?: string; +}) => { + const q = new URLSearchParams(); + if (params?.vendor) q.set("vendor", params.vendor); + if (params?.device_type) q.set("device_type", params.device_type); + if (params?.kind) q.set("kind", params.kind); + const qs = q.toString(); + return apiGet<{ items: Record[] }>( + `/v1/biz-state/profiles${qs ? `?${qs}` : ""}`, + ); +}; + +export const bizStateDiscover = (body: Record) => + apiPost<{ + ok: boolean; + error?: string; + command?: string; + cache_hit?: boolean; + pair_mode?: boolean; + discover_profile_id?: string; + candidates?: Array<{ + value: string; + label: string; + rd?: string; + protocols?: string; + bindings?: Record; + }>; + raw_preview?: string; + }>("/v1/biz-state/discover", body); + +export const bizStateSetBindings = ( + taskId: string, + itemId: string, + bindings: Array<{ placeholder: string; value: string }>, +) => + apiPut>( + `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/items/${encodeURIComponent(itemId)}/bindings`, + { bindings }, + ); + +export const bizStateListTasks = (purpose = "") => { + const q = purpose ? `?purpose=${encodeURIComponent(purpose)}` : ""; + return apiGet<{ items: Record[] }>(`/v1/biz-state/tasks${q}`); +}; + +export const bizStateCreateTask = (body: Record) => + apiPost>("/v1/biz-state/tasks", body); + +export const bizStateGetTask = (taskId: string) => + apiGet>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`); + +export const bizStatePatchTask = (taskId: string, body: Record) => + apiPatch>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`, body); + +export const bizStatePauseTask = (taskId: string) => + apiPost>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/pause`, {}); + +export const bizStateStartTask = (taskId: string) => + apiPost>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/start`, {}); + +export const bizStateDeleteTask = (taskId: string) => + apiDelete<{ ok: boolean }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`); + +export const bizStateCollectNow = (taskId: string) => + apiPost<{ + ok: boolean; + started?: boolean; + queued?: boolean; + reason?: string; + batch_id?: string; + collect_running?: boolean; + }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect`, {}); + +export const bizStateCollectStop = (taskId: string) => + apiPost<{ + ok: boolean; + stopped?: boolean; + reason?: string; + cancelled_batches?: string[]; + signaled_batches?: string[]; + }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect/stop`, {}); + +type BizStateImportResult = { + ok: boolean; + started?: boolean; + queued?: boolean; + reason?: string; + batch_id?: string; + task_id?: string; + vendor_key?: string; + filename?: string; + ne_name?: string; + created_task?: boolean; + segments_preview?: Record; + collect_running?: boolean; +}; + +export const bizStateImportLog = async ( + taskId: string, + file: File, + opts?: { vendor_key?: string }, +): Promise => { + const form = new FormData(); + form.append("file", file); + if (opts?.vendor_key?.trim()) form.append("vendor_key", opts.vendor_key.trim()); + const path = `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/import`; + const res = await fetch(path, { + method: "POST", + headers: authHeaders(), + body: form, + credentials: fetchCreds, + }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("unauthorized"); + } + const data = await parseApiResponse(res); + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} import`); + return data as BizStateImportResult; +}; + +/** Standalone offline import: creates source=import task (no inventory NE). */ +export const bizStateImportLogStandalone = async ( + file: File, + opts?: { vendor_key?: string; ne_name?: string; note?: string }, +): Promise => { + const form = new FormData(); + form.append("file", file); + form.append("vendor_key", (opts?.vendor_key || "zte").trim() || "zte"); + if (opts?.ne_name?.trim()) form.append("ne_name", opts.ne_name.trim()); + if (opts?.note?.trim()) form.append("note", opts.note.trim()); + const path = "/v1/biz-state/import"; + const res = await fetch(path, { + method: "POST", + headers: authHeaders(), + body: form, + credentials: fetchCreds, + }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("unauthorized"); + } + const data = await parseApiResponse(res); + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} import`); + return data as BizStateImportResult; +}; + +export const bizStateListBatches = (taskId: string, limit = 50) => + apiGet<{ items: Record[] }>( + `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches?limit=${limit}`, + ); + +export const bizStateSetBatchBaseline = (batchId: string, marked: boolean) => + apiPost>( + `/v1/biz-state/batches/${encodeURIComponent(batchId)}/baseline`, + { marked }, + ); + +export const bizStateSetBatchAlias = (batchId: string, alias: string) => + apiPatch>( + `/v1/biz-state/batches/${encodeURIComponent(batchId)}/alias`, + { alias }, + ); + +export const bizStateDeleteBatch = (batchId: string) => + apiDelete<{ ok: boolean }>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`); + +export const bizStateBulkDeleteBatches = (batchIds: string[]) => + apiPost<{ + ok: boolean; + deleted: string[]; + skipped: { batch_id: string; reasons: string[] }[]; + deleted_count: number; + }>("/v1/biz-state/batches/bulk-delete", { batch_ids: batchIds }); + +export const bizStatePurgeTask = (taskId: string) => + apiPost>( + `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/purge`, + {}, + ); + +export const bizStateGetBatch = (batchId: string) => + apiGet>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`); + +export const bizStateGetTaskProgress = (taskId: string) => + apiGet>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/progress`); + +export const bizStateListBatchMetricRows = (params: { + batchId: string; + metricId: string; + page?: number; + pageSize?: number; + kw?: string; + column?: string; +}) => { + const p = new URLSearchParams(); + 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 || 50))))); + if (params.kw) p.set("kw", params.kw); + if (params.column) p.set("column", params.column); + return apiGet<{ + batch_id: string; + metric_id: string; + total: number; + page: number; + page_size: number; + pages: number; + columns: Array<{ key: string; header: string; role?: string; is_key?: boolean }>; + items: Record[]; + }>( + `/v1/biz-state/batches/${encodeURIComponent(params.batchId)}/metrics/${encodeURIComponent(params.metricId)}?${p.toString()}`, + ); +}; + +export const bizStateGetBatchCommand = (batchId: string, commandId: string) => + apiGet<{ + id: string; + batch_id: string; + raw_command: string; + metric_id: string; + parse_status: string; + row_count: number; + raw_line_count?: number; + declared_total?: number; + message: string; + raw_text: string; + collected_at?: string | null; + device?: { ne_id?: string; ne_name?: string; ne_ip?: string }; + }>( + `/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, + ); + +/** Download one command's raw CLI output as .txt */ +export const bizStateDownloadCommandRaw = async (batchId: string, commandId: string): Promise => { + const path = + `/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}/raw.txt`; + const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("unauthorized"); + } + if (!res.ok) throw new Error(`${res.status} download raw`); + const blob = await res.blob(); + const cd = res.headers.get("Content-Disposition") || ""; + const m = /filename="?([^";]+)"?/i.exec(cd); + const filename = m?.[1] || `biz_state_${commandId}.txt`; + const url = URL.createObjectURL(blob); + try { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + } finally { + URL.revokeObjectURL(url); + } +}; + +export const bizStateDownloadExport = async (batchId: string): Promise => { + const path = `/v1/biz-state/batches/${encodeURIComponent(batchId)}/export`; + const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("unauthorized"); + } + if (!res.ok) throw new Error(`${res.status} export`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + try { + const a = document.createElement("a"); + a.href = url; + a.download = `biz_state_${batchId}.zip`; + a.click(); + } finally { + URL.revokeObjectURL(url); + } +}; + +export const bizStateDownloadTaskCommands = async ( + taskId: string, + opts?: { includeAux?: boolean; enabledOnly?: boolean }, +): Promise => { + const q = new URLSearchParams(); + // Default includes aux (matches live collect); pass includeAux:false to omit. + q.set("include_aux", opts?.includeAux === false ? "false" : "true"); + if (opts?.enabledOnly === false) q.set("enabled_only", "false"); + const qs = q.toString(); + const path = + `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/commands/export` + + (qs ? `?${qs}` : ""); + const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("unauthorized"); + } + if (!res.ok) { + let detail = `${res.status} export commands`; + try { + const j = (await res.json()) as { detail?: unknown }; + if (j?.detail != null) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); + } catch { + /* ignore */ + } + throw new Error(detail); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + try { + const a = document.createElement("a"); + a.href = url; + a.download = `biz_state_commands_${taskId}.txt`; + a.click(); + } finally { + URL.revokeObjectURL(url); + } +}; diff --git a/web/src/services/api/cli.ts b/web/src/services/api/cli.ts new file mode 100644 index 0000000..5bdcab5 --- /dev/null +++ b/web/src/services/api/cli.ts @@ -0,0 +1,53 @@ +import type { + CliConnectProfileItem, + CliMeta, + CliTargetListResponse, + UmeAlarmItem, + UmeCliOverrideItem, +} from "../../types"; +import { apiGet, apiPost } from "./http"; + +export const fetchCliMeta = () => apiGet("/v1/cli/meta"); + +export const fetchCliProfiles = () => + apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles"); + +export const fetchCliTargets = (params: { + source?: "managed" | "ume" | "webcrt" | "all"; + keyword?: string; + page?: number; + pageSize?: number; +}) => { + const p = new URLSearchParams(); + p.set("source", params.source || "all"); + if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); + 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(`/v1/cli/targets?${p.toString()}`); +}; + +export const postUmeConnectTest = (umeNeIds: string[]) => + apiPost<{ ok: boolean; submitted: number }>("/v1/cli/ume-connect-test", { ume_ne_ids: umeNeIds }); + +export const fetchUmeCliOverride = (umeNeId: string) => + apiGet(`/v1/cli/ume-overrides/${encodeURIComponent(umeNeId)}`); + +export const fetchUmeCurrentAlarms = (params: { + severity: string; + isCleared: string; + hostName: string; + keyword: string; + page: number; + pageSize: number; +}) => { + const p = new URLSearchParams(); + if (params.severity) p.set("severity", params.severity); + if (params.isCleared) p.set("is_cleared", params.isCleared); + if (params.hostName) p.set("host_name", params.hostName); + if (params.keyword) p.set("keyword", params.keyword); + 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[] }>( + `/v1/ume/alarms?${p.toString()}`, + ); +}; diff --git a/web/src/services/api/collection.ts b/web/src/services/api/collection.ts new file mode 100644 index 0000000..1b0712a --- /dev/null +++ b/web/src/services/api/collection.ts @@ -0,0 +1,79 @@ +import type { + CollectionDashboard, + CollectionJobDetail, + CollectionJobItem, + CollectionPolicy, + CollectionRunList, +} from "../../types"; +import { apiDelete, apiGet, apiPost, apiPut } from "./http"; + +export const createNeCollection = (body: { + title?: string; + commands: string; + ne_ids?: string[]; + ume_ne_ids?: string[]; +}) => + apiPost("/v1/ne-collections", body); + +export const fetchNeCollections = (params: { + page: number; + pageSize: number; + status?: string; + keyword?: string; +}) => { + const p = new URLSearchParams(); + p.set("page", String(Math.max(1, params.page))); + p.set("page_size", String(Math.max(1, Math.min(100, params.pageSize)))); + if (params.status?.trim()) p.set("status", params.status.trim()); + if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); + return apiGet<{ total: number; page: number; page_size: number; items: CollectionJobItem[] }>( + `/v1/ne-collections?${p.toString()}`, + ); +}; + +export const fetchCollectionDashboard = () => + apiGet("/v1/ne-collections/dashboard"); + +export const fetchCollectionPolicy = () => apiGet("/v1/ne-collections/policy"); + +export const updateCollectionPolicy = (body: Partial) => + apiPut("/v1/ne-collections/policy", body); + +export const startCollectionFromPolicy = () => + apiPost("/v1/ne-collections/start-from-policy", {}); + +export const fetchCollectionJob = (jobId: string) => apiGet(`/v1/ne-collections/${jobId}`); + +export const fetchCollectionRuns = (params: { + jobId: string; + page: number; + pageSize: number; + status?: string; + keyword?: string; +}) => { + const p = new URLSearchParams(); + p.set("page", String(Math.max(1, params.page))); + p.set("page_size", String(Math.max(1, Math.min(200, params.pageSize)))); + if (params.status) p.set("status", params.status); + if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); + return apiGet(`/v1/ne-collections/${params.jobId}/runs?${p.toString()}`); +}; + +export const pauseCollectionJob = (jobId: string) => + apiPost(`/v1/ne-collections/${jobId}/pause`, {}); + +export const startCollectionJob = (jobId: string) => + apiPost(`/v1/ne-collections/${jobId}/start`, {}); + +export const restartCollectionJob = (jobId: string) => + apiPost(`/v1/ne-collections/${jobId}/restart`, {}); + +export const retryFailedCollectionJob = (jobId: string) => + apiPost(`/v1/ne-collections/${jobId}/retry-failed`, {}); + +export const deleteCollectionJob = (jobId: string) => + apiDelete<{ ok: boolean }>(`/v1/ne-collections/${jobId}`); + +export const collectionRunDownloadUrl = (runId: string) => `/v1/ne-collections/runs/${runId}/download`; + +export const collectionJobDownloadUrl = (jobId: string) => `/v1/ne-collections/${jobId}/download`; diff --git a/web/src/services/api/configSync.ts b/web/src/services/api/configSync.ts new file mode 100644 index 0000000..a44ffa5 --- /dev/null +++ b/web/src/services/api/configSync.ts @@ -0,0 +1,123 @@ +import type { + ConfigSyncCycle, + ConfigSyncDashboard, + ConfigSyncPolicy, + ConfigSyncTask, + NeConfigSnapshotDetail, + NeConfigSnapshotMeta, +} from "../../types"; +import { apiGet, apiPost, apiPut, authHeaders, fetchCreds, handleUnauthorized } from "./http"; + +export const fetchConfigSyncDashboard = () => + apiGet("/v1/config-sync/dashboard"); + +export const fetchConfigSyncPolicy = () => apiGet("/v1/config-sync/policy"); + +export const updateConfigSyncPolicy = (body: Partial) => + apiPut("/v1/config-sync/policy", body); + +export const fetchConfigSyncCycles = (params: { + page?: number; + pageSize?: number; + status?: string; + keyword?: string; +}) => { + const p = new URLSearchParams(); + 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))))); + if (params.status?.trim()) p.set("status", params.status.trim()); + if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); + return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncCycle[] }>( + `/v1/config-sync/cycles?${p.toString()}`, + ); +}; + +export const createConfigSyncCycle = (body: { mode: "full" | "retry_failed"; cycle_id?: string }) => + apiPost("/v1/config-sync/cycles", body); + +export const pauseConfigSyncCycle = (cycleId: string) => + apiPost(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/pause`, {}); + +export const resumeConfigSyncCycle = (cycleId: string) => + apiPost(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/resume`, {}); + +export const stopConfigSyncCycle = (cycleId: string) => + apiPost(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/stop`, {}); + +export const fetchConfigSyncCycleTasks = (params: { + cycleId: string; + page?: number; + pageSize?: number; + status?: string; + keyword?: string; +}) => { + const p = new URLSearchParams(); + 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 || 20))))); + if (params.status) p.set("status", params.status); + if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); + return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncTask[] }>( + `/v1/config-sync/cycles/${encodeURIComponent(params.cycleId)}/tasks?${p.toString()}`, + ); +}; + +export const fetchNeConfigSnapshots = (params: { + page?: number; + pageSize?: number; + keyword?: string; + source?: string; + vendor?: string; +}) => { + const p = new URLSearchParams(); + 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))))); + if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); + if (params.source) p.set("source", params.source); + if (params.vendor?.trim()) p.set("vendor", params.vendor.trim()); + return apiGet<{ total: number; page: number; page_size: number; items: NeConfigSnapshotMeta[] }>( + `/v1/config-sync/snapshots?${p.toString()}`, + ); +}; + +export const fetchNeConfigSnapshotDetail = ( + source: string, + targetId: string, + field: "primary" | "alt" | "both" = "both", +) => + apiGet( + `/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}?field=${field}`, + ); + +export const downloadNeConfigSnapshot = async ( + source: string, + targetId: string, + field: "primary" | "alt" | "both" = "primary", +): Promise => { + const path = + `/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}` + + `/download?field=${encodeURIComponent(field)}`; + const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("unauthorized"); + } + if (!res.ok) throw new Error(`${res.status} download`); + const blob = await res.blob(); + const cd = res.headers.get("content-disposition") || ""; + const star = /filename\*=UTF-8''([^;]+)/i.exec(cd); + const plain = /filename="?([^";]+)"?/i.exec(cd); + const filename = star + ? decodeURIComponent(star[1]) + : plain + ? plain[1] + : `ne-config-${source}-${targetId}.txt`; + const url = URL.createObjectURL(blob); + try { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + } finally { + URL.revokeObjectURL(url); + } +}; diff --git a/web/src/services/api/http.ts b/web/src/services/api/http.ts new file mode 100644 index 0000000..e98f722 --- /dev/null +++ b/web/src/services/api/http.ts @@ -0,0 +1,223 @@ +export const AUTH_TOKEN_KEY = "netx_access_token"; +export const AUTH_REFRESH_KEY = "netx_refresh_token"; + +/** Clear legacy localStorage tokens; browser auth now uses HttpOnly cookies. */ +export const clearAuthToken = (): void => { + try { + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_REFRESH_KEY); + } catch { + // ignore + } +}; + +/** @deprecated Cookie session — always null for browser UI. */ +export const getAuthToken = (): string | null => null; + +/** No-op kept for call-site compatibility during cookie migration. */ +export const setAuthToken = (_token: string): void => { + clearAuthToken(); +}; + +export const setAuthTokens = (_access: string, _refresh?: string | null): void => { + clearAuthToken(); +}; + +export const fetchCreds: RequestCredentials = "include"; + +export const authHeaders = (extra?: Record): Record => { + // Browser JWT rides HttpOnly cookies (credentials: include). + // Authorization Bearer is only needed for non-browser API tokens if ever injected. + return { accept: "application/json", ...(extra || {}) }; +}; + +export const handleUnauthorized = (path: string): void => { + if (path.startsWith("/v1/auth/login") || path.startsWith("/v1/auth/refresh")) return; + clearAuthToken(); + if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) { + const next = `${window.location.pathname}${window.location.search || ""}`; + window.location.assign(`/login?next=${encodeURIComponent(next)}`); + } +}; + +let refreshInFlight: Promise | null = null; + +const tryRefreshAccessToken = async (): Promise => { + if (!refreshInFlight) { + refreshInFlight = (async () => { + try { + // Refresh token comes from HttpOnly cookie when body is empty. + const res = await fetch("/v1/auth/refresh", { + method: "POST", + credentials: fetchCreds, + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify({}), + }); + if (!res.ok) { + clearAuthToken(); + return false; + } + return true; + } catch { + clearAuthToken(); + return false; + } finally { + refreshInFlight = null; + } + })(); + } + return refreshInFlight; +}; + +const shouldAttemptRefresh = (path: string): boolean => + !path.startsWith("/v1/auth/login") && !path.startsWith("/v1/auth/refresh"); + +export const parseApiResponse = async (res: Response): Promise> => { + const text = await res.text(); + if (!text) return {}; + try { + return JSON.parse(text) as Record; + } catch { + const snippet = text.replace(/\s+/g, " ").trim().slice(0, 160); + throw new Error(res.ok ? "invalid_json_response" : `${res.status} ${snippet || res.statusText}`); + } +}; + +/** Preserve FastAPI structured ``detail`` objects (e.g. connect_failed + ne). */ +export class ApiRequestError extends Error { + status: number; + detail: unknown; + + constructor(status: number, detail: unknown) { + super(formatApiDetail(detail) || `${status}`); + this.name = "ApiRequestError"; + this.status = status; + this.detail = detail; + } +} + +/** User-facing message from API or thrown errors (prefers ApiRequestError.message). */ +export function formatErr(err: unknown): string { + if (err instanceof ApiRequestError) return err.message; + if (err instanceof Error) return err.message; + return String(err); +} + +export function formatApiDetail(detail: unknown): string { + if (typeof detail === "string") return detail; + if (Array.isArray(detail)) { + return detail + .map((item) => { + if (typeof item === "string") return item; + if (item && typeof item === "object" && "msg" in item) { + return String((item as { msg?: unknown }).msg || ""); + } + return ""; + }) + .filter(Boolean) + .join("; "); + } + if (detail && typeof detail === "object") { + const obj = detail as Record; + if (typeof obj.message === "string" && obj.message.trim()) return obj.message; + if (typeof obj.error === "string" && obj.error.trim()) return obj.error; + try { + return JSON.stringify(detail); + } catch { + /* fall through */ + } + } + return detail == null ? "" : String(detail); +} + +export const apiGet = async (path: string): Promise => { + const doFetch = () => fetch(path, { headers: authHeaders(), credentials: fetchCreds }); + let res = await doFetch(); + if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { + res = await doFetch(); + } + const data = await parseApiResponse(res); + if (res.status === 401) { + handleUnauthorized(path); + throw new ApiRequestError(401, data.detail || "unauthorized"); + } + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); + return data as T; +}; + +export const apiPost = async (path: string, body: unknown): Promise => { + const doFetch = () => + fetch(path, { + method: "POST", + credentials: fetchCreds, + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify(body), + }); + let res = await doFetch(); + if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { + res = await doFetch(); + } + const data = await parseApiResponse(res); + if (res.status === 401) { + handleUnauthorized(path); + throw new ApiRequestError(401, data.detail || "unauthorized"); + } + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); + return data as T; +}; + +export const apiPatch = async (path: string, body: unknown): Promise => { + const doFetch = () => + fetch(path, { + method: "PATCH", + credentials: fetchCreds, + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify(body), + }); + let res = await doFetch(); + if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { + res = await doFetch(); + } + const data = await parseApiResponse(res); + if (res.status === 401) { + handleUnauthorized(path); + throw new ApiRequestError(401, data.detail || "unauthorized"); + } + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); + return data as T; +}; + +export const apiDelete = async (path: string): Promise => { + let res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds }); + if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { + res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds }); + } + const data = await parseApiResponse(res); + if (res.status === 401) { + handleUnauthorized(path); + throw new ApiRequestError(401, data.detail || "unauthorized"); + } + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); + return data as T; +}; + +export const apiPut = async (path: string, body: unknown): Promise => { + const doFetch = () => + fetch(path, { + method: "PUT", + credentials: fetchCreds, + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify(body), + }); + let res = await doFetch(); + if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { + res = await doFetch(); + } + const data = await parseApiResponse(res); + if (res.status === 401) { + handleUnauthorized(path); + throw new ApiRequestError(401, data.detail || "unauthorized"); + } + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); + return data as T; +}; diff --git a/web/src/services/api/index.ts b/web/src/services/api/index.ts new file mode 100644 index 0000000..c19df32 --- /dev/null +++ b/web/src/services/api/index.ts @@ -0,0 +1,15 @@ +// Barrel: keep `import { … } from "…/services/api"` working. Add new endpoints to the matching domain file. +export * from "./http"; +export * from "./system"; +export * from "./ume"; +export * from "./managedNe"; +export * from "./collection"; +export * from "./webcrt"; +export * from "./cli"; +export * from "./topology"; +export * from "./configSync"; +export * from "./portTraffic"; +export * from "./bizState"; +export * from "./bizCompare"; +export * from "./bizMigration"; +export * from "./bizMonitor"; diff --git a/web/src/services/api/managedNe.ts b/web/src/services/api/managedNe.ts new file mode 100644 index 0000000..e949bf0 --- /dev/null +++ b/web/src/services/api/managedNe.ts @@ -0,0 +1,161 @@ +import type { + EligibleNeItem, + ManagedNeImportResult, + ManagedNeItem, + ManagedNeListResponse, + ManagedNeMeta, +} from "../../types"; +import { ApiRequestError, apiDelete, apiGet, apiPatch, apiPost, authHeaders, fetchCreds, handleUnauthorized, parseApiResponse } from "./http"; + +export const fetchManagedNeMeta = () => + Promise.all([ + apiGet("/v1/managed-ne/meta/device-types"), + apiGet<{ configured: boolean }>("/v1/managed-ne/meta/credentials-configured"), + ]).then(([types, creds]) => ({ + device_types: types.device_types, + vendors: types.vendors, + credentials_configured: creds.configured, + exec_policy_enabled: Boolean(types.exec_policy_enabled), + })); + +export type ManagedNeStats = { + total: number; + by_status: Record; + tags: string[]; + no_tag_count: number; + tag_counts: Record; + per_tag: Record< + string, + { + total: number; + by_status: Record; + } + >; +}; + +export const fetchManagedNeStats = () => apiGet("/v1/managed-ne/meta/stats"); + +export const fetchIdsByTag = (tag: string | null) => { + const url = tag ? `/v1/managed-ne/meta/ids-by-tag?tag=${encodeURIComponent(tag)}` : "/v1/managed-ne/meta/ids-by-tag"; + return apiGet<{ ids: string[] }>(url); +}; + +export const fetchManagedNe = (params: { + keyword: string; + vendor: string; + connectStatus: string; + page: number; + pageSize: number; +}) => { + const p = new URLSearchParams(); + if (params.keyword) p.set("keyword", params.keyword); + if (params.vendor) p.set("vendor", params.vendor); + if (params.connectStatus) p.set("connect_status", params.connectStatus); + p.set("page", String(Math.max(1, params.page))); + p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize)))); + return apiGet(`/v1/managed-ne?${p.toString()}`); +}; + +export const createManagedNe = (body: Record) => + apiPost("/v1/managed-ne", body); + +export const updateManagedNe = (id: string, body: Record) => + apiPatch(`/v1/managed-ne/${id}`, body); + +export const deleteManagedNe = (id: string) => apiDelete<{ ok: boolean }>(`/v1/managed-ne/${id}`); + +export const batchDeleteManagedNe = (ids: string[]) => + apiPost<{ ok: boolean; deleted: number }>("/v1/managed-ne/batch-delete", { ids }); + +export const connectTestManagedNe = (ids: string[]) => + apiPost<{ ok: boolean; submitted: number }>("/v1/managed-ne/connect-test", { ids }); + +export const batchApplyHopManagedNe = ( + ids: string[], + hop: { + hop_host: string; + hop_port: number; + hop_protocol: string; + hop_username: string; + hop_password: string; + hop_command_template: string; + hop_vrf: string; + hop_vendor?: string; + hop_target_auth_mode?: string; + hop_enter_system_view?: boolean; + }, +) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-hop", { ids, hop }); + +export const batchApplyAccountManagedNe = ( + ids: string[], + account: { + username?: string; + password?: string; + }, +) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-account", { ids, account }); + +export const syncUmeManagedNe = () => + apiPost<{ inserted: number; updated: number; deleted: number; total_inventory: number }>("/v1/managed-ne/ume-sync", {}); + +export const deleteUmeManagedNe = () => + apiDelete<{ deleted: number }>("/v1/managed-ne/ume-sync"); + +export const managedNeImportTemplateUrl = (format: "xlsx" | "csv" = "xlsx") => + `/v1/managed-ne/import/template?format=${format}`; + +export const downloadManagedNeImportTemplate = async (format: "xlsx" | "csv" = "xlsx"): Promise => { + const path = managedNeImportTemplateUrl(format); + const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("unauthorized"); + } + if (!res.ok) throw new Error(`${res.status} template`); + const blob = await res.blob(); + const cd = res.headers.get("content-disposition") || ""; + const matched = /filename\*?=(?:UTF-8''|")?([^\";]+)/i.exec(cd); + const filename = matched + ? decodeURIComponent(matched[1].replace(/"/g, "")) + : `managed_ne_import_template.${format}`; + const url = URL.createObjectURL(blob); + try { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + } finally { + URL.revokeObjectURL(url); + } +}; + +export const importManagedNe = async (file: File): Promise => { + const form = new FormData(); + form.append("file", file); + const path = "/v1/managed-ne/import"; + // Do not set content-type — browser must add multipart boundary. + const res = await fetch(path, { method: "POST", headers: authHeaders(), body: form, credentials: fetchCreds }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("unauthorized"); + } + const data = await parseApiResponse(res); + if (res.status === 401) { + handleUnauthorized(path); + throw new ApiRequestError(401, data.detail || "unauthorized"); + } + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} import`); + return data as ManagedNeImportResult; +}; + +export const fetchEligibleNe = (params: { page: number; pageSize: number; keyword?: string }) => { + const p = new URLSearchParams(); + p.set("page", String(Math.max(1, params.page))); + p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize)))); + if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); + return apiGet<{ total: number; page: number; page_size: number; items: EligibleNeItem[] }>( + `/v1/ne-collections/eligible-ne?${p.toString()}`, + ); +}; + +export const fetchManagedNeById = (neId: string) => + apiGet(`/v1/managed-ne/${encodeURIComponent(neId)}`); diff --git a/web/src/services/api/portTraffic.ts b/web/src/services/api/portTraffic.ts new file mode 100644 index 0000000..2497bab --- /dev/null +++ b/web/src/services/api/portTraffic.ts @@ -0,0 +1,168 @@ +import type { + PortTrafficBoard, + PortTrafficBoardPanelIn, + PortTrafficBoardSummary, + PortTrafficCompare, + PortTrafficDashboard, + PortTrafficDevice, + PortTrafficDiscoverResponse, + PortTrafficEvent, + PortTrafficIfaceIn, + PortTrafficSamples, + PortTrafficTarget, +} from "../../types"; +import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "./http"; + +export const fetchPortTrafficDashboard = () => + apiGet("/v1/port-traffic/dashboard"); + +export const fetchPortTrafficDevices = (params: { + page?: number; + pageSize?: number; + status?: string; + keyword?: string; +}) => { + const p = new URLSearchParams(); + 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))))); + if (params.status?.trim()) p.set("status", params.status.trim()); + if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); + return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficDevice[] }>( + `/v1/port-traffic/devices?${p.toString()}`, + ); +}; + +/** @deprecated */ +export const fetchPortTrafficTasks = fetchPortTrafficDevices; + +export const createPortTrafficDevice = (body: { + source: "managed" | "ume"; + ne_id: string; + ne_name?: string; + ne_ip?: string; + vendor?: string; + note?: string; + interval_sec?: number; + retention_days?: number; + concurrency?: number; + interfaces?: PortTrafficIfaceIn[]; + start_now?: boolean; +}) => apiPost("/v1/port-traffic/devices", body); + +export const updatePortTrafficDevice = ( + deviceId: string, + body: { + note?: string; + interval_sec?: number; + retention_days?: number; + concurrency?: number; + ne_name?: string; + ne_ip?: string; + vendor?: string; + }, +) => apiPatch(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`, body); + +export const startPortTrafficDevice = (deviceId: string) => + apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/start`, {}); + +export const pausePortTrafficDevice = (deviceId: string) => + apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/pause`, {}); + +export const stopPortTrafficDevice = (deviceId: string) => + apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/stop`, {}); + +export const collectPortTrafficNow = (deviceId: string) => + apiPost( + `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/collect-now`, + {}, + ); + +export const deletePortTrafficDevice = (deviceId: string) => + apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`); + +export const rebindPortTrafficDevice = (deviceId: string, body: { ne_id: string }) => + apiPost( + `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/rebind`, + body, + ); + +export const fetchPortTrafficTargets = (deviceId: string) => + apiGet<{ items: PortTrafficTarget[] }>( + `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/targets`, + ); + +export const fetchPortTrafficEvents = (deviceId: string, limit = 100) => + apiGet<{ items: PortTrafficEvent[]; total: number }>( + `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/events?limit=${limit}`, + ); + +export const putPortTrafficInterfaces = (deviceId: string, interfaces: PortTrafficIfaceIn[]) => + apiPut<{ items: PortTrafficTarget[] }>( + `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/interfaces`, + { interfaces }, + ); + +export const discoverPortTrafficPorts = (body: { source: "managed" | "ume"; id: string }) => + apiPost("/v1/port-traffic/discover/ports", body); + +export const fetchPortTrafficSamples = (params: { + targetId: string; + from?: string; + to?: string; +}) => { + const p = new URLSearchParams(); + p.set("target_id", params.targetId); + if (params.from) p.set("from", params.from); + if (params.to) p.set("to", params.to); + return apiGet(`/v1/port-traffic/samples?${p.toString()}`); +}; + +export const fetchPortTrafficCompare = (params: { + targetId: string; + rangeHours: number; + baseline: string; + offsetHours?: number; + aheadHours?: number; + baselineTargetId?: string; + to?: string; +}) => { + const p = new URLSearchParams(); + p.set("target_id", params.targetId); + p.set("range_hours", String(params.rangeHours)); + p.set("baseline", params.baseline); + if (params.offsetHours != null) p.set("offset_hours", String(params.offsetHours)); + if (params.aheadHours != null && params.aheadHours > 0) { + p.set("ahead_hours", String(params.aheadHours)); + } + if (params.baselineTargetId) p.set("baseline_target_id", params.baselineTargetId); + if (params.to) p.set("to", params.to); + return apiGet(`/v1/port-traffic/compare?${p.toString()}`); +}; + +export const fetchPortTrafficBoards = () => + apiGet<{ items: PortTrafficBoardSummary[] }>("/v1/port-traffic/boards"); + +export const fetchPortTrafficBoard = (boardId: string) => + apiGet(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`); + +export const createPortTrafficBoard = (body: { + name: string; + remark?: string; + cols?: number; + panels?: PortTrafficBoardPanelIn[]; +}) => apiPost("/v1/port-traffic/boards", body); + +export const updatePortTrafficBoard = ( + boardId: string, + body: { name?: string; remark?: string; cols?: number }, +) => apiPatch(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`, body); + +export const putPortTrafficBoardPanels = (boardId: string, panels: PortTrafficBoardPanelIn[]) => + apiPut(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}/panels`, { + panels, + }); + +export const deletePortTrafficBoard = (boardId: string) => + apiDelete<{ ok: boolean; id: string }>( + `/v1/port-traffic/boards/${encodeURIComponent(boardId)}`, + ); diff --git a/web/src/services/api/system.ts b/web/src/services/api/system.ts new file mode 100644 index 0000000..508fcf9 --- /dev/null +++ b/web/src/services/api/system.ts @@ -0,0 +1,115 @@ +import type { + IntegrationStatus, +} from "../../types"; +import { apiGet } from "./http"; + +export const fetchIntegrationStatus = () => apiGet("/v1/integrations/status"); + +export type OpsTaskItem = { + kind: string; + id: string; + title: string; + status: string; + trigger: string; + actor: string; + started_at: string | null; + updated_at: string | null; + progress: string; + inflight?: number; + detail: string; + href: string; +}; + +export type OpsTasksResponse = { + generated_at: string; + total: number; + active: number; + by_kind: Record; + by_status: Record; + items: OpsTaskItem[]; +}; + +export const fetchOpsTasks = () => apiGet("/v1/ops/tasks"); + +export type RuntimeMetrics = { + uptime_sec?: number; + pid?: number; + thread_count?: number; + host?: { + source?: string; + platform?: string; + cpu_percent?: number; + mem_percent?: number; + mem_used_bytes?: number; + mem_total_bytes?: number; + mem_available_bytes?: number; + error?: string; + }; + db_pool?: { + checked_out?: number; + checked_in?: number; + overflow?: number; + size?: number; + }; + db_storage?: { + db_name?: string; + used_bytes?: number; + source?: string; + error?: string; + }; + cli_budget?: { + limit?: number; + in_use?: number; + available?: number; + }; + webcrt?: { + active_sessions?: number; + max_sessions?: number; + }; + config_sync?: { running?: boolean }; + lldp_collect?: { running?: boolean }; + port_traffic?: { running?: boolean }; + device_schedulers?: { + stale?: boolean; + mode?: string; + source?: string; + age_sec?: number | null; + hint?: string | null; + config_sync?: { running?: boolean; last_tick_age_sec?: number | null }; + lldp_collect?: { running?: boolean; last_tick_age_sec?: number | null }; + ne_collect?: { running?: boolean; last_tick_age_sec?: number | null }; + port_traffic?: { running?: boolean; last_tick_age_sec?: number | null }; + fabric_reconcile?: { running?: boolean; last_run_age_sec?: number | null }; + }; + [key: string]: unknown; +}; + +export const fetchRuntimeMetrics = () => apiGet("/v1/metrics/json"); + +export type AuditLogItem = { + id: string; + ts: string | null; + actor_username: string; + action: string; + method: string; + path: string; + status_code: number; + client_ip: string; + detail: Record; +}; + +export const fetchAuditLogs = (params?: { + page?: number; + pageSize?: number; + username?: string; + action?: string; +}) => { + const p = new URLSearchParams(); + 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))))); + if (params?.username?.trim()) p.set("username", params.username.trim()); + if (params?.action?.trim()) p.set("action", params.action.trim()); + return apiGet<{ total: number; page: number; page_size: number; items: AuditLogItem[] }>( + `/v1/audit-logs?${p.toString()}`, + ); +}; diff --git a/web/src/services/api/topology.ts b/web/src/services/api/topology.ts new file mode 100644 index 0000000..2a74192 --- /dev/null +++ b/web/src/services/api/topology.ts @@ -0,0 +1,409 @@ +import type { + FabricEdge, + FabricSummary, + LldpCollectDashboard, + LldpCollectJobSummary, + LldpCollectPolicy, + TopologyDiscoverJob, + TopologyOutsidePeer, + TopologyTree, + TopologyViewGraph, + TopologyViewItem, +} from "../../types"; +import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "./http"; + +export const fetchTopologyViews = () => + apiGet<{ total: number; items: TopologyViewItem[] }>("/v1/topology/views"); + +export const fetchTopologyTree = () => apiGet("/v1/topology/tree"); + +export const createTopologyFolder = (body: { + name: string; + kind?: string; + parent_id?: string; + sort_order?: number; + /** UI locale so auto-spawned root map is「根图」or "Root map". */ + locale?: string; +}) => + apiPost<{ + id: string; + parent_id: string; + kind: string; + name: string; + sort_order: number; + is_system: boolean; + }>("/v1/topology/folders", body); + +export const updateTopologyFolder = ( + folderId: string, + body: { name?: string; parent_id?: string; sort_order?: number }, +) => + apiPatch<{ + id: string; + parent_id: string; + kind: string; + name: string; + sort_order: number; + is_system: boolean; + }>(`/v1/topology/folders/${encodeURIComponent(folderId)}`, body); + +export const deleteTopologyFolder = (folderId: string, force = false) => + apiDelete<{ ok: boolean; folder_id: string; deleted: boolean }>( + `/v1/topology/folders/${encodeURIComponent(folderId)}?force=${force ? "true" : "false"}`, + ); + +export const createTopologyView = (body: { + name: string; + remark?: string; + filter?: Record; + folder_id?: string; + kind?: string; + role?: string; + sort_order?: number; +}) => apiPost("/v1/topology/views", body); + +export const populateTopologyView = ( + viewId: string, + body?: { dry_run?: boolean; membership?: Record; freeze_after?: boolean }, +) => + apiPost<{ + view_id: string; + dry_run: boolean; + candidate_count: number; + would_add: number; + added: number; + max_nodes: number; + truncated: boolean; + outside_peers: TopologyOutsidePeer[]; + graph: TopologyViewGraph | null; + }>(`/v1/topology/views/${encodeURIComponent(viewId)}/populate`, body || {}); + +export const fetchTopologyViewGraph = ( + viewId: string, + opts?: { + min_x?: number; + max_x?: number; + min_y?: number; + max_y?: number; + sbn_id?: string; + folder_id?: string; + lod?: "auto" | "sbn" | "me" | "overview" | "detail"; + status?: string; + }, +) => { + const q = new URLSearchParams(); + if (opts?.min_x != null) q.set("min_x", String(opts.min_x)); + if (opts?.max_x != null) q.set("max_x", String(opts.max_x)); + if (opts?.min_y != null) q.set("min_y", String(opts.min_y)); + if (opts?.max_y != null) q.set("max_y", String(opts.max_y)); + if (opts?.sbn_id) q.set("sbn_id", opts.sbn_id); + if (opts?.folder_id) q.set("folder_id", opts.folder_id); + if (opts?.lod) q.set("lod", opts.lod); + if (opts?.status) q.set("status", opts.status); + const qs = q.toString(); + return apiGet( + `/v1/topology/views/${encodeURIComponent(viewId)}${qs ? `?${qs}` : ""}`, + ); +}; + +export const fetchTopologyWorld = () => + apiGet<{ view_id: string; folder_id: string; name: string }>("/v1/topology/world"); + +export const fetchTopologyFolderBBox = (folderId: string) => + apiGet<{ folder_id: string; min_x: number; max_x: number; min_y: number; max_y: number; n: number }>( + `/v1/topology/folders/${encodeURIComponent(folderId)}/bbox`, + ); + +export const updateTopologyView = ( + viewId: string, + body: { + name?: string; + remark?: string; + filter?: Record; + viewport?: Record; + folder_id?: string; + role?: string; + sort_order?: number; + }, +) => apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}`, body); + +export const deleteTopologyView = (viewId: string, force = false) => + apiDelete<{ ok: boolean; view_id: string; deleted: boolean }>( + `/v1/topology/views/${encodeURIComponent(viewId)}?force=${force ? "true" : "false"}`, + ); + +export const patchTopologyPositions = ( + viewId: string, + positions: Array<{ + fabric_node_id: string; + x?: number; + y?: number; + label?: string; + locked?: boolean; + }>, +) => + apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, { + positions, + }); + +export const addTopologyViewNodes = ( + viewId: string, + body: { + managed_ne_ids?: string[]; + ume_ne_ids?: string[]; + fabric_node_ids?: string[]; + layout?: string; + }, +) => apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body); + +export const createTopologyPlaceholder = ( + viewId: string, + body: { name: string; ip_address?: string; x?: number; y?: number }, +) => + apiPost( + `/v1/topology/views/${encodeURIComponent(viewId)}/nodes/create-placeholder`, + body, + ); + +export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) => + apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, { + fabric_node_ids: fabricNodeIds, + }); + +export const projectTopologyNeighbors = ( + viewId: string, + body?: { seed_fabric_node_ids?: string[]; managed_ne_ids?: string[]; dry_run?: boolean }, +) => + apiPost( + `/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, + body || {}, + ); + +export const patchTopologyEdgeStyle = ( + viewId: string, + body: { fabric_edge_id: string; stroke_color?: string; stroke_width?: number; line_style?: string }, +) => + apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}/edge-style`, body); + +export const fetchFabricSummary = () => apiGet("/v1/topology/fabric/summary"); + +export const fetchFabricNodes = (params?: { + keyword?: string; + role?: string; + level?: string; + levelMajor?: string; + regionFolderId?: string; + unmatched?: string; + linkStatus?: string; + page?: number; + pageSize?: number; +}) => { + const p = new URLSearchParams(); + if (params?.keyword) p.set("keyword", params.keyword); + if (params?.role) p.set("role", params.role); + if (params?.level) p.set("level", params.level); + if (params?.levelMajor) p.set("level_major", params.levelMajor); + if (params?.regionFolderId) p.set("region_folder_id", params.regionFolderId); + if (params?.unmatched) p.set("unmatched", params.unmatched); + if (params?.linkStatus) p.set("link_status", params.linkStatus); + 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: import("../../types").FabricNodeSearchHit[]; + }>(`/v1/topology/fabric/nodes?${p.toString()}`); +}; + +export const matchFabricNodes = (body: { + pattern: string; + match_field?: string; + sample_limit?: number; +}) => + apiPost<{ + pattern: string; + match_field: string; + total_matched: number; + samples: Array>; + fabric_node_ids: string[]; + }>("/v1/topology/fabric/nodes/match", body); + +export const bulkTagFabricNodes = (body: { + fabric_node_ids?: string[]; + pattern?: string; + match_field?: string; + level?: number | null; + role?: string | null; + region_folder_id?: string | null; + dry_run?: boolean; +}) => + apiPost<{ + dry_run: boolean; + matched: number; + updated: number; + level?: number | null; + role?: string | null; + region_folder_id?: string | null; + samples: Array>; + }>("/v1/topology/fabric/nodes/tags/bulk", body); + +export const patchFabricNodeTags = ( + fabricNodeId: string, + body: { level?: number | null; role?: string | null; region_folder_id?: string | null }, +) => + apiPatch( + `/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}/tags`, + body, + ); + +export const deleteFabricNode = (fabricNodeId: string) => + apiDelete<{ deleted: number; edges_deleted: number; placements_deleted: number }>( + `/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}`, + ); + +export const deleteFabricNodes = (fabricNodeIds: string[]) => + apiPost<{ deleted: number; edges_deleted: number; placements_deleted: number }>( + "/v1/topology/fabric/nodes/delete", + { fabric_node_ids: fabricNodeIds }, + ); + +/** Hard-delete topology/LLDP placeholders (ManagedNE + fabric + edges). */ +export const purgePlaceholderFabricNodes = (fabricNodeIds: string[]) => + apiPost<{ + deleted: number; + edges_deleted: number; + placements_deleted: number; + managed_deleted: number; + membership_views: number; + }>("/v1/topology/fabric/nodes/purge-placeholders", { + fabric_node_ids: fabricNodeIds, + }); + +export const searchFabricNodes = (params?: { q?: string; page?: number; pageSize?: number }) => { + const p = new URLSearchParams(); + if (params?.q) p.set("q", params.q); + 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 || 50))))); + return apiGet<{ + total: number; + page: number; + page_size: number; + items: import("../../types").FabricNodeSearchHit[]; + }>(`/v1/topology/fabric/nodes/search?${p.toString()}`); +}; + +export const fetchFabricEdges = (params?: { + keyword?: string; + status?: string; + source?: string; + nodeId?: string; + page?: number; + pageSize?: number; +}) => { + const p = new URLSearchParams(); + if (params?.keyword) p.set("keyword", params.keyword); + if (params?.status) p.set("status", params.status); + if (params?.source) p.set("source", params.source); + if (params?.nodeId) p.set("node_id", params.nodeId); + 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 || 20))))); + return apiGet<{ total: number; page: number; page_size: number; items: FabricEdge[] }>( + `/v1/topology/fabric/edges?${p.toString()}`, + ); +}; + +export const startLldpDiscover = (body?: { + scope?: "all_inventory" | "ne_ids"; + ne_ids?: string[]; + auto_add_unmatched?: boolean; + concurrency?: number; + trigger_mode?: "manual" | "schedule" | "topology"; +}) => apiPost("/v1/topology/fabric/discover", body || {}); + +export const fetchLldpDiscoverJob = ( + jobId: string, + params?: { page?: number; pageSize?: number }, +) => { + const p = new URLSearchParams(); + 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( + `/v1/topology/fabric/discover/${encodeURIComponent(jobId)}?${p.toString()}`, + ); +}; + +export const fetchLldpCollectDashboard = () => + apiGet("/v1/topology/lldp-collect/dashboard"); + +export const updateLldpCollectPolicy = (body: Partial) => + apiPut("/v1/topology/lldp-collect/policy", body); + +export const startLldpCollect = (body?: { mode?: "full" | "retry_failed"; job_id?: string }) => + apiPost<{ ok: boolean; job: TopologyDiscoverJob }>("/v1/topology/lldp-collect/start", { + mode: body?.mode || "full", + ...(body?.job_id ? { job_id: body.job_id } : {}), + }); + +export const pauseLldpCollectJob = (jobId: string) => + apiPost(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/pause`, {}); + +export const resumeLldpCollectJob = (jobId: string) => + apiPost(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/resume`, {}); + +export const stopLldpCollectJob = (jobId: string) => + apiPost(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/stop`, {}); + +export const fetchLldpCollectJobs = (params: { + page?: number; + pageSize?: number; + status?: string; + keyword?: string; +}) => { + const p = new URLSearchParams(); + 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))))); + if (params.status?.trim()) p.set("status", params.status.trim()); + if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); + return apiGet<{ total: number; page: number; page_size: number; items: LldpCollectJobSummary[] }>( + `/v1/topology/lldp-collect/jobs?${p.toString()}`, + ); +}; + +export const fetchLldpCollectJob = ( + jobId: string, + params?: { page?: number; pageSize?: number; status?: string; keyword?: string }, +) => { + const p = new URLSearchParams(); + 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))))); + if (params?.status?.trim()) p.set("status", params.status.trim()); + if (params?.keyword?.trim()) p.set("keyword", params.keyword.trim()); + return apiGet( + `/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}?${p.toString()}`, + ); +}; + +export const createFabricManualEdge = (body: { + a_node_id: string; + b_node_id: string; + a_port?: string; + b_port?: string; +}) => apiPost<{ ok: boolean; action: string; edge: Record }>("/v1/topology/fabric/edges", body); + +export const deleteFabricEdges = (edgeIds: string[]) => + apiPost<{ ok: boolean; deleted: number }>("/v1/topology/fabric/edges/delete", { + edge_ids: edgeIds, + }); + +export const deleteFabricEdge = (edgeId: string) => + apiDelete<{ ok: boolean; deleted: number }>( + `/v1/topology/fabric/edges/${encodeURIComponent(edgeId)}`, + ); + +/** Back-compat aliases used by older call sites during cutover. */ +export const fetchTopologyMaps = fetchTopologyViews; +export const createTopologyMap = createTopologyView; +export const fetchTopologyGraph = fetchTopologyViewGraph; +export const updateTopologyMap = updateTopologyView; +export const deleteTopologyMap = deleteTopologyView; diff --git a/web/src/services/api/ume.ts b/web/src/services/api/ume.ts new file mode 100644 index 0000000..ede561b --- /dev/null +++ b/web/src/services/api/ume.ts @@ -0,0 +1,110 @@ +import type { + UmeAlarmSubscriptionStatus, + UmeKeyAlertMonitorResponse, + UmeNeItem, + UmeSyncStatusResponse, + UmeTokenStatus, +} from "../../types"; +import { apiDelete, apiGet, apiPatch, apiPost } from "./http"; + +export const fetchUmeAlarmSubscriptionStatus = () => + apiGet("/v1/ume/alarm-subscription/status"); + +export const establishUmeAlarmSubscription = (opts?: { forceReestablish?: boolean }) => + apiPost("/v1/ume/alarm-subscription/establish", { + force_reestablish: Boolean(opts?.forceReestablish), + }); + +export const cancelUmeAlarmSubscription = (opts?: { forceClearLocal?: boolean }) => + apiPost("/v1/ume/alarm-subscription/cancel", { + force_clear_local: Boolean(opts?.forceClearLocal), + }); + +export const clearLocalUmeAlarmSubscription = () => + apiPost("/v1/ume/alarm-subscription/clear-local", {}); + +export const fetchUmeKeyAlertMonitor = (params?: { + page?: number; + pageSize?: number; + keyword?: string; + enabled?: "" | "true" | "false"; + matchType?: "" | "notification_id" | "keyword"; +}) => { + const p = new URLSearchParams(); + 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 || 50))))); + const kw = String(params?.keyword || "").trim(); + if (kw) p.set("keyword", kw); + const en = String(params?.enabled || "").trim(); + if (en) p.set("enabled", en); + const mt = String(params?.matchType || "").trim(); + if (mt) p.set("match_type", mt); + return apiGet(`/v1/ume/key-alert-monitor?${p.toString()}`); +}; + +export const upsertUmeKeyAlertRule = (payload: { + match_type: "notification_id" | "keyword"; + match_value: string; + label: string; + enabled?: boolean; + ne_types?: string[]; +}) => apiPost<{ ok: boolean; item?: unknown }>("/v1/ume/key-alert-rules", payload); + +export const updateUmeKeyAlertMonitorConfig = (payload: { forward_on_clear: boolean }) => + apiPatch<{ ok: boolean; config: { forward_on_clear: boolean } }>("/v1/ume/key-alert-monitor/config", payload); + +export const deleteUmeKeyAlertRule = (ruleKey: string) => + apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`); + +export const patchUmeKeyAlertRule = ( + ruleKey: string, + payload: { enabled?: boolean; ne_types?: string[] }, +) => + apiPatch<{ ok: boolean; item?: unknown }>( + `/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`, + payload, + ); + +export const fetchUmeNotificationIds = (limit = 200) => + apiGet<{ items: Array<{ notification_id: string; native_probable_cause_sample: string }> }>( + `/v1/ume/notification-ids?limit=${encodeURIComponent(String(limit))}`, + ); + +export const fetchUmeAlarmKeywords = (limit = 200) => + apiGet<{ items: Array<{ keyword: string; alarm_count: number }> }>( + `/v1/ume/alarm-keywords?limit=${encodeURIComponent(String(limit))}`, + ); + +export const fetchUmeInventoryNeTypes = (limit = 500) => + apiGet<{ items: Array<{ ne_type: string; ne_count: number }>; total: number }>( + `/v1/ume/inventory/ne-types?limit=${encodeURIComponent(String(limit))}`, + ); + +export const fetchUmeSyncStatus = (params: { page: number; pageSize: number }) => { + const p = new URLSearchParams(); + 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 || 20))))); + return apiGet(`/v1/ume/sync/status?${p.toString()}`); +}; + +/** Dock tables → Fabric + UME World (does not pull from UME). */ +export const applyUmeTopologyToFabric = () => + apiPost<{ + ok: boolean; + fabric_apply?: Record; + world?: Record; + }>("/v1/topology/world/apply-ume", {}); + +export const fetchUmeTokenStatus = () => apiGet("/v1/ume/token/status"); +export const refreshUmeToken = () => apiPost("/v1/ume/token/refresh", {}); +export const disconnectUmeToken = () => apiPost("/v1/ume/token/disconnect", {}); + +export const fetchUmeNe = (params: { keyword: string; page: number; pageSize: number }) => { + const p = new URLSearchParams(); + if (params.keyword) p.set("keyword", params.keyword); + 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: UmeNeItem[] }>( + `/v1/ume/inventory/ne?${p.toString()}`, + ); +}; diff --git a/web/src/services/api/webcrt.ts b/web/src/services/api/webcrt.ts new file mode 100644 index 0000000..e6f39e1 --- /dev/null +++ b/web/src/services/api/webcrt.ts @@ -0,0 +1,339 @@ +import type { + ManagedNeItem, +} from "../../types"; +import { apiDelete, apiPost, authHeaders, fetchCreds, handleUnauthorized } from "./http"; + +export type WebcrtSessionCreateResult = { + session_id: string; + ne_id: string; + ne_name: string; + ne_ip: string; + protocol: string; + cols: number; + rows: number; + ws_path: string; + encoding?: string; + state?: string; + /** True when session used a vendor CLI hop (nested stelnet/telnet). */ + cli_hop?: boolean; + /** True when SFTP channel opened on the same SSH transport. */ + sftp_ready?: boolean; +}; + +export const createWebcrtSession = (body: { + ne_id?: string; + ume_ne_id?: string; + cols?: number; + rows?: number; + encoding?: string; + keepalive_sec?: number; + post_login_commands?: string[]; + async_connect?: boolean; + username?: string; + password?: string; +}) => apiPost("/v1/webcrt/sessions", body); + +export type WebcrtQuickConnectResult = WebcrtSessionCreateResult & { + ne: ManagedNeItem; + ne_action: "created" | "updated" | "reused" | string; + list_source: "webcrt" | "managed" | string; +}; + +export const quickConnectWebcrtSession = (body: { + name?: string; + ip_address: string; + port?: number; + protocol?: string; + username?: string; + password?: string; + save_password?: boolean; + /** Claim existing LLDP / incomplete ManagedNE → promote to webcrt. */ + ne_id?: string; + cols?: number; + rows?: number; + encoding?: string; + keepalive_sec?: number; + async_connect?: boolean; +}) => apiPost("/v1/webcrt/sessions/quick-connect", body); + +export const closeWebcrtSession = (sessionId: string) => + apiDelete<{ ok: boolean; session_id: string; closed: boolean }>( + `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`, + ); + +/** Best-effort close during tab/window unload (fetch keepalive survives navigation). */ +export function closeWebcrtSessionsKeepalive(sessionIds: string[]): void { + const ids = [...new Set(sessionIds.map((id) => String(id || "").trim()).filter(Boolean))]; + if (!ids.length) return; + const headers = authHeaders(); + for (const id of ids) { + const path = `/v1/webcrt/sessions/${encodeURIComponent(id)}`; + try { + void fetch(path, { method: "DELETE", headers, credentials: fetchCreds, keepalive: true }); + } catch { + /* ignore unload failures */ + } + } +} + +export type WebcrtSftpItem = { + name: string; + size: number; + mtime: number; + is_dir: boolean; + mode?: string; + owner?: string; + group?: string; + uid?: number; + gid?: number; +}; + +export type WebcrtSftpListResult = { + ne_id: string; + ne_name: string; + path: string; + items: WebcrtSftpItem[]; + truncated?: boolean; + max_entries?: number; +}; + +export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) => + apiPost("/v1/webcrt/sftp/list", body); + +export const webcrtSftpMkdir = (body: { ne_id?: string; ume_ne_id?: string; path: string }) => + apiPost<{ ok: boolean; path: string; ne_id: string }>("/v1/webcrt/sftp/mkdir", body); + +export const webcrtSftpRemove = (body: { + ne_id?: string; + ume_ne_id?: string; + path: string; + recursive?: boolean; +}) => apiPost<{ ok: boolean; path: string; ne_id: string }>("/v1/webcrt/sftp/remove", body); + +export const webcrtSftpRename = (body: { + ne_id?: string; + ume_ne_id?: string; + old_path: string; + new_path: string; +}) => apiPost<{ ok: boolean; old_path: string; new_path: string; ne_id: string }>("/v1/webcrt/sftp/rename", body); + +export const webcrtSftpChmod = (body: { + ne_id?: string; + ume_ne_id?: string; + path: string; + mode: string; +}) => apiPost<{ ok: boolean; path: string; mode: string; ne_id: string }>("/v1/webcrt/sftp/chmod", body); + +export type WebcrtSftpTransferProgress = { + loaded: number; + total: number; +}; + +export type WebcrtSftpTransferOpts = { + signal?: AbortSignal; + onProgress?: (p: WebcrtSftpTransferProgress) => void; + /** Extra attempts after the first failure. Default 2 (3 tries total). */ + retries?: number; + onRetry?: (attempt: number, err: unknown) => void; +}; + +const isAbortError = (err: unknown): boolean => { + const raw = String(err || "").toLowerCase(); + return raw.includes("aborted") || raw.includes("abort"); +}; + +async function withSftpRetries( + run: () => Promise, + opts?: Pick, +): Promise { + const retries = Math.max(0, Number(opts?.retries ?? 2)); + let lastErr: unknown; + for (let attempt = 0; attempt <= retries; attempt += 1) { + if (opts?.signal?.aborted) throw new Error("aborted"); + try { + return await run(); + } catch (err) { + lastErr = err; + if (isAbortError(err) || attempt >= retries) break; + opts?.onRetry?.(attempt + 1, err); + } + } + throw lastErr instanceof Error ? lastErr : new Error(String(lastErr || "transfer_failed")); +} + +async function webcrtSftpDownloadOnce( + body: { ne_id?: string; ume_ne_id?: string; path: string }, + opts?: Pick, +): Promise { + const path = "/v1/webcrt/sftp/download"; + const res = await fetch(path, { + method: "POST", + credentials: fetchCreds, + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(body), + signal: opts?.signal, + }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("401 unauthorized"); + } + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `HTTP ${res.status}`); + } + const total = Number(res.headers.get("content-length") || 0); + if (!res.body) { + const blob = await res.blob(); + opts?.onProgress?.({ loaded: blob.size, total: total || blob.size }); + return blob; + } + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let loaded = 0; + try { + for (;;) { + if (opts?.signal?.aborted) { + await reader.cancel().catch(() => undefined); + throw new Error("aborted"); + } + const { done, value } = await reader.read(); + if (done) break; + if (value) { + chunks.push(value); + loaded += value.byteLength; + opts?.onProgress?.({ loaded, total }); + } + } + } catch (err) { + if (opts?.signal?.aborted || isAbortError(err)) throw new Error("aborted"); + throw err; + } + return new Blob(chunks as BlobPart[], { type: "application/octet-stream" }); +} + +export async function webcrtSftpDownload( + body: { + ne_id?: string; + ume_ne_id?: string; + path: string; + }, + optsOrProgress?: WebcrtSftpTransferOpts | ((p: WebcrtSftpTransferProgress) => void), +): Promise { + const opts: WebcrtSftpTransferOpts = + typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress || {}; + return withSftpRetries( + () => webcrtSftpDownloadOnce(body, opts), + opts, + ); +} + +function webcrtSftpUploadOnce( + body: { + ne_id?: string; + ume_ne_id?: string; + remote_path: string; + file: File; + }, + opts?: Pick, +): Promise<{ ok: boolean; path: string; size: number }> { + const path = "/v1/webcrt/sftp/upload"; + const fd = new FormData(); + if (body.ne_id) fd.append("ne_id", body.ne_id); + if (body.ume_ne_id) fd.append("ume_ne_id", body.ume_ne_id); + fd.append("remote_path", body.remote_path); + fd.append("file", body.file); + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const onAbort = () => { + try { + xhr.abort(); + } catch { + /* ignore */ + } + }; + if (opts?.signal) { + if (opts.signal.aborted) { + reject(new Error("aborted")); + return; + } + opts.signal.addEventListener("abort", onAbort, { once: true }); + } + xhr.open("POST", path); + xhr.withCredentials = true; + xhr.responseType = "text"; + xhr.upload.onprogress = (ev) => { + if (!opts?.onProgress) return; + opts.onProgress({ + loaded: Number(ev.loaded || 0), + total: ev.lengthComputable ? Number(ev.total || 0) : body.file.size || 0, + }); + }; + xhr.onload = () => { + opts?.signal?.removeEventListener("abort", onAbort); + if (xhr.status === 401) { + handleUnauthorized(path); + reject(new Error("401 unauthorized")); + return; + } + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(xhr.responseText || `HTTP ${xhr.status}`)); + return; + } + try { + resolve(JSON.parse(xhr.responseText || "{}") as { ok: boolean; path: string; size: number }); + } catch { + reject(new Error("invalid_upload_response")); + } + }; + xhr.onerror = () => { + opts?.signal?.removeEventListener("abort", onAbort); + reject(new Error("network_error")); + }; + xhr.onabort = () => { + opts?.signal?.removeEventListener("abort", onAbort); + reject(new Error("aborted")); + }; + xhr.send(fd); + }); +} + +export function webcrtSftpUpload( + body: { + ne_id?: string; + ume_ne_id?: string; + remote_path: string; + file: File; + }, + optsOrProgress?: WebcrtSftpTransferOpts | ((p: WebcrtSftpTransferProgress) => void), +): Promise<{ ok: boolean; path: string; size: number }> { + const opts: WebcrtSftpTransferOpts = + typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress || {}; + return withSftpRetries(() => webcrtSftpUploadOnce(body, opts), opts); +} + +export async function webcrtWsUrl(sessionId: string): Promise { + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`; + let qs = ""; + try { + const ticketResp = await apiPost<{ ticket: string; expires_in: number }>("/v1/webcrt/ws-ticket", {}); + if (ticketResp?.ticket) { + qs = `?ws_ticket=${encodeURIComponent(ticketResp.ticket)}`; + } + } catch { + qs = ""; + } + // Optional override, e.g. ws://127.0.0.1:8890 + const override = String((import.meta as ImportMeta & { env?: Record }).env?.VITE_NETX_WS_BASE || "").trim(); + if (override) { + return `${override.replace(/\/$/, "")}${path}${qs}`; + } + // Vite/preview: HTTP is proxied, but WS proxy is often flaky — hit API directly. + const port = window.location.port; + if (port === "5173" || port === "4173") { + const apiHost = window.location.hostname === "localhost" ? "127.0.0.1" : window.location.hostname; + return `${proto}//${apiHost}:8890${path}${qs}`; + } + return `${proto}//${window.location.host}${path}${qs}`; +} diff --git a/web/src/styles/base/base.css b/web/src/styles/base/base.css new file mode 100644 index 0000000..bfeb91c --- /dev/null +++ b/web/src/styles/base/base.css @@ -0,0 +1,45 @@ +/* Base: HeroUI token alignment, scrollbars, body */ + +* { + box-sizing: border-box; +} + +html { + color-scheme: dark; + scrollbar-color: var(--nx-line-strong) var(--nx-surface-2); + scrollbar-width: thin; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: var(--nx-surface-2); +} + +*::-webkit-scrollbar-thumb { + background: var(--nx-line-strong); + border: 2px solid var(--nx-surface-2); + border-radius: 999px; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--nx-text-faint); +} + +*::-webkit-scrollbar-corner { + background: var(--nx-surface-2); +} + +body { + margin: 0; + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + background: #070b14; + color: var(--nx-text); +} + +#root { + min-height: 100vh; +} diff --git a/web/src/styles/components/primitives.css b/web/src/styles/components/primitives.css new file mode 100644 index 0000000..245f766 --- /dev/null +++ b/web/src/styles/components/primitives.css @@ -0,0 +1,855 @@ +/* Shared primitives: header menu, buttons, inputs, cards, tables, pills, toast, spinner, help-hint */ + +.header-menu__panel--light { + background: var(--nx-surface-2); + border-color: var(--nx-line); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); +} + +.header-menu__label--light { + color: var(--nx-text-muted); +} + +.header-menu__item--light { + color: var(--nx-text); +} + +.header-menu__item--light:hover { + background: var(--nx-text-strong); + color: var(--nx-surface-2); +} + +.header-menu__item--active-light { + background: #e3f2fd; + color: var(--nm-accent, var(--nx-accent-strong)); +} + +.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; +} + +.header-menu__item--active { + background: rgba(61, 130, 247, 0.22); + color: #dce8ff; +} + +.app-main button, +.app-main select, +.app-main input, +.app-main textarea { + border: 1px solid var(--nm-panel-border, var(--nx-line)); + background: var(--nx-field); + color: var(--nm-text, var(--nx-text)); + border-radius: 6px; + padding: 8px 10px; +} + +button, +select, +input, +textarea { + border: 1px solid var(--nm-panel-border, var(--nx-line)); + background: var(--nx-field); + color: var(--nm-text, var(--nx-text)); + border-radius: 6px; + padding: 8px 10px; +} + +textarea { + width: 100%; + resize: vertical; +} + +button { + cursor: pointer; +} + +.cards { + display: grid; + grid-template-columns: repeat(3, minmax(220px, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.app-main .card, +.app-main .panel { + border: 1px solid var(--nm-ops-line, var(--nm-panel-border, var(--nx-line))); + background: var(--nm-panel-solid, var(--nx-surface-1)); + border-radius: 6px; + padding: 14px; + box-shadow: none; + color: var(--nm-text, var(--nx-text)); +} + +.card, +.panel { + border: 1px solid var(--nm-ops-line, var(--nm-panel-border, var(--nx-line))); + background: var(--nm-panel-solid, var(--nx-surface-1)); + border-radius: 6px; + padding: 14px; + box-shadow: none; + color: var(--nm-text, var(--nx-text)); +} + +.card h3, +.panel h2 { + margin: 0 0 10px; + font-size: 16px; +} + +.card__section-title { + margin: 14px 0 8px; + font-size: 14px; + font-weight: 600; +} + +.card input:not([type="checkbox"]):not([type="radio"]), +.card select, +.card textarea { + width: 100%; + margin-bottom: 8px; +} + +.card input[type="checkbox"], +.card input[type="radio"] { + width: auto; + margin-bottom: 0; + flex: 0 0 auto; +} + +.pager input, +.pager select { + width: auto; + margin-bottom: 0; +} + +.actions-row { + margin-top: 8px; +} + +.actions-row--inline { + display: flex; + gap: 8px; + flex-wrap: nowrap; + overflow-x: auto; +} + +.card--full { + grid-column: 1 / -1; +} + +.filter-inline { + display: grid; + grid-template-columns: minmax(280px, 2fr) minmax(180px, 1fr) minmax(180px, 1fr) 180px; + gap: 8px; + align-items: center; +} + +.filter-inline input, +.filter-inline select { + width: 100%; + margin-bottom: 0; +} + +a { + color: var(--nm-accent, var(--nx-accent-strong)); + text-decoration: none; +} + +.muted { + color: var(--nx-text-muted); +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + border-bottom: 1px solid var(--nm-panel-border, var(--nx-line)); + padding: 8px; + text-align: left; + font-size: 13px; + vertical-align: top; + color: var(--nm-text, var(--nx-text)); +} + +th { + color: var(--nm-muted, var(--nx-text-muted)); + font-weight: 600; +} + +.split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +pre { + white-space: pre-wrap; + margin: 0; + background: var(--nx-surface-2); + color: var(--nx-text); + border: 1px solid #223651; + border-radius: 8px; + padding: 10px; +} + +.ws-log-panel { + margin-top: 12px; + max-height: 240px; + overflow-y: auto; + background: rgb(var(--nx-ink-deep-rgb) / 0.55); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 8px; + padding: 8px 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.45; + color: var(--nx-text-soft); +} + +.ws-log-line { + padding: 3px 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.12); + word-break: break-word; +} + +.ws-log-line:last-child { + border-bottom: none; +} + +.ws-log-line--error { + color: #fca5a5; +} + +.ws-log-line--warning { + color: #fdba74; +} + +.ws-log-line--info { + color: var(--nx-text-muted); +} + +.ws-log-ts { + color: var(--nx-text-muted); + margin-right: 8px; +} + +.pill { + margin-top: 8px; + padding: 6px 10px; + border-radius: 6px; + width: fit-content; + font-size: 12px; + background: rgba(148, 163, 184, 0.12); + color: var(--nx-text-soft); + border: 1px solid rgba(148, 163, 184, 0.22); +} + +.pill--high { + background: rgba(239, 68, 68, 0.16); + border-color: rgba(248, 113, 113, 0.35); + color: #fecaca; +} + +.pill--medium { + background: rgba(245, 158, 11, 0.14); + border-color: rgba(251, 191, 36, 0.35); + color: #fde68a; +} + +.pill--low { + background: rgba(34, 197, 94, 0.14); + border-color: rgba(74, 222, 128, 0.3); + color: #bbf7d0; +} + +.link-btn { + border: none; + background: transparent; + color: var(--nm-accent, var(--nx-accent-strong)); + padding: 0; + cursor: pointer; +} + +.link-btn--danger { + color: #f87171; +} + +.link-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.table-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.status { + margin-top: 12px; + color: #9db2cf; + font-size: 12px; +} + +.app-main .conn-pill:not(.conn-pill--on-brand) { + font-size: 12px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 999px; + padding: 4px 10px; + background: rgb(var(--nx-ink-rgb) / 0.75); + color: var(--nx-text-soft); +} + +.conn-pill { + font-size: 12px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 999px; + padding: 4px 10px; + background: rgb(var(--nx-ink-rgb) / 0.75); + color: var(--nx-text-soft); +} + +.conn-pill--up { + border-color: rgba(74, 222, 128, 0.4); + color: #86efac; + background: rgba(34, 197, 94, 0.14); +} + +.conn-pill--down { + border-color: rgba(248, 113, 113, 0.4); + color: #fca5a5; + background: rgba(239, 68, 68, 0.14); +} + +.conn-pill--unknown { + border-color: rgba(148, 163, 184, 0.3); + color: var(--nx-text-muted); + background: rgba(148, 163, 184, 0.1); +} + +/* Brand header chips/buttons — must follow global button/conn-pill rules. */ +.app-brand .conn-pill.conn-pill--on-brand { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.82); + font-size: 11px; + font-weight: 400; + line-height: 1; + white-space: nowrap; +} + +.app-brand .conn-pill.conn-pill--on-brand::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--nx-text-muted); + flex-shrink: 0; +} + +.app-brand .conn-pill.conn-pill--on-brand.conn-pill--up { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.82); +} + +.app-brand .conn-pill.conn-pill--on-brand.conn-pill--up::before { + background: #4ade80; +} + +.app-brand .conn-pill.conn-pill--on-brand.conn-pill--down { + border-color: rgba(248, 113, 113, 0.35); + background: rgba(127, 29, 29, 0.35); + color: #fecaca; +} + +.app-brand .conn-pill.conn-pill--on-brand.conn-pill--down::before { + background: #f87171; +} + +.app-brand .conn-pill.conn-pill--on-brand.conn-pill--unknown { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); +} + +.app-brand .conn-pill.conn-pill--on-brand.conn-pill--unknown::before { + background: var(--nx-text-muted); +} + +.app-brand .conn-pill.conn-pill--on-brand.conn-pill--user { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.16); + color: rgba(255, 255, 255, 0.78); + font-size: 11px; + font-weight: 400; +} + +.app-brand .conn-pill.conn-pill--on-brand.conn-pill--user::before { + display: none; +} + +.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks { + cursor: pointer; + font: inherit; + font-size: 11px; + font-weight: 400; +} + +.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:hover, +.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:focus-visible { + border-color: rgb(var(--nx-accent-rgb) / 0.55); + background: rgb(var(--nx-accent-rgb) / 0.22); + color: #fff; + outline: none; +} + +.app-brand .header-menu__trigger.header-menu__trigger--on-brand { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + min-width: 24px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.78); + font-size: 11px; + font-weight: 400; + letter-spacing: normal; + line-height: 1; + box-shadow: none; +} + +.app-brand .header-menu__trigger.header-menu__trigger--on-brand.header-menu__trigger--icon { + width: 24px; + padding: 0; + font-size: 14px; + letter-spacing: 0.06em; +} + +.app-brand .header-menu__trigger.header-menu__trigger--on-brand.header-menu__trigger--locale { + min-width: 36px; + padding: 0 8px; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; +} + +.app-brand .header-menu__trigger.header-menu__trigger--on-brand:hover, +.app-brand .header-menu__trigger.header-menu__trigger--on-brand:focus-visible { + background: rgb(var(--nx-accent-rgb) / 0.22); + border-color: rgb(var(--nx-accent-rgb) / 0.55); + color: #fff; +} + +.app-brand .conn-pill.conn-pill--on-brand.conn-pill--up { + border-color: rgb(var(--nx-accent-rgb) / 0.35); +} + +.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:hover, +.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:focus-visible { + border-color: rgb(var(--nx-accent-rgb) / 0.55); + background: rgb(var(--nx-accent-rgb) / 0.22); + color: #fff; + outline: none; +} + +.toast, +.app-toast { + position: fixed; + right: max(12px, env(safe-area-inset-right, 0px)); + bottom: max(12px, env(safe-area-inset-bottom, 0px)); + left: auto; + top: auto; + margin: 0; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: var(--nx-surface-2); + color: var(--nx-text); + font-size: 12.5px; + font-weight: 500; + line-height: 1.35; + z-index: 10050; + width: max-content; + max-width: min(280px, calc(100vw - 24px)); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.45); + pointer-events: none; + word-break: break-word; +} + +.toast--ok, +.app-toast--ok { + border-color: rgba(74, 222, 128, 0.35); + background: rgba(20, 83, 45, 0.92); + color: #bbf7d0; +} + +.toast--error, +.app-toast--error { + border-color: rgba(248, 113, 113, 0.4); + background: rgba(127, 29, 29, 0.92); + color: #fecaca; +} + +.inline-spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid rgba(255, 255, 255, 0.22); + border-top-color: rgba(255, 255, 255, 0.85); + border-radius: 50%; + vertical-align: -2px; + animation: inline-spin 0.65s linear infinite; +} + +@keyframes inline-spin { + to { + transform: rotate(360deg); + } +} + +.conn-pill--warn { + border-color: rgba(251, 191, 36, 0.4); + color: #fde68a; + background: rgba(245, 158, 11, 0.14); +} + +.app-main .inline-spinner { + border-color: rgba(21, 101, 192, 0.2); + border-top-color: var(--nm-accent, var(--nx-accent-strong)); +} + +.skeleton-line { + height: 32px; + border-radius: 8px; + margin-bottom: 10px; + background: linear-gradient(90deg, #14253e 25%, #1d3558 50%, #14253e 75%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; +} + +.mini-chart { + margin-top: 10px; +} + +.mini-chart__row { + display: grid; + grid-template-columns: 70px 1fr 36px; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.mini-chart__label, +.mini-chart__value { + font-size: 12px; + color: #a8bad5; +} + +.card-title-with-hint { + display: flex; + align-items: center; + gap: 8px; + margin: 0; +} + +.help-hint { + position: relative; + display: inline-flex; + vertical-align: middle; +} + +.app-main .help-hint__trigger, +.help-hint__trigger { + width: auto; + height: auto; + min-width: 0; + padding: 0 2px; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + color: var(--nx-text-faint); + font-size: 13px; + font-weight: 700; + line-height: 1; + cursor: pointer; +} + +.app-main .help-hint__trigger:hover, +.app-main .help-hint__trigger:focus-visible, +.help-hint__trigger:hover, +.help-hint__trigger:focus-visible { + border: none; + background: transparent; + box-shadow: none; + color: var(--nm-accent, var(--nx-accent-strong)); + outline: none; +} + +.help-hint__popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 40; + width: min(360px, 80vw); + padding: 10px 12px; + border: 1px solid var(--nm-ops-line, var(--nx-line)); + border-radius: 6px; + background: var(--nx-surface-1); + color: #d7e3f2; + font-size: 12px; + font-weight: 400; + line-height: 1.5; + white-space: pre-line; + box-shadow: none; + overflow: auto; +} + +.help-hint--align-end .help-hint__popover { + left: auto; + right: 0; +} + +.help-hint__popover--portal { + z-index: 2000; + width: auto; +} + +.help-hint__popover--structured { + width: min(420px, 86vw); + white-space: normal; + padding: 12px 14px; +} + +.help-hint__popover--nowrap { + width: max-content; + max-width: min(90vw, 720px); + white-space: nowrap; +} + +.help-hint__body { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.help-hint__plain { + white-space: pre-line; +} + +.help-hint__section-title { + margin: 2px 0 0; + padding-bottom: 5px; + border-bottom: 1px solid var(--nm-ops-line, var(--nx-line)); + color: var(--nm-ops-accent, var(--nx-accent)); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.06em; + text-transform: uppercase; + line-height: 1.35; +} + +.help-hint__section-title:first-child { + margin-top: 0; +} + +.help-hint__list { + margin: 0; + padding: 0 0 0 1.05rem; + display: flex; + flex-direction: column; + gap: 5px; + color: var(--nm-ops-muted, var(--nx-text-muted)); +} + +.help-hint__list > li { + padding-left: 2px; + line-height: 1.45; +} + +.help-hint__list > li::marker { + color: #526f94; +} + +.help-hint__para { + margin: 0; + color: var(--nm-ops-muted, var(--nx-text-muted)); + line-height: 1.5; +} + +.help-hint__code { + display: inline; + padding: 0 4px; + border: 1px solid rgba(43, 55, 72, 0.9); + border-radius: 3px; + background: var(--nx-surface-2); + color: #c4ddfa; + font: 11px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; + white-space: nowrap; +} + +.panel__toolbar-end { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} + +.stat-card { + border: 1px solid var(--border, rgba(148, 163, 184, 0.22)); + border-radius: 8px; + padding: 12px 14px; + background: var(--panel-bg, rgb(var(--nx-ink-rgb) / 0.72)); + color: var(--nm-text, var(--nx-text)); +} + +.stat-card strong { + display: block; + margin-top: 4px; + font-size: 1.05rem; +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.btn-primary { + background: var(--nx-accent-strong); + color: #fff; + border-color: #1d4ed8; +} + +.btn-primary:disabled { + opacity: 0.55; +} + +.data-table { + width: 100%; +} + +.config-sync-policy-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; +} + +.config-sync-policy-check { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: 13px; + color: var(--nx-text-soft); + white-space: nowrap; + cursor: pointer; +} + +.config-sync-policy-check input { + width: 14px; + height: 14px; + margin: 0; + flex-shrink: 0; +} + +.config-sync-policy-field { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: 13px; + color: var(--nx-text-soft); + white-space: nowrap; +} + +.config-sync-policy-field input[type="number"] { + width: 64px; + margin: 0; +} + +.config-sync-policy-field select { + width: auto; + min-width: 110px; + margin: 0; +} diff --git a/web/src/styles/layout/app-shell.css b/web/src/styles/layout/app-shell.css new file mode 100644 index 0000000..322b3e1 --- /dev/null +++ b/web/src/styles/layout/app-shell.css @@ -0,0 +1,105 @@ +/* App shell + brand header (.app--shell, .app-brand, .app-main) */ + +.app--shell { + /* --nm-* chrome tokens: see styles/theme/tokens.css */ + display: flex; + flex-direction: column; + min-height: 100vh; + color: var(--nm-text); +} + +.app-brand { + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + padding: 0 20px; + background: var(--nm-header, #0f1b2d); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: none; +} + +.app-brand__logo { + font-size: 18px; + font-weight: 700; + letter-spacing: 0.12em; + color: #fff; + text-decoration: none; + flex-shrink: 0; + line-height: 1; +} + +.app-brand__logo--static { + display: inline-block; +} + +.app-brand__sep { + width: 1px; + height: 16px; + background: rgba(255, 255, 255, 0.22); + flex-shrink: 0; +} + +.app-brand__start { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.app-brand__apps { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: rgba(255, 255, 255, 0.88); + cursor: pointer; +} + +.app-brand__apps:hover, +.app-brand__apps:focus-visible { + background: rgb(var(--nx-accent-rgb) / 0.22); + color: #fff; + outline: none; +} + +.app-brand__module-title { + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.92); + white-space: nowrap; + letter-spacing: 0.02em; +} + +.app-brand__actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.app-brand__actions-group { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.app-brand__actions-sep { + width: 1px; + height: 14px; + margin: 0 4px; + background: rgba(255, 255, 255, 0.18); + flex-shrink: 0; +} + +.app-main { + flex: 1; + padding: 20px 28px 32px; + background: var(--nm-chrome, #e8eef6); + color: var(--nm-text, var(--nx-line)); +} diff --git a/web/src/styles/layout/network-shell.css b/web/src/styles/layout/network-shell.css new file mode 100644 index 0000000..01b5fab --- /dev/null +++ b/web/src/styles/layout/network-shell.css @@ -0,0 +1,691 @@ +/* Network / audit shell (left nav + main), ops list chrome, shared dialogs */ + +/* —— Network Management shell (left nav + main) —— + Fill the app chrome without a second document scrollbar. + Negative margins + 100vh used to fight app-main padding and over-expand the page. */ +.app--shell:has(.network-shell), +.app--shell:has(.audit-shell) { + height: 100vh; + max-height: 100vh; + overflow: hidden; +} + +.app-main:has(> .network-shell), +.app-main:has(> .audit-shell) { + flex: 1 1 auto; + min-height: 0; + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.network-shell { + display: flex; + flex: 1 1 auto; + width: 100%; + min-height: 0; + height: auto; + margin: 0; + background: var(--nm-chrome); + overflow: hidden; +} + +.network-nav { + width: 232px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: #0a121c; + border-right: 1px solid var(--nm-ops-line); + min-height: 0; + color: var(--nm-text); + box-shadow: none; +} + +.network-nav__scroll { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px 8px 16px; +} + +.network-nav__accordion.accordion { + width: 100%; + background: transparent; + border: 0; + border-radius: 0; + contain: none; +} + +.network-nav__group { + margin: 0 0 2px; + border-radius: 0; + background: transparent; +} + +.network-nav__group.is-active-group { + background: transparent; +} + +.app-main .network-nav__group-toggle, +.network-nav__group-toggle, +.network-nav .accordion__trigger.network-nav__group-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--nm-ops-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + text-align: left; + cursor: pointer; +} + +.app-main .network-nav__group-toggle:hover, +.network-nav__group-toggle:hover, +.network-nav .accordion__trigger.network-nav__group-toggle:hover { + background: rgb(var(--nx-ink-rgb) / 0.8); + color: #d7e6f8; +} + +.network-nav__group.is-active-group .network-nav__group-toggle, +.network-nav__group.is-active-group .accordion__trigger.network-nav__group-toggle { + color: var(--nm-ops-accent); + background: transparent; +} + +.network-nav__chevron, +.network-nav .accordion__indicator.network-nav__chevron { + display: inline-flex; + width: 12px; + height: 12px; + color: var(--nx-text-faint); + flex-shrink: 0; + opacity: 0.85; +} + +.network-nav__group.is-active-group .network-nav__chevron, +.network-nav__group.is-active-group .accordion__indicator.network-nav__chevron { + color: var(--nm-ops-accent); +} + +.network-nav__group-label { + flex: 1; + min-width: 0; +} + +.network-nav .accordion__body-inner.network-nav__panel-body, +.network-nav .network-nav__panel-body { + padding: 0 0 6px !important; + color: inherit !important; +} + +.network-nav__list { + list-style: none; + margin: 0; + padding: 0 0 0 6px; + display: flex; + flex-direction: column; + gap: 1px; + border-left: 1px solid var(--nm-ops-line); + margin-left: 14px; +} + +.network-nav__list > li { + width: 100%; +} + +.network-nav__link { + display: block; + box-sizing: border-box; + width: 100%; + padding: 7px 10px; + border-radius: 4px; + color: var(--nm-ops-muted); + text-decoration: none; + font-size: 13px; + line-height: 1.35; + border: 0; + box-shadow: none; + transition: + background 120ms ease, + color 120ms ease; +} + +.network-nav__link:hover { + background: var(--nm-ops-surface); + color: #e8f0fa; +} + +.network-nav__link.is-active { + background: rgb(var(--nx-accent-rgb) / 0.1); + color: #edf5ff; + font-weight: 600; + box-shadow: inset 0 0 0 1px rgb(var(--nx-accent-rgb) / 0.3); +} + +.network-nav__brand { + padding: 14px 14px 12px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #edf5ff; + border-bottom: 1px solid var(--nm-ops-line); + background: var(--nx-surface-1); +} + +.network-nav__list--flat { + padding: 0; + margin-left: 0; + border-left: 0; + gap: 1px; +} + +.audit-shell .network-main { + overflow: auto; +} + +.network-main { + flex: 1; + min-width: 0; + min-height: 0; + overflow: auto; + padding: 20px 22px 28px; + background: + radial-gradient(1200px 480px at 0% 0%, rgb(var(--nx-accent-rgb) / 0.07), transparent 55%), + var(--nm-main, var(--nm-chrome)); +} + +/* Compact ops chrome (aligned with biz-monitor / biz-compare). */ +.network-main > .panel, +.network-main .page-stack > .panel, +.network-main > .nm-page-panel { + padding: 18px 20px 20px; + border: 1px solid var(--nm-ops-line); + border-radius: 6px; + background: var(--nx-surface-1); + box-shadow: none; +} + +.network-main .panel__toolbar { + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px solid var(--nm-ops-line); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.network-main .panel__toolbar h2 { + margin: 0; + font-size: 18px; + font-weight: 650; + color: var(--nm-brand); + letter-spacing: -0.02em; +} + +.network-main .button, +.network-main .input, +.network-main .ui-field__select { + border-radius: 4px; + box-shadow: none; +} + +.network-main .filter-inline { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 0; + padding: 12px 14px; + border: 1px solid var(--nm-ops-line); + border-radius: 4px; + background: rgb(var(--nx-ink-deep-rgb) / 0.45); + grid-template-columns: none; +} + +.network-main .filter-inline input, +.network-main .filter-inline > select { + width: auto; + min-width: 140px; + flex: 1 1 160px; + max-width: 280px; + height: 34px; + margin: 0; + border: 1px solid var(--nm-ops-line); + border-radius: 4px; + background: var(--nx-field); + color: var(--nx-text); + padding: 0 12px; + font-size: 13px; +} + +/* FieldSelect wrapper is the flex item; nested select must NOT inherit flex-basis as height */ +.network-main .filter-inline > .ui-field { + flex: 1 1 160px; + max-width: 280px; + min-width: 140px; + align-self: center; + gap: 0; +} + +.network-main .filter-inline > .ui-field > .ui-field__select { + width: 100%; + height: 34px; + min-height: 34px; + max-height: 34px; + flex: none; + margin: 0; + border: 1px solid var(--nm-ops-line); + border-radius: 4px; + background: var(--nx-field); + color: var(--nx-text); + padding: 0 12px; + font-size: 13px; +} + +.network-main .filter-inline button { + height: 34px; + flex: 0 0 auto; +} + +.network-main .pt-list { + gap: 14px; +} + +.network-main .pt-list-kpis { + gap: 0; + border: 1px solid var(--nm-ops-line); + border-radius: 4px; + overflow: hidden; + background: var(--nm-ops-surface); +} + +.network-main .pt-list-kpi { + border: 0; + border-right: 1px solid var(--nm-ops-line); + border-radius: 0; + background: transparent; +} + +.network-main .pt-list-kpi:last-child { + border-right: 0; +} + +.network-main .pt-list-kpi__value { + font-family: ui-monospace, Consolas, monospace; +} + +.network-main .pt-list-kpi__label { + font-size: 11px; + color: var(--nm-ops-muted); +} + +.network-main .pt-list-table-wrap { + border: 1px solid var(--nm-ops-line); + border-radius: 4px; + overflow: hidden; + background: rgb(var(--nx-ink-deep-rgb) / 0.28); +} + +.network-main .pt-list-table { + margin: 0; +} + +.network-main .pt-list-table thead th { + background: var(--nx-surface-3); + color: var(--nx-text-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + padding-top: 12px; + padding-bottom: 12px; +} + +.network-main .pt-list-table tbody td { + padding-top: 11px; + padding-bottom: 11px; + font-size: 12px; + border-bottom-color: var(--nm-ops-line); +} + +.network-main .pt-list-table tbody tr:last-child td { + border-bottom: 0; +} + +.network-main .pt-list-actions .button, +.network-main .table-actions .button { + min-height: 28px; +} + +@media (max-width: 800px) { + .network-main .pt-list-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .network-main .pt-list-kpi:nth-child(2) { + border-right: 0; + } + + .network-main .pt-list-kpi:nth-child(-n+2) { + border-bottom: 1px solid var(--nm-ops-line); + } +} + +.network-main .pager.pt-list-pager, +.network-main .pt-list-pager { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 4px; + padding-top: 4px; +} + +.pt-port-updown { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.pt-port-updown .conn-pill { + min-width: 2.4em; + justify-content: center; + text-transform: lowercase; + font-variant-numeric: tabular-nums; +} + +.pt-wizard__confirm-port { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; +} + +.pt-list-status--ok, +.pt-list-status--up, +.pt-list-status--connected { + background: rgba(34, 197, 94, 0.16); + color: #86efac; +} + +.pt-list-status--down, +.pt-list-status--failed, +.pt-list-status--critical { + background: rgba(239, 68, 68, 0.16); + color: #fca5a5; +} + +.pt-list-status--major { + background: rgba(249, 115, 22, 0.16); + color: #fdba74; +} + +.pt-list-status--minor, +.pt-list-status--warning { + background: rgba(245, 158, 11, 0.16); + color: #fde68a; +} + +.pt-list-status--info { + background: rgba(56, 189, 248, 0.14); + color: #7dd3fc; +} + +.pt-list-status--unknown { + background: rgba(148, 163, 184, 0.14); + color: var(--nx-text-muted); +} + +/* Shared detail dialog for config view / LLDP jobs / config-sync cycles */ +.ops-detail-modal, +.nm-config-modal { + display: flex; + flex-direction: column; + width: min(960px, 100%); + max-height: min(90vh, 840px); + overflow: hidden; +} + +.ops-detail-modal__head, +.nm-config-modal__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; + flex: 0 0 auto; +} + +.ops-detail-modal__title h3, +.nm-config-modal__title h3 { + margin: 0 0 4px; +} + +.ops-detail-modal__title .muted, +.nm-config-modal__title .muted { + margin: 0; + font-size: 13px; +} + +.ops-detail-modal__actions, +.nm-config-modal__actions { + flex: 0 0 auto; +} + +.ops-detail-modal__toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 10px; + flex: 0 0 auto; +} + +.ops-detail-modal__scroll { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + background: rgb(var(--nx-ink-deep-rgb) / 0.35); +} + +.ops-detail-modal__scroll .pt-list-table-wrap, +.ops-detail-modal__scroll .data-table { + border: 0; +} + +.ops-detail-modal__foot { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 12px; + flex: 0 0 auto; +} + +.ops-detail-modal__error { + margin: 0 0 10px; + color: #fca5a5; + font-size: 13px; +} + +.ops-detail-modal--xl { + width: min(1100px, 100%); +} + +.ops-detail-modal__scroll--pad { + padding: 12px 14px; +} + +/* Form + list share one scroll body (no cramped nested table viewport). */ +.ops-detail-modal__scroll--flow { + border: 0; + background: transparent; + border-radius: 0; +} + +.ops-detail-modal__scroll--flow .pt-list-table-wrap { + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + overflow: visible; +} + +.ume-entry-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.ume-page > .ume-entry-grid > .panel { + padding: 16px 18px 18px; + border: 1px solid var(--nm-ops-line); + border-radius: 6px; + background: var(--nx-surface-1); + box-shadow: none; + margin: 0; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 118px; +} + +.ume-entry .panel__toolbar { + margin-bottom: 0; + padding-bottom: 10px; +} + +.ume-entry__summary { + margin: 0; + font-size: 13px; + line-height: 1.45; + flex: 1 1 auto; +} + +.ume-entry__pills { + margin: 0; + flex-wrap: wrap; + gap: 6px; +} + +.ume-entry__link { + color: var(--nx-accent-text); + text-decoration: none; + font-weight: 600; +} + +.ume-entry__link:hover { + color: #bfdbfe; + text-decoration: underline; +} + +.ume-modal-form { + flex: 0 0 auto; + margin-bottom: 10px; +} + +.ume-modal-form__grow { + flex: 1 1 160px; + min-width: 140px; +} + +.ume-modal-form__label { + margin: 0 0 6px; + font-size: 13px; +} + +.ume-modal-form__hint { + margin: 6px 0 0; + font-size: 12px; +} + +.ume-modal-form__opts { + margin-top: 8px; + margin-bottom: 0; +} + +.ume-ne-types { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + max-height: 120px; + overflow: auto; + padding: 8px 10px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + background: rgb(var(--nx-ink-deep-rgb) / 0.35); +} + +.ume-ne-types--tall { + max-height: 240px; +} + +.ume-ne-types__item { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + font-size: 13px; +} + +.ume-ne-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 18px; + font-size: 13px; +} + +.ume-ne-detail-grid > div > .muted { + display: block; + font-size: 11px; + margin-bottom: 2px; +} + +.nowrap { + white-space: nowrap; +} + +.nm-config-modal__tabs { + margin-bottom: 10px; +} + +.nm-page-panel .nm-config-modal__tabs button.is-active, +.bs-cmp-job-body .nm-config-modal__tabs button.is-active, +.app-heroui-modal .nm-config-modal__tabs button.is-active, +.nm-config-modal__tabs button.is-active { + outline: 2px solid rgba(37, 99, 235, 0.55); + outline-offset: 1px; + box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.25); + font-weight: 600; +} + +.bs-cmp-metric-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.bs-cmp-metric-chip { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + background: rgb(var(--nx-ink-rgb) / 0.72); + border: 1px solid rgba(148, 163, 184, 0.22); + font-size: 12px; + color: var(--nx-text-soft); +} diff --git a/web/src/styles/legacy/overrides-dark.css b/web/src/styles/legacy/overrides-dark.css new file mode 100644 index 0000000..7eeddfb --- /dev/null +++ b/web/src/styles/legacy/overrides-dark.css @@ -0,0 +1,268 @@ +/* LEGACY: unified dark surfaces, dark modals, audit logs. Dissolve into components/ over time. */ + +/* ===== Unified dark content surfaces (forms / tables / status) ===== */ +.page-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 240px; + color: var(--nx-text-muted); + font-size: 13px; +} +.page-loading--soft { + min-height: 120px; + opacity: 0; + animation: page-loading-soft-in 160ms ease 180ms forwards; +} +@keyframes page-loading-soft-in { + to { + opacity: 1; + } +} + +.app-main option, +option { + background: var(--nx-surface-2); + color: var(--nx-text); +} + +.app-main .filter-inline, +.ne-page .filter-inline, +.ume-page .filter-inline, +.system-page .filter-inline, +.network-main .filter-inline { + background: rgb(var(--nx-ink-deep-rgb) / 0.45) !important; + border-color: var(--nm-ops-line) !important; +} + +.app-main .filter-inline input, +.app-main .filter-inline select, +.ne-page .filter-inline input, +.ne-page .filter-inline select, +.ume-page .filter-inline input, +.ume-page .filter-inline select, +.system-page .filter-inline input, +.system-page .filter-inline select, +.network-main .filter-inline input, +.network-main .filter-inline select { + background: var(--nx-field) !important; + border-color: var(--nm-ops-line) !important; + color: var(--nx-text) !important; +} + +.app-main .pt-list-table-wrap, +.ne-page .pt-list-table-wrap, +.ume-page .pt-list-table-wrap, +.system-page .pt-list-table-wrap, +.network-main .pt-list-table-wrap, +.pt-list-table-wrap { + border-color: var(--nm-ops-line) !important; + background: rgb(var(--nx-ink-deep-rgb) / 0.28); +} + +.app-main .pt-list-table thead th, +.ne-page .pt-list-table thead th, +.ume-page .pt-list-table thead th, +.system-page .pt-list-table thead th, +.network-main .pt-list-table thead th, +.pt-list-table thead th, +.app-main .data-table thead th, +.data-table thead th { + background: var(--nx-surface-3) !important; + color: var(--nx-text-muted) !important; + border-bottom-color: var(--nm-ops-line); +} + +.app-main .pt-list-table tbody tr:hover td, +.pt-list-table tbody tr:hover td, +.app-main .data-table tbody tr:hover td, +.data-table tbody tr:hover td { + background: rgb(var(--nx-surface-3-rgb) / 0.55) !important; +} + +.app-main .pt-list-status--unknown, +.pt-list-status--unknown, +.app-main .pt-list-status--other, +.pt-list-status--other, +.app-main .pt-list-status--stopped, +.pt-list-status--stopped { + background: rgba(148, 163, 184, 0.14) !important; + color: var(--nx-text-muted) !important; +} + +.app-main .pt-list-status--ok, +.pt-list-status--ok, +.app-main .pt-list-status--pass, +.pt-list-status--pass, +.app-main .pt-list-status--up, +.pt-list-status--up { + background: rgba(34, 197, 94, 0.16) !important; + color: #86efac !important; +} + +.app-main table, +.app-main .data-table { + color: var(--nx-text); +} + +.app-main .muted, +.muted { + color: var(--nx-text-muted); +} + +/* ===== Dark modals + topology non-canvas ===== */ +.modal-backdrop, +.token-scopes-modal__backdrop, +.topo-modal__backdrop { + background: rgb(var(--nx-ink-deep-rgb) / 0.72) !important; +} + +.modal, +.app-main .modal, +.token-scopes-modal__panel, +.topo-modal__panel, +.webcrt-auth-modal, +.webcrt-new-session-modal, +.webcrt-keyword-modal, +.webcrt-session-opts-modal { + background: var(--nx-surface-2) !important; + color: var(--nx-text) !important; + border: 1px solid rgba(148, 163, 184, 0.22) !important; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45) !important; +} + +.modal h3, +.token-scopes-modal__head h3, +.topo-modal__head h3, +.pt-log-modal__head h3 { + color: var(--nx-text-strong) !important; +} + +.modal .muted, +.modal .form-hint, +.modal label, +.modal .form-label, +.token-scopes-modal__row, +.topo-modal__hint, +.topo-modal__selectall { + color: var(--nx-text-soft) !important; +} + +.modal input, +.modal select, +.modal textarea, +.topo-modal__panel input, +.topo-modal__panel select, +.token-scopes-modal__panel input, +.token-scopes-modal__panel select { + background: var(--nx-field) !important; + border-color: rgba(148, 163, 184, 0.28) !important; + color: var(--nx-text) !important; +} + +.topo-browser, +.topo-main { + background: var(--nm-chrome, #070b14); + color: var(--nm-text, var(--nx-text)); + border-color: rgba(148, 163, 184, 0.2); +} +.topo-browser__head, +.topo-toolbar { + background: rgb(var(--nx-ink-rgb) / 0.9); + border-color: rgba(148, 163, 184, 0.18); +} +.topo-browser__grid, +.topo-browser__grid--regions { + background: rgb(var(--nx-ink-deep-rgb) / 0.4); +} +.topo-map-list__row, +.topo-palette__row { + background: rgb(var(--nx-ink-rgb) / 0.75); + border-color: rgba(148, 163, 184, 0.22); +} + +.app-main .topo-toolbar__select, +.topo-toolbar__select, +.app-main .topo-toolbar__display > summary, +.topo-toolbar__display > summary, +.app-main .topo-toolbar__actions > .btn:not([data-variant="primary"]):not([data-variant="secondary"]), +.topo-toolbar__actions > .btn:not([data-variant="primary"]):not([data-variant="secondary"]), +.app-main .topo-toolbar__group > .btn, +.topo-toolbar__group > .btn { + background: var(--nx-field) !important; + border-color: rgba(148, 163, 184, 0.28) !important; + color: var(--nx-text) !important; +} + +.topo-nav-node { + background: linear-gradient(165deg, var(--nx-line) 0%, var(--nx-surface-2) 100%); + border-color: rgba(148, 163, 184, 0.28); + color: var(--nx-text); +} +.topo-nav-node__icon { + background: rgba(37, 99, 235, 0.18); +} + +.webcrt-menubar__menu { + background: var(--nx-surface-2) !important; + border-color: rgba(148, 163, 184, 0.28) !important; + color: var(--nx-text) !important; +} +.webcrt-menubar__menu-item { + color: var(--nx-text) !important; +} +.webcrt-menubar__menu-item:hover { + background: rgba(37, 99, 235, 0.2) !important; +} + +.pt-log-modal__list { + background: rgb(var(--nx-ink-deep-rgb) / 0.55) !important; + border-color: rgba(148, 163, 184, 0.2) !important; + color: var(--nx-text) !important; +} + +/* Operation audit logs */ +.system-page .audit-quick-filters button.is-active { + background: rgba(37, 99, 235, 0.18); + border-color: rgb(var(--nx-accent-rgb) / 0.55); + color: #1e3a8a; + font-weight: 600; +} + +.system-page .audit-summary-cell { + max-width: 420px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.system-page button.linkish { + background: transparent; + border: none; + color: var(--nx-accent-strong); + cursor: pointer; + padding: 0; + height: auto; + font: inherit; + text-decoration: underline; +} + +.system-page .audit-detail-row td { + background: rgb(var(--nx-ink-rgb) / 0.04); + padding: 10px 12px 14px; +} + +.system-page .audit-detail-pre { + margin: 0; + max-height: 240px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.45; +} + +.system-page .audit-detail-meta { + margin: 8px 0 0; +} diff --git a/web/src/styles/legacy/overrides-late.css b/web/src/styles/legacy/overrides-late.css new file mode 100644 index 0000000..9efa29c --- /dev/null +++ b/web/src/styles/legacy/overrides-late.css @@ -0,0 +1,280 @@ +/* LEGACY: HeroUI modal chrome, profiles table, ui-field, cutover wall. Dissolve into components/ over time. */ + +/* HeroUI Modal chrome used by NetX dialogs */ +.app-heroui-modal-backdrop { + z-index: 1200; +} + +.app-heroui-modal .modal__dialog, +.app-heroui-modal__dialog, +.topo-heroui-modal .modal__dialog, +.topo-heroui-modal__dialog { + background: var(--nx-surface-2); + color: var(--nx-text); + border: 1px solid rgba(148, 163, 184, 0.22); + max-height: min(90vh, 920px); + overflow: hidden; +} + +/* + * HeroUI lg = max-w-lg (~32rem) — too narrow for NetX ops tables. + * Match legacy ops-detail-modal--xl (~1100px). + */ +.app-heroui-modal .modal__dialog--lg, +.topo-heroui-modal .modal__dialog--lg { + width: min(1100px, 96vw); + max-width: min(1100px, 96vw); +} + +.app-heroui-modal--xl .modal__dialog, +.app-heroui-modal--xl .modal__dialog--lg, +.app-heroui-modal--xl .modal__dialog--md { + width: min(1280px, 98vw); + max-width: min(1280px, 98vw); +} + +.app-heroui-modal__dialog [data-slot="modal-body"], +.app-heroui-modal__dialog .modal__body { + overflow: auto; + max-height: min(78vh, 780px); +} + +/* + * Modal.Body often uses flex column; default flex-shrink:1 crushes the rules + * table to thead-only while the pager still shows total > 0. + */ +.app-heroui-modal__dialog [data-slot="modal-body"] > *, +.app-heroui-modal__dialog .modal__body > * { + flex-shrink: 0; +} + +.app-heroui-modal--xl .app-heroui-modal__dialog [data-slot="modal-body"], +.app-heroui-modal--xl .app-heroui-modal__dialog .modal__body { + max-height: min(86vh, 900px); +} + +.app-heroui-modal .pt-list-table-wrap { + overflow-x: auto; + max-width: 100%; + width: 100%; + min-height: 10rem; +} + +.app-heroui-modal .pt-list-table { + width: 100%; + min-width: 100%; + table-layout: auto; +} + +.app-heroui-modal .pt-list-table th, +.app-heroui-modal .pt-list-table td { + white-space: nowrap; +} + +/* Biz-state profiles: keep wide table + H-scroll inside a fixed viewport. */ +.app-heroui-modal__dialog [data-slot="modal-body"] > .bs-profiles-panel, +.app-heroui-modal__dialog .modal__body > .bs-profiles-panel { + flex-shrink: 1; + min-height: 0; +} + +.app-heroui-modal .pt-list-table-wrap.bs-profiles-table-wrap { + overflow: auto !important; + max-width: 100%; + width: 100%; + max-height: none; +} + +.app-heroui-modal .pt-list-table.bs-profiles-table { + width: max-content !important; + min-width: 100% !important; + table-layout: fixed !important; +} + +.app-heroui-modal .pt-list-table.bs-profiles-table th, +.app-heroui-modal .pt-list-table.bs-profiles-table td { + white-space: normal; +} + +.app-heroui-modal .pt-list-table.bs-profiles-table .bs-cmd-cell { + white-space: nowrap; +} + +/* Last flexible column absorbs leftover width so the table fills the row */ +.app-heroui-modal .pt-list-table td.ume-col-error, +.app-heroui-modal .pt-list-table th.ume-col-error { + white-space: normal; + min-width: 12rem; + width: 100%; + max-width: none; + word-break: break-word; +} + +/* Keep dense filter bars compact when FieldSelect has no label */ +.filter-inline > .ui-field { + gap: 0; + align-self: center; +} + +.filter-inline > .ui-field > .ui-field__select { + height: 32px; + min-height: 32px; + max-height: 32px; + flex: none; +} + +.login-card--heroui { + gap: 0; +} + +.login-card__form { + display: flex; + flex-direction: column; + gap: 15px; + width: 100%; +} + +.ui-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.ui-field--full { + grid-column: 1 / -1; +} + +.ui-field__label { + font-size: 12px; + color: var(--nm-muted, var(--nx-text-muted)); +} + +.ui-field__select { + width: 100%; + min-height: 36px; + padding: 0 10px; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: color-mix(in oklab, var(--field-background, var(--nx-surface-2)) 92%, #020617); + color: inherit; + font: inherit; +} + +.ui-field__hint { + font-size: 11px; + color: var(--nm-muted, var(--nx-text-muted)); + line-height: 1.35; +} + +.pager__size-field { + min-width: 7.5rem; +} + +.pager__jump .input, +.pager__jump-input { + width: 4.25rem; +} + +/* Cutover wall: keep one board/poller alive in native or window fullscreen. */ +.app-heroui-modal.bm-wall-modal { + width: 100vw !important; + max-width: 100vw !important; + height: 100dvh; + max-height: 100dvh; + padding: 0; + margin: 0; + transform: none; + box-sizing: border-box; + border: 0; +} +.app-heroui-modal.bm-wall-modal .app-heroui-modal__dialog { + width: 100% !important; + max-width: 100% !important; + height: 100%; + max-height: 100%; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + transform: none; + overflow: hidden; + box-sizing: border-box; +} +.bm-wall-modal .modal__header, +.bm-wall-modal .modal__footer, +.bm-wall-modal .bm-detail__chrome, +.bm-wall-modal .nm-config-modal__tabs { display: none; } +.app-heroui-modal.bm-wall-modal .bm-detail { + width: 100%; + min-width: 0; + height: 100%; + max-height: 100%; + margin: 0; + padding: 0; + gap: 0; + overflow: hidden; +} +.bm-board.is-wall, +.bm-board:fullscreen { + width: 100%; + min-width: 0; + height: 100dvh; + min-height: 0; + max-height: 100dvh; + margin: 0; + padding: clamp(12px, 1.25vw, 28px); + gap: 12px; + border-radius: 0; + background: var(--nx-field); + color: var(--nx-text); + box-sizing: border-box; + overflow: auto; +} +.bm-board.is-wall > * { flex-shrink: 0; } +.bm-wall-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; min-width: 0; } +.bm-wall-heading > div { min-width: 0; } +.bm-wall-heading__label { color: var(--nx-text-muted); font-size: 13px; letter-spacing: .08em; } +.bm-wall-heading h2 { margin: 4px 0 0; font-size: clamp(22px, 2vw, 36px); line-height: 1.2; overflow-wrap: anywhere; } +.bm-wall-heading__status { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; } +.bm-wall-heading time { color: var(--nx-text-muted); font: 14px ui-monospace, monospace; white-space: nowrap; } +.bm-wall-state { padding: 6px 12px; font-size: 15px; border: 1px solid currentColor; border-radius: 3px; } +.bm-wall-state.is-normal { color: #34d399; } +.bm-wall-state.is-anomaly { color: #f87171; } +.bm-wall-state.is-waiting, .bm-wall-state.is-unknown { color: #fbbf24; } +.bm-board.is-wall .bm-live-header { position: sticky; top: 0; z-index: 5; background: var(--nx-field); padding: 8px 0; } +.bm-board.is-wall .bm-live-header strong { font-size: 17px; } +.bm-board.is-wall .bm-live-sources strong { font-size: 19px; } +.bm-board.is-wall .bm-live-kpis > div { padding: 16px 20px; } +.bm-board.is-wall .bm-live-kpis span { font-size: 16px; } +.bm-board.is-wall .bm-live-kpis strong { font-size: clamp(40px, 3.5vw, 68px); } +.bm-board.is-wall .bm-live-kpis strong small { font-size: 20px; } +.bm-board.is-wall .bm-live-warning { font-size: 14px; } +.bm-wall-progress { display: flex; align-items: center; gap: 20px; padding: 4px 0; color: #34d399; } +.bm-wall-progress > div:first-child { display: flex; align-items: baseline; gap: 16px; } +.bm-wall-progress span { font-size: 13px; } +.bm-wall-progress strong { font: 600 24px ui-monospace, monospace; } +.bm-wall-progress.is-unknown { color: #fbbf24; } +.bm-wall-progress__track { flex: 1; height: 8px; min-width: 60px; background: #243044; border-radius: 2px; overflow: hidden; } +.bm-wall-progress__track > span { display: block; height: 100%; background: currentColor; } +.bm-board.is-wall .bm-board-controls > summary { display: list-item; cursor: pointer; color: var(--nx-text-muted); font-size: 13px; padding: 6px 0; } +.bm-board.is-wall .bm-sampling { max-height: 240px; overflow: auto; } +.bm-board.is-wall .bm-sampling td, .bm-board.is-wall .bm-sampling th { font-size: 14px; } +.bm-board.is-wall .bs-cmp-board__body { flex: 1 0 260px; min-height: 260px; } +.bm-board.is-wall .bs-cmp-main { display: flex; min-height: 0; } +.bm-board.is-wall .bm-board__table { flex: 1; width: 100%; min-height: 0; max-height: none; } +.bm-board.is-wall .bm-board__table td { font-size: 15px; } +.bm-board.is-wall .bs-cmp-nav__list { max-height: none; } +@media (max-width: 720px) { + .bm-wall-heading { align-items: flex-start; gap: 12px; } + .bm-wall-heading__status { max-width: 48%; } + .bm-wall-heading time { font-size: 10px; white-space: normal; } + .bm-wall-state { font-size: 12px; padding: 4px 6px; } + .bm-board.is-wall .bm-live-kpis > div { padding: 12px; } + .bm-board.is-wall .bm-live-kpis strong { font-size: 36px; } + .bm-board.is-wall .bm-live-kpis span { font-size: 13px; } + .bm-wall-progress { flex-wrap: wrap; gap: 8px; } + .bm-wall-progress__track { flex-basis: 100%; } + .bm-board.is-wall .bs-cmp-board__body { flex-basis: auto; } + .bm-board.is-wall .bm-board__table { max-height: 50dvh; } +} diff --git a/web/src/styles/pages/biz-compare-templates.css b/web/src/styles/pages/biz-compare-templates.css new file mode 100644 index 0000000..8dbaec4 --- /dev/null +++ b/web/src/styles/pages/biz-compare-templates.css @@ -0,0 +1,183 @@ +/* Compare template editor */ + +/* Compare template editor (aligned with monitor template editor) */ +.ct-editor__basics { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +@media (max-width: 720px) { + .ct-editor__basics { + grid-template-columns: 1fr; + } +} +.ct-editor__pick { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 8px 10px; + background: rgb(var(--nx-ink-rgb) / 0.28); +} +.ct-editor__pick-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.ct-editor__pick-title { + font-size: 13px; + font-weight: 650; +} +.ct-editor__pick-hint { + margin: 6px 0 8px; + font-size: 12px; +} +.ct-metric-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 140px; + overflow: auto; +} +.ct-metric-chip__n { + margin-left: 6px; + opacity: 0.65; + font-size: 11px; +} +.ct-editor__main { + display: grid; + grid-template-columns: 200px 1fr; + gap: 12px; + min-height: 360px; + align-items: stretch; +} +@media (max-width: 900px) { + .ct-editor__main { + grid-template-columns: 1fr; + } +} +.ct-editor__nav { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 8px; + background: rgb(var(--nx-ink-rgb) / 0.35); + display: flex; + flex-direction: column; + gap: 6px; + max-height: 70vh; + overflow: auto; +} +.ct-editor__nav-head { + font-size: 12px; + font-weight: 600; + padding: 2px 4px 6px; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} +.ct-editor__nav-list { + display: flex; + flex-direction: column; + gap: 4px; +} +.ct-editor__nav-item { + appearance: none; + text-align: left; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: inherit; + padding: 8px 10px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; +} +.ct-editor__nav-item:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.65); +} +.ct-editor__nav-item.is-active { + border-color: rgb(var(--nx-accent-rgb) / 0.55); + background: rgba(37, 99, 235, 0.18); +} +.ct-editor__nav-name { + font-size: 13px; + font-weight: 600; +} +.ct-editor__nav-tag { + font-size: 11px; + opacity: 0.7; +} +.ct-editor__pane { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} +.ct-editor__pane-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} +.ct-editor__metric { + font-size: 15px; + font-weight: 700; +} +.ct-editor__meta { + font-size: 12px; + margin-top: 4px; +} +.ct-filters__hint, +.ct-filters__empty, +.ct-field-hint { + margin: 0 0 8px; + font-size: 12px; +} +.ct-filters__actions { + display: flex; + gap: 12px; + margin-bottom: 8px; +} +.ct-filter-row { + display: grid; + grid-template-columns: minmax(90px, 1.1fr) minmax(72px, 0.8fr) minmax(80px, 1.2fr) auto; + gap: 6px; + align-items: center; + margin: 4px 0; +} +.ct-filter-row input { + min-width: 0; + width: 100%; +} +.ct-field-table-wrap { + max-height: min(55vh, 520px); + overflow: auto; +} +.ct-field-table { + border-collapse: separate; + border-spacing: 0; +} +.ct-field-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: rgb(var(--nx-ink-rgb) / 0.98) !important; + box-shadow: 0 1px 0 rgba(148, 163, 184, 0.22); +} +.ct-field-table th, +.ct-field-table td { + vertical-align: middle; +} +.ct-field-table th:nth-child(n + 2), +.ct-field-table td:nth-child(n + 2) { + text-align: center; + white-space: nowrap; +} +.ct-field-table td:first-child { + text-align: left; +} +.ct-field-name { + font-size: 12px; +} diff --git a/web/src/styles/pages/biz-compare.css b/web/src/styles/pages/biz-compare.css new file mode 100644 index 0000000..1dcf51f --- /dev/null +++ b/web/src/styles/pages/biz-compare.css @@ -0,0 +1,2395 @@ +/* biz_state compare workspace + result workbook */ + +/* biz_state compare result workbook / board — dark surfaces (match nm modal) */ +.bs-cmp-job-body { + min-height: min(64vh, 680px); +} + +.bs-cmp-board-modal .modal__dialog, +.bs-cmp-board-modal .modal__dialog--lg, +.bs-cmp-board-modal .modal__dialog--md, +.bs-cmp-board-modal .modal__dialog--cover { + width: min(96vw, 1600px) !important; + max-width: min(96vw, 1600px) !important; +} + +.bs-cmp-board-modal .app-heroui-modal__dialog [data-slot="modal-body"].bs-cmp-job-body, +.bs-cmp-board-modal .app-heroui-modal__dialog .modal__body.bs-cmp-job-body { + display: flex; + flex-direction: column; + overflow: hidden; + max-height: min(90vh, 980px); + min-height: min(64vh, 680px); +} + +.bs-cmp-board-modal .bs-cmp-job-body > .nm-config-modal__tabs { + flex: 0 0 auto; +} + +.bs-cmp-board-modal .app-heroui-modal__dialog [data-slot="modal-body"].bs-cmp-job-body > .bs-cmp-board, +.bs-cmp-board-modal .app-heroui-modal__dialog .modal__body.bs-cmp-job-body > .bs-cmp-board, +.bs-cmp-board-modal .bs-cmp-job-body > .bs-cmp-board { + flex: 1 1 auto !important; + flex-shrink: 1 !important; + min-height: 0 !important; + overflow: hidden; +} + +.bs-cmp-board { + --bs-cmp-surface: rgb(var(--nx-ink-rgb) / 0.78); + --bs-cmp-border: rgba(148, 163, 184, 0.2); + --bs-cmp-ink: var(--nx-text); + --bs-cmp-muted: var(--nx-text-muted); + --bs-cmp-row: rgb(var(--nx-ink-rgb) / 0.42); + --bs-cmp-row-alt: rgb(var(--nx-surface-3-rgb) / 0.28); + display: flex; + flex-direction: column; + gap: 8px; + flex: 1 1 auto; + min-height: min(62vh, 720px); + padding: 8px; + border-radius: 12px; + color: var(--bs-cmp-ink); + background: + radial-gradient(1100px 380px at 8% -12%, rgba(37, 99, 235, 0.09), transparent 58%), + rgb(var(--nx-ink-deep-rgb) / 0.4); +} + +.bs-cmp-progress { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid rgb(var(--nx-accent-rgb) / 0.35); + background: rgba(37, 99, 235, 0.12); + color: var(--bs-cmp-ink, var(--nx-text)); + font-size: 0.875rem; +} + +.bs-cmp-progress.is-failed { + border-color: rgba(239, 68, 68, 0.4); + background: rgba(239, 68, 68, 0.12); +} + +.bs-cmp-progress.is-cancelled { + border-color: rgba(245, 158, 11, 0.4); + background: rgba(245, 158, 11, 0.1); +} + +.bs-cmp-progress__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; +} + +.bs-cmp-progress__actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; +} + +.bs-cmp-progress__engine, +.bs-cmp-progress__elapsed { + font-size: 0.8125rem; +} + +.bs-cmp-progress__track, +.bs-cmp-run-prog__track { + height: 6px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.28); + overflow: hidden; +} + +.bs-cmp-progress__fill, +.bs-cmp-run-prog__fill { + height: 100%; + width: 0; + border-radius: inherit; + background: linear-gradient(90deg, var(--nx-accent-strong), #60a5fa); + transition: width 0.35s ease; +} + +.bs-cmp-progress__fill.is-indeterminate, +.bs-cmp-run-prog__fill.is-indeterminate { + width: 36%; + animation: bs-cmp-prog-slide 1.25s ease-in-out infinite; +} + +@keyframes bs-cmp-prog-slide { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(320%); + } +} + +.bs-cmp-progress__meta { + font-size: 0.8125rem; + color: var(--bs-cmp-muted, var(--nx-text-muted)); + line-height: 1.4; + word-break: break-word; +} + +.bs-cmp-progress__msg { + flex: 1 1 100%; + font-size: 0.8125rem; +} + +/* Compare batch list (job detail → 对比批次) */ +.bs-cmp-runs { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 260px; +} + +.bs-cmp-runs__toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 10px 14px; +} + +.bs-cmp-runs__hint { + margin: 0; + flex: 1 1 220px; + line-height: 1.45; +} + +.bs-cmp-runs__table tbody tr.is-selected td { + background: rgba(37, 99, 235, 0.08); +} + +.bs-cmp-runs__elapsed { + margin-top: 4px; + font-size: 0.75rem; + color: var(--muted, var(--nx-text-faint)); + font-variant-numeric: tabular-nums; +} + +.bs-cmp-runs__sides { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 6px; + font-size: 0.8125rem; + line-height: 1.35; + max-width: 22rem; +} + +.bs-cmp-runs__arrow { + color: var(--muted, var(--nx-text-muted)); + flex: 0 0 auto; +} + +.bs-cmp-runs__summary { + font-size: 0.8125rem; + line-height: 1.4; + color: var(--ink, var(--nx-line-strong)); + max-width: 18rem; +} + +.bs-cmp-run-prog { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 160px; + max-width: 22rem; +} + +.bs-cmp-run-prog__meta { + font-size: 0.75rem; + line-height: 1.35; + color: var(--muted, var(--nx-text-faint)); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Business compare workspace: compact, flat ops surfaces. + Panel / table / control chrome inherits from .network-main ops defaults. */ +.bs-cmp-workspace, +.bs-cmp-job-modal, +.bs-cmp-create { + --cmp-line: var(--nm-ops-line, var(--nx-line)); + --cmp-muted: var(--nm-ops-muted, var(--nx-text-muted)); + --cmp-surface: var(--nm-ops-surface, var(--nx-surface-2)); + --cmp-accent: var(--nm-ops-accent, var(--nx-accent)); +} + +.bs-cmp-workspace .bs-cmp-runs__delete { + color: #fca5a5; +} + +.bs-cmp-job-modal .modal__dialog, +.bs-cmp-create .modal__dialog { + border-radius: 6px; + background: var(--nx-surface-1); + border-color: var(--cmp-line); + max-height: min(92dvh, 1000px); +} + +.bs-cmp-job-modal:not(.bs-cmp-board-modal) .modal__dialog { + width: min(1180px, 96vw); + max-width: min(1180px, 96vw); + display: flex; + flex-direction: column; +} + +.bs-cmp-job-modal:not(.bs-cmp-board-modal) .modal__body.bs-cmp-job-body { + min-height: 0; + max-height: none; + flex: 1 1 auto; + overflow: auto; +} + +.bs-cmp-job-modal .button, +.bs-cmp-create .button, +.bs-cmp-job-modal .ui-field__select, +.bs-cmp-create .ui-field__select, +.bs-cmp-job-modal .input, +.bs-cmp-create .input { + border-radius: 4px; + box-shadow: none; +} + +.bs-cmp-job-heading { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; + overflow-wrap: anywhere; + font-size: 16px; +} + +.bs-cmp-job-heading__id { + color: var(--cmp-muted); + font: 10px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; + letter-spacing: 0.08em; +} + +.bs-cmp-job-modal .bs-cmp-job-tabs { + margin: 0; + gap: 0; + flex-wrap: nowrap; + border-bottom: 1px solid var(--cmp-line); +} + +.bs-cmp-job-tabs .button { + flex: 0 1 auto; + min-width: 0; + height: 38px; + padding: 0 15px; + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + color: var(--cmp-muted); +} + +.bs-cmp-job-modal .bs-cmp-job-tabs .button.is-active { + outline: none; + box-shadow: none; + border-bottom-color: var(--cmp-accent); + color: #edf5ff; + background: rgb(var(--nx-accent-rgb) / 0.08); +} + +.bs-cmp-job-tabs__index { + font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; + opacity: 0.7; +} + +.bs-cmp-job-tabs__count { + padding: 1px 5px; + background: #263347; + border-radius: 3px; + font-size: 10px; +} + +.bs-cmp-config { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.bs-cmp-config__section { + min-width: 0; + padding: 14px; + border: 1px solid var(--cmp-line); + border-radius: 4px; + background: var(--cmp-surface); +} + +.bs-cmp-config .is-wide, +.bs-cmp-config__fields > .is-wide { + grid-column: 1 / -1; +} + +.bs-cmp-config__title { + display: flex; + align-items: center; + gap: 9px; + margin: 0 0 14px; + color: #e7eef7; + font-size: 12px; + font-weight: 650; +} + +.bs-cmp-config__title > span { + color: var(--cmp-accent); + font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.bs-cmp-config__fields, +.bs-cmp-mapping__fields, +.bs-cmp-sources { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 16px; + min-width: 0; +} + +.bs-cmp-config__fields > .ui-field--full, +.bs-cmp-mapping__fields > .ui-field--full { + grid-column: auto; +} + +.bs-cmp-config__hint { + color: var(--cmp-muted); + font-size: 11px; + line-height: 1.65; + margin: 0; +} + +.bs-cmp-source { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 12px; + border: 1px solid var(--cmp-line); + border-radius: 3px; + background: var(--nx-surface-1); +} + +.bs-cmp-source__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.bs-cmp-source__head > span:last-child { + margin-left: auto; + color: var(--cmp-muted); + font-size: 10px; +} + +.bs-cmp-source__tag { + padding: 2px 5px; + border: 1px solid #465569; + color: #c6d3e3; + font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.bs-cmp-source.is-after .bs-cmp-source__tag { + border-color: #3f6a97; + color: var(--nx-accent-text); +} + +.bs-cmp-source__meta { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 5px; + margin-top: auto; + padding-top: 9px; + border-top: 1px solid var(--cmp-line); + color: var(--cmp-muted); + font: 11px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.bs-cmp-source__auto { + min-height: 58px; +} + +.bs-cmp-scope__heading { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; +} + +.bs-cmp-scope__count { + color: var(--cmp-accent); + font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.bs-cmp-scope .bm-hint { + margin: 8px 0 12px; + font-size: 11px; + line-height: 1.55; +} + +.bs-cmp-scope__items { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); + gap: 6px; +} + +.bs-cmp-scope__item { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--cmp-line); + border-radius: 3px; + background: var(--nx-surface-1); + color: var(--cmp-muted); + text-align: left; + cursor: pointer; +} + +.bs-cmp-scope__item.is-on { + border-color: #3c5f89; + background: rgba(61, 108, 160, 0.1); + color: #dbeafe; +} + +.bs-cmp-scope__item:disabled { + cursor: default; +} + +.bs-cmp-scope__check { + display: grid; + place-items: center; + flex: 0 0 15px; + height: 15px; + border: 1px solid #53677f; + border-radius: 2px; + color: var(--cmp-accent); + font-size: 11px; +} + +.bs-cmp-scope__content { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + overflow-wrap: anywhere; +} + +.bs-cmp-scope__content strong { + font-size: 12px; + font-weight: 550; +} + +.bs-cmp-scope__content > span { + color: var(--cmp-muted); + font-size: 10px; +} + +.bs-cmp-mapping { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.bs-cmp-mapping__editor { + width: 100%; + min-height: 100px; + padding: 8px 10px; + border: 1px solid var(--cmp-line); + border-radius: 3px; + background: #0a121d; + color: #dce8f6; + resize: vertical; + font: 11px/1.7 ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.bs-cmp-mapping__actions { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; +} + +.bs-cmp-create .bm-create__pane, +.bs-cmp-create .bm-steps__item { + border-radius: 4px; + box-shadow: none; +} + +.bs-cmp-job-modal .bs-cmp-runs { + gap: 14px; +} + +.bs-cmp-runs__intro { + flex: 1 1 300px; +} + +.bs-cmp-runs__intro h3 { + margin: 0 0 5px; + color: #e7eef7; + font-size: 14px; +} + +.bs-cmp-job-modal .bs-cmp-runs__hint { + color: var(--cmp-muted); + font-size: 11px; + line-height: 1.55; +} + +.bs-cmp-run-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0; + border: 1px solid var(--cmp-line); + border-radius: 4px; + background: var(--cmp-surface); +} + +.bs-cmp-run-stats > div { + padding: 12px 15px; + border-right: 1px solid var(--cmp-line); +} + +.bs-cmp-run-stats > div:last-child { + border-right: 0; +} + +.bs-cmp-run-stats dt { + color: var(--cmp-muted); + font-size: 11px; +} + +.bs-cmp-run-stats dd { + margin: 5px 0 0; + color: #e7eef7; + font: 24px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; + font-variant-numeric: tabular-nums; +} + +.bs-cmp-run-stats .is-active dd { + color: var(--nx-accent-text); +} + +.bs-cmp-run-stats .is-stopped dd { + color: #fbbf24; +} + +.bs-cmp-job-modal .bs-cmp-runs .pt-list-table-wrap { + max-height: min(54vh, 580px); + overflow: auto; + border: 1px solid var(--cmp-line); + border-radius: 4px; +} + +.bs-cmp-runs__table { + min-width: 1000px; + table-layout: fixed; +} + +.bs-cmp-runs__table th:nth-child(1) { width: 12%; } +.bs-cmp-runs__table th:nth-child(2) { width: 18%; } +.bs-cmp-runs__table th:nth-child(3) { width: 27%; } +.bs-cmp-runs__table th:nth-child(4) { width: 25%; } +.bs-cmp-runs__table th:nth-child(5) { width: 18%; } + +.bs-cmp-job-modal .bs-cmp-runs__table th { + position: sticky; + top: 0; + z-index: 1; + background: var(--nx-surface-3); + color: #a7b6ca; + font-size: 11px; + font-weight: 550; +} + +.bs-cmp-job-modal .bs-cmp-runs__table td { + height: 80px; + border-bottom: 1px solid var(--cmp-line); + background: var(--nx-surface-1); + vertical-align: middle; + font-size: 12px; +} + +.bs-cmp-job-modal .bs-cmp-runs__table tr.is-selected td { + background: #142237; +} + +.bs-cmp-runs__table tr td:first-child { + border-left: 3px solid transparent; +} + +.bs-cmp-runs__table tr.is-selected td:first-child, +.bs-cmp-runs__table tr[data-status="running"] td:first-child, +.bs-cmp-runs__table tr[data-status="queued"] td:first-child { + border-left-color: var(--cmp-accent); +} + +.bs-cmp-runs__table tr[data-status="failed"] td:first-child { + border-left-color: #f87171; +} + +.bs-cmp-runs__id { + display: block; + margin-top: 5px; + color: var(--cmp-muted); + font-size: 10px; +} + +.bs-cmp-job-modal .bs-cmp-runs__sides { + display: grid; + gap: 8px; + max-width: none; + font-size: 11px; +} + +.bs-cmp-runs__sides > div { + display: grid; + grid-template-columns: 42px minmax(0, 1fr) auto; + align-items: center; + gap: 6px; +} + +.bs-cmp-runs__side-tag, +.bs-cmp-runs__sides time { + color: var(--cmp-muted); + font-size: 10px; + white-space: nowrap; +} + +.bs-cmp-runs__sides strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 550; +} + +.bs-cmp-run-verdict { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.bs-cmp-run-verdict > span { + display: flex; + flex-direction: column; + gap: 4px; + color: var(--cmp-muted); + font-size: 10px; +} + +.bs-cmp-run-verdict strong { + color: #dbe7f5; + font: 16px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; + font-variant-numeric: tabular-nums; +} + +.bs-cmp-run-verdict .is-fail strong { color: #fca5a5; } +.bs-cmp-run-verdict .is-pass strong { color: #86efac; } + +.bs-cmp-job-modal .bs-cmp-runs__summary, +.bs-cmp-job-modal .bs-cmp-run-prog__meta, +.bs-cmp-job-modal .bs-cmp-runs__elapsed { + color: var(--cmp-muted); + font-size: 11px; + overflow-wrap: anywhere; +} + +.bs-cmp-runs__summary.is-stopped { + color: #fbbf24; +} + +.bs-cmp-job-modal .bs-cmp-runs__delete { + color: #fca5a5; +} + +.bs-cmp-runs__error { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 10px; + border: 1px solid #7f4343; + border-radius: 4px; + color: #fecaca; + background: rgba(127, 67, 67, 0.1); + font-size: 12px; + overflow-wrap: anywhere; +} + +.bs-cmp-job-modal .bs-cmp-board, +.bs-cmp-job-modal .bs-cmp-strip, +.bs-cmp-job-modal .bs-cmp-sides, +.bs-cmp-job-modal .bs-cmp-nav, +.bs-cmp-job-modal .bs-cmp-progress { + border-radius: 4px; + background-image: none; +} + +.bs-cmp-job-modal .bs-cmp-progress__track, +.bs-cmp-job-modal .bs-cmp-run-prog__track { + border-radius: 2px; +} + +.bs-cmp-job-modal .bs-cmp-progress__fill, +.bs-cmp-job-modal .bs-cmp-run-prog__fill { + background: #5795d9; +} + +.bs-cmp-scope__item:focus-visible, +.bs-cmp-job-modal .bs-cmp-job-tabs .button:focus-visible, +.bs-cmp-mapping__editor:focus-visible { + outline: 2px solid var(--cmp-accent); + outline-offset: 2px; +} + +@media (max-width: 800px) { + .bs-cmp-config { grid-template-columns: minmax(0, 1fr); } + .bs-cmp-job-tabs .button { padding: 0 10px; } +} + +@media (max-width: 600px) { + .bs-cmp-sources, + .bs-cmp-config__fields, + .bs-cmp-mapping__fields { grid-template-columns: minmax(0, 1fr); } + .bs-cmp-config__section { padding: 11px; } + .bs-cmp-run-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .bs-cmp-run-stats > div:nth-child(2) { border-right: 0; } + .bs-cmp-run-stats > div:nth-child(-n+2) { border-bottom: 1px solid var(--cmp-line); } + .bs-cmp-job-tabs__index { display: none; } + .bs-cmp-job-modal .bs-cmp-job-tabs .button { flex: 1 1 0; padding: 0 7px; font-size: 12px; } +} + +@media (prefers-reduced-motion: reduce) { + .bs-cmp-job-modal .bs-cmp-progress__fill, + .bs-cmp-job-modal .bs-cmp-run-prog__fill { transition: none; } + .bs-cmp-job-modal .is-indeterminate { animation: none; } +} + +/* Business monitoring: modal + task chrome; list panel inherits .network-main ops. */ +.bs-monitor-workspace, +.bs-monitor-modal { + --mon-line: var(--nm-ops-line, var(--nx-line)); + --mon-muted: var(--nm-ops-muted, var(--nx-text-muted)); + --mon-accent: var(--nm-ops-accent, var(--nx-accent)); +} + +.bs-monitor-modal .modal__dialog { + border-color: var(--mon-line); + border-radius: 6px; + background: var(--nx-surface-1); +} + +.bs-monitor-modal .button, +.bs-monitor-modal .input, +.bs-monitor-modal .ui-field__select { + border-radius: 4px; + box-shadow: none; +} + +.bs-monitor-modal .filter-inline { + flex-wrap: wrap; + border-radius: 4px; +} + +.bs-monitor-workspace > .pt-list > .pt-list-table-wrap, +.bs-monitor-modal .pt-list-table-wrap { + min-width: 0; +} + +.bs-monitor-workspace > .pt-list > .pt-list-table-wrap .pt-list-table { min-width: 1120px; } +.bs-monitor-batches .pt-list-table { min-width: 1100px; } +.bs-monitor-modal .pt-list-table th { background: var(--nx-surface-3); font-size: 11px; } +.bs-monitor-modal .pt-list-table td { border-bottom-color: var(--mon-line); } +.bs-monitor-workspace .pt-list-task-name + .muted { font-size: 11px; } + +.bs-monitor-heading { display: flex; flex-direction: column; gap: 5px; min-width: 0; } +.bs-monitor-id { color: var(--mon-muted); font: 10px/1.4 ui-monospace, Consolas, monospace; } +.bs-monitor-heading > span:last-child { font-size: 16px; overflow-wrap: anywhere; } +.bs-monitor-task-content { display: flex; flex-direction: column; gap: 14px; min-width: 0; } +.bs-monitor-task-content[hidden] { display: none; } +.bs-monitor-task-content[inert] { opacity: 0.5; } + +.bs-monitor-task .bs-schedule-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: start; + gap: 12px; + padding: 14px; + border: 1px solid var(--mon-line); + border-radius: 4px; + background: var(--nx-surface-2); +} + +.bs-monitor-task .bs-schedule-row > .muted { grid-column: 1 / -1; font-size: 11px; line-height: 1.6; } +.bs-monitor-task .config-sync-policy-field { min-width: 0; flex-wrap: wrap; } +.bs-monitor-task .config-sync-policy-field .input { min-width: 0; width: 100%; } +.bs-monitor-task .config-sync-policy-field > select { min-width: 0; border-radius: 4px; } +.bs-monitor-task .bs-monitor-interval-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; } +.bs-monitor-task .bs-monitor-interval-field > span { grid-column: 1 / -1; } +.bs-monitor-modal .modal__footer { min-width: 0; max-width: 100%; flex-wrap: wrap; } +.bs-monitor-task .bs-monitor-task-tabs { gap: 0; margin: 0; border-bottom: 1px solid var(--mon-line); } +.bs-monitor-task-tabs .button { border: 0; border-radius: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--mon-muted); } +.bs-monitor-task .bs-monitor-task-tabs .button.is-active { border-bottom-color: var(--mon-accent); color: #edf5ff; background: rgb(var(--nx-accent-rgb) / 0.08); outline: none; box-shadow: none; } +.bs-monitor-task-tabs .button:focus-visible { outline: 2px solid var(--mon-accent); outline-offset: -2px; } +.bs-monitor-history-count { flex: 1 1 240px; font-size: 11px; } +.bs-monitor-batches > .btn-row { padding: 10px; } +.bs-monitor-workspace .bs-monitor-delete, +.bs-monitor-modal .bs-monitor-delete { color: #fca5a5; } + +.bs-monitor-loading { padding: 16px; color: var(--mon-muted); font-size: 12px; } +.bs-monitor-error { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 10px; + border: 1px solid #7f4343; + border-radius: 4px; + color: #fecaca; + background: rgba(127,67,67,0.1); + font-size: 12px; + overflow-wrap: anywhere; +} + +.bs-monitor-modal .bs-workbook-meta, +.bs-monitor-modal .bs-sheet-cmd-bar, +.bs-monitor-modal .bs-sheet-cmd-row, +.bs-monitor-modal .bs-sheet-tab { border-radius: 4px; background-image: none; } +.bs-monitor-modal .bs-sheet-tabs { min-width: 0; flex-wrap: nowrap; overflow-x: auto; } +.bs-monitor-modal .bs-sheet-tab { flex: 0 0 auto; } +.bs-monitor-modal .bs-sheet-filter { gap: 8px; } +.bs-monitor-modal .bs-sheet-filter .input { flex: 1 1 200px; min-width: 0; } +.bs-monitor-modal .bs-sheet-filter .ui-field { min-width: 0; max-width: 100%; } +.bs-monitor-modal .bs-sheet-filter .ui-field__select { max-width: 100%; } +.bs-monitor-modal .bs-sheet-count { margin-left: auto; } + +@media (max-width: 800px) { + .bs-monitor-task .bs-schedule-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 600px) { + .bs-monitor-task .bs-schedule-row { grid-template-columns: minmax(0, 1fr); } + .bs-monitor-modal .bs-sheet-filter > .input { flex-basis: 100%; } +} + +/* Compare templates: compact operations editor and safe nested-rule readout. */ +.bs-template-editor .modal__dialog { border-radius: 6px; border-color: var(--nx-line); background: var(--nx-surface-1); } +.bs-template-editor .button, +.bs-template-editor .input, +.bs-template-editor textarea, +.bs-template-editor .mt-select, +.bs-template-editor .ui-field__select { border-radius: 4px; box-shadow: none; } +.bs-template-editor .modal__footer { flex-wrap: wrap; } +.bs-template-editor .ct-editor__norm, +.bs-template-editor .ct-editor__pick, +.bs-template-editor .ct-editor__nav, +.bs-template-editor .ct-editor__pane { border: 1px solid var(--nx-line); border-radius: 4px; background: var(--nx-surface-2); min-width: 0; } +.bs-template-editor .ct-editor__norm { padding: 10px; } +.bs-template-editor .ct-editor__norm summary { cursor: pointer; color: #c4ddfa; } +.bs-template-editor .ct-editor__norm-area { width: 100%; max-width: 100%; resize: vertical; font: 12px/1.6 ui-monospace, Consolas, monospace; } +.bs-template-editor .ct-editor__main { grid-template-columns: 190px minmax(0, 1fr); min-width: 0; } +.bs-template-editor .ct-editor__nav { max-height: 560px; } +.bs-template-editor .ct-editor__nav-item { border-radius: 3px; font-size: 12px; } +.bs-template-editor .ct-editor__nav-item.is-active { background: #1b2c43; border-color: #526f94; } +.bs-template-editor .ct-editor__meta { font: 11px/1.6 ui-monospace, Consolas, monospace; overflow-wrap: anywhere; } +.bs-template-editor .mt-rule-tab.is-active { color: #9cc7f7; border-color: #526f94; background: #1b2c43; } +.bs-template-editor .ct-field-table-wrap { min-width: 0; overflow: auto; border: 1px solid var(--nx-line); border-radius: 4px; } +.bs-template-editor .ct-field-table { min-width: 800px; } +.bs-template-editor .ct-field-table td { font-size: 12px; } +.bs-template-editor .ct-field-table th { font-size: 11px; } +.bs-template-editor .ct-field-table .input { max-width: 100px; } +.bs-template-editor .ct-filter-row { min-width: 0; } +.bs-template-editor .ct-filter-row .mt-select { min-width: 0; max-width: 100%; } +.bs-template-editor .ct-complex-filters pre { max-height: 360px; overflow: auto; background: var(--nx-surface-1); border: 1px solid var(--nx-line); border-radius: 4px; padding: 10px; font: 11px/1.6 ui-monospace, Consolas, monospace; } +.bs-template-editor button:focus-visible { outline: 2px solid var(--nx-accent); outline-offset: 2px; } +.bs-template-workspace .pt-list-table { min-width: 720px; } +.bs-template-workspace .bs-cmp-metric-chips { max-height: 84px; overflow: auto; } +@media (max-width: 900px) { + .bs-template-editor .ct-editor__main { grid-template-columns: minmax(0, 1fr); } + .bs-template-editor .ct-editor__nav-list { flex-direction: row; overflow-x: auto; } + .bs-template-editor .ct-editor__nav-item { flex: 0 0 160px; } + .bs-template-editor .ct-editor__nav { max-height: none; } +} +@media (max-width: 600px) { + .bs-template-editor .ct-editor__pane { padding: 10px; } + .bs-template-editor .ct-editor__pane-head { flex-direction: column; } + .bs-template-editor .ct-editor__pane-head > div:first-child { width: 100%; } + .bs-template-editor .ct-editor__pane-head > .btn-row { align-self: flex-end; } + .bs-template-editor .ct-filter-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; } + .bs-template-editor .ct-filter-row > .input { grid-column: 1 / 3; grid-row: 2; } + .bs-template-editor .ct-filter-row > button { grid-column: 3; grid-row: 1 / 3; } +} + +/* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */ +.bs-cmp-board.is-fullscreen, +.bs-cmp-board:fullscreen { + --bs-cmp-surface: rgb(var(--nx-ink-rgb) / 0.92); + background: var(--nx-field); + color: var(--nx-text); + padding: 12px 14px; + gap: 10px; + overflow: hidden; + box-sizing: border-box; +} + +.bs-cmp-board:fullscreen { + width: 100%; + height: 100%; + max-height: 100%; + min-height: 100%; + border-radius: 0; + margin: 0; +} + +/* CSS fallback only — used when requestFullscreen is blocked */ +.bs-cmp-board.is-fullscreen:not(:fullscreen) { + position: fixed; + inset: 0; + z-index: 2147483000; + width: 100vw; + height: 100vh; + max-height: 100vh; + min-height: 100vh; + margin: 0; + border-radius: 0; + box-shadow: none; +} + +body:has(.bs-cmp-board.is-fullscreen), +body:has(.bs-cmp-board:fullscreen) { + overflow: hidden; +} + +.bs-cmp-board__toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px 10px; + align-items: center; + justify-content: flex-start; + flex: 0 0 auto; + min-height: 36px; + padding: 2px 0; +} + +.bs-cmp-board__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; +} + +.bs-cmp-board__run-select { + flex: 0 1 280px; + min-width: 140px; + max-width: 360px; + height: 32px; + margin: 0; +} + +.bs-cmp-board__run-count { + flex: 0 0 auto; + font-size: 11px; + white-space: nowrap; + opacity: 0.75; +} + +.bs-cmp-board__config { + flex: 0 1 auto; + min-width: 0; + padding: 4px 7px; + border: 1px solid var(--bs-cmp-border, rgba(148, 163, 184, 0.22)); + border-radius: 6px; + color: var(--nm-muted, var(--nx-text-muted)); + font-size: 11px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.bs-cmp-board__config.is-legacy { + color: #fbbf24; +} + +.bs-cmp-board__actions { + margin-left: auto; + flex: 0 0 auto; + align-items: center; + gap: 6px; +} + +.bs-cmp-sides { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + gap: 6px 8px; + min-width: 0; + flex: 1 1 280px; + height: 32px; + padding: 0 8px; + border-radius: 8px; + border: 1px solid var(--bs-cmp-border, rgba(148, 163, 184, 0.22)); + background: rgb(var(--nx-ink-rgb) / 0.5); + overflow: hidden; +} + +.bs-cmp-sides__side { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; + padding: 0 8px; + border-radius: 6px; + height: 24px; + flex: 1 1 auto; +} + +.bs-cmp-sides__side.is-before { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(248, 113, 113, 0.28); +} + +.bs-cmp-sides__side.is-after { + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(74, 222, 128, 0.28); +} + +.bs-cmp-sides__tag { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--nx-text-muted); + flex: 0 0 auto; +} + +.bs-cmp-sides__side.is-before .bs-cmp-sides__tag { + color: #fca5a5; +} + +.bs-cmp-sides__side.is-after .bs-cmp-sides__tag { + color: #86efac; +} + +.bs-cmp-sides__name { + font-size: 12px; + font-weight: 700; + color: var(--nx-text-strong); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 12rem; + min-width: 0; + flex: 1 1 auto; + line-height: 1.2; +} + +.bs-cmp-sides__meta { + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + flex: 0 0 auto; + color: var(--nx-text-muted); +} + +.bs-cmp-sides__arrow { + flex: 0 0 auto; + color: var(--nx-accent-text); + font-weight: 700; + opacity: 0.85; +} + +.bs-cmp-sides__vs { + display: none; +} + +@media (max-width: 1100px) { + .bs-cmp-board__actions { + margin-left: 0; + } + + .bs-cmp-board__meta { + flex: 1 1 100%; + } + + .bs-cmp-sides { + flex: 1 1 100%; + height: auto; + min-height: 32px; + padding: 4px 8px; + overflow: visible; + } + + .bs-cmp-sides__name { + max-width: none; + } + + .bs-cmp-board__run-select { + flex: 1 1 200px; + max-width: none; + } +} + +.bs-cmp-board__body { + display: grid; + grid-template-columns: minmax(220px, 268px) minmax(0, 1fr); + gap: 10px; + flex: 1 1 auto; + min-height: 0; + align-items: stretch; + overflow: hidden; + transition: grid-template-columns 0.18s ease; +} + +.bs-cmp-board__body.is-nav-collapsed { + grid-template-columns: 44px minmax(0, 1fr); +} + +.bs-cmp-nav { + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid var(--bs-cmp-border); + border-radius: 10px; + background: var(--bs-cmp-surface); + overflow: hidden; +} + +.bs-cmp-nav__head { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + align-items: center; + gap: 0 6px; + padding: 6px 8px; + border-bottom: 1px solid var(--bs-cmp-border); + flex: 0 0 auto; + min-height: 36px; +} + +.bs-cmp-nav.is-collapsed .bs-cmp-nav__head { + grid-template-columns: 1fr; + justify-items: center; + padding: 6px 4px; +} + +.bs-cmp-nav__toggle { + appearance: none; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: rgb(var(--nx-ink-rgb) / 0.55); + color: var(--nx-accent-text); + font-size: 14px; + font-weight: 700; + line-height: 1; + cursor: pointer; + padding: 0; +} + +.bs-cmp-nav__toggle:hover { + background: rgba(37, 99, 235, 0.22); + border-color: rgb(var(--nx-accent-rgb) / 0.45); +} + +.bs-cmp-nav__dot--spacer { + visibility: hidden; +} + +.bs-cmp-nav__head-main { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.bs-cmp-nav__title { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--nx-text-strong); + white-space: nowrap; +} + +.bs-cmp-nav__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.35rem; + height: 1.2rem; + padding: 0 5px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; + color: var(--nx-accent-text); + background: rgb(var(--nx-accent-rgb) / 0.18); + border: 1px solid rgba(96, 165, 250, 0.35); +} + +.bs-cmp-nav__hint { + display: none; +} + +.bs-cmp-nav__legend, +.bs-cmp-nav__stats { + display: inline-grid; + grid-template-columns: 2.6em 2.6em 2.8em; + gap: 2px; + justify-items: end; + align-items: center; + flex: 0 0 auto; + font-variant-numeric: tabular-nums; +} + +.bs-cmp-nav__legend { + margin: 0; + font-size: 10px; + font-weight: 700; +} + +.bs-cmp-nav__num { + font-size: 11px; + font-weight: 700; + line-height: 1; + color: var(--nx-text-muted); + opacity: 0.55; + white-space: nowrap; +} + +.bs-cmp-nav__legend .bs-cmp-nav__num { + font-size: 10px; +} + +.bs-cmp-nav__num--fail.is-hot { + color: #f87171; + opacity: 1; +} + +.bs-cmp-nav__num--ok.is-hot { + color: #4ade80; + opacity: 1; +} + +.bs-cmp-nav__num--rate { + color: var(--nx-accent-text); + opacity: 0.95; +} + +.bs-cmp-nav__list { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.bs-cmp-nav__item { + appearance: none; + display: grid; + grid-template-columns: 8px minmax(0, 1fr) auto; + gap: 0 8px; + align-items: center; + width: 100%; + text-align: left; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--bs-cmp-ink); + padding: 7px 8px; + cursor: pointer; + line-height: 1.25; + transition: background 0.12s ease, border-color 0.12s ease; +} + +.bs-cmp-nav.is-collapsed .bs-cmp-nav__item { + grid-template-columns: 1fr; + justify-items: center; + padding: 8px 4px; + border-radius: 6px; +} + +.bs-cmp-nav.is-collapsed .bs-cmp-nav__name, +.bs-cmp-nav.is-collapsed .bs-cmp-nav__stats { + display: none; +} + +.bs-cmp-nav.is-collapsed .bs-cmp-nav__list { + padding: 4px; + gap: 2px; + align-items: stretch; +} + +.bs-cmp-nav.is-collapsed .bs-cmp-nav__dot { + width: 8px; + height: 8px; +} + +.bs-cmp-nav.is-collapsed .bs-cmp-nav__item.is-active { + box-shadow: none; + background: rgba(37, 99, 235, 0.28); +} + +.bs-cmp-nav__item:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.75); +} + +.bs-cmp-nav__item.is-active { + background: rgba(37, 99, 235, 0.22); + border-color: rgb(var(--nx-accent-rgb) / 0.5); +} + +.bs-cmp-nav__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--nx-text-faint); + justify-self: center; + align-self: center; + box-shadow: 0 0 0 2px rgba(100, 116, 139, 0.2); +} + +.bs-cmp-nav__item.is-clean .bs-cmp-nav__dot { + background: #22c55e; + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); +} + +.bs-cmp-nav__item.has-diff .bs-cmp-nav__dot { + background: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.22); +} + +.bs-cmp-nav__item.is-pending .bs-cmp-nav__dot { + background: var(--nx-text-faint); + box-shadow: 0 0 0 2px rgba(100, 116, 139, 0.25); +} + +.bs-cmp-nav__item.is-pending .bs-cmp-nav__name { + opacity: 0.75; +} + +.bs-cmp-nav__name { + font-size: 12px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--nx-text); + min-width: 0; +} + +.bs-cmp-nav__item.is-active .bs-cmp-nav__name { + color: var(--nx-text-strong); +} + +.bs-cmp-main { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.bs-cmp-main > .pt-list-pager, +.bs-cmp-main > .pager { + flex: 0 0 auto; +} + +/* Run-level overview: never changes when switching sheets */ +.bs-cmp-overview { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + min-height: 34px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--bs-cmp-border); + background: rgb(var(--nx-ink-rgb) / 0.45); + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--nx-text-soft); + overflow: visible; + flex: 0 0 auto; +} + +.bs-cmp-overview__stat { + flex: 0 0 auto; + white-space: nowrap; + padding: 2px 8px; + border-radius: 6px; + background: rgba(148, 163, 184, 0.1); + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.bs-cmp-overview.is-ok { + border-color: rgba(34, 197, 94, 0.28); +} + +.bs-cmp-overview.is-warn { + border-color: rgba(245, 158, 11, 0.32); +} + +.bs-cmp-overview__tag { + flex: 0 0 auto; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--bs-cmp-muted); + padding: 2px 6px; + border-radius: 4px; + background: rgba(148, 163, 184, 0.12); +} + +.bs-cmp-overview__pass b { + color: var(--nx-text-strong); + font-weight: 800; + margin-left: 2px; +} + +.bs-cmp-overview.is-ok .bs-cmp-overview__pass b { + color: #86efac; +} + +.bs-cmp-overview.is-warn .bs-cmp-overview__pass b { + color: #fbbf24; +} + +.bs-cmp-overview__stat b { + color: var(--nx-text); + font-weight: 700; + margin-left: 1px; +} + +.bs-cmp-overview__time { + margin-left: auto; + white-space: nowrap; +} + +/* Sheet strip: sheet + pass rate + kind filters + search */ +.bs-cmp-strip { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + min-height: 44px; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--bs-cmp-border); + background: var(--bs-cmp-surface); + flex: 0 0 auto; +} + +.bs-cmp-strip.is-ok { + border-color: rgba(34, 197, 94, 0.32); + background: + linear-gradient(90deg, rgba(34, 197, 94, 0.06), transparent 28%), + var(--bs-cmp-surface); +} + +.bs-cmp-strip.is-warn { + border-color: rgba(245, 158, 11, 0.38); + background: + linear-gradient(90deg, rgba(245, 158, 11, 0.07), transparent 28%), + var(--bs-cmp-surface); +} + +.bs-cmp-strip__sheet { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 180px; +} + +.bs-cmp-strip__sheet-tag { + flex: 0 0 auto; + font-size: 10px; + font-weight: 700; + color: var(--nx-accent-text); + padding: 2px 6px; + border-radius: 4px; + background: rgba(37, 99, 235, 0.18); +} + +.bs-cmp-strip__sheet-name { + font-size: 13px; + color: var(--nx-text-strong); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.bs-cmp-strip__sheet-mode { + flex: 0 0 auto; + font-size: 11px; +} + +.bs-cmp-strip__pass { + display: flex; + flex-direction: column; + justify-content: center; + gap: 1px; + min-width: 0; + padding: 2px 10px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgb(var(--nx-ink-deep-rgb) / 0.35); +} + +.bs-cmp-strip__pass-label { + font-size: 10px; + color: var(--bs-cmp-muted); + line-height: 1.2; + white-space: nowrap; +} + +.bs-cmp-strip__pass-value { + font-size: 18px; + font-weight: 800; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + line-height: 1.1; + min-width: 4.2ch; +} + +.bs-cmp-strip.is-ok .bs-cmp-strip__pass-value { + color: #86efac; +} + +.bs-cmp-strip.is-warn .bs-cmp-strip__pass-value { + color: #fbbf24; +} + +.bs-cmp-strip__kinds { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + flex: 1 1 100%; + min-width: 0; + padding: 2px; + border-radius: 8px; + background: rgb(var(--nx-ink-deep-rgb) / 0.28); +} + +.bs-cmp-strip__kind { + appearance: none; + flex: 0 0 auto; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--nx-text-muted); + padding: 5px 10px; + font-size: 12px; + cursor: pointer; + line-height: 1.35; + white-space: nowrap; + overflow: visible; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; +} + +.bs-cmp-strip__kind b { + font-variant-numeric: tabular-nums; + margin-left: 4px; + display: inline-block; + min-width: 1.5ch; +} + +.bs-cmp-strip__kind:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.85); + color: var(--nx-text); +} + +.bs-cmp-strip__kind.is-active { + border-color: rgb(var(--nx-accent-rgb) / 0.45); + background: rgba(37, 99, 235, 0.22); + color: var(--nx-accent-text); + font-weight: 600; +} + +.bs-cmp-strip__kind--diff.is-active { + border-color: rgba(248, 113, 113, 0.4); + background: rgba(239, 68, 68, 0.14); + color: #fecaca; +} + +.bs-cmp-strip__kind--added b { + color: #86efac; +} + +.bs-cmp-strip__kind--removed b { + color: #fca5a5; +} + +.bs-cmp-strip__kind--changed b { + color: #fbbf24; +} + +.bs-cmp-strip__kind--unchanged b { + color: #4ade80; +} + +.bs-cmp-strip__kind--diff b { + color: #f87171; +} + +.bs-cmp-strip__range { + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + flex: 0 0 auto; + margin-left: auto; +} + +.bs-cmp-top-fields { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.bs-cmp-top-fields--inline { + justify-content: flex-end; +} + +.bs-cmp-top-fields__label { + font-size: 11px; + color: var(--bs-cmp-muted); + margin-right: 2px; +} + +.bs-cmp-top-fields__chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 7px; + border-radius: 999px; + border: 1px solid rgba(245, 158, 11, 0.35); + font-size: 11px; + background: rgba(120, 53, 15, 0.28); + color: #fde68a; +} + +.bs-cmp-top-fields__chip em { + font-style: normal; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #fbbf24; +} + +.bs-cmp-result { + min-height: 0; +} + +.bs-cmp-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + flex: 1 1 100%; + min-width: 0; + max-width: none; + margin-left: 0; + min-height: 32px; +} + +.bs-cmp-filter-bar .input, +.bs-cmp-filter-bar [data-slot="input"], +.bs-cmp-filter-bar input { + min-width: 0; + width: 100%; + flex: 1 1 220px; + max-width: 440px; +} + +.bs-cmp-filter-bar .bs-sheet-count { + flex: 0 0 auto; + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.bs-cmp-key-filters { + display: flex; + flex-wrap: wrap; + gap: 8px 10px; + align-items: flex-end; + width: 100%; +} + +.bs-cmp-key-filter { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 110px; + max-width: 160px; + flex: 0 1 140px; +} + +.bs-cmp-key-filter > span { + font-size: 11px; + line-height: 1.2; +} + +.bs-cmp-key-filter .input, +.bs-cmp-key-filter [data-slot="input"], +.bs-cmp-key-filter input { + min-width: 0; + width: 100%; + font-size: 12px; +} + +.bs-cmp-kind-pills { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.bs-cmp-kind-pill { + appearance: none; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 999px; + background: rgb(var(--nx-ink-rgb) / 0.55); + color: var(--nx-text-soft); + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + line-height: 1.3; +} + +.bs-cmp-kind-pill:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.9); + color: var(--nx-text); +} + +.bs-cmp-kind-pill.is-active { + background: rgba(37, 99, 235, 0.22); + border-color: rgb(var(--nx-accent-rgb) / 0.55); + font-weight: 600; + color: var(--nx-accent-text); +} + +.bs-cmp-result-table { + flex: 1 1 auto; + min-height: 0; + max-height: none; + overflow: auto; + border-color: var(--bs-cmp-border); + background: rgb(var(--nx-ink-deep-rgb) / 0.4); + transition: opacity 0.15s ease; + border-radius: 10px; + -webkit-overflow-scrolling: touch; +} + +.bs-cmp-result-table.bs-sheet-table { + max-height: none; + min-height: 0; +} + +.bs-cmp-result-table.is-loading { + opacity: 1; + /* Keep header/filters clickable; only mute row hit-testing via opacity cue */ + pointer-events: auto; +} + +.bs-cmp-result-state { + min-height: 180px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px; + text-align: center; + white-space: normal; + overflow-wrap: anywhere; + color: var(--bs-cmp-muted); +} + +.bs-cmp-result-state strong { + color: var(--bs-cmp-ink); + font-size: 14px; +} + +.bs-cmp-result-state.is-error strong { + color: var(--danger, #fca5a5); +} + +.bs-cmp-strip__kind:focus-visible, +.bs-cmp-strip__toggle:focus-visible, +.bs-cmp-nav__item:focus-visible { + outline: 2px solid var(--accent, #60a5fa); + outline-offset: 2px; +} + +@media (max-width: 600px) { + .bs-cmp-strip__sheet { + flex-wrap: wrap; + } + + .bs-cmp-filter-bar input { + flex-basis: 100%; + max-width: none; + } + + .bs-cmp-key-filter { + flex: 1 1 120px; + max-width: none; + } +} + +.bs-cmp-board.is-fullscreen .bs-cmp-result-table { + max-height: none; + min-height: 0; + flex: 1 1 auto; +} + +@media (max-width: 1100px) { + .bs-cmp-strip__kinds { + flex: 1 1 100%; + } + + .bs-cmp-filter-bar { + flex: 1 1 100%; + max-width: none; + margin-left: 0; + } + + .bs-cmp-strip__range { + margin-left: 0; + } + + .bs-cmp-overview__time { + margin-left: 0; + flex-basis: 100%; + } +} + +@media (max-width: 960px) { + .bs-cmp-board__body { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + } + + .bs-cmp-board__body.is-nav-collapsed { + grid-template-columns: 1fr; + } + + .bs-cmp-nav { + max-height: 180px; + align-self: start; + } + + .bs-cmp-nav.is-collapsed { + max-height: 52px; + } + + .bs-cmp-nav__list { + flex-direction: row; + flex-wrap: nowrap; + flex: 0 0 auto; + align-items: flex-start; + overflow-x: auto; + overflow-y: hidden; + } + + .bs-cmp-nav.is-collapsed .bs-cmp-nav__list { + display: none; + } + + .bs-cmp-nav__item { + flex: 0 0 auto; + width: auto; + min-width: 240px; + grid-template-columns: 8px minmax(0, 1fr) auto; + } +} + +.bs-cmp-diff-table { + border-collapse: separate; + border-spacing: 0; + --bs-cmp-group-h: 30px; + --bs-cmp-line: rgba(148, 163, 184, 0.22); +} + +.bs-cmp-diff-table thead th { + position: sticky; + z-index: 2; + background: rgb(var(--nx-ink-rgb) / 0.98); + color: var(--nx-text-muted); + font-size: 12px; + white-space: nowrap; + vertical-align: middle; + padding: 7px 10px; + border-bottom: 1px solid var(--bs-cmp-line); + border-right: 1px solid var(--bs-cmp-line); +} + +.bs-cmp-diff-table thead tr.bs-cmp-group-row th { + top: 0; + z-index: 3; + text-align: center; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + padding: 6px 12px; + height: var(--bs-cmp-group-h); + color: var(--nx-text-soft); + background: rgb(var(--nx-ink-rgb) / 0.98); + border-top: 1px solid var(--bs-cmp-line); +} + +.bs-cmp-diff-table thead tr.bs-cmp-field-row th { + top: var(--bs-cmp-group-h); + z-index: 2; + vertical-align: middle; + padding-top: 6px; + padding-bottom: 7px; + background: rgb(var(--nx-ink-rgb) / 0.98); + color: var(--nx-text); +} + +.bs-cmp-diff-table thead th.bs-cmp-zone-start, +.bs-cmp-diff-table tbody td.bs-cmp-zone-start { + border-left: 1px solid var(--bs-cmp-line); +} + +.bs-cmp-diff-table tbody td { + padding: 7px 10px; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); +} + +.bs-cmp-diff-table tbody tr:nth-child(even) td { + background: var(--bs-cmp-row-alt, rgb(var(--nx-surface-3-rgb) / 0.28)); +} + +.bs-cmp-diff-table tbody tr:hover td { + background: rgba(37, 99, 235, 0.1); +} + +/* 结果 column sticky */ +.bs-cmp-sticky-kind { + position: sticky; + left: 0; + z-index: 1; + background: rgb(var(--nx-ink-rgb) / 0.96); +} + +.bs-cmp-diff-table thead th.bs-cmp-sticky-kind { + z-index: 4; + background: rgb(var(--nx-ink-rgb) / 0.98); + text-align: center; + box-shadow: 1px 0 0 var(--bs-cmp-line); +} + +.bs-cmp-diff-table thead th.bs-cmp-col-fail { + z-index: 3; + text-align: center; +} + +.bs-cmp-diff-table tbody tr:nth-child(even) td.bs-cmp-sticky-kind { + background: rgb(var(--nx-surface-3-rgb) / 0.98); +} + +.bs-cmp-diff-table tbody tr:hover td.bs-cmp-sticky-kind { + background: rgba(30, 48, 80, 0.98); +} + +.bs-cmp-th__name { + color: var(--nx-text); + font-weight: 600; +} + +.bs-cmp-col-kind { + width: 3.5rem; + min-width: 3.5rem; + max-width: 4.5rem; + text-align: center; + white-space: nowrap; + padding-left: 8px !important; + padding-right: 8px !important; +} + +.bs-cmp-verdict { + display: block; + font-size: 12px; + font-weight: 600; + line-height: 1.3; + letter-spacing: 0.02em; +} + +.bs-cmp-verdict--pass { + color: #4ade80; +} + +.bs-cmp-verdict--fail { + color: #f87171; +} + +.bs-cmp-verdict--added { + color: #86efac; +} + +.bs-cmp-verdict__sub { + display: block; + margin-top: 2px; + font-size: 10px; + font-weight: 500; + color: var(--nx-text-muted); + line-height: 1.2; +} + +.bs-cmp-col-fail, +.bs-cmp-fail-cell { + max-width: 11rem; + font-size: 0.85em; + color: var(--bs-cmp-muted, var(--nx-text-muted)); +} +.bs-cmp-val-reason { + font-size: 0.75em; + margin-top: 2px; +} + +.bs-cmp-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1.4; +} + +.bs-cmp-badge--added { + background: rgba(34, 197, 94, 0.18); + color: #86efac; +} + +.bs-cmp-badge--removed { + background: rgba(239, 68, 68, 0.18); + color: #fca5a5; +} + +.bs-cmp-badge--changed { + background: rgba(245, 158, 11, 0.18); + color: #fbbf24; +} + +.bs-cmp-badge--unchanged { + background: rgba(148, 163, 184, 0.16); + color: var(--nx-text-muted); +} + +.bs-cmp-key-cell { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; + white-space: nowrap; + color: var(--nx-text); +} + +.bs-cmp-val-cell { + font-size: 12px; + vertical-align: middle; +} + +.bs-cmp-pair { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 4.5rem; +} + +.bs-cmp-pair--same { + min-width: 3rem; +} + +.bs-cmp-pair__row { + display: grid; + grid-template-columns: 1.6em minmax(0, 1fr); + gap: 6px; + align-items: baseline; +} + +.bs-cmp-pair__tag { + flex: 0 0 auto; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--nx-text-faint); + line-height: 1.3; +} + +.bs-cmp-val-cell--diff .bs-cmp-pair__row:first-child .bs-cmp-pair__tag { + color: #fca5a5; +} + +.bs-cmp-val-cell--diff .bs-cmp-pair__row:last-child .bs-cmp-pair__tag { + color: #86efac; +} + +.bs-cmp-val-cell--pair.is-same { + background: transparent; +} + +.bs-cmp-val--same { + color: var(--nx-text-soft); + opacity: 0.92; +} + +.bs-cmp-val-cell--diff { + background: rgba(245, 158, 11, 0.1); + box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.22); +} + +.bs-cmp-val { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + color: var(--nx-text); + word-break: break-all; + line-height: 1.35; +} + +.bs-cmp-progress.is-compact { + padding: 6px 10px; + gap: 4px; +} + +.bs-cmp-progress.is-compact .bs-cmp-progress__track { + height: 4px; +} + +.bs-cmp-progress.is-compact .bs-cmp-progress__meta { + font-size: 0.75rem; +} + +.bs-cmp-strip__pass-scope { + margin-left: 4px; + font-size: 9px; + font-weight: 600; + color: var(--nx-text-faint); + text-transform: none; +} + +.bs-cmp-strip__toggle { + flex: 0 0 auto; + height: 28px; + padding: 0 8px; + border-radius: 6px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgb(var(--nx-ink-rgb) / 0.45); + color: var(--nx-text-soft); + font-size: 11px; + cursor: pointer; + white-space: nowrap; +} + +.bs-cmp-strip__toggle:hover { + border-color: rgba(96, 165, 250, 0.45); + color: var(--nx-text-strong); +} + +.bs-cmp-strip__toggle.is-active { + border-color: rgb(var(--nx-accent-rgb) / 0.5); + background: rgba(37, 99, 235, 0.18); + color: var(--nx-accent-text); +} + +.bs-cmp-strip__hint { + flex: 1 1 100%; + margin: 0; + font-size: 0.75rem; + line-height: 1.35; +} + +.bs-cmp-strip .bs-cmp-key-filters { + flex: 1 1 100%; + margin-top: 0; +} + +.bs-cmp-val.is-empty { + color: var(--nx-text-faint); + font-style: italic; +} + +.bs-cmp-val--pre { + color: #fca5a5; + text-decoration: line-through; + text-decoration-thickness: 1px; + opacity: 0.95; +} + +.bs-cmp-val--post { + color: #86efac; + font-weight: 600; +} + +/* Whole-row 缺失/多余: keep the present side readable (no strikethrough) */ +.bs-cmp-val--present-pre { + color: #fecaca; + font-weight: 600; +} + +.bs-cmp-val--present-post { + color: #86efac; + font-weight: 600; +} + +.bs-cmp-val-cell--pair.is-removed { + background: rgba(239, 68, 68, 0.08); +} + +.bs-cmp-val-cell--pair.is-added { + background: rgba(34, 197, 94, 0.08); +} + +.bs-cmp-val-arrow { + margin: 0 6px; + opacity: 0.45; + font-weight: 600; + color: var(--nx-text-muted); +} + +.bs-cmp-row--added { + background: rgba(34, 197, 94, 0.06); +} + +.bs-cmp-row--removed { + background: rgba(239, 68, 68, 0.06); +} + +.bs-cmp-row--changed { + background: transparent; +} + +.bs-cmp-row--unchanged { + opacity: 0.78; +} + +.bs-cmp-cell--pre { + background: rgba(239, 68, 68, 0.14); + font-weight: 600; +} + +.bs-cmp-cell--post { + background: rgba(34, 197, 94, 0.14); + font-weight: 600; +} + +@media (max-width: 900px) { + .bs-cmp-board__hero { + grid-template-columns: 1fr; + } + + .bs-cmp-hero-metrics, + .bs-cmp-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.nm-config-modal__body { + flex: 1 1 auto; + width: 100%; + min-height: min(52vh, 480px); + margin: 0; + padding: 12px 14px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + background: var(--nx-field) !important; + color: var(--nx-text) !important; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; + line-height: 1.45; + resize: vertical; + overflow: auto; +} + +.nm-config-modal__meta { + margin: 10px 0 0; + font-size: 12px; + word-break: break-all; +} + +.network-main:has(.topo-page), +.network-main:has(.webcrt-shell), +.network-main:has(.pt-wall-shell) { + padding: 0; + overflow: hidden; +} + +.app-main:has(> .pt-wall-shell) { + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; +} + +.network-main .topo-page, +.network-main .topo-page.is-sidebar-collapsed { + height: 100%; + margin: 0; + min-height: 0; +} + +.network-main .webcrt-shell { + height: 100%; + margin: 0; + min-height: 0; + border: 0; +} + +.network-main .pt-wall-shell, +.app-main > .pt-wall-shell { + flex: 1; + height: 100%; + margin: 0; + min-height: 0; +} + +.pt-board-toolbar__title { + font-size: 15px; + font-weight: 600; + color: var(--nx-text); + line-height: 30px; + max-width: min(420px, 50vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-placeholder h2 { + margin: 0 0 8px; +} + +.network-placeholder__list { + margin: 12px 0 0; + padding-left: 1.25rem; + color: var(--nx-text-muted); + line-height: 1.6; +} diff --git a/web/src/styles/pages/biz-migration.css b/web/src/styles/pages/biz-migration.css new file mode 100644 index 0000000..4e6bda6 --- /dev/null +++ b/web/src/styles/pages/biz-migration.css @@ -0,0 +1,711 @@ +/* Cutover monitor / migration */ + +/* Cutover monitor (biz migration) */ +.bm-hint { + margin: 0; + font-size: 12px; +} +.bm-pair-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + align-items: start; +} +@media (max-width: 720px) { + .bm-pair-grid { + grid-template-columns: 1fr; + } +} +.bm-pair-card { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 10px 12px; + background: rgb(var(--nx-ink-rgb) / 0.28); +} +.bm-pair-card__label { + font-size: 11px; + opacity: 0.7; + margin-bottom: 4px; +} +.bm-mapping { + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 10px 12px; + background: rgb(var(--nx-ink-rgb) / 0.22); +} +.bm-mapping__label { + font-size: 13px; + font-weight: 650; +} +.bm-mapping__row { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(120px, 1fr) auto; + gap: 8px; + align-items: end; +} +@media (max-width: 720px) { + .bm-mapping__row { + grid-template-columns: 1fr; + } +} +.bm-mapping__text { + width: 100%; + min-height: 110px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 6px; + background: rgb(var(--nx-ink-deep-rgb) / 0.35); + color: inherit; + padding: 8px 10px; + resize: vertical; +} +.bm-detail__chrome { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + align-items: center; + justify-content: space-between; +} +.bm-detail__meta { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + align-items: center; + font-size: 12px; +} +.bm-detail__arrow { + opacity: 0.55; + font-weight: 700; +} +.bm-detail__actions { + flex-wrap: wrap; + gap: 6px; +} +.bm-detail__link { + font-size: 12px; + align-self: center; +} +.bm-setup__meta { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: center; + font-size: 12px; + font-variant-numeric: tabular-nums; + padding: 6px 0 2px; +} +.bm-setup__sep { + opacity: 0.4; +} +.bm-setup__binds { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; +} +.bm-setup__cmp { + display: flex; + align-items: end; + font-size: 12px; + padding-bottom: 6px; +} +.bm-batches__toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + justify-content: space-between; + align-items: center; +} +.bm-batches__toolbar-main, +.bm-batches__toolbar-side { + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.bm-board__status { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + align-items: baseline; + padding: 8px 10px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: rgb(var(--nx-ink-rgb) / 0.35); + font-variant-numeric: tabular-nums; + font-size: 13px; +} +.bm-board__verdict { + font-weight: 700; + letter-spacing: 0.02em; +} +.bm-board__verdict.is-ok { + color: #34d399; +} +.bm-board__verdict.is-bad, +.bm-board__num.is-bad { + color: #f87171; +} +.bm-board__num { + opacity: 0.9; +} +.bm-batches { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + flex: 1; +} +.bm-batches__main { + min-height: 320px; + flex: 1; +} +.bm-expect-table { + max-height: min(48vh, 420px); + overflow: auto; +} +.bm-expect-desc { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.bm-board { + flex: 1; + min-height: 0; +} +.bm-board__accept { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + font-size: 12px; + font-variant-numeric: tabular-nums; + align-items: center; +} +.bm-board__table { + max-height: min(58vh, 560px); + overflow: auto; +} + +/* Cutover operations console: readable device samples, continuity and scope. */ +.bm-live-header, .bm-live-header > div, .bm-live-filters { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} +.bm-live-header { justify-content: space-between; border-bottom: 1px solid rgba(148,163,184,.25); padding-bottom: 10px; } +.bm-live-state { font-size: 11px; padding: 3px 8px; border: 1px solid rgba(148,163,184,.35); border-radius: 3px; } +.bm-live-state.is-live { color: #34d399; border-color: rgba(52,211,153,.45); } +.bm-live-state.is-live::before { content: ""; display: inline-block; width: 6px; height: 6px; background: currentColor; margin-right: 6px; border-radius: 50%; } +.bm-live-sources { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; } +.bm-live-sources > div { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; padding: 10px 12px; border: 1px solid rgba(148,163,184,.24); border-left: 3px solid var(--nx-text-faint); border-radius: 4px; min-width: 0; } +.bm-live-sources > div:last-child { border-left-color: #38bdf8; } +.bm-live-sources small { flex-basis: 100%; font-variant-numeric: tabular-nums; } +.bm-live-sources strong { overflow-wrap: anywhere; } +.bm-live-kpis { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; } +.bm-live-kpis > div { display: flex; flex-direction: column; gap: 5px; padding: 12px 14px; border: 1px solid rgba(148,163,184,.25); border-radius: 4px; } +.bm-live-kpis span { font-size: 12px; opacity: .8; } +.bm-live-kpis strong { font: 600 26px/1.2 ui-monospace,monospace; font-variant-numeric: tabular-nums; } +.bm-live-kpis strong small { font-size: 13px; opacity: .7; } +.bm-live-kpis .is-ok strong { color: #34d399; } +.bm-live-kpis .is-bad strong { color: #f87171; } +.bm-live-warning { display: flex; flex-wrap: wrap; gap: 6px 14px; padding: 10px 12px; border: 1px solid rgba(251,191,36,.3); border-left: 3px solid #fbbf24; border-radius: 3px; background: rgba(251,191,36,.06); font-size: 12px; overflow-wrap: anywhere; } +.bm-live-warning strong { flex-basis: 100%; } +.bm-live-filters > input { flex: 1 1 240px; min-width: 0; } +.bm-live-filters > .ui-field-select { max-width: 230px; } +.bm-diff-row.is-red > td:first-child { box-shadow: inset 3px 0 #f87171; } +.bm-diff-row.is-yellow > td:first-child { box-shadow: inset 3px 0 #fbbf24; } +.bm-scope-label { display: block; font-size: 10px; opacity: .65; padding-top: 3px; } +.bm-field-changes { font-size: 11px; margin-top: 5px; } +.bm-field-changes summary { cursor: pointer; color: #fbbf24; } +.bm-field-changes div { padding-top: 5px; overflow-wrap: anywhere; } +.bm-field-changes ins { color: #f87171; text-decoration: none; } +.bm-board .bs-cmp-main { min-width: 0; } +.bm-sampling { border: 1px solid rgba(148,163,184,.25); border-radius: 4px; min-width: 0; } +.bm-sampling > summary { padding: 8px 12px; cursor: pointer; font-size: 12px; } +.app-heroui-modal .bm-sampling .pt-list-table-wrap { max-width: 100%; min-height: 0; } +.bm-live-header__actions { display: flex; gap: 8px; align-items: center; } +.bm-board-controls > summary { display: none; } +.bm-board-controls > .bm-live-filters { margin-top: 8px; } +@media (max-width: 720px) { + .bm-live-sources { grid-template-columns: 1fr; } + .bm-live-kpis { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 6px; } + .bm-live-header > .muted { flex: 1 1 100%; font-size: 11px; } + .bm-board .bs-cmp-board__toolbar { display: grid; grid-template-columns: minmax(0,1fr); } + .bm-board .bs-id-row { min-width: 0; } + .bm-board .bs-cmp-board__body { display: flex; flex-direction: column; min-width: 0; } + .bm-board .bs-cmp-nav { width: 100%; max-width: none; } + .bm-board .bs-cmp-nav__list { max-height: 180px; } + .bm-live-filters > .ui-field-select { max-width: none; width: 100%; } + .bm-board__table { max-width: 100%; } +} + +/* Cutover create wizard */ +.bm-create { + min-height: min(52vh, 520px); +} +.bm-create__pane { + display: flex; + flex-direction: column; + gap: 12px; +} +.bm-steps { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 4px; +} +.bm-steps__item { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + background: rgb(var(--nx-ink-rgb) / 0.22); + color: inherit; + padding: 8px 10px; + cursor: pointer; + text-align: left; + font: inherit; + opacity: 0.72; + transition: border-color 0.15s ease, opacity 0.15s ease, background 0.15s ease; +} +.bm-steps__item:hover { + opacity: 0.92; +} +.bm-steps__item.is-active { + opacity: 1; + border-color: rgba(148, 163, 184, 0.55); + background: rgba(51, 65, 85, 0.35); +} +.bm-steps__item.is-done { + opacity: 0.9; +} +.bm-steps__num { + flex: 0 0 auto; + width: 22px; + height: 22px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + background: rgba(148, 163, 184, 0.22); +} +.bm-steps__item.is-active .bm-steps__num, +.bm-steps__item.is-done .bm-steps__num { + background: rgba(148, 163, 184, 0.4); +} +.bm-steps__label { + font-size: 12px; + font-weight: 600; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +@media (max-width: 720px) { + .bm-steps { + grid-template-columns: 1fr 1fr; + } +} +.bm-ne-slot { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 12px 14px; + background: rgb(var(--nx-ink-rgb) / 0.28); + color: inherit; + text-align: left; + cursor: pointer; + font: inherit; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + min-height: 78px; +} +.bm-ne-slot:hover { + border-color: rgba(148, 163, 184, 0.5); +} +.bm-ne-slot.is-picking { + border-color: rgba(148, 163, 184, 0.65); + box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.2); +} +.bm-ne-slot.is-filled { + background: rgb(var(--nx-ink-rgb) / 0.4); +} +.bm-ne-slot__name { + font-weight: 650; + font-size: 14px; +} +.bm-ne-slot__meta, +.bm-ne-slot__empty { + font-size: 12px; + margin-top: 4px; +} +.bm-ne-slot__empty { + opacity: 0.55; +} +.bm-ne-picker { + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 10px 12px; + background: rgb(var(--nx-ink-deep-rgb) / 0.28); +} +.bm-ne-picker__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.bm-ne-picker__table { + max-height: min(36vh, 320px); + overflow: auto; +} +.bm-ne-picker .pt-list-pager, +.bm-ne-picker .pager { + flex-shrink: 0; + margin-top: 4px; +} +.bm-ne-picker__table tr.is-selected td { + background: rgba(51, 65, 85, 0.45); +} +.bm-metric-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.bm-metric-chip { + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 6px; + background: rgb(var(--nx-ink-rgb) / 0.35); + color: inherit; + font: inherit; + font-size: 12px; + padding: 6px 10px; + cursor: pointer; + opacity: 0.7; +} +.bm-metric-chip.is-on { + opacity: 1; + border-color: rgba(148, 163, 184, 0.65); + background: rgba(51, 65, 85, 0.45); + font-weight: 600; +} +.bm-sched-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +@media (max-width: 720px) { + .bm-sched-grid { + grid-template-columns: 1fr; + } +} +.bm-sched-field { + display: flex; + flex-direction: column; + gap: 4px; +} +.bm-interval-table { + max-height: min(28vh, 240px); + overflow: auto; +} +.bm-hf-bindings { + margin: 6px 0 0; + padding-left: 1.1em; + list-style: disc; +} +.bm-create__summary { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 14px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + background: rgb(var(--nx-ink-rgb) / 0.28); +} +@media (max-width: 720px) { + .bm-create__summary { + grid-template-columns: 1fr; + } +} +.bm-create-modal .modal-footer, +.bm-create-modal [data-slot="footer"], +.bm-create-modal .app-heroui-modal__dialog [data-slot="footer"] { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.bm-create__footer-spacer { + flex: 1 1 auto; + min-width: 8px; +} +.bm-list-hf { + display: flex; + flex-direction: column; + gap: 2px; + align-items: flex-start; +} +.bm-list-hf__iv { + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.bs-bind-panel { + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 8px; +} + +.bs-bind-list { + max-height: 200px; + overflow: auto; +} + +.bs-bind-list--modal { + max-height: min(48vh, 360px); + padding: 4px 2px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 8px; +} + +.bs-bind-item { + display: block; + margin-bottom: 4px; +} + +.bs-profiles-panel { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + flex: 1 1 auto; + max-height: min(62vh, 620px); +} + +.bs-profiles-toolbar { + flex: 0 0 auto; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.bs-profiles-stats { + font-size: 12px; + white-space: nowrap; +} + +.bs-profiles-table-wrap { + flex: 1 1 auto; + min-height: 0; + overflow: auto !important; + max-height: none; + max-width: 100%; + /* Always reserve room for both scrollbars so H-bar stays in the visible viewport. */ + scrollbar-gutter: stable both-edges; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.65) rgb(var(--nx-ink-rgb) / 0.9); +} + +.bs-profiles-table-wrap::-webkit-scrollbar { + height: 12px; + width: 10px; +} + +.bs-profiles-table-wrap::-webkit-scrollbar-track { + background: rgb(var(--nx-ink-rgb) / 0.9); + border-radius: 6px; +} + +.bs-profiles-table-wrap::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.55); + border-radius: 6px; + border: 2px solid rgb(var(--nx-ink-rgb) / 0.9); +} + +.bs-profiles-table-wrap::-webkit-scrollbar-thumb:hover { + background: rgba(148, 163, 184, 0.75); +} + +.bs-profiles-table { + width: max-content; + min-width: 100%; + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; +} + +.bs-profiles-table thead th { + position: sticky; + top: 0; + z-index: 3; + background: rgb(var(--nx-ink-rgb) / 0.97); + box-shadow: 0 1px 0 rgba(148, 163, 184, 0.25); +} + +/* enable | title | lane | actions | params | command | aux */ +.bs-profiles-table th:nth-child(1), +.bs-profiles-table td:nth-child(1) { + width: 52px; +} + +.bs-profiles-table th:nth-child(2), +.bs-profiles-table td:nth-child(2) { + width: 200px; +} + +.bs-profiles-table th:nth-child(3), +.bs-profiles-table td:nth-child(3) { + width: 88px; +} + +.bs-profiles-table th:nth-child(4), +.bs-profiles-table td:nth-child(4) { + width: 120px; +} + +.bs-profiles-table th:nth-child(5), +.bs-profiles-table td:nth-child(5) { + width: 200px; +} + +.bs-profiles-table th:nth-child(6), +.bs-profiles-table td:nth-child(6) { + width: 360px; +} + +.bs-profiles-table th:nth-child(7), +.bs-profiles-table td:nth-child(7) { + width: 280px; +} + +.bs-profiles-sticky-col { + position: sticky; + z-index: 2; + background: rgb(var(--nx-ink-rgb) / 0.97); +} + +.bs-profiles-table thead .bs-profiles-sticky-col, +.bs-profiles-table thead th:nth-child(1), +.bs-profiles-table thead th:nth-child(2) { + z-index: 4; +} + +.bs-profiles-sticky-col--1, +.bs-profiles-table th:nth-child(1), +.bs-profiles-table td.bs-profiles-sticky-col--1 { + left: 0; + box-shadow: 1px 0 0 rgba(148, 163, 184, 0.2); +} + +.bs-profiles-sticky-col--2, +.bs-profiles-table th:nth-child(2), +.bs-profiles-table td.bs-profiles-sticky-col--2 { + left: 52px; + box-shadow: 1px 0 0 rgba(148, 163, 184, 0.2); +} + +.bs-profiles-table .bs-cmd-cell { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + max-width: 100%; +} + +.bs-profiles-table .bs-aux-cell { + vertical-align: top; + font-size: 12px; +} + +.bs-profiles-table .bs-aux-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; +} + +.bs-profiles-table .bs-aux-list li { + min-width: 0; +} + +.bs-profiles-table .bs-aux-key { + font-weight: 600; + color: var(--nx-text-soft); +} + +.bs-profiles-table .bs-aux-cmd { + display: block; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11px; + color: var(--nx-text-muted); + max-width: 100%; +} + +.bs-profiles-table .bs-params-cell { + vertical-align: top; +} + +.bs-profiles-table .bs-params-scroll { + max-width: 100%; + max-height: 3.2em; + overflow: hidden; + white-space: normal; + word-break: break-word; + line-height: 1.35; + font-size: 12px; +} + +.bs-profiles-table .bs-params-scroll--clickable { + cursor: pointer; + user-select: none; +} + +.bs-profiles-table .bs-params-scroll--clickable:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + +.bs-params-detail-list { + max-height: min(50vh, 420px); +} + +.bs-params-detail-item { + padding: 4px 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); + font-size: 12px; + word-break: break-all; +} + +.bs-params-detail-item:last-child { + border-bottom: none; +} + +.bs-profiles-table .bs-actions-cell { + white-space: nowrap; + vertical-align: middle; +} + +.bs-bind-hint { + margin-top: 4px; + font-size: 12px; +} + +.bs-params-warn { + color: #fbbf24; +} + +.bs-bind-modal.app-heroui-modal { + z-index: 80; +} diff --git a/web/src/styles/pages/biz-monitor-templates.css b/web/src/styles/pages/biz-monitor-templates.css new file mode 100644 index 0000000..91ab9db --- /dev/null +++ b/web/src/styles/pages/biz-monitor-templates.css @@ -0,0 +1,487 @@ +/* Monitor template editor */ + +/* Monitor template visual rule editor */ +.mt-editor__basics { + display: grid; + grid-template-columns: 1.2fr 1.2fr 1fr; + gap: 12px; + align-items: start; +} +@media (max-width: 900px) { + .mt-editor__basics { + grid-template-columns: 1fr; + } +} +.mt-editor__main { + display: grid; + grid-template-columns: 200px 1fr; + gap: 12px; + min-height: 360px; + align-items: stretch; +} +@media (max-width: 900px) { + .mt-editor__main { + grid-template-columns: 1fr; + } +} +.mt-editor__nav { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 8px; + background: rgb(var(--nx-ink-rgb) / 0.35); + display: flex; + flex-direction: column; + gap: 6px; + max-height: min(70vh, 720px); + overflow: auto; + min-height: 0; + align-self: stretch; +} +.mt-editor__nav-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + font-size: 12px; + font-weight: 600; + padding: 2px 4px 6px; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); + flex-shrink: 0; +} +.mt-editor__nav-list { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-height: 0; + overflow: auto; +} +.mt-editor__nav-item { + appearance: none; + text-align: left; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: inherit; + padding: 8px 10px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; +} +.mt-editor__nav-item:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.65); +} +.mt-editor__nav-item.is-active { + border-color: rgb(var(--nx-accent-rgb) / 0.55); + background: rgba(37, 99, 235, 0.18); +} +.mt-editor__nav-name { + font-size: 13px; + font-weight: 600; +} +.mt-editor__nav-tag { + font-size: 11px; + opacity: 0.7; +} +.mt-editor__pane { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + min-height: 0; + max-height: min(70vh, 720px); + overflow: auto; + align-self: stretch; +} +.mt-editor__pane-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} +.mt-editor__metric { + font-size: 15px; + font-weight: 700; +} +.mt-editor__meta { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + font-size: 12px; + margin-top: 4px; +} +.mt-editor__uv { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 8px; +} +.mt-block__title { + font-size: 13px; + font-weight: 650; + margin-bottom: 6px; +} +.mt-block__hint { + margin: 0 0 10px; + font-size: 12px; + line-height: 1.4; +} +.mt-chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.mt-chip { + appearance: none; + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 999px; + background: transparent; + color: inherit; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + line-height: 1.3; +} +.mt-chip:hover { + border-color: rgb(var(--nx-accent-rgb) / 0.55); +} +.mt-chip.is-on { + border-color: rgba(37, 99, 235, 0.75); + background: rgba(37, 99, 235, 0.22); + font-weight: 600; +} +.mt-skip-check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + margin: 0; + cursor: pointer; +} +.mt-rule-tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid rgba(148, 163, 184, 0.28); + padding-bottom: 0; +} +.mt-rule-tab { + appearance: none; + border: 1px solid transparent; + border-bottom: none; + border-radius: 6px 6px 0 0; + background: transparent; + color: inherit; + padding: 7px 12px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + opacity: 0.72; +} +.mt-rule-tab:hover { + opacity: 1; + background: rgb(var(--nx-surface-3-rgb) / 0.45); +} +.mt-rule-tab.is-active { + opacity: 1; + border-color: rgba(148, 163, 184, 0.35); + background: rgb(var(--nx-ink-rgb) / 0.55); + color: rgba(74, 222, 128, 0.95); +} +.mt-rule-tab--anomaly.is-active { + color: rgba(248, 113, 113, 0.95); +} +.mt-rule-tab__n { + font-size: 11px; + font-weight: 600; + opacity: 0.7; + min-width: 1.2em; + text-align: center; + padding: 0 5px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.2); +} +.mt-rule-card { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 10px; + background: rgb(var(--nx-ink-rgb) / 0.28); +} +.mt-rule-card--anomaly { + border-color: rgba(239, 68, 68, 0.4); + background: rgba(239, 68, 68, 0.05); +} +.mt-rule-card__head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + font-size: 12px; + font-weight: 650; +} +.mt-rule-card__grid { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 10px; + align-items: stretch; +} +@media (max-width: 900px) { + .mt-rule-card__grid { + grid-template-columns: 1fr; + } + .mt-rule-arrow { + display: none; + } +} +.mt-rule-arrow { + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 700; + opacity: 0.55; + padding: 0 2px; + align-self: stretch; +} +.mt-side-col { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +.mt-side-col__label { + font-size: 12px; + font-weight: 650; + margin-bottom: 6px; + flex-shrink: 0; +} +.mt-side { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex: 1 1 auto; + min-height: 0; +} +.mt-side-empty-box { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + border: 1px dashed rgba(148, 163, 184, 0.35); + border-radius: 6px; + padding: 14px 12px; + min-height: 88px; + background: rgb(var(--nx-ink-deep-rgb) / 0.2); +} +.mt-side-empty { + margin: 0; + font-size: 12px; + line-height: 1.45; +} +.mt-or-wrap { + display: flex; + flex-direction: column; + gap: 4px; +} +.mt-and-box { + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 6px; + padding: 8px; + background: rgb(var(--nx-ink-deep-rgb) / 0.25); +} +.mt-and-box__head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + margin-bottom: 4px; +} +.mt-logic-badge { + align-self: center; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 1px 8px; + border-radius: 999px; + opacity: 0.85; +} +.mt-logic-badge--or { + background: rgb(var(--nx-accent-rgb) / 0.2); + color: rgba(147, 197, 253, 0.95); +} +.mt-logic-badge--and { + background: rgba(148, 163, 184, 0.18); + color: rgba(203, 213, 225, 0.95); + margin: 2px auto; + width: fit-content; +} +.mt-select { + appearance: auto; + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 6px; + background: rgb(var(--nx-ink-rgb) / 0.45); + color: inherit; + padding: 4px 8px; + font-size: 12px; + min-width: 0; +} +.mt-select--grow { + flex: 1 1 120px; +} +.mt-cond-row { + display: grid; + grid-template-columns: 88px minmax(0, 1fr) auto; + gap: 6px; + align-items: center; + margin: 4px 0; +} +.mt-cond-row.is-value { + grid-template-columns: 88px minmax(72px, 1fr) minmax(88px, 1.1fr) minmax(64px, 1fr) auto; +} +.mt-cond-row .mt-cond-value, +.mt-cond-row input { + min-width: 0; + width: 100%; +} +.mt-icon-btn { + appearance: none; + border: none; + background: transparent; + color: inherit; + opacity: 0.55; + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 2px 6px; + border-radius: 4px; +} +.mt-icon-btn:hover { + opacity: 1; + background: rgba(148, 163, 184, 0.15); +} +.mt-link-btn { + appearance: none; + border: none; + background: transparent; + color: rgba(147, 197, 253, 0.95); + font-size: 12px; + cursor: pointer; + padding: 4px 0 0; + text-align: left; + width: fit-content; +} +.mt-link-btn:hover { + text-decoration: underline; +} +.mt-dontcare { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + margin: 0 0 4px; + cursor: pointer; +} +/* legacy aliases kept for safety */ +.mt-success-card { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 10px; + margin-bottom: 8px; +} +.mt-anomaly-card { + border-color: rgba(239, 68, 68, 0.45); + background: rgba(239, 68, 68, 0.06); +} +.mt-block--success .mt-block__title { + color: rgba(74, 222, 128, 0.95); +} +.mt-block--anomaly .mt-block__title { + color: rgba(248, 113, 113, 0.95); +} +.mt-rule-sections { + display: flex; + flex-direction: column; + gap: 16px; +} +.mt-success-card__head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 12px; + font-weight: 600; +} +.mt-success-card__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +@media (max-width: 800px) { + .mt-success-card__grid { + grid-template-columns: 1fr; + } +} +.mt-success-side__label { + font-size: 12px; + font-weight: 600; + margin-bottom: 6px; +} +.mt-group { + border: 1px dashed rgba(148, 163, 184, 0.35); + border-radius: 8px; + padding: 8px; + margin-bottom: 6px; +} +.mt-group__head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + opacity: 0.75; +} +.mt-group__or, +.mt-group__and { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + opacity: 0.7; + margin: 2px 0; +} +.mt-side-hint { + margin: 0 0 6px; + font-size: 11px; +} +.mt-chip-group { + margin-bottom: 8px; +} +.mt-chip-group__label { + display: block; + font-size: 11px; + opacity: 0.65; + margin-bottom: 4px; +} +.mt-success-card__formula { + margin-top: 8px; + font-size: 12px; + padding-top: 8px; + border-top: 1px dashed rgba(148, 163, 184, 0.35); +} +.mt-arrow { + font-weight: 700; + opacity: 0.85; +} +.mt-editor__more { + border-top: 1px solid rgba(148, 163, 184, 0.22); + padding-top: 8px; +} diff --git a/web/src/styles/pages/biz-state.css b/web/src/styles/pages/biz-state.css new file mode 100644 index 0000000..55ef566 --- /dev/null +++ b/web/src/styles/pages/biz-state.css @@ -0,0 +1,377 @@ +/* biz_state batch workbook */ + +/* biz_state batch workbook (Excel-like sheets) */ +.bs-workbook-modal .bs-workbook-body { + min-height: min(62vh, 640px); +} + +.bs-workbook-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; +} + +.bs-alias-link { + appearance: none; + border: 0; + background: transparent; + padding: 0; + margin: 0; + color: var(--nx-accent-text); + cursor: pointer; + text-align: left; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bs-alias-link:hover { + text-decoration: underline; +} + +.bs-alias-link:disabled { + cursor: default; + opacity: 0.6; + text-decoration: none; +} + +.bs-id-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgb(var(--nx-ink-deep-rgb) / 0.45); +} + +.bs-id-row--inline { + width: auto; + flex: 1 1 220px; +} + +.bs-id-row__label { + flex: 0 0 auto; + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--nx-text-muted); +} + +.bs-id-row__value { + flex: 1 1 auto; + min-width: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.4; + color: var(--nx-text); + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; + word-break: normal; +} + +.bs-sheet-cmd-bar { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgb(var(--nx-ink-rgb) / 0.45); +} + +.bs-sheet-cmd-bar__label { + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--nx-text-muted); +} + +.bs-sheet-cmd-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.bs-sheet-cmd-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + justify-content: space-between; +} + +.bs-sheet-cmd-code { + flex: 1 1 240px; + min-width: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.45; + color: var(--nx-text); + background: rgb(var(--nx-ink-deep-rgb) / 0.55); + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 6px; + padding: 6px 8px; + overflow: auto; + white-space: nowrap; +} + +.bs-sheet-cmd-code--block { + white-space: pre-wrap; + word-break: break-all; +} + +.bs-sheet-cmd-actions { + display: flex; + align-items: center; + gap: 8px; + flex: 0 1 auto; + flex-wrap: wrap; + max-width: 100%; +} + +.bs-commands-card-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: min(48vh, 520px); + overflow: auto; + padding: 2px; +} + +.bs-commands-card { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgb(var(--nx-ink-rgb) / 0.45); +} + +.bs-commands-card__msg { + flex: 1 0 100%; + margin-top: 2px; + font-size: 12px; + color: var(--nx-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bs-cmd-metric { + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.bs-inline-cmd { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + color: var(--nx-text-soft); +} + +.bs-rawlog-body { + min-height: min(50vh, 480px); +} + +.bs-rawlog-stats { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; +} + +.bs-batch-message { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgb(var(--nx-ink-rgb) / 0.55); +} + +.bs-batch-message__label { + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--nx-text-muted); +} + +.bs-batch-message__body { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.45; + color: var(--nx-text); +} + +.bs-batch-message--alert { + border-color: rgba(248, 113, 113, 0.28); + background: rgba(127, 29, 29, 0.22); +} + +.bs-batch-message--alert .bs-batch-message__label { + color: #fca5a5; +} + +.bs-batch-message--alert .bs-batch-message__body { + color: #fecaca; +} + +.bs-msg-cell { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + +.bs-cmd-stat { + font-size: 11px; + white-space: nowrap; +} + +.bs-collect-detail-modal .bs-sheet-table { + max-height: min(48vh, 480px); + overflow: auto; +} + +.bs-collect-detail-modal .bs-collect-detail-table table { + border-collapse: separate; + border-spacing: 0; +} + +.bs-collect-detail-modal .bs-collect-detail-table thead th { + position: sticky; + top: 0; + z-index: 3; + background: rgb(var(--nx-ink-rgb) / 0.97); + box-shadow: 0 1px 0 rgba(148, 163, 184, 0.25); +} + +.bs-rawlog-pre { + flex: 1 1 auto; + margin: 0; + padding: 12px 14px; + max-height: min(56vh, 560px); + overflow: auto; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgb(var(--nx-ink-deep-rgb) / 0.72); + color: var(--nx-text); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.bs-sheet-filter { + align-items: center; +} + +.bs-sheet-count { + white-space: nowrap; + font-size: 12px; +} + +.bs-sheet-table { + flex: 1 1 auto; + min-height: 240px; + max-height: min(48vh, 520px); + overflow: auto; + border-collapse: separate; +} + +.bs-workbook-modal .bs-sheet-table { + overflow: auto; +} + +.bs-workbook-modal .bs-sheet-table table { + border-collapse: separate; + border-spacing: 0; +} + +.bs-workbook-modal .bs-sheet-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: rgb(var(--nx-ink-rgb) / 0.98) !important; + box-shadow: 0 1px 0 rgba(148, 163, 184, 0.22); +} + +.bs-cmp-result-table.bs-sheet-table { + max-height: none; +} + +.bs-sheet-tabs { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: flex-end; + margin-top: 4px; + padding: 6px 4px 0; + border-top: 1px solid rgba(148, 163, 184, 0.2); +} + +.bs-sheet-tabs--top { + align-items: center; + margin-top: 0; + margin-bottom: 2px; + padding: 0 0 8px; + border-top: none; + border-bottom: 1px solid rgba(148, 163, 184, 0.22); + max-height: 88px; + overflow: auto; +} + +.bs-sheet-tabs--top .bs-sheet-tab { + border-radius: 6px; + border-bottom: 1px solid rgba(148, 163, 184, 0.28); +} + +.bs-sheet-tabs--top .bs-sheet-tab.is-active { + box-shadow: none; + border-color: rgb(var(--nx-accent-rgb) / 0.55); + background: rgba(37, 99, 235, 0.2); + color: var(--nx-accent-text); +} + +.bs-sheet-tab { + appearance: none; + border: 1px solid rgba(148, 163, 184, 0.28); + border-bottom: none; + border-radius: 6px 6px 0 0; + background: rgb(var(--nx-ink-rgb) / 0.55); + color: var(--nx-text-soft); + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + line-height: 1.2; +} + +.bs-sheet-tab:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.9); + color: var(--nx-text); +} + +.bs-sheet-tab.is-active { + background: rgb(var(--nx-surface-3-rgb) / 0.98); + border-color: rgb(var(--nx-accent-rgb) / 0.55); + color: var(--nx-text-strong); + font-weight: 600; + box-shadow: 0 -1px 0 rgb(var(--nx-accent-rgb) / 0.45) inset; +} + +.bs-sheet-tab__count { + margin-left: 6px; + opacity: 0.65; + font-variant-numeric: tabular-nums; +} diff --git a/web/src/styles/pages/collect.css b/web/src/styles/pages/collect.css new file mode 100644 index 0000000..a386bc2 --- /dev/null +++ b/web/src/styles/pages/collect.css @@ -0,0 +1,171 @@ +/* Batch collection page */ + +.collect-commands { + width: 100%; + min-height: 160px; + font-family: ui-monospace, Consolas, monospace; + font-size: 13px; + line-height: 1.4; + padding: 10px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + color: var(--nx-text); + resize: vertical; +} + +.collect-meta { + font-size: 13px; + color: var(--nx-text-muted); + margin-right: auto; +} + +.collect-create-panel__head { + margin-bottom: 0; +} + +.collect-create-panel--collapsed { + padding-bottom: 16px; +} + +.collect-create-panel h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--nx-text-strong); +} + +.collect-selected-block, +.collect-pick-block { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgba(148, 163, 184, 0.18); +} + +.collect-selected-block__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; + margin-bottom: 10px; +} + +.collect-selected-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 220px; + overflow: auto; + padding: 4px 0; +} + +.collect-selected-chip { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + background: rgb(var(--nx-ink-rgb) / 0.85); + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + color: var(--nx-text); +} + +.collect-selected-chip__main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.collect-selected-chip__meta { + font-size: 12px; + color: var(--nx-text-muted); +} + +.collect-pick-filters { + margin-bottom: 12px; +} + +.collect-cmd-preview { + font-size: 12px; + background: rgb(var(--nx-ink-deep-rgb) / 0.55); + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + padding: 10px; + max-height: 120px; + overflow: auto; + white-space: pre-wrap; + color: var(--nx-text-soft); + font-family: ui-monospace, Consolas, monospace; +} + +.collect-cmd-preview--modal { + flex: 0 0 auto; + margin: 0 0 10px; + max-height: 88px; +} + +.collect-create-modal .collect-selected-block, +.collect-create-modal .collect-pick-block { + margin-top: 16px; +} + +.collect-create-modal .panel__toolbar { + margin-bottom: 10px; + padding-bottom: 8px; +} + +.collect-runs-panel { + display: flex; + flex-direction: column; + gap: 10px; +} + +.collect-runs-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.collect-runs-filter { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--nx-text-muted); +} + +.collect-runs-filter select { + min-width: 140px; + padding: 6px 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + color: var(--nx-text); +} + +.collect-runs-filter input { + min-width: 180px; + padding: 6px 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + color: var(--nx-text); +} + +.collect-run-message { + max-width: 420px; + max-height: 160px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.45; + color: var(--nx-text-soft); + padding: 6px 8px; + background: rgb(var(--nx-ink-deep-rgb) / 0.55); + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; +} diff --git a/web/src/styles/pages/login.css b/web/src/styles/pages/login.css new file mode 100644 index 0000000..a6a6545 --- /dev/null +++ b/web/src/styles/pages/login.css @@ -0,0 +1,543 @@ +/* Login page */ + +.login-page { + --login-accent: var(--nx-accent-strong); + --login-accent-deep: var(--nx-accent-strong); + --login-ink: rgba(255, 255, 255, 0.92); + --login-muted: rgba(255, 255, 255, 0.55); + --login-field: rgba(255, 255, 255, 0.08); + --login-field-border: rgba(255, 255, 255, 0.14); + --login-danger: #fda29b; + --login-rail-w: clamp(360px, 30vw, 420px); + position: relative; + isolation: isolate; + min-height: 100vh; + overflow: hidden; + display: grid; + grid-template-columns: minmax(0, 1fr) var(--login-rail-w); + align-items: stretch; + background: #00050a; + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + color: var(--login-ink); +} + +.login-page__space { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + align-items: center; + /* Center earth within the left stage (not under the form rail). */ + justify-content: center; + padding: 3vh calc(var(--login-rail-w) + 3vw) 3vh 5vw; + background: url("/login-space.webp") center / cover no-repeat; +} + +.login-page__space::before { + content: ""; + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + background: + linear-gradient( + 90deg, + transparent 0%, + transparent 52%, + rgb(var(--nx-ink-deep-rgb) / 0.18) 70%, + rgb(var(--nx-ink-deep-rgb) / 0.48) 88%, + rgb(var(--nx-ink-deep-rgb) / 0.72) 100% + ), + linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, transparent 30%, transparent 72%, rgba(0, 0, 0, 0.38) 100%); +} + +/* Tiny sun flare — upper-left void. */ +.login-page__flares { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; + overflow: hidden; +} + +.login-page__flare { + position: absolute; + border-radius: 50%; + pointer-events: none; + opacity: 0.85; +} + +.login-page__flare--a { + width: 44px; + height: 44px; + left: 7%; + top: 11%; + background: + radial-gradient( + circle at 50% 50%, + rgba(245, 250, 255, 0.92) 0%, + rgba(170, 210, 255, 0.48) 28%, + rgba(90, 150, 245, 0.14) 55%, + transparent 78% + ); + box-shadow: 0 0 16px 3px rgba(120, 180, 255, 0.2); + animation: login-flare-pulse-a 10s ease-in-out infinite alternate; +} + +.login-planet { + position: relative; + z-index: 1; + width: min(500px, 38vw, 62vmin); + height: min(500px, 38vw, 62vmin); + border-radius: 50%; + transform: rotate(5deg); + animation: login-planet-enter 1.8s cubic-bezier(0.76, -0.46, 0.37, 0.99) both; +} + +.login-planet__earth { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + border-radius: 50%; + overflow: hidden; + /* Soft dissolve into space — no outer glow (Earth does not emit light). */ + -webkit-mask-image: radial-gradient(circle at 50% 50%, #000 87%, transparent 100%); + mask-image: radial-gradient(circle at 50% 50%, #000 87%, transparent 100%); + transform-origin: center center; +} + +/* Gentle volume shade — cooler blue midtones. */ +.login-planet__earth::before { + content: ""; + position: absolute; + z-index: 1; + inset: 0; + border-radius: 50%; + pointer-events: none; + background: linear-gradient( + 148deg, + rgba(70, 130, 210, 0.16) 0%, + rgba(45, 100, 185, 0.08) 20%, + transparent 42%, + rgb(var(--nx-ink-deep-rgb) / 0.22) 72%, + rgb(var(--nx-ink-deep-rgb) / 0.42) 100% + ); +} + +/* + * Blue atmospheric limb (scattered sunlight) — stays on the sphere only. + */ +.login-planet__earth::after { + content: ""; + position: absolute; + z-index: 2; + inset: 0; + border-radius: 50%; + pointer-events: none; + background: + radial-gradient( + 120% 95% at 10% 12%, + rgba(55, 130, 230, 0.42) 0%, + rgba(40, 110, 210, 0.22) 28%, + rgba(30, 90, 185, 0.08) 48%, + transparent 66% + ), + radial-gradient( + circle at 50% 50%, + transparent 74%, + rgba(40, 105, 200, 0.14) 88%, + rgba(50, 120, 215, 0.22) 94%, + transparent 100% + ); + box-shadow: + inset 0 0 20px 4px rgba(45, 110, 200, 0.28), + inset 22px 26px 32px 4px rgba(60, 130, 220, 0.12), + inset -20px -24px 40px 10px rgb(var(--nx-ink-deep-rgb) / 0.3); + animation: login-earth-inner 2.2s ease-in-out 0.7s both; +} + +.login-planet__textures { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background-color: rgba(8, 28, 72, 0.2); + /* Pre-brightened asset — no live CSS filter (that was the GPU thrash). */ + background-image: url("/login-earth-night-bright.webp"); + background-repeat: repeat-x; + background-size: 200%; + animation: login-earth-spin 80s linear infinite; +} + +.login-page__rail { + position: relative; + z-index: 5; + grid-column: 2; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-width: 0; + padding: 48px clamp(32px, 3.5vw, 52px) 48px 8px; +} + +.login-card { + width: 100%; + max-width: 340px; + display: flex; + flex-direction: column; + gap: 15px; + padding: 26px 24px 28px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgb(var(--nx-ink-rgb) / 0.82); + /* Lighter blur — full blur over the spinning globe was a steady GPU cost. */ + backdrop-filter: blur(8px) saturate(1.08); + -webkit-backdrop-filter: blur(8px) saturate(1.08); + box-shadow: + 0 18px 48px rgba(0, 0, 0, 0.32), + 0 0 0 1px rgba(120, 170, 255, 0.07), + inset 0 1px 0 rgba(255, 255, 255, 0.07); + animation: login-card-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.login-card__head { + display: flex; + align-items: baseline; + justify-content: flex-start; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 2px; +} + +.login-card__brand, +.login-card__title { + margin: 0; + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + font-size: clamp(1.55rem, 4vw, 1.85rem); + font-weight: 700; + line-height: 1.15; + color: rgba(226, 232, 240, 0.88); +} + +.login-card__brand { + letter-spacing: 0.1em; + text-shadow: 0 0 22px rgb(var(--nx-accent-rgb) / 0.28); + animation: login-brand-in 700ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.login-card__title { + font-weight: 650; + letter-spacing: 0.02em; + color: rgba(203, 213, 225, 0.9); +} + +.login-card__hint { + margin: -4px 0 4px; + color: var(--login-muted); + font-size: 13px; + line-height: 1.45; +} + +.login-card__sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.login-card__label { + position: relative; + display: block; +} + +.login-card__label input, +.login-card__password input { + width: 100%; + height: 44px; + padding: 0 14px; + border: 1px solid var(--login-field-border); + border-radius: 10px; + background: var(--login-field); + color: #fff; + font: inherit; + font-size: 15px; + box-sizing: border-box; + transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; +} + +.login-card__label input::placeholder, +.login-card__password input::placeholder { + color: rgba(255, 255, 255, 0.42); +} + +.login-card__password { + position: relative; + display: block; + width: 100%; +} + +.login-card__password input { + padding-right: 44px !important; +} + +.login-card__eye { + position: absolute; + top: 50%; + right: 6px; + z-index: 2; + transform: translateY(-50%); + width: 34px; + height: 34px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: rgba(255, 255, 255, 0.55); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.login-card__eye:hover { + color: var(--nx-accent-text); +} + +.login-card__eye:focus-visible { + outline: none; + color: var(--nx-accent-text); + box-shadow: 0 0 0 2px rgb(var(--nx-accent-rgb) / 0.35); +} + +.login-card__eye svg { + display: block; + pointer-events: none; +} + +.login-card__label input:focus, +.login-card__password input:focus { + outline: none; + border-color: rgb(var(--nx-accent-rgb) / 0.7); + box-shadow: 0 0 0 3px rgb(var(--nx-accent-rgb) / 0.22); + background: rgba(255, 255, 255, 0.1); +} + +.login-card__label input:disabled, +.login-card__password input:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.login-card__error { + color: var(--login-danger); + font-size: 13px; + line-height: 1.4; + padding: 8px 10px; + border-radius: 8px; + background: rgba(180, 35, 24, 0.22); + border: 1px solid rgba(253, 162, 155, 0.28); + animation: login-error-in 220ms ease both; +} + +.login-card__submit { + margin-top: 8px; + height: 44px; + border: 0; + border-radius: 9999px; + background: linear-gradient(to right, var(--nx-accent-strong), var(--nx-accent-strong), var(--nx-accent-strong)); + color: #fff; + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.04em; + box-shadow: + 0 8px 15px -3px rgb(var(--nx-accent-rgb) / 0.5), + 0 4px 6px -4px rgb(var(--nx-accent-rgb) / 0.5); + cursor: pointer; + transition: transform 140ms ease, filter 140ms ease, opacity 140ms ease; +} + +.login-card__submit:hover:not(:disabled) { + filter: brightness(1.06); + transform: translateY(-1px); +} + +.login-card__submit:active:not(:disabled) { + transform: translateY(0); + filter: brightness(0.98); +} + +.login-card__submit:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.login-card__submit--ghost, +.login-card__submit + .login-card__submit { + margin-top: 0; + background: transparent; + border: 1px solid rgb(var(--nx-accent-rgb) / 0.7); + color: var(--nx-accent-text); + box-shadow: none; +} + +.login-card__submit--ghost:hover:not(:disabled), +.login-card__submit + .login-card__submit:hover:not(:disabled) { + background: rgb(var(--nx-accent-rgb) / 0.12); + filter: none; +} + +@keyframes login-planet-enter { + 0% { + transform: scale(0.92) rotate(-5deg); + opacity: 0.65; + } + 100% { + transform: rotate(5deg); + opacity: 1; + } +} + +@keyframes login-earth-spin { + from { + background-position: calc(120% + 120px) 0; + } + to { + background-position: calc(-80% + 120px) 0; + } +} + +@keyframes login-earth-inner { + 0% { + opacity: 0.55; + } + 100% { + opacity: 1; + } +} + +@keyframes login-card-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes login-brand-in { + from { + opacity: 0; + letter-spacing: 0.24em; + transform: translateY(8px); + } + to { + opacity: 1; + letter-spacing: 0.12em; + transform: translateY(0); + } +} + +@keyframes login-error-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes login-flare-pulse-a { + from { opacity: 0.7; transform: scale(0.96); } + to { opacity: 1; transform: scale(1.08); } +} + +/* Pause infinite loops when the login tab is backgrounded (class set by LoginShell). */ +html.login-page--paused .login-planet__textures, +html.login-page--paused .login-page__flare { + animation-play-state: paused !important; +} + +@media (prefers-reduced-motion: reduce) { + /* Keep ambient earth spin; only drop entrance flourishes. */ + .login-planet, + .login-planet__earth::after, + .login-card, + .login-card__brand, + .login-card__error, + .login-page__flare { + animation: none !important; + } + + .login-page__flare { + opacity: 0.85; + } + + .login-planet { + transform: rotate(5deg); + } + + .login-card__submit:hover:not(:disabled) { + transform: none; + } +} + +@media (max-width: 1200px) { + .login-page { + --login-rail-w: clamp(340px, 34vw, 400px); + } + + .login-planet { + width: min(440px, 40vw, 58vmin); + height: min(440px, 40vw, 58vmin); + } +} + +@media (max-width: 820px) { + .login-page { + grid-template-columns: 1fr; + } + + .login-page__space { + justify-content: center; + padding: 0; + } + + .login-page__space::before { + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.18) 0%, transparent 30%, transparent 48%, rgba(0, 0, 0, 0.72) 100%); + } + + .login-planet { + width: min(420px, 78vmin); + height: min(420px, 78vmin); + opacity: 0.9; + } + + .login-page__rail { + grid-column: 1; + min-height: 100vh; + padding: 28px 18px 36px; + } + + .login-card { + max-width: 380px; + padding: 20px 18px 22px; + } + + .login-card__brand, + .login-card__title { + font-size: 1.4rem; + } +} diff --git a/web/src/styles/pages/ne-ume-system.css b/web/src/styles/pages/ne-ume-system.css new file mode 100644 index 0000000..164bf46 --- /dev/null +++ b/web/src/styles/pages/ne-ume-system.css @@ -0,0 +1,1223 @@ +/* Managed NE / UME / system pages, API tokens, CLI connect panel, form-grid */ + +.ne-stats-card { + padding: 12px 14px; +} + +.ne-stats-card__header { + display: flex; + align-items: center; + gap: 12px; + justify-content: space-between; + margin-bottom: 10px; +} + +.ne-stats-card__header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.ne-stats-card__header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ne-stats-card__title { + font-size: 15px; + font-weight: 600; + color: var(--nx-text); +} + +.ne-stats-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 9px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; +} + +.ne-stats-pill--pass { background: rgba(34, 197, 94, 0.16); color: #86efac; } +.ne-stats-pill--fail { background: rgba(239, 68, 68, 0.16); color: #fca5a5; } +.ne-stats-pill--testing{ background: rgba(245, 158, 11, 0.16); color: #fde68a; } +.ne-stats-pill--unknown{ background: rgba(148, 163, 184, 0.14); color: var(--nx-text-muted); } + +.ne-stats-card__actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ne-stats-card__tag-select { + height: 32px; + font-size: 13px; + padding: 0 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + color: var(--nx-text); + min-width: 150px; +} + +.ne-stats-card__loading { + color: var(--nx-text-muted); + font-size: 13px; + margin: 0; +} + +.ne-stats-card__tag-cards { + width: 100%; + display: grid; + grid-template-columns: repeat(4, minmax(190px, 1fr)); + gap: 10px; +} + +.ne-tag-card { + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + background: rgb(var(--nx-ink-rgb) / 0.72); + padding: 10px 12px; +} + +.ne-tag-card__title { + font-size: 13px; + font-weight: 650; + color: var(--nx-text); + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ne-tag-card__total { + font-size: 12px; + color: var(--nx-text-muted); + margin-bottom: 8px; +} + +.ne-tag-card__pills { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* Managed NE / UME / System pages — light content on shared chrome tokens */ +.ne-page, +.ume-page, +.system-page { + margin: -20px -28px -32px; + padding: 20px 22px 28px; + min-height: calc(100vh - 48px); + background: + radial-gradient(1200px 480px at 0% 0%, rgb(var(--nx-accent-rgb) / 0.07), transparent 55%), + var(--nm-main, var(--nm-chrome)); + gap: 16px; +} + +/* Already inside network/audit shell — don't re-bleed or force full-viewport height + (that left a huge empty panel void under short lists like 任务概览). */ +.network-main .ne-page, +.network-main .ume-page, +.network-main .system-page { + margin: 0; + padding: 0; + min-height: 0; + background: transparent; +} + +.ne-page > .panel, +.ume-page > .panel, +.ume-page > .cards > .panel, +.ume-page > .cards > .card, +.system-page > .panel { + padding: 18px 20px 20px; + border: 1px solid var(--nm-ops-line); + border-radius: 6px; + background: var(--nx-surface-1); + box-shadow: none; +} + +.ume-page > .cards { + display: flex; + flex-direction: column; + gap: 16px; + margin: 0; + grid-template-columns: none; +} + +.ne-page .panel__toolbar, +.ume-page .panel__toolbar, +.system-page .panel__toolbar { + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px solid var(--nm-ops-line); +} + +.ne-page .button, +.ume-page .button, +.system-page .button, +.ne-page .input, +.ume-page .input, +.system-page .input, +.ne-page .ui-field__select, +.ume-page .ui-field__select, +.system-page .ui-field__select { + border-radius: 4px; + box-shadow: none; +} + +.ne-page .panel__toolbar h2, +.ume-page .panel__toolbar h2, +.system-page .panel__toolbar h2, +.ume-page .panel h2, +.ume-page .panel > h3, +.ume-page .card > h3, +.ume-page .card-title-with-hint { + font-size: 17px; + font-weight: 650; + color: var(--nm-brand); + letter-spacing: -0.02em; + margin: 0; +} + +.system-page .panel__hint { + margin: 0 0 10px; + color: var(--nm-ops-muted, var(--nm-muted)); + font-size: 12px; +} + +.ume-page .panel > h3, +.ume-page .card > h3, +.ume-page .card > .panel__toolbar { + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px solid var(--nm-ops-line); +} + +.ume-page .card__section-title { + margin: 16px 0 10px; + padding-top: 12px; + border-top: 1px solid var(--nm-ops-line); + font-size: 14px; + font-weight: 650; + color: var(--nm-brand); +} + +.ume-page .ume-status-row { + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.ume-conn-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px 28px; + align-items: start; +} + +.ume-conn-col { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.ume-conn-col .panel__toolbar { + margin-bottom: 0; + padding-bottom: 10px; +} + +.ume-conn-col + .ume-conn-col { + padding-left: 28px; + border-left: 1px solid var(--nm-ops-line); +} + +.ume-conn-panel .ume-ws-log { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--nm-ops-line); +} + +@media (max-width: 960px) { + .ume-conn-grid { + grid-template-columns: 1fr; + gap: 18px; + } + + .ume-conn-col + .ume-conn-col { + padding-left: 0; + padding-top: 14px; + border-left: 0; + border-top: 1px solid var(--nm-ops-line); + } +} + +.ne-page .pt-list-table-wrap, +.ume-page .pt-list-table-wrap, +.system-page .pt-list-table-wrap { + border: 1px solid var(--nm-ops-line); + border-radius: 4px; + overflow: hidden; + background: rgb(var(--nx-ink-deep-rgb) / 0.28); +} + +.ne-page .pt-list-table thead th, +.ume-page .pt-list-table thead th, +.system-page .pt-list-table thead th, +.ne-page .data-table thead th, +.ume-page .data-table thead th, +.system-page .data-table thead th { + background: var(--nx-surface-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.ne-page .pt-list-table tbody td, +.ume-page .pt-list-table tbody td, +.system-page .pt-list-table tbody td { + font-size: 12px; + border-bottom-color: var(--nm-ops-line); +} + +.ume-page .ume-ws-log { + margin-top: 14px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.ume-page .ume-ws-log__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 8px 12px; +} + +.ume-page .ume-ws-log__head strong { + font-size: 13px; + color: var(--nm-brand); +} + +.ume-page .ume-ws-log__head .muted { + font-size: 12px; +} + +.ume-page .ws-log-panel { + margin-top: 0; + max-height: 180px; + border: 1px solid var(--nm-ops-line); + border-radius: 4px; + background: rgb(var(--nx-ink-deep-rgb) / 0.55); +} + +.ume-page .error-text { + color: #fca5a5; +} + +.ne-page .filter-inline, +.ume-page .filter-inline, +.system-page .filter-inline { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 0; + padding: 10px 12px; + border: 1px solid var(--nm-ops-line); + border-radius: 4px; + background: rgb(var(--nx-ink-deep-rgb) / 0.45); + grid-template-columns: none; +} + +.ne-page .filter-inline input, +.ne-page .filter-inline > select, +.ume-page .filter-inline input, +.ume-page .filter-inline > select, +.system-page .filter-inline input, +.system-page .filter-inline > select { + width: auto; + min-width: 140px; + flex: 1 1 160px; + max-width: 280px; + height: 32px; + margin: 0; + border: 1px solid var(--nm-ops-line); + border-radius: 4px; + background: var(--nx-field); + color: var(--nx-text); + padding: 0 10px; + font-size: 13px; +} + +.ne-page .filter-inline > .ui-field, +.ume-page .filter-inline > .ui-field, +.system-page .filter-inline > .ui-field { + flex: 1 1 160px; + max-width: 280px; + min-width: 140px; + align-self: center; + gap: 0; +} + +.ne-page .filter-inline > .ui-field > .ui-field__select, +.ume-page .filter-inline > .ui-field > .ui-field__select, +.system-page .filter-inline > .ui-field > .ui-field__select { + width: 100%; + height: 32px; + min-height: 32px; + max-height: 32px; + flex: none; + margin: 0; + border: 1px solid var(--nm-ops-line); + border-radius: 4px; + background: var(--nx-field); + color: var(--nx-text); + padding: 0 10px; + font-size: 13px; +} + +.ne-page .filter-inline button, +.ume-page .filter-inline button, +.system-page .filter-inline button { + height: 32px; + flex: 0 0 auto; +} + +.ne-page .pt-list, +.ume-page .pt-list, +.system-page .pt-list { + gap: 12px; +} + +.ne-page .pt-list-table-wrap, +.ume-page .pt-list-table-wrap, +.system-page .pt-list-table-wrap { + border-color: rgba(148, 163, 184, 0.2); +} + +.ne-page .pt-list-table thead th, +.ume-page .pt-list-table thead th, +.system-page .pt-list-table thead th { + background: rgb(var(--nx-ink-rgb) / 0.9); + color: var(--nx-text-muted); +} + +.ne-page .pager.pt-list-pager, +.ne-page .pt-list-pager, +.ume-page .pager.pt-list-pager, +.ume-page .pt-list-pager, +.ume-page .pager, +.system-page .pager.pt-list-pager, +.system-page .pt-list-pager { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 2px; +} + +.ne-page .pt-list-pager select, +.ume-page .pt-list-pager select, +.ume-page .pager select, +.system-page .pt-list-pager select { + width: auto; + min-width: 110px; + height: 32px; + margin: 0; +} + +.system-page .pt-list-actions select, +.system-page .pt-list-actions input { + width: auto; + min-width: 0; + height: 30px; + margin: 0; + flex: 0 0 auto; +} + +.system-page .token-once { + padding: 12px 14px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + background: rgb(var(--nx-ink-rgb) / 0.85); + color: var(--nx-text); +} + +.system-page .token-once .panel__hint { + color: var(--nx-text-muted); +} + +.system-page .token-once code { + display: block; + margin: 8px 0; + word-break: break-all; + font-size: 12px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: var(--nx-field); + color: var(--nx-accent-text); +} + +.system-page .token-create { + display: flex; + flex-direction: column; + gap: 12px; +} + +.system-page .token-scopes { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + background: rgb(var(--nx-ink-rgb) / 0.85); + color: var(--nx-text); +} + +.system-page .token-scopes__head { + display: flex; + flex-direction: column; + gap: 4px; +} + +.system-page .token-scopes__presets { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.system-page .token-scopes__inherit { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--nx-text-soft); +} + +.system-page .token-scopes__grid { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.system-page .token-scopes__grid.is-disabled { + opacity: 0.45; +} + +.system-page .token-scopes__grid label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--nx-text-soft); + white-space: nowrap; +} + +.system-page .token-scopes-cell { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--nx-text-muted); +} + +.token-page__title { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; +} + +.help-q { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 999px; + border: 1px solid var(--nx-text-muted); + color: var(--nx-text-faint); + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: help; + user-select: none; + flex: 0 0 auto; +} + +.help-q:hover, +.help-q:focus-visible { + border-color: var(--nx-text-faint); + color: var(--nx-text); + outline: none; +} + +.help-q__tip { + display: none; + position: absolute; + left: 0; + top: calc(100% + 8px); + z-index: 90; + width: max-content; + max-width: min(360px, 70vw); + padding: 8px 10px; + border-radius: 6px; + background: var(--nx-surface-2); + color: var(--nx-text-strong); + font-size: 12px; + font-weight: 400; + line-height: 1.45; + white-space: normal; + text-align: left; + box-shadow: 0 6px 16px rgb(var(--nx-ink-rgb) / 0.22); + pointer-events: none; +} + +.help-q__tip::after { + content: ""; + position: absolute; + bottom: 100%; + left: 8px; + border: 5px solid transparent; + border-bottom-color: var(--nx-surface-2); +} + +.help-q:hover .help-q__tip, +.help-q:focus-visible .help-q__tip { + display: block; +} + +.token-scopes-modal { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.token-scopes-modal__backdrop { + position: absolute; + inset: 0; + background: rgb(var(--nx-ink-deep-rgb) / 0.72); +} + +.token-scopes-modal__panel { + position: relative; + z-index: 1; + width: min(480px, 100%); + max-height: min(80vh, 640px); + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 18px; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: var(--nx-surface-2); + color: var(--nx-text); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); +} + +.token-scopes-modal__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.token-scopes-modal__head h3 { + margin: 0; + font-size: 16px; + font-weight: 650; + color: var(--nx-text-strong); +} + +.token-scopes-modal__list { + list-style: none; + margin: 0; + padding: 0; + overflow: auto; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 8px; + background: rgb(var(--nx-ink-deep-rgb) / 0.45); +} + +.token-scopes-modal__list > li + li { + border-top: 1px solid rgba(148, 163, 184, 0.14); +} + +.token-scopes-modal__row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + margin: 0; + cursor: pointer; + font-size: 13px; + color: var(--nx-text-soft); +} + +.token-scopes-modal__row:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.65); +} + +.token-scopes-modal__row input { + margin-top: 2px; + flex: 0 0 auto; +} + +.token-scopes-modal__label { + line-height: 1.4; +} + +.token-scopes-modal__foot { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + padding-top: 4px; +} + + +.ume-page .pt-list-actions, +.ume-page .pt-list-table .btn-row { + gap: 6px; +} + +.ume-page .actions-row--inline { + margin-top: 10px; +} + +.ume-page .panel__toolbar + .actions-row--inline, +.ume-page .panel__toolbar + .pt-list { + margin-top: 0; +} + +.mini-chart__bar-wrap { + height: 10px; + border-radius: 999px; + background: #10213a; + border: 1px solid #223651; + overflow: hidden; +} + +.mini-chart__bar { + height: 100%; + background: linear-gradient(90deg, #3973d2, #5aa1ff); +} + +.pager { + margin-top: 12px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.pager__meta { + color: #9db2cf; + font-size: 12px; +} + +.pager__controls { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: nowrap; +} + +.pager__btn, +.pager__num { + min-width: 34px; + padding: 6px 10px; +} + +.pager__num.active { + border-color: #4f7ec7; + background: rgba(61, 130, 247, 0.24); + color: #dce8ff; +} + +.pager__size { + width: auto; + min-width: 88px; +} + +.pager__jump { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; +} + +.pager__jump-input { + width: 3.2rem; + min-width: 2.5rem; + max-width: 4.5rem; + padding: 4px 6px; + text-align: center; +} + +.visually-hidden { + position: absolute; + inset: 0; + width: 1px; + height: 1px; + padding: 0; + margin: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.page-stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.page-stack > .cards { + margin-bottom: 0; +} + +.panel--warn { + border-color: rgba(251, 191, 36, 0.4); + background: rgba(120, 53, 15, 0.35); + color: #fde68a; +} + +.panel__toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.panel__toolbar h2, +.panel__toolbar h3 { + margin: 0; +} + +.panel__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.btn--danger { + color: #fca5a5; + border-color: rgba(248, 113, 113, 0.35); + background: rgba(127, 29, 29, 0.35); +} + +.btn--danger:hover:not(:disabled) { + background: rgba(153, 27, 27, 0.5); + color: #fecaca; +} + +.btn--danger:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.table-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + background: rgb(var(--nx-ink-deep-rgb) / 0.72); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.modal { + width: min(720px, 100%); + max-height: 90vh; + overflow: auto; + background: var(--nx-surface-2); + color: var(--nx-text); + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 10px; + padding: 20px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); +} + +.modal--wide { + width: min(920px, 100%); +} + +.connect-detail-summary { + margin-left: 8px; + color: var(--nx-text-muted); + font-size: 13px; +} + +.connect-log { + margin: 12px 0 0; + padding: 12px; + max-height: min(52vh, 480px); + overflow: auto; + background: var(--nx-surface-2); + color: var(--nx-text); + border-radius: 8px; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.modal h3 { + margin: 0 0 16px; +} + +.modal__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.cli-connect-panel { + width: 100%; + text-align: left; +} + +.cli-connect-panel .form-grid, +.cli-connect-panel .form-fieldset { + width: 100%; + max-width: none; +} + +.cli-connect-panel .form-grid label:not(.form-check) { + align-items: stretch; + text-align: left; +} + +.cli-connect-panel .form-label { + display: block; + margin: 0; + text-align: left; +} + +.cli-connect-panel .form-grid input, +.cli-connect-panel .form-grid select, +.cli-connect-panel .form-fieldset input, +.cli-connect-panel .form-fieldset select { + width: 100%; + margin: 0; + text-align: left; +} + +.cli-connect-panel .form-fieldset { + text-align: left; + width: 100%; + min-width: 0; + min-inline-size: 0; +} + +.cli-connect-panel .hop-proxy-fields, +.cli-connect-panel .hop-proxy-fields .form-grid { + width: 100%; +} + +.cli-connect-panel .hop-proxy-fields .form-grid label { + align-items: flex-start; + justify-content: flex-start; + text-align: left; + width: 100%; +} + +.cli-connect-panel .hop-proxy-fields .form-grid select, +.cli-connect-panel .hop-proxy-fields .form-grid input { + display: block; + width: 100%; + max-width: 100%; + margin: 0; + box-sizing: border-box; + text-align: left; + text-align-last: left; +} + +.cli-connect-panel .hop-proxy-fields .form-field-hint { + text-align: left; +} + +.cli-connect-panel .form-fieldset legend { + margin: 0 0 8px; + padding: 0 6px; + text-align: left; +} + +.cli-connect-panel .form-check { + display: flex; + flex-direction: row; + align-items: center; + width: fit-content; + max-width: 100%; +} + +.card .cli-connect-panel input, +.card .cli-connect-panel select, +.card .cli-connect-panel textarea { + margin-bottom: 0; +} + +.cli-connect-test { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + min-width: 0; +} + +.cli-connect-test-row { + display: flex; + flex-wrap: nowrap; + gap: 8px; + align-items: center; + width: 100%; +} + +.cli-connect-test-pick { + position: relative; + flex: 1 1 auto; + min-width: 0; +} + +.cli-connect-test-pick > input, +.cli-connect-test-pick [data-slot="input"], +.cli-connect-test-pick .input { + width: 100%; + margin-bottom: 0; +} + +.cli-connect-test-suggest { + width: 100%; + margin: 0; + padding: 4px; + border: 1px solid rgba(56, 189, 248, 0.28); + border-radius: 6px; + background: rgb(var(--nx-ink-deep-rgb) / 0.92); + box-shadow: inset 0 0 0 1px rgb(var(--nx-ink-rgb) / 0.55); + overflow: visible; +} + +.cli-connect-test-suggest__item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + width: 100%; + margin: 0; + padding: 8px 10px; + border: 0; + border-radius: 4px; + background: transparent; + text-align: left; + cursor: pointer; + color: inherit; +} + +.cli-connect-test-suggest__item + .cli-connect-test-suggest__item { + margin-top: 2px; +} + +.cli-connect-test-suggest__item:hover, +.cli-connect-test-suggest__item.is-active { + background: rgba(56, 189, 248, 0.12); +} + +.cli-connect-test-suggest__name { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 650; + color: var(--nx-text); +} + +.cli-connect-test-suggest__meta { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + color: var(--nx-text-muted); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; +} + +.cli-connect-test-suggest__empty { + padding: 10px 12px; + font-size: 12px; + color: var(--nx-text-faint); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.form-hint { + margin: 0 0 12px; + font-size: 12px; + color: var(--nx-text-faint); +} + +.form-label__required { + color: #c62828; + font-weight: 600; +} + +.form-label__optional { + color: var(--nx-text-muted); + font-weight: 400; +} + +.form-field-hint { + font-size: 11px; + color: var(--nx-text-muted); + line-height: 1.35; +} + +.form-label { + display: block; + margin: 0; + font-size: 12px; + color: var(--nx-text-muted); + text-align: left; +} + +.form-grid label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--nx-text-muted); +} + +.form-grid input, +.form-grid select { + font-size: 14px; +} + +.form-grid__full { + grid-column: 1 / -1; +} + +.form-fieldset { + margin-top: 16px; + padding: 12px; + border: 1px solid var(--nm-panel-border, var(--nx-line)); + border-radius: 8px; +} + +.form-fieldset legend, +.form-fieldset__title { + padding: 0 6px; + font-size: 13px; + font-weight: 600; + color: var(--nm-text, var(--nx-text)); +} + +.form-fieldset__title { + display: block; + margin: 0 0 10px; + padding: 0; + text-align: left; +} + +.form-check { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-size: 13px; + color: var(--nx-text-soft); + width: fit-content; + max-width: 100%; +} + +.form-check__text { + white-space: nowrap; + line-height: 1.4; +} + +/* form-grid labels are column layout; checkbox rows must stay horizontal */ +.form-grid label.form-check { + flex-direction: row; + align-items: center; + width: fit-content; + max-width: 100%; +} + +.table-tag { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + font-size: 11px; + background: rgba(56, 189, 248, 0.14); + color: #7dd3fc; + border: 1px solid rgba(56, 189, 248, 0.22); +} + +.form-grid--single { + grid-template-columns: 1fr; +} + +/* Status / empty-state copy only — conceptual docs go in HelpHint on the title. */ +.panel__hint { + margin: 0 0 10px; + font-size: 12px; + color: var(--nm-ops-muted, var(--nx-text-muted)); + line-height: 1.4; +} + +.panel__hint--live { + color: var(--nm-ops-accent, var(--nx-accent)); + border: 1px solid var(--nm-ops-line, var(--nx-line)); + border-radius: 4px; + padding: 8px 10px; + background: rgb(var(--nx-accent-rgb) / 0.06); +} diff --git a/web/src/styles/pages/port-traffic.css b/web/src/styles/pages/port-traffic.css new file mode 100644 index 0000000..52f38c9 --- /dev/null +++ b/web/src/styles/pages/port-traffic.css @@ -0,0 +1,1206 @@ +/* Port traffic monitoring */ + +/* Port traffic monitoring */ +.pt-wizard__steps { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.pt-wizard__step { + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgb(var(--nx-ink-rgb) / 0.75); + color: var(--nx-text-soft); + border-radius: 6px; + padding: 6px 10px; + font-size: 13px; + cursor: pointer; +} + +.pt-wizard__step.is-active { + border-color: rgba(45, 212, 191, 0.45); + background: rgba(15, 118, 110, 0.28); + color: #5eead4; +} + +.pt-wizard__ne-block { + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(148, 163, 184, 0.18); +} + +.pt-wizard__confirm-list { + margin: 8px 0 16px; + padding-left: 1.2rem; + max-height: 220px; + overflow: auto; + color: var(--nx-text-soft); +} + +.pt-list { + display: flex; + flex-direction: column; + gap: 14px; +} + +.pt-list-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.pt-list-kpi { + padding: 12px 14px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + background: rgb(var(--nx-ink-rgb) / 0.72); +} + +.pt-list-kpi--live { + background: rgba(37, 99, 235, 0.18); + border-color: rgb(var(--nx-accent-rgb) / 0.4); +} + +.pt-list-kpi__label { + font-size: 12px; + color: var(--nx-text-muted); + margin-bottom: 4px; +} + +.pt-list-kpi__value { + font-size: 22px; + font-weight: 650; + font-variant-numeric: tabular-nums; + color: var(--nx-text-strong); + line-height: 1.2; +} + +.pt-list-kpi--live .pt-list-kpi__value { + color: var(--nx-accent-text); +} + +.pt-list-table-wrap { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 8px; + overflow: auto; +} + +.pt-list-table { + margin: 0; +} + +.pt-list-table thead th { + background: rgb(var(--nx-ink-rgb) / 0.9); + font-size: 12px; + font-weight: 600; + color: var(--nx-text-muted); + white-space: nowrap; +} + +.pt-list-table tbody td { + vertical-align: middle; +} + +.pt-list-table tbody tr:hover td { + background: rgb(var(--nx-surface-3-rgb) / 0.55); +} + +.pt-list-task-name { + font-weight: 600; + color: var(--nx-text); +} + +.pt-list-task-err { + margin-top: 2px; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: #fca5a5; +} + +/* Beat .app-main button { color/background } so error state shows without hover. */ +.app-main .pt-list-log-btn--error, +.pt-list-actions .pt-list-log-btn--error { + color: #fca5a5; + border-color: rgba(248, 113, 113, 0.35); + background: rgba(127, 29, 29, 0.35); + font-weight: 600; +} + +.app-main .pt-list-log-btn--error:hover, +.pt-list-actions .pt-list-log-btn--error:hover { + color: #fecaca; + background: rgba(153, 27, 27, 0.5); +} + +.pt-log-modal__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.pt-log-modal__head h3 { + margin: 0; +} + +.pt-log-modal__sub { + margin: 4px 0 0; + font-size: 13px; +} + +.pt-log-modal__list { + display: flex; + flex-direction: column; + gap: 10px; + max-height: min(60vh, 520px); + overflow: auto; +} + +.pt-log-item { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 8px; + padding: 10px 12px; + background: rgb(var(--nx-ink-rgb) / 0.72); + color: var(--nx-text); +} + +.pt-log-item--error { + border-color: rgba(248, 113, 113, 0.35); + background: rgba(239, 68, 68, 0.12); +} + +.pt-log-item--warn { + border-color: rgba(251, 191, 36, 0.35); + background: rgba(245, 158, 11, 0.12); +} + +.pt-log-item__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-bottom: 6px; + font-size: 12px; + color: var(--nx-text-muted); +} + +.pt-log-item__level { + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; + color: #fca5a5; +} + +.pt-log-item--warn .pt-log-item__level { + color: #fde68a; +} + +.pt-log-item--info .pt-log-item__level { + color: #7dd3fc; +} + +.pt-log-item__if { + font-family: ui-monospace, SFMono-Regular, Consolas, "Cascadia Mono", Consolas, monospace; + color: var(--nx-text-soft); +} + +.pt-log-item__msg { + margin: 0; + background: rgb(var(--nx-ink-deep-rgb) / 0.55); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 6px; + padding: 8px 10px; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.45; + color: var(--nx-text); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.pt-rebind-modal__current { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 8px; + background: rgb(var(--nx-ink-rgb) / 0.72); + color: var(--nx-text); +} + +.pt-rebind-modal__current strong { + margin-left: 8px; + font-weight: 600; +} + +.pt-rebind-modal__table-wrap { + max-height: min(40vh, 360px); + overflow: auto; +} + +.pt-rebind-row--same-ip { + background: rgba(120, 53, 15, 0.35); +} + +.pt-rebind-same-ip { + margin-left: 8px; + font-size: 11px; + font-weight: 600; + color: #fbbf24; +} + +.pt-list-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; +} + +.pt-list-status + .pt-list-status { + margin-left: 6px; +} + +.pt-list-status--running { + background: rgba(34, 197, 94, 0.16); + color: #86efac; +} + +.pt-list-status--running::before, +.pt-list-status--collect::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.pt-list-status--paused { + background: rgba(245, 158, 11, 0.16); + color: #fde68a; +} + +.pt-list-status--stopped, +.pt-list-status--other { + background: rgba(148, 163, 184, 0.14); + color: var(--nx-text-muted); +} + +.pt-list-status--collect { + background: rgba(99, 102, 241, 0.18); + color: #a5b4fc; +} + +.pt-list-num, +.pt-list-time { + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: var(--nx-text-muted); +} + +.pt-list-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.pt-list-actions .btn-primary, +.pt-list-actions .btn--danger, +.pt-list-actions button { + padding: 4px 10px; + font-size: 12px; + line-height: 1.4; +} + +.pt-list-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 200px; + padding: 32px 16px; + border: 1px dashed rgba(148, 163, 184, 0.28); + border-radius: 8px; + background: rgb(var(--nx-ink-rgb) / 0.55); + color: var(--nx-text-muted); + text-align: center; +} + +.pt-list-empty p { + margin: 0; +} + +.pt-list-pager { + justify-content: center; +} + +@media (max-width: 900px) { + .pt-list-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.pt-wall-shell { + display: flex; + flex-direction: column; + min-height: 0; + color: var(--nx-text); + background: + radial-gradient(1200px 480px at 12% -10%, rgba(45, 212, 191, 0.08), transparent 55%), + radial-gradient(900px 420px at 88% 0%, rgba(56, 189, 248, 0.06), transparent 50%), + linear-gradient(180deg, #0a1220 0%, #070d18 100%); +} + +.pt-board-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 10px 16px; + flex: 0 0 auto; + padding: 10px 14px; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); + background: rgb(var(--nx-ink-rgb) / 0.88); +} + +.pt-board-toolbar__left { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px 12px; + min-width: 0; +} + +.pt-board-toolbar__field { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 11px; + color: var(--nx-text-muted); + min-width: 0; +} + +.pt-board-toolbar__field select, +.pt-board-toolbar__field input { + height: 30px; + min-width: 160px; + padding: 0 9px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: rgb(var(--nx-ink-rgb) / 0.7); + color: var(--nx-text); + font-size: 13px; +} + +.pt-board-toolbar__field--sm select { + min-width: 72px; +} + +.pt-board-toolbar__actions { + flex-wrap: wrap; +} + +.app-main .pt-board-toolbar button { + height: 30px; + font-size: 12px; +} + +.pt-board-page { + padding-top: 10px; +} + +.pt-board-add { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 12px; + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 8px; + background: rgb(var(--nx-ink-rgb) / 0.45); +} + +.pt-board-grid { + display: grid; + gap: 12px; + flex: 1; + min-height: 0; + align-content: start; + overflow: auto; + padding-bottom: 8px; +} + +.pt-board-panel { + min-height: 320px; + display: flex; + flex-direction: column; + min-width: 0; + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 10px; + background: rgb(var(--nx-ink-deep-rgb) / 0.35); + overflow: hidden; +} + +.pt-board-panel.is-stale { + border-color: rgba(248, 113, 113, 0.45); +} + +.pt-board-panel .pt-wall { + flex: 1; + min-height: 280px; + padding: 12px 12px 8px; +} + +.pt-board-panel__editbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex: 0 0 auto; + padding: 6px 10px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); + background: rgb(var(--nx-ink-rgb) / 0.72); +} + +.pt-board-panel__edit-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--nx-text-soft); +} + +.pt-board-panel__stale { + flex: 1; + display: grid; + place-content: center; + gap: 10px; + padding: 24px; + text-align: center; + color: #fca5a5; +} + +.pt-wall-shell__empty { + flex: 1; + display: grid; + place-content: center; + gap: 12px; + color: var(--nx-text-muted); +} + +.pt-wall-page { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 10px 14px 12px; + gap: 0; +} + +.pt-wall-page__hint { + margin: 0 0 8px; + flex: 0 0 auto; + font-size: 12px; + color: var(--nx-text-muted); +} + +.pt-wall-page__stage { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.pt-wall-page__stage:fullscreen, +.pt-wall-page__stage.is-fullscreen:fullscreen { + width: 100vw; + height: 100vh; + padding: 12px 14px; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: hidden; + background: + radial-gradient(1200px 480px at 12% -10%, rgba(45, 212, 191, 0.08), transparent 55%), + linear-gradient(180deg, #0a1220 0%, #070d18 100%); +} + +.pt-wall-page__stage:fullscreen .pt-board-grid, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-grid { + flex: 1 1 auto; + width: 100%; + height: auto; + min-height: 0; + overflow: hidden; + align-content: stretch; + /* Equal row heights that fit the viewport — no fixed min that forces scroll. */ + grid-auto-rows: minmax(0, 1fr); + gap: 6px; + padding-bottom: 0; +} + +.pt-board-fs-legend { + display: none; +} + +.pt-wall-page__stage:fullscreen .pt-board-fs-legend, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-fs-legend { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px 18px; + margin-top: 6px; + padding: 4px 8px 0; + color: var(--nx-text-muted); + font-size: 11px; +} + +.pt-wall-page__stage:fullscreen .pt-board-panel, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-panel { + min-height: 0; + height: auto; + max-height: 100%; + overflow: hidden; + border-radius: 8px; +} + +.pt-wall-page__stage:fullscreen .pt-board-panel .pt-wall, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-panel .pt-wall { + flex: 1 1 auto; + min-height: 0; + height: 100%; + padding: 6px 8px 2px; +} + +.pt-wall--dense .pt-wall__ne { + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pt-wall--dense .pt-wall__ne-base { + color: var(--nx-text-faint); + font-weight: 400; +} + +.pt-wall--dense .pt-wall__if { + font-size: clamp(13px, 1.5vh, 18px); + max-width: min(560px, 55vw); +} + +.pt-wall--dense .pt-wall__range { + font-size: 11px; +} + +.pt-wall--dense .pt-wall__head { + margin-bottom: 4px; + align-items: flex-start; +} + +.pt-wall--dense .pt-wall__kpis { + margin-bottom: 4px; +} + +.pt-wall--dense .pt-wall__kpi { + padding: 4px 8px; +} + +.pt-wall--dense .pt-wall__kpi-label { + font-size: 9px; +} + +.pt-wall--dense .pt-wall__kpi-value { + font-size: clamp(12px, 1.6vh, 16px); +} + +.pt-wall--dense .pt-wall__kpi-unit { + font-size: 10px; +} + +.pt-wall-page__stage:fullscreen .pt-wall__if, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__if { + font-size: clamp(13px, 1.5vh, 18px); +} + +.pt-wall-page__stage:fullscreen .pt-wall__head, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__head { + margin-bottom: 4px; +} + +.pt-wall-page__stage:fullscreen .pt-wall__kpis, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__kpis { + margin-bottom: 4px; +} + +.pt-wall-page__stage:fullscreen .pt-wall__kpi, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__kpi { + padding: 4px 8px; +} + +.pt-wall-page__stage:fullscreen .pt-wall__kpi-value, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__kpi-value { + font-size: clamp(12px, 1.6vh, 16px); +} + +.pt-wall-page__stage:fullscreen .pt-wall__chart-wrap, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__chart-wrap { + min-height: 0; + flex: 1 1 auto; +} + +.pt-wall-page__stage .pt-wall { + flex: 1; + min-height: 0; + height: 100%; +} + +.pt-wall-page__fs-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 11px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: transparent; + color: var(--nx-text-soft); + font-size: 12px; + cursor: pointer; +} + +.pt-wall-page__fs-btn--toolbar { + flex: 0 0 auto; + align-self: flex-end; +} + +.pt-wall-page__fs-btn:hover { + background: rgba(148, 163, 184, 0.12); + border-color: rgba(148, 163, 184, 0.45); + color: var(--nx-text-strong); +} + +.app-main .pt-wall-page__fs-btn { + background: transparent; + color: var(--nx-text-soft); +} + +.app-main .pt-wall-page__fs-btn:hover { + background: rgba(148, 163, 184, 0.12); + color: var(--nx-text-strong); +} + +.pt-wall-page__toolbar { + display: flex; + flex-direction: column; + gap: 8px; + flex: 0 0 auto; + margin-bottom: 10px; + padding: 0 0 10px; + background: transparent; + border: 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 0; +} + +.pt-wall-page__row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px 10px; +} + +.pt-wall-page__row--primary { + padding-bottom: 0; + border-bottom: 0; +} + +.pt-wall-page__row--secondary { + align-items: flex-end; +} + +.pt-wall-page__field { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 11px; + color: var(--nx-text-faint); + min-width: 0; +} + +.pt-wall-page__field select, +.pt-wall-page__field input { + height: 30px; + padding: 0 9px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + background: rgb(var(--nx-ink-rgb) / 0.55); + color: var(--nx-text); + font-size: 12px; +} + +.pt-wall-page__field select:hover, +.pt-wall-page__field input:hover, +.pt-wall-page__field select:focus, +.pt-wall-page__field input:focus { + border-color: rgba(45, 212, 191, 0.45); + outline: none; +} + +.app-main .pt-wall-page__field select, +.app-main .pt-wall-page__field input { + background: rgb(var(--nx-ink-rgb) / 0.55); + color: var(--nx-text); + border-color: rgba(148, 163, 184, 0.22); +} + +.pt-wall-page__field--task { + flex: 0 1 160px; + min-width: 128px; +} + +.pt-wall-page__field--port { + flex: 1 1 220px; + min-width: 180px; +} + +.pt-wall-page__field--sm { + flex: 0 0 78px; +} + +.pt-wall-page__field--md { + flex: 0 1 148px; + min-width: 120px; +} + +.pt-wall-page__field--port select { + width: 100%; +} + +.pt-wall-page__collect { + flex: 0 0 auto; + height: 30px; + margin-left: auto; + align-self: flex-end; + white-space: nowrap; + border-radius: 6px; + border: 1px solid rgba(45, 212, 191, 0.35); + background: rgba(45, 212, 191, 0.16); + color: #99f6e4; + font-size: 12px; +} + +.app-main .pt-wall-page__collect.btn-primary { + background: rgba(45, 212, 191, 0.16); + border-color: rgba(45, 212, 191, 0.35); + color: #99f6e4; +} + +.app-main .pt-wall-page__collect.btn-primary:hover:not(:disabled) { + background: rgba(45, 212, 191, 0.28); + color: #ecfdf5; +} + +.pt-wall-page__cluster { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 8px 10px; + min-width: 0; +} + +.pt-wall-page__cluster--map { + flex: 1 1 280px; +} + +.pt-wall-page__cluster-label { + flex: 0 0 auto; + padding-bottom: 7px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--nx-text-muted); + white-space: nowrap; +} + +.pt-wall-page__cluster-fields { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px 10px; +} + +.pt-wall-page__cluster-sep { + width: 1px; + align-self: stretch; + margin: 4px 6px 0; + background: rgba(148, 163, 184, 0.14); + flex: 0 0 1px; +} + +@media (max-width: 900px) { + .pt-wall-page__cluster { + flex-direction: column; + align-items: stretch; + } + + .pt-wall-page__cluster-label { + padding-bottom: 0; + } + + .pt-wall-page__cluster-sep { + display: none; + } + + .pt-wall-page__collect { + margin-left: 0; + } + + .pt-wall-page__field--port { + flex-basis: 100%; + } +} + +.pt-wall { + --pt-bg: #0a1220; + --pt-panel: transparent; + --pt-line: rgba(148, 163, 184, 0.16); + --pt-text: var(--nx-text); + --pt-muted: #8b9bb0; + --pt-in: #2dd4bf; + --pt-out: #fbbf24; + display: flex; + flex-direction: column; + min-height: 0; + background: transparent; + border: 0; + border-radius: 0; + padding: 4px 2px 0; + color: var(--pt-text); +} + +.pt-wall__head { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 16px; + margin-bottom: 12px; + flex: 0 0 auto; +} + +.pt-wall__head-title { + font-size: 14px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--nx-text-soft); +} + +.pt-wall__title { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.pt-wall__ne { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--pt-muted); +} + +.pt-wall__if { + font-size: 22px; + font-weight: 600; + letter-spacing: 0.01em; + font-family: ui-monospace, SFMono-Regular, Consolas, "Cascadia Mono", Consolas, monospace; + color: var(--nx-text-strong); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: min(720px, 72vw); +} + +.pt-wall__range { + font-size: 12px; + color: var(--pt-muted); + white-space: nowrap; + padding-bottom: 3px; + font-variant-numeric: tabular-nums; +} + +.pt-wall__kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + margin-bottom: 14px; + flex: 0 0 auto; + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 8px; + background: rgb(var(--nx-ink-rgb) / 0.35); + overflow: hidden; +} + +.pt-wall__kpi { + background: transparent; + border: 0; + border-radius: 0; + border-right: 1px solid rgba(148, 163, 184, 0.1); + padding: 12px 16px; +} + +.pt-wall__kpi:last-child { + border-right: 0; +} + +.pt-wall__kpi-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--nx-text-faint); + margin-bottom: 6px; +} + +.pt-wall__kpi-value { + font-size: 20px; + font-weight: 600; + font-variant-numeric: tabular-nums; + font-family: ui-monospace, SFMono-Regular, Consolas, "Cascadia Mono", Consolas, monospace; + line-height: 1.15; + color: var(--nx-text-strong); +} + +.pt-wall__kpi--in .pt-wall__kpi-value { + color: var(--pt-in); +} + +.pt-wall__kpi--out .pt-wall__kpi-value { + color: var(--pt-out); +} + +.pt-wall__kpi--util .pt-wall__kpi-value { + color: #7dd3fc; +} + +.pt-wall__kpi-unit, +.pt-wall__kpi-sep { + margin-left: 4px; + font-size: 11px; + color: var(--pt-muted); + font-weight: 500; +} + +.pt-wall__chart-wrap { + position: relative; + flex: 1; + min-height: 180px; + width: 100%; + border-radius: 8px; + background: rgb(var(--nx-ink-deep-rgb) / 0.45); + border: 1px solid rgba(148, 163, 184, 0.1); + padding: 8px 8px 4px; + box-sizing: border-box; +} + +.pt-wall__chart { + min-height: 0; + height: 100%; + width: 100%; +} + +.pt-wall__chart .uplot, +.pt-wall__chart .u-wrap { + font-family: ui-monospace, SFMono-Regular, Consolas, "Cascadia Mono", Consolas, monospace; +} + +.pt-wall__tip { + position: absolute; + top: 0; + left: 0; + z-index: 5; + min-width: 150px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgb(var(--nx-ink-deep-rgb) / 0.94); + color: var(--nx-text); + font-size: 12px; + line-height: 1.45; + pointer-events: none; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(6px); +} + +.pt-wall__tip-time { + margin-bottom: 6px; + color: var(--nx-text-muted); + font-variant-numeric: tabular-nums; +} + +.pt-wall__tip-row { + display: flex; + justify-content: space-between; + gap: 12px; + font-variant-numeric: tabular-nums; +} + +.pt-wall__tip-row--in strong { + color: #2dd4bf; +} + +.pt-wall__tip-row--out strong { + color: #fbbf24; +} + +.pt-wall__chart .u-axis { + border: 0 !important; +} + +.pt-wall__chart .u-legend { + display: none !important; +} + +.pt-wall__foot { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 20px; + margin-top: 8px; + padding-top: 0; + border-top: 0; + flex: 0 0 auto; + min-height: 28px; +} + +.pt-wall__legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + color: var(--pt-muted); + font-size: 12px; + line-height: 1.2; +} + +.pt-wall__legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.pt-wall__legend-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + flex: 0 0 10px; +} + +.pt-wall__legend-item--in .pt-wall__legend-swatch { + background: #2dd4bf; +} + +.pt-wall__legend-item--out .pt-wall__legend-swatch { + background: #fbbf24; +} + +.pt-wall__legend-item--base-in .pt-wall__legend-swatch { + background: rgba(45, 212, 191, 0.45); + box-shadow: inset 0 0 0 1px rgba(45, 212, 191, 0.7); +} + +.pt-wall__legend-item--base-out .pt-wall__legend-swatch { + background: rgba(251, 191, 36, 0.4); + box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.65); +} + +.pt-wall__foot-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 20px; + margin-left: auto; +} + +.pt-wall__foot-ne, +.pt-wall__foot-if { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + min-width: 0; +} + +.pt-wall__foot-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--nx-text-faint); +} + +.pt-wall__foot-value { + font-size: 13px; + font-weight: 600; + color: var(--nx-text); +} + +.pt-wall__foot-value--mono { + font-family: ui-monospace, SFMono-Regular, Consolas, "Cascadia Mono", Consolas, monospace; + letter-spacing: 0.02em; +} + +.pt-wall__foot-ip { + font-size: 12px; + color: var(--pt-muted); + font-variant-numeric: tabular-nums; +} + +.pt-wall__empty { + position: absolute; + inset: 8px; + display: flex; + align-items: center; + justify-content: center; + padding: 16px 24px; + text-align: center; + line-height: 1.5; + color: var(--pt-muted); + background: rgb(var(--nx-ink-deep-rgb) / 0.55); + border: 1px dashed rgba(148, 163, 184, 0.22); + border-radius: 6px; + pointer-events: none; +} + +@media (max-width: 900px) { + .pt-wall__kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .pt-wall__kpi:nth-child(2n) { + border-right: 0; + } + + .pt-wall__kpi:nth-child(-n + 2) { + border-bottom: 1px solid rgba(148, 163, 184, 0.1); + } +} diff --git a/web/src/styles/pages/topology.css b/web/src/styles/pages/topology.css new file mode 100644 index 0000000..1d34c46 --- /dev/null +++ b/web/src/styles/pages/topology.css @@ -0,0 +1,3667 @@ +/* Topology editor + canvas */ + +/* ---- Topology — dark sidebar + canvas (shared chrome tokens) ---- */ +.topo-page { + --topo-sidebar-w: 260px; + display: grid; + grid-template-columns: var(--topo-sidebar-w) 1fr; + gap: 12px; + height: calc(100vh - 48px); + min-height: 520px; + margin: -20px -28px -32px; + padding: 12px 14px 14px; + background: var(--nm-chrome); + transition: grid-template-columns 0.18s ease; +} + +.topo-page.is-sidebar-collapsed { + grid-template-columns: 36px 1fr; +} + +.topo-page.is-sidebar-resizing { + transition: none; + cursor: col-resize; +} + +.topo-sidebar { + position: relative; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + background: #0a121c; + border: 1px solid var(--nm-ops-line, var(--nm-nav-border)); + border-radius: 0; + border-left: 0; + border-top: 0; + border-bottom: 0; + padding: 10px; + overflow: hidden; + color: var(--nm-text); + box-shadow: none; +} + +.topo-sidebar__resizer { + position: absolute; + top: 0; + right: -8px; + z-index: 6; + width: 12px; + height: 100%; + cursor: col-resize; + touch-action: none; +} + +.topo-sidebar__resizer::after { + content: ""; + position: absolute; + top: 12%; + bottom: 12%; + left: 50%; + width: 2px; + transform: translateX(-50%); + border-radius: 1px; + background: transparent; + transition: background 0.12s ease; +} + +.topo-sidebar__resizer:hover::after, +.topo-sidebar__resizer.is-dragging::after { + background: rgb(var(--nx-accent-rgb) / 0.75); +} + +.topo-page.is-sidebar-collapsed .topo-sidebar { + padding: 4px; + align-items: stretch; +} + +.topo-sidebar__rail-wrap { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + gap: 4px; +} + +.topo-sidebar__rail { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 8px; + width: 100%; + border: 0; + background: transparent; + padding: 8px 0; + cursor: pointer; + color: var(--nm-muted); + border-radius: 6px; +} + +.topo-sidebar__rail-wrap > .topo-sidebar__rail:first-child { + flex: 1; +} + +.topo-sidebar__rail .topo-sidebar__rail-icon { + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid transparent; +} + +.topo-sidebar__rail:hover .topo-sidebar__rail-icon { + border-color: rgba(148, 163, 184, 0.28); + background: rgb(var(--nx-surface-3-rgb) / 0.85); +} + +.topo-sidebar__rail:hover { + background: var(--nm-nav-hover); + color: var(--nm-brand); +} + +.topo-sidebar__rail-icon { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; + font-weight: 600; +} + +.topo-sidebar__rail-label { + writing-mode: vertical-rl; + text-orientation: mixed; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.topo-sidebar__section { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 0; + min-width: 0; +} + +.topo-sidebar__section--grow { + flex: 1; +} + +.app-main .topo-sidebar__icon-btn, +.topo-sidebar__icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + min-width: 24px; + padding: 0 !important; + margin: 0; + border: 0 !important; + border-radius: 4px !important; + background: transparent !important; + box-shadow: none !important; + color: var(--nx-text-faint); + cursor: pointer; + flex: 0 0 auto; +} + +.app-main .topo-sidebar__icon-btn:hover:not(:disabled), +.topo-sidebar__icon-btn:hover:not(:disabled) { + color: var(--nm-brand, var(--nx-text-strong)); + background: var(--nm-nav-hover, var(--nx-surface-3)) !important; +} + +.app-main .topo-sidebar__icon-btn:disabled, +.topo-sidebar__icon-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.topo-tree-search { + position: relative; + margin: 0 0 4px; +} + +.topo-tree-search__bar { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + +.topo-tree-search__bar > .input { + flex: 1 1 auto; + min-width: 0; +} + +.topo-tree-search__panel { + position: absolute; + z-index: 40; + top: calc(100% + 4px); + left: 0; + right: 0; + max-height: min(360px, 50vh); + overflow: auto; + padding: 4px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + background: var(--nx-field); + color: var(--nx-text); + box-shadow: 0 8px 24px rgb(var(--nx-ink-deep-rgb) / 0.55); +} + +.topo-tree-search__item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 2px; + border-radius: 6px; +} + +.topo-tree-search__item + .topo-tree-search__item { + margin-top: 2px; + border-top: 1px solid rgba(148, 163, 184, 0.16); + padding-top: 4px; +} + +.topo-tree-search__ne { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 0 8px; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 6px; + background: transparent; + text-align: left; + cursor: pointer; + color: inherit; +} + +.topo-tree-search__ne:hover { + background: rgba(148, 163, 184, 0.12); +} + +.topo-tree-search__name { + grid-column: 1; + font-size: 12px; + font-weight: 600; + color: var(--nx-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topo-tree-search__ip { + grid-column: 1; + font-size: 11px; +} + +.topo-tree-search__meta { + grid-column: 2; + grid-row: 1 / span 2; + align-self: center; + font-size: 11px; + white-space: nowrap; +} + +.topo-tree-search__views { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 6px 4px; +} + +.topo-tree-search__view { + max-width: 100%; + padding: 2px 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: rgb(var(--nx-surface-3-rgb) / 0.9); + font-size: 11px; + color: var(--nx-accent-text); + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topo-tree-search__view:hover { + background: rgba(51, 65, 85, 0.95); + border-color: rgba(96, 165, 250, 0.45); +} + +.topo-tree-search__hint { + margin: 0; + padding: 8px; + font-size: 12px; +} + +.topo-palette-source { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px; + padding: 2px; + background: rgb(var(--nx-ink-rgb) / 0.85); + border: 1px solid var(--nm-nav-border, rgba(148, 163, 184, 0.22)); + border-radius: 6px; +} + +.topo-palette-source__btn { + border: 0; + background: transparent; + border-radius: 4px; + padding: 5px 6px; + font-size: 11px; + font-weight: 600; + color: var(--nm-muted); + cursor: pointer; +} + +.topo-palette-source__btn.is-active { + background: rgb(var(--nx-surface-3-rgb) / 0.95); + color: var(--nx-accent-text); + box-shadow: inset 0 0 0 1px rgb(var(--nx-accent-rgb) / 0.4); +} + +.topo-page .topo-sidebar__section { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.topo-dir { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + background: rgb(var(--nx-ink-deep-rgb) / 0.45); +} +.topo-dir__list { + list-style: none; + margin: 0; + padding: 4px 0; +} +.topo-dir__list--nested { + margin: 0 0 2px 10px; + padding: 2px 0 4px; + border-left: 1px solid rgba(148, 163, 184, 0.18); +} +.topo-dir__block { + list-style: none; +} +.topo-dir__block--sep { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid rgba(148, 163, 184, 0.18); +} +.topo-dir__row { + display: flex; + align-items: center; + min-height: 28px; + gap: 2px; + margin: 0 4px; + padding: 0 4px 0 2px; + border-radius: 4px; +} +.topo-dir__row:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.65); +} +.topo-dir__row:last-child { + border-bottom: 0; +} +.topo-dir__row--root { + min-height: 32px; + background: rgb(var(--nx-surface-3-rgb) / 0.55); + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + position: sticky; + top: 0; + z-index: 1; +} +.topo-dir__row--file { + min-height: 26px; +} +.topo-dir__row.is-active { + background: rgba(37, 99, 235, 0.22); + box-shadow: inset 0 0 0 1px rgb(var(--nx-accent-rgb) / 0.4); +} +.topo-dir__row.is-active .topo-dir__name { + font-weight: 600; + color: var(--nm-accent); +} +.topo-dir__twist { + border: 0; + background: transparent; + width: 18px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--nx-text-faint); + flex: 0 0 auto; +} +.topo-dir__indent { + width: 10px; + flex: 0 0 auto; +} +.topo-dir__item { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + display: flex; + align-items: center; + gap: 6px; + padding: 2px 2px; + cursor: pointer; + color: inherit; + text-align: left; +} +.topo-dir__icon { + width: 16px; + height: 16px; + border-radius: 3px; + display: grid; + place-items: center; + flex: 0 0 auto; + color: var(--nx-text-muted); +} +.topo-dir__icon--root { + color: #1d4ed8; +} +.topo-dir__icon--region { + color: #0f766e; +} +.topo-dir__icon--core { + color: #b45309; +} +.topo-dir__icon--aggregation { + color: #1d4ed8; +} +.topo-dir__icon--access { + color: var(--nm-accent, var(--nx-accent-strong)); +} +.topo-dir__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12.5px; + color: var(--nm-text); +} +.topo-dir__text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0; +} +.topo-dir__meta { + font-size: 11px; + color: var(--nm-muted, var(--nx-text-muted)); +} +.topo-dir__action { + border: 0; + background: transparent; + width: 20px; + height: 20px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--nx-text-muted); + opacity: 0; + flex: 0 0 auto; +} +.topo-dir__row:hover .topo-dir__action, +.topo-dir__row.is-active .topo-dir__action { + opacity: 1; +} +.topo-dir__action:hover { + color: var(--nm-brand, var(--nx-text-strong)); + background: var(--nm-nav-hover, var(--nx-surface-3)); +} +.topo-dir__toolbar { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 2px 6px 6px 20px; +} +.topo-dir__toolbar button { + border: 0; + background: transparent; + border-radius: 4px; + font-size: 11px; + padding: 2px 6px; + cursor: pointer; + color: var(--nm-accent); + display: inline-flex; + align-items: center; + gap: 4px; +} +.topo-dir__toolbar button:hover { + background: rgb(var(--nx-accent-rgb) / 0.08); +} +.topo-dir__hint { + margin: 0; + font-size: 11px; + line-height: 1.4; + color: var(--nx-text-muted); +} +.topo-dir__icon svg, +.topo-tree__glyph-svg { + display: block; +} +.topo-browser { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin: 0; + border: 0; + border-radius: 0; + background: rgb(var(--nx-ink-deep-rgb) / 0.92); + overflow: hidden; + color: var(--nx-text); +} +.topo-browser__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 14px 18px 10px; + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + background: rgb(var(--nx-ink-rgb) / 0.85); +} +.topo-browser__head strong { + font-size: 15px; + font-weight: 650; + color: var(--nx-text-strong); +} +.topo-browser__sub { + margin: 4px 0 0; + font-size: 12px; + color: var(--nx-text-muted); +} +.topo-browser__actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + flex: 0 0 auto; +} +.topo-browser__actions .btn { + display: inline-flex; + align-items: center; + gap: 4px; +} +.topo-browser__empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 48px 20px; + color: var(--nx-text-muted); + font-size: 13px; +} +.topo-browser__empty p { + margin: 0; +} +.topo-browser__empty-actions { + display: inline-flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 8px; +} +.topo-browser__empty-icon { + display: grid; + place-items: center; + width: 64px; + height: 64px; + border-radius: 14px; + background: rgb(var(--nx-ink-rgb) / 0.85); + border: 1px solid rgba(148, 163, 184, 0.22); + color: #5eead4; + box-shadow: none; +} +.topo-browser__grid { + flex: 1; + min-height: 0; + overflow: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 14px; + align-content: start; + padding: 16px 18px 20px; + background: rgb(var(--nx-ink-deep-rgb) / 0.35); +} +.topo-browser__grid--regions { + grid-template-columns: repeat(auto-fill, minmax(148px, 160px)); + gap: 20px 22px; + padding: 24px 22px 32px; + background: rgb(var(--nx-ink-deep-rgb) / 0.45); + justify-content: start; +} +/* Beat .app-main button { background:#fff; color:#1f2937; border } */ +.app-main .topo-region-hex, +.topo-region-hex { + width: 140px; + height: 156px; + box-sizing: border-box; + border: 0 !important; + border-radius: 0; + box-shadow: none; + padding: 34px 18px 30px; + margin: 0; + clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + text-align: center; + cursor: pointer; + color: #fff !important; + transition: transform 0.15s ease, filter 0.15s ease; +} +.app-main .topo-region-hex:hover, +.topo-region-hex:hover, +.app-main .topo-region-hex.is-hot, +.topo-region-hex.is-hot { + transform: translateY(-2px) scale(1.03); + filter: brightness(1.06); +} +.app-main .topo-region-hex.is-selected, +.topo-region-hex.is-selected, +.app-main .topo-region-hex.is-hot, +.topo-region-hex.is-hot { + box-shadow: + 0 0 0 3px rgba(255, 255, 255, 0.92), + 0 0 0 6px rgba(26, 86, 168, 0.55); + z-index: 1; +} +.app-main .topo-region-hex.is-selected, +.topo-region-hex.is-selected { + filter: brightness(1.08); + transform: translateY(-2px) scale(1.04); +} +.app-main .topo-region-hex--tone-0, +.topo-region-hex--tone-0 { + background: linear-gradient(160deg, var(--nx-accent-strong) 0%, #1e3a5f 55%, var(--nx-surface-2) 100%) !important; +} +.app-main .topo-region-hex--tone-1, +.topo-region-hex--tone-1 { + background: linear-gradient(160deg, #38bdf8 0%, #1e40af 52%, var(--nx-surface-2) 100%) !important; +} +.app-main .topo-region-hex--tone-2, +.topo-region-hex--tone-2 { + background: linear-gradient(160deg, #2dd4bf 0%, #0f766e 50%, var(--nx-surface-2) 100%) !important; +} +.app-main .topo-region-hex--tone-3, +.topo-region-hex--tone-3 { + background: linear-gradient(160deg, var(--nx-text-faint) 0%, var(--nx-line-strong) 48%, var(--nx-surface-2) 100%) !important; +} +.app-main .topo-region-hex--tone-4, +.topo-region-hex--tone-4 { + background: linear-gradient(160deg, var(--nx-text-muted) 0%, var(--nx-line-strong) 48%, var(--nx-line) 100%) !important; +} +.app-main .topo-region-hex--tone-physical, +.topo-region-hex--tone-physical { + background: linear-gradient(160deg, #f59e0b 0%, #92400e 46%, var(--nx-surface-2) 100%) !important; +} +.app-main .topo-region-hex--add, +.topo-region-hex--add { + background: linear-gradient(160deg, var(--nx-line) 0%, var(--nx-surface-2) 100%) !important; + color: var(--nx-accent-text) !important; +} +.app-main .topo-region-hex--add:hover, +.topo-region-hex--add:hover { + background: linear-gradient(160deg, var(--nx-line-strong) 0%, var(--nx-line) 100%) !important; + color: #bfdbfe !important; +} +.app-main .topo-region-hex--add:disabled, +.topo-region-hex--add:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.topo-region-hex__icon { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.22); + color: #fff; + flex: 0 0 auto; +} +.topo-region-hex--add .topo-region-hex__icon { + background: rgb(var(--nx-accent-rgb) / 0.2); + color: var(--nx-accent-text); +} +.topo-region-hex__name { + max-width: 100%; + font-size: 14px; + font-weight: 700; + line-height: 1.25; + color: inherit; + white-space: normal; + overflow-wrap: anywhere; + text-align: center; +} +.topo-region-hex__title { + display: flex; + align-items: baseline; + justify-content: center; + flex-wrap: wrap; + gap: 4px 6px; + max-width: 100%; + min-width: 0; +} +.topo-region-hex__title .topo-region-hex__name { + max-width: 100%; +} +.topo-region-hex__meta { + display: inline; + flex: 0 0 auto; + font-size: 11px; + font-weight: 600; + line-height: 1.25; + color: rgba(255, 255, 255, 0.95); + white-space: nowrap; +} +.topo-region-hex--add .topo-region-hex__meta { + color: var(--nx-accent-text); +} +.topo-region-hex__plus { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 8px; + font-size: 22px; + line-height: 1; + background: rgb(var(--nx-accent-rgb) / 0.2); + color: var(--nx-accent-text); + flex: 0 0 auto; +} +.topo-map-card { + display: flex; + flex-direction: column; + min-height: 168px; + border: 1px solid var(--nm-panel-border, rgba(148, 163, 184, 0.22)); + border-radius: 8px; + background: var(--nm-panel-solid, var(--nx-surface-2)); + box-shadow: none; + overflow: hidden; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; + color: var(--nm-text, var(--nx-text)); +} +.topo-map-card:hover { + border-color: rgb(var(--nx-accent-rgb) / 0.45); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); + transform: translateY(-1px); +} +.topo-map-card.is-physical { + border-color: rgba(245, 158, 11, 0.4); + background: linear-gradient(180deg, rgba(69, 26, 3, 0.35) 0%, rgb(var(--nx-ink-rgb) / 0.9) 42%); +} +.topo-map-card__body { + flex: 1; + min-height: 0; + border: 0; + background: transparent; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 16px 14px 10px; + cursor: pointer; + text-align: left; + color: inherit; +} +.topo-map-card__icon { + width: 44px; + height: 44px; + border-radius: 10px; + display: grid; + place-items: center; + background: rgba(37, 99, 235, 0.18); + color: var(--nx-accent-text); + border: 1px solid rgb(var(--nx-accent-rgb) / 0.3); +} +.topo-map-card.is-physical .topo-map-card__icon { + background: rgba(245, 158, 11, 0.16); + color: #fbbf24; + border-color: rgba(245, 158, 11, 0.35); +} +.topo-map-card__kind { + font-size: 11px; + font-weight: 650; + letter-spacing: 0.02em; + color: var(--nx-accent-text); + background: rgba(37, 99, 235, 0.18); + border-radius: 6px; + padding: 2px 8px; +} +.topo-map-card__kind.is-physical { + color: #fdba74; + background: rgba(245, 158, 11, 0.16); +} +.topo-map-card__name { + font-size: 14px; + font-weight: 650; + color: var(--nx-text-strong); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} +.topo-map-card__meta { + font-size: 11px; + line-height: 1.4; + color: var(--nx-text-faint); + width: 100%; +} +.topo-map-card__actions { + display: flex; + gap: 6px; + padding: 0 12px 12px; +} +.topo-map-card__actions .btn { + flex: 1; +} +.topo-map-card--add { + align-items: center; + justify-content: center; + gap: 8px; + min-height: 168px; + border-style: dashed; + border-color: rgba(148, 163, 184, 0.35); + background: rgb(var(--nx-ink-rgb) / 0.45); + color: var(--nm-accent); + font-size: 13px; + font-weight: 600; + cursor: pointer; + box-shadow: none; +} +.topo-map-card--add:hover { + background: rgba(37, 99, 235, 0.14); + border-color: var(--nm-accent); + color: var(--nx-accent-text); + box-shadow: none; +} +.topo-map-card__add-plus { + width: 36px; + height: 36px; + border-radius: 8px; + display: grid; + place-items: center; + font-size: 22px; + line-height: 1; + border: 1px dashed currentColor; + opacity: 0.85; +} +.topo-breadcrumb__crumb { + display: inline-flex; + align-items: center; + gap: 4px; +} +.topo-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 6px; + min-width: 0; + font-size: 13px; + font-weight: 550; + letter-spacing: -0.01em; + line-height: 1.3; + color: var(--nm-brand, var(--nx-text-strong)); +} +.app-main .topo-breadcrumb__link, +.topo-breadcrumb__link { + border: 0 !important; + background: transparent !important; + box-shadow: none !important; + color: var(--nx-text-muted) !important; + cursor: pointer; + padding: 0 !important; + margin: 0; + border-radius: 0 !important; + font: inherit; + font-weight: 550; + line-height: inherit; + width: auto; +} +.app-main .topo-breadcrumb__link:hover, +.topo-breadcrumb__link:hover { + color: var(--nm-accent, var(--nx-accent-strong)) !important; + background: transparent !important; + text-decoration: underline; + text-underline-offset: 2px; +} +.topo-breadcrumb__sep { + color: var(--nx-text-muted); + opacity: 1; + font-weight: 400; + user-select: none; +} +.topo-breadcrumb__current { + display: inline; + padding: 0; + border-radius: 0; + font: inherit; + font-weight: 700; + color: var(--nm-accent, var(--nx-accent-strong)); + background: transparent; + box-shadow: none; +} +.topo-toolbar__level { + margin-left: 8px; +} +.topo-nav-node { + min-width: 180px; + max-width: 220px; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: linear-gradient(165deg, var(--nx-line) 0%, var(--nx-surface-2) 100%); + box-shadow: none; + cursor: pointer; + display: flex; + align-items: flex-start; + gap: 12px; + color: var(--nx-text); +} +.topo-nav-node.is-selected, +.topo-nav-node:hover { + border-color: var(--nm-accent, var(--nx-accent-strong)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--nm-accent, var(--nx-accent-strong)) 22%, transparent), + 0 8px 18px rgba(37, 99, 235, 0.12); +} +.topo-nav-node__icon { + width: 44px; + height: 44px; + border-radius: 8px; + display: grid; + place-items: center; + flex: 0 0 auto; + color: var(--nx-accent-text); + background: rgba(37, 99, 235, 0.18); +} +.topo-nav-node--region .topo-nav-node__icon { + color: #5eead4; + background: rgba(13, 148, 136, 0.22); +} +.topo-nav-node--core .topo-nav-node__icon { + color: #fbbf24; + background: rgba(245, 158, 11, 0.2); +} +.topo-nav-node--aggregation .topo-nav-node__icon { + color: var(--nx-accent-text); + background: rgb(var(--nx-accent-rgb) / 0.2); +} +.topo-nav-node--access .topo-nav-node__icon { + color: var(--nx-accent-text); + background: rgba(37, 99, 235, 0.22); +} +.topo-nav-node__body { + min-width: 0; + flex: 1; +} +.topo-nav-node__badge { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.55; + margin-bottom: 2px; +} +.topo-nav-node__title { + font-weight: 650; + font-size: 14px; + line-height: 1.25; +} +.topo-nav-node__sub, +.topo-nav-node__meta { + margin-top: 4px; + font-size: 12px; + opacity: 0.68; + line-height: 1.3; +} +.topo-modal { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; +} +.topo-modal__backdrop { + position: absolute; + inset: 0; + background: rgb(var(--nx-ink-deep-rgb) / 0.72); +} +.topo-modal__panel { + position: relative; + width: min(480px, calc(100vw - 32px)); + max-height: min(70vh, 640px); + overflow: hidden; + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border-radius: 12px; + background: var(--nx-surface-2); + color: var(--nx-text); + border: 1px solid rgba(148, 163, 184, 0.22); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); +} +.topo-modal__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.topo-modal__hint { + margin: 0; +} +.topo-modal__selectbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.topo-modal__selectall { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--nm-muted, #5b6b7c); + cursor: pointer; + user-select: none; +} +.topo-modal__list { + max-height: 360px; + overflow: auto; +} +.topo-modal__foot { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding-top: 6px; + border-top: 1px solid rgba(148, 163, 184, 0.18); +} +.topo-palette__check { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0 8px; + border-right: 1px solid rgba(148, 163, 184, 0.22); + cursor: pointer; +} +.topo-palette__row.is-selected { + border-color: rgb(var(--nx-accent-rgb) / 0.5); + background: rgba(37, 99, 235, 0.2); +} +.topo-map-list, +.topo-palette { + list-style: none; + margin: 0; + padding: 0; + overflow: auto; + flex: 1; + min-width: 0; +} + +.topo-classify .topo-classify__head-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.topo-classify__section { + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid rgba(148, 163, 184, 0.18); +} +.topo-classify__section--first { + margin-top: 8px; + padding-top: 0; + border-top: 0; +} +.topo-classify__regex-bar, +.topo-classify__assign-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin: 0 0 12px; + padding: 10px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + background: rgb(var(--nx-ink-rgb) / 0.65); +} +.topo-classify__regex-input { + min-width: 220px; + flex: 1 1 220px; +} +.topo-classify__table tr.is-match { + background: rgba(37, 99, 235, 0.18); +} +.topo-classify__row-actions { + display: inline-flex; + gap: 4px; +} +.topo-classify__help { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + border: 1px solid rgba(148, 163, 184, 0.45); + color: var(--nx-text-muted); + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: help; + user-select: none; + flex: 0 0 auto; +} +.topo-classify__help:hover, +.topo-classify__help:focus-visible { + border-color: var(--nx-accent-text); + color: var(--nx-text); + outline: none; +} +.topo-classify__help-tip { + display: none; + position: absolute; + left: 0; + top: calc(100% + 8px); + z-index: 40; + width: max-content; + max-width: min(280px, 70vw); + padding: 8px 10px; + border-radius: 6px; + background: var(--nx-surface-2); + color: var(--nx-text-strong); + font-size: 12px; + font-weight: 400; + line-height: 1.45; + white-space: normal; + text-align: left; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); + pointer-events: none; +} +.topo-classify__help-tip::after { + content: ""; + position: absolute; + bottom: 100%; + left: 8px; + border: 5px solid transparent; + border-bottom-color: var(--nx-surface-2); +} +.topo-classify__help:hover .topo-classify__help-tip, +.topo-classify__help:focus-visible .topo-classify__help-tip { + display: block; +} +.topo-classify__section h3 { + margin: 0 0 10px; + font-size: 14px; + color: var(--nm-brand, var(--nx-text-strong)); +} +.topo-classify__section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} +.topo-classify__section-head h3 { + margin: 0; +} +.topo-classify__draft { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + align-items: center; +} +.topo-classify__draft .input { + min-width: 140px; + max-width: 280px; +} +.topo-classify__preview { + margin: 10px 0 14px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + background: rgb(var(--nx-ink-rgb) / 0.65); +} +.topo-classify__pager { + display: flex; + align-items: center; + gap: 10px; + margin-top: 8px; +} +.topo-classify__check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--nx-text-muted); +} +.topo-classify__slice-list, +.topo-classify__view-links { + margin: 6px 0 0; + padding-left: 18px; +} +.topo-classify__view-links { + list-style: none; + padding-left: 0; +} +.topo-classify__view-links a { + color: var(--nm-accent, var(--nx-accent-strong)); +} + +.topo-region-list { + display: flex; + flex-direction: column; + gap: 2px; + padding: 2px 0; + /* Allow deep indent + actions to extend; parent scroll shows a horizontal bar. */ + width: max-content; + min-width: 100%; + max-width: none; + box-sizing: border-box; +} + +.topo-region-list-scroll { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow-x: auto; + overflow-y: auto; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + background: rgb(var(--nx-ink-deep-rgb) / 0.45); + padding: 2px 0; +} + +.topo-region-list__block { + margin: 0; + width: auto; + max-width: none; + min-width: 0; + padding: 2px 0 4px; + box-sizing: border-box; +} + +.topo-region-list__row { + align-items: center; +} + +/* Flat list rows — no boxed cards */ +.topo-region-list .topo-map-list__row { + border: 0; + border-radius: 8px; + background: transparent; + box-shadow: none; + overflow: visible; + gap: 2px; + padding-right: 2px; + width: max-content; + min-width: 100%; + transition: background 0.12s ease; +} + +.topo-region-list .topo-map-list__row:hover, +.topo-region-list__block.is-hot > .topo-region-list__row, +.topo-region-list__maps > li.is-hot > .topo-map-list__row, +.topo-region-list__view.is-hot > .topo-map-list__row { + background: var(--nm-nav-hover, var(--nx-surface-3)); +} + +/* Selected = soft wash + hairline ring */ +.topo-region-list__block.is-active > .topo-region-list__row, +.topo-region-list__maps > li.is-active > .topo-map-list__row, +.topo-region-list__view.is-active > .topo-map-list__row { + border: 0; + background: var(--nm-nav-active, #1a2740); + box-shadow: inset 0 0 0 1px rgb(var(--nx-accent-rgb) / 0.3); +} + +.topo-region-list__block.is-branch-active > .topo-region-list__row { + border: 0; + background: rgba(37, 99, 235, 0.16); + box-shadow: none; +} + +.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__actions, +.topo-region-list__maps > li.is-active > .topo-map-list__row .topo-map-list__actions { + border-left: 0; +} + +.topo-region-list__glyph { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin-right: 2px; + border-radius: 6px; + background: rgba(45, 212, 191, 0.12); + color: #5eead4; + flex: 0 0 auto; +} + +.topo-region-list__maps .topo-region-list__glyph { + background: rgb(var(--nx-accent-rgb) / 0.18); + color: var(--nx-accent-text); +} + +.topo-region-list__maps { + position: relative; + margin: 2px 0 0; + /* One nest level = one indent; do not also set marginLeft on child blocks. */ + padding: 2px 0 2px 14px; + overflow: visible; + flex: none; +} + +.topo-region-list__maps::before { + content: ""; + position: absolute; + left: 6px; + top: 0; + bottom: 6px; + width: 1px; + background: rgba(148, 163, 184, 0.55); +} + +.topo-region-list__maps > li { + margin: 0; + width: auto; + max-width: 100%; + position: relative; + box-sizing: border-box; +} + +.topo-region-list__maps > li + li { + margin-top: 1px; +} + +.topo-region-list__maps .topo-map-list__row { + width: 100%; +} + +.app-main .topo-region-list .topo-map-list__item, +.topo-region-list .topo-map-list__item { + padding: 8px 6px 8px 8px; + border-radius: 8px; +} + +/* View rows use a chevron spacer — same padding as folder rows (no extra 36px). */ +.app-main .topo-region-list__maps .topo-map-list__item, +.topo-region-list__maps .topo-map-list__item { + padding: 8px 6px 8px 8px; +} + +.topo-region-list .topo-map-list__item:hover, +.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__item, +.topo-region-list__block.is-branch-active > .topo-region-list__row .topo-map-list__item, +.topo-region-list__maps > li.is-active .topo-map-list__item, +.topo-region-list__maps > li.is-active .topo-map-list__item:hover { + background: transparent !important; +} + +.topo-region-list .topo-map-list__actions { + opacity: 0; + pointer-events: none; + flex: 0 0 auto; + border-left: 0; + gap: 4px; + padding-right: 4px; + align-items: center; + transition: opacity 0.12s ease; +} + +/* Edit/delete only while the row itself is hovered */ +.topo-region-list .topo-map-list__row:hover .topo-map-list__actions { + opacity: 1; + pointer-events: auto; +} + +.app-main .topo-region-list .topo-map-list__icon, +.topo-region-list .topo-map-list__icon { + width: 24px; + height: 24px; + min-height: 24px; + border-radius: 5px; + color: var(--nx-text-faint); + background: transparent !important; + border: 0 !important; +} + +.app-main .topo-region-list .topo-map-list__icon:hover:not(:disabled), +.topo-region-list .topo-map-list__icon:hover:not(:disabled) { + background: rgba(148, 163, 184, 0.16) !important; + color: var(--nm-brand); +} + +.topo-region-list .topo-map-list__name { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + width: 100%; +} + +.topo-region-list .topo-map-list__title { + min-width: max-content; + flex: 0 1 auto; + overflow: visible; + text-overflow: clip; + white-space: nowrap; + font-size: 13px; + font-weight: 650; + color: var(--nm-brand, var(--nx-text-strong)); +} + +.topo-region-list__maps .topo-map-list__title { + font-size: 12px; + font-weight: 600; +} + +.topo-region-list .topo-map-list__count { + flex: 0 0 auto; + margin-left: 4px; + padding: 0; + border-radius: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--nm-muted); + background: transparent; + box-shadow: none; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; +} + +.topo-region-list__maps .topo-map-list__count { + color: var(--nm-muted); + background: transparent; +} + +.topo-region-list__block.is-active .topo-map-list__count, +.topo-region-list__maps > li.is-active .topo-map-list__count { + background: transparent; + color: var(--nx-text-soft); +} + +.topo-region-list__block.is-hot:not(.is-active) .topo-map-list__count, +.topo-region-list__maps > li.is-hot:not(.is-active) .topo-map-list__count { + background: transparent; + color: var(--nm-accent); +} + +.topo-region-list__block.is-active .topo-map-list__title, +.topo-region-list__maps > li.is-active .topo-map-list__title { + color: var(--nx-text-strong); + font-weight: 700; +} + +.topo-region-list__maps > li.is-active .topo-region-list__glyph { + background: rgba(37, 99, 235, 0.35); + color: #dbeafe; +} + +.topo-map-list li, +.topo-palette > li { + margin: 0 0 4px; + width: 100%; + min-width: 0; +} + +.topo-map-list li:last-child, +.topo-palette > li:last-child { + margin-bottom: 0; +} + +.topo-map-list__row, +.topo-palette__row { + display: flex; + align-items: stretch; + width: 100%; + box-sizing: border-box; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + background: rgb(var(--nx-ink-rgb) / 0.75); + overflow: hidden; + min-width: 0; +} + +.topo-map-list li.is-active .topo-map-list__row { + border-color: var(--nm-accent); + background: var(--nm-nav-active); +} + +.topo-region-list__block.is-active > .topo-map-list__row, +.topo-region-list__maps > li.is-active > .topo-map-list__row { + border: 0 !important; + background: var(--nm-nav-active, #1a2740) !important; + box-shadow: inset 0 0 0 1px rgb(var(--nx-accent-rgb) / 0.3) !important; +} + +.topo-region-list__block.is-active > .topo-map-list__row:hover, +.topo-region-list__maps > li.is-active > .topo-map-list__row:hover { + background: #243552 !important; +} + +.topo-region-list__block.is-branch-active > .topo-map-list__row { + border: 0 !important; + background: rgba(37, 99, 235, 0.16) !important; + box-shadow: none !important; +} + +.topo-region-list__block.is-hot:not(.is-active):not(.is-branch-active) > .topo-map-list__row, +.topo-region-list__maps > li.is-hot:not(.is-active) > .topo-map-list__row { + background: rgb(var(--nx-accent-rgb) / 0.14) !important; +} + +.topo-palette > li.is-on-canvas .topo-palette__row { + background: rgb(var(--nx-surface-3-rgb) / 0.55); + border-color: rgba(148, 163, 184, 0.18); +} + +.topo-map-list__item, +.topo-palette__item { + display: block; + flex: 1 1 auto; + width: 100%; + max-width: 100%; + text-align: left; + border: 0; + background: transparent; + padding: 6px 8px; + cursor: pointer; + border-radius: 0; + min-width: 0; + box-sizing: border-box; +} + +.app-main .topo-map-list__item, +.app-main .topo-map-list__icon, +.app-main .topo-palette__item { + border: none; + background: transparent; + border-radius: 0; + box-shadow: none; + width: auto; +} + +.app-main .topo-map-list__item, +.app-main .topo-palette__item { + display: block; + width: 100%; + padding: 6px 8px; +} + +.app-main .topo-map-list__icon { + width: 28px; + height: auto; + padding: 0; + color: var(--nx-text-faint); + font-size: 14px; + line-height: 1; +} + +.topo-map-list__item:hover, +.topo-palette__item:hover:not(:disabled) { + background: var(--nm-nav-hover); +} + +.topo-map-list li.is-active .topo-map-list__item:hover { + background: rgba(37, 99, 235, 0.12); +} + +.topo-map-list__actions { + display: flex; + align-items: stretch; + flex: 0 0 auto; + border-left: 1px solid rgba(148, 163, 184, 0.22); +} + +.topo-map-list li.is-active .topo-map-list__actions { + border-left-color: rgb(var(--nx-accent-rgb) / 0.35); +} + +.topo-map-list__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + min-height: 100%; + border: 0; + background: transparent; + color: var(--nm-muted); + cursor: pointer; +} + +.topo-map-list__icon:hover:not(:disabled) { + background: #e8eef8; + color: var(--nm-brand); +} + +.topo-map-list__icon:disabled { + opacity: 0.45; + cursor: default; +} + +.topo-palette__item:disabled { + opacity: 0.55; + cursor: default; +} + +.topo-palette__empty { + padding: 8px; + box-sizing: border-box; + width: 100%; +} + +.topo-map-list__name, +.topo-palette__name { + display: block; + font-weight: 650; + font-size: 12px; + color: var(--nm-brand, var(--nx-text-strong)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topo-map-list__meta, +.topo-palette__meta { + display: block; + font-size: 11px; + color: var(--nm-muted, var(--nx-text-muted)); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topo-main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--nm-chrome, #070b14); + border: 0; + border-radius: 0; + overflow: hidden; + box-shadow: none; + color: var(--nm-text, var(--nx-text)); +} + +.topo-toolbar { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + background: rgb(var(--nx-ink-rgb) / 0.9); +} + +.topo-toolbar__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.topo-toolbar__row--tools { + justify-content: flex-start; + flex-wrap: wrap; + gap: 8px; + padding-top: 4px; + border-top: 1px solid rgba(148, 163, 184, 0.12); +} + +.topo-toolbar__group + .topo-toolbar__group, +.topo-toolbar__cluster + .topo-toolbar__group, +.topo-toolbar__group + .topo-toolbar__group--find, +.topo-toolbar__cluster + .topo-toolbar__group--find { + margin-left: 2px; + padding-left: 10px; + border-left: 1px solid rgba(148, 163, 184, 0.2); +} + +/* Flat cluster: no nested chrome — only a separator from tool modes. */ +.topo-toolbar__cluster { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + min-width: 0; + margin-left: 2px; + padding-left: 10px; + border-left: 1px solid rgba(148, 163, 184, 0.2); +} + +.topo-toolbar__group--find { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + margin-left: auto; +} + +.topo-find-suggest { + position: absolute; + top: calc(100% + 4px); + right: 0; + left: auto; + z-index: 30; + width: min(340px, 72vw); + max-height: 280px; + overflow: auto; + background: var(--nm-panel-solid, var(--nx-surface-2)); + border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.18)); + border-radius: 8px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4); + color: var(--nm-text, var(--nx-text)); +} + +.topo-find-suggest__item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + width: 100%; + padding: 8px 10px; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; + color: inherit; +} + +.topo-find-suggest__item:hover, +.topo-find-suggest__item.is-active { + background: rgba(37, 99, 235, 0.22); +} + +.topo-find-suggest__name { + font-size: 12px; + font-weight: 650; + color: var(--nx-text-strong); +} + +.topo-find-suggest__meta { + font-size: 11px; + color: var(--nx-text-muted); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; +} + +.topo-find-suggest__empty, +.topo-find-suggest__more { + padding: 8px 10px; + font-size: 12px; + color: var(--nx-text-muted); +} + +.app-main .topo-toolbar__find, +.topo-toolbar__find { + width: 190px; + max-width: 36vw; + height: 30px; + box-sizing: border-box; + padding: 0 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + font-size: 12px; + line-height: 1.2; + background: var(--nx-field); + color: var(--nm-text, var(--nx-text)); +} + +.topo-toolbar__find::placeholder { + color: var(--nm-muted, var(--nx-text-muted)); +} + +.topo-toolbar__find:disabled { + opacity: 0.55; +} + +.topo-toolbar__title { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.topo-toolbar__title strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + font-weight: 650; + color: var(--nm-brand, var(--nx-text-strong)); + letter-spacing: -0.01em; +} + +.topo-toolbar__meta { + font-size: 12px; + color: var(--nm-muted, var(--nx-text-muted)); + white-space: nowrap; +} + +.topo-toolbar__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + justify-content: flex-end; + flex-shrink: 0; +} + +.topo-toolbar__primary { + gap: 6px; +} + +.app-main .topo-toolbar__save--dirty, +.topo-toolbar__save--dirty { + background: var(--nm-accent, var(--nx-accent-strong)) !important; + border-color: transparent !important; + color: var(--nx-text-strong) !important; +} + +.topo-toolbar__actions > .btn, +.topo-toolbar__primary > button, +.topo-toolbar__group > .btn { + width: auto; + flex: 0 0 auto; + height: 30px; + padding: 0 10px; + font-size: 12px; + line-height: 1; + border-radius: 6px; +} + +.topo-toolbar__group > select.topo-toolbar__find { + width: auto; + min-width: 140px; +} + +.topo-toolbar__group { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.topo-tools { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + padding: 2px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 8px; + background: rgb(var(--nx-ink-rgb) / 0.65); +} + +.topo-tools__btn { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 30px; + border: 0; + background: transparent; + color: var(--nm-muted, var(--nx-text-muted)); + font-size: 12px; + font-weight: 600; + padding: 5px 9px; + border-radius: 6px; + cursor: pointer; + line-height: 1.2; +} + +.topo-tools__btn:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.9); + color: var(--nm-text, var(--nx-text-strong)); +} + +.topo-tools__btn.is-active, +.app-main .topo-tools__btn.is-active { + background: var(--nm-accent, var(--nx-accent-strong)) !important; + border-color: transparent !important; + color: var(--nx-text-strong) !important; +} + +.topo-tools__key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 14px; + height: 14px; + padding: 0 3px; + border: 1px solid currentColor; + border-radius: 2px; + font-size: 10px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-weight: 700; + opacity: 0.75; + line-height: 1; +} + +.app-main .topo-toolbar__select, +.topo-toolbar__select { + min-width: 88px; + height: 30px; + box-sizing: border-box; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + color: var(--nx-text); + font-size: 12px; + line-height: 1.2; + padding: 0 8px; + vertical-align: middle; +} + +.app-main .topo-toolbar__display > summary, +.topo-toolbar__display > summary { + list-style: none; + cursor: pointer; + user-select: none; + height: 30px; + padding: 0 10px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + color: var(--nx-text); + font-size: 12px; + font-weight: 600; + line-height: 1.2; + display: inline-flex; + align-items: center; +} + +.topo-toolbar__display { + position: relative; +} + +.topo-toolbar__display > summary::-webkit-details-marker { + display: none; +} + +.topo-toolbar__display[open] > summary { + border-color: rgb(var(--nx-accent-rgb) / 0.55); + background: rgba(37, 99, 235, 0.22); + color: var(--nx-accent-text); +} + +.topo-toolbar__display .topo-display-toggles { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 20; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px 14px; + align-content: start; + width: min(560px, calc(100vw - 32px)); + max-height: min(70vh, 520px); + overflow: auto; + padding: 10px 12px; + border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.18)); + border-radius: 8px; + background: var(--nm-panel-solid, var(--nx-surface-2)); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4); + color: var(--nm-text, var(--nx-text)); +} + +.topo-toolbar__more-popover { + min-width: 168px; + padding: 4px; + border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.22)); + border-radius: 8px; + background: var(--nm-panel-solid, var(--nx-surface-2)); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + color: var(--nm-text, var(--nx-text)); +} + +.topo-toolbar__more-popover [role="menuitem"] { + border-radius: 6px; + font-size: 12px; + color: var(--nm-text, var(--nx-text)); +} + +.topo-toolbar__more-popover [role="menuitem"][data-disabled], +.topo-toolbar__more-popover [role="menuitem"][aria-disabled="true"] { + opacity: 0.45; +} + +.topo-mode-hint { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + z-index: 6; + padding: 6px 12px; + border-radius: 3px; + border: 1px solid #0ea5e9; + background: rgb(var(--nx-ink-rgb) / 0.9); + color: #e0f2fe; + font-size: 12px; + font-weight: 600; + pointer-events: none; + white-space: nowrap; + max-width: calc(100% - 24px); + overflow: hidden; + text-overflow: ellipsis; +} + +.topo-svg-icon { + display: block; +} + +.topo-icon-btn { + display: inline-flex !important; + align-items: center; + justify-content: center; + width: 28px; + min-width: 28px; + padding: 0 !important; +} + +.topo-display-defaults { + grid-column: 1 / -1; + display: grid; + gap: 8px; + margin-top: 2px; + padding-top: 8px; + border-top: 1px solid rgba(148, 163, 184, 0.18); +} + +.topo-display-defaults__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: var(--nx-text-soft); + grid-column: 1 / -1; +} + +.topo-display-defaults__row { + display: grid; + grid-template-columns: 48px 28px minmax(72px, 1fr) 64px; + align-items: center; + gap: 6px; +} + +.topo-display-defaults--canvas-bg .topo-display-defaults__row { + grid-template-columns: minmax(72px, auto) 28px minmax(88px, 1fr); +} + +/* Color pickers / vendor list: two columns inside the wide card */ +.topo-display-defaults--colors { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: 14px; +} + +.topo-display-defaults--colors .topo-display-defaults__row { + grid-template-columns: minmax(64px, auto) 28px minmax(72px, 1fr); +} + +.topo-canvas-bg-hex { + min-width: 88px !important; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.topo-display-defaults__name { + font-size: 12px; + color: var(--nx-text-muted); +} + +.topo-display-defaults__row input[type="color"] { + width: 28px; + height: 28px; + padding: 0; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + cursor: pointer; +} + +.topo-display-defaults__row .topo-toolbar__select { + min-width: 0; + width: 100%; +} + +.topo-canvas.is-pan-mode .react-flow__pane { + cursor: grab; +} + +.topo-legend { + position: absolute; + left: 10px; + bottom: 10px; + z-index: 5; + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + padding: 6px 10px; + border-radius: 6px; + background: rgb(var(--nx-surface-3-rgb) / 0.92); + border: 1px solid rgba(255, 255, 255, 0.12); + font-size: 11px; + color: var(--nx-text); + pointer-events: none; +} + +.topo-legend__item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.topo-legend__swatch { + display: inline-block; + width: 18px; + height: 0; + border-top: 2px solid var(--nx-text-muted); + vertical-align: middle; +} + +.topo-node.is-connect-mode .topo-node__handle { + width: 14px !important; + height: 14px !important; + min-width: 14px !important; + min-height: 14px !important; + opacity: 0.9 !important; + background: rgba(14, 165, 233, 0.35) !important; + border: 1px solid #38bdf8 !important; + border-radius: 50% !important; + z-index: 5; +} + +.react-flow__selection { + background: rgba(51, 65, 85, 0.08); + border: 1px solid rgba(51, 65, 85, 0.7); +} + +.topo-display-toggles { + display: grid; + gap: 6px; +} + +.topo-display-toggles__item { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--nm-text, var(--nx-text)); + white-space: nowrap; + cursor: pointer; + user-select: none; + min-width: 0; +} + +.topo-ctx { + position: fixed; + z-index: 40; + min-width: 168px; + padding: 4px 0; + margin: 0; + list-style: none; + background: var(--nm-panel-solid, var(--nx-surface-2)); + color: var(--nm-text, var(--nx-text)); + border: 1px solid var(--nm-glass-border, rgba(148, 163, 184, 0.18)); + border-radius: 8px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); + font-size: 12px; + line-height: 1.3; +} + +.topo-ctx--edge { + min-width: 196px; + max-width: min(320px, calc(100vw - 16px)); + max-height: min(72vh, calc(100vh - 16px)); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; +} + +.topo-ctx__head { + margin: 0; + padding: 8px 12px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--nx-text-muted); +} + +.topo-ctx__style { + padding: 4px 12px 6px; +} + +.topo-ctx__style-title { + margin: 0 0 6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--nx-text-muted); +} + +.topo-ctx__style-grid { + display: grid; + gap: 6px; +} + +.topo-ctx__style-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0; + font-size: 12px; + font-weight: 400; + color: var(--nx-text); + line-height: 1.2; +} + +.topo-ctx__style-row > span { + flex: 1 1 auto; + min-width: 2.5em; + max-width: 11.5em; + color: var(--nx-text-soft); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topo-ctx__style-row input[type="color"] { + flex: 0 0 auto; + box-sizing: border-box; + width: 28px; + height: 22px; + padding: 0; + border: 1px solid var(--nx-line-strong); + border-radius: 4px; + background: transparent; + cursor: pointer; +} + +.topo-ctx__style-row input[type="color"]::-webkit-color-swatch-wrapper { + padding: 1px; +} + +.topo-ctx__style-row input[type="color"]::-webkit-color-swatch { + border: 0; + border-radius: 2px; +} + +.topo-ctx__style-row select, +.topo-ctx__style-input { + flex: 1 1 auto; + max-width: 108px; + height: 24px; + border: 1px solid var(--nx-line-strong); + border-radius: 4px; + background: var(--nx-line); + color: var(--nx-text); + font-size: 12px; + line-height: 1.2; + padding: 0 6px; +} + +.topo-ctx__style-input { + max-width: 120px; + min-width: 0; +} + +/* Beat .app-main button { color:#1f2937; background:#fff } so hover stays readable. */ +.app-main .topo-ctx__item, +.topo-ctx__item { + display: block; + width: 100%; + box-sizing: border-box; + padding: 7px 12px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--nx-text); + text-align: left; + font-size: 12px; + font-weight: 400; + line-height: 1.3; + cursor: pointer; +} + +.app-main .topo-ctx__item:hover:not(:disabled), +.app-main .topo-ctx__item:focus-visible:not(:disabled), +.topo-ctx__item:hover:not(:disabled), +.topo-ctx__item:focus-visible:not(:disabled) { + background: var(--nx-line); + color: var(--nx-text-strong); +} + +.app-main .topo-ctx__item:disabled, +.topo-ctx__item:disabled { + opacity: 0.45; + cursor: not-allowed; + color: var(--nx-text-muted); +} + +.app-main .topo-ctx__item--danger, +.topo-ctx__item--danger { + color: #fca5a5; +} + +.app-main .topo-ctx__item--danger:hover:not(:disabled), +.app-main .topo-ctx__item--danger:focus-visible:not(:disabled), +.topo-ctx__item--danger:hover:not(:disabled), +.topo-ctx__item--danger:focus-visible:not(:disabled) { + background: #7f1d1d; + color: #fecaca; +} + +.topo-ctx__sep { + height: 1px; + margin: 4px 0; + background: var(--nm-glass-border, rgba(148, 163, 184, 0.18)); +} + +.topo-discover { + display: flex; + flex-direction: column; + gap: 8px; + max-height: none; + padding: 10px 12px; + background: rgb(var(--nx-ink-rgb) / 0.96); + color: var(--nx-text); + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + overflow: hidden; +} + +.topo-discover__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--nx-text-strong); +} + +.topo-discover__head strong { + color: var(--nx-text-strong); +} + +.topo-discover .panel__hint, +.topo-discover .panel__hint--live { + color: var(--nx-text-muted); +} + +.topo-discover__progress { + display: flex; + flex-direction: column; + gap: 4px; +} + +.topo-discover__bar { + height: 6px; + border-radius: 6px; + background: rgba(51, 65, 85, 0.9); + overflow: hidden; +} + +.topo-discover__bar-fill { + height: 100%; + background: #38bdf8; + transition: width 0.2s ease; +} + +.topo-discover__progress-meta { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: var(--nx-text-muted); +} + +.topo-discover__summary { + margin: 0; + font-size: 13px; + color: var(--nx-text-soft); +} + +.topo-discover__error { + margin: 0; + font-size: 13px; + color: #fca5a5; +} + +.topo-discover__item-meta, +.topo-discover__item-cmd, +.topo-discover__item-warn { + margin-top: 2px; + font-size: 12px; + color: var(--nx-text-faint); +} + +.topo-discover__item-warn { + color: #b45309; +} + +.topo-discover__unmatched { + margin: 6px 0 0; + padding-left: 1.1rem; + font-size: 11px; + color: #92400e; +} + +.topo-discover__unmatched-more { + list-style: none; + margin-left: -1.1rem; + color: #a16207; +} + +.topo-discover__row-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.topo-discover__detail-btn { + font-size: 11px; + color: var(--nm-accent, var(--nx-accent-strong)); + white-space: nowrap; +} + +.topo-discover__badge--lg { + font-size: 12px; + padding: 2px 8px; + border-radius: 6px; + border: 1px solid rgba(34, 197, 94, 0.35); + background: rgba(20, 83, 45, 0.45); + color: #bbf7d0; +} + +.topo-discover__badge--lg.is-fail { + border-color: rgba(248, 113, 113, 0.4); + background: rgba(127, 29, 29, 0.45); + color: #fecaca; +} + +.topo-discover__badge--lg.is-warn { + border-color: rgba(245, 158, 11, 0.4); + background: rgba(120, 53, 15, 0.45); + color: #fde68a; +} + +.topo-discover-modal { + display: block; + width: min(960px, 100%); + max-height: min(88vh, 900px); + overflow: auto; + color: var(--nm-text, var(--nx-text)); +} + +.topo-discover-modal__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.topo-discover-modal__head h3 { + margin: 0; + color: var(--nm-brand, var(--nx-text-strong)); +} + +.topo-discover-modal__sub { + margin: 4px 0 0; + font-size: 12px; + color: var(--nm-muted, var(--nx-text-muted)); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; +} + +.topo-discover-modal__stats { + margin-top: 8px; + font-size: 13px; + color: var(--nm-muted, var(--nx-text-muted)); +} + +.topo-discover-modal__section { + margin: 14px 0 8px; + font-size: 13px; + font-weight: 700; + color: var(--nm-brand, var(--nx-text-strong)); +} + +.topo-discover-modal__table-wrap { + overflow-x: auto; + overflow-y: visible; + max-height: none; + border: 1px solid var(--nm-panel-border, rgba(148, 163, 184, 0.22)); + border-radius: 8px; + background: var(--nm-panel-solid, var(--nx-surface-2)); +} + +.topo-discover-modal__table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + table-layout: auto; +} + +.topo-discover-modal__table th, +.topo-discover-modal__table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); + text-align: left; + vertical-align: top; + color: var(--nm-text, var(--nx-text)); + background: transparent; +} + +.topo-discover-modal__table th { + position: static; + background: rgb(var(--nx-ink-rgb) / 0.95); + color: var(--nm-muted, var(--nx-text-muted)); + font-weight: 600; + white-space: nowrap; +} + +.topo-discover-modal__table tbody tr:hover td { + background: rgb(var(--nx-surface-3-rgb) / 0.55); +} + +.topo-discover-modal__table tbody tr:last-child td { + border-bottom: 0; +} + +.topo-discover-modal__peer { + display: flex; + flex-direction: column; + gap: 2px; +} + +.topo-discover-modal__peer span { + color: var(--nm-muted, var(--nx-text-muted)); +} + +.topo-discover-action.is-added { + color: #86efac; + font-weight: 600; +} + +.topo-discover-action.is-updated { + color: var(--nx-accent-text); + font-weight: 600; +} + +.topo-discover-action.is-kept_manual { + color: #fbbf24; + font-weight: 600; +} + +.topo-discover-modal__raw { + margin: 0; + padding: 10px 12px; + max-height: min(36vh, 360px); + overflow: auto; + background: var(--nx-field); + color: var(--nm-text, var(--nx-text)); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 8px; + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.topo-canvas { + position: relative; + flex: 1; + min-height: 0; + background: var(--topo-canvas-bg, var(--nx-surface-2)); + --xy-edge-label-background-color: transparent; + --xy-edge-label-color: var(--topo-edge-label-color, var(--nx-text)); + /* Defaults = dark canvas; TopologyPage overrides via canvasFloatChromeVars. */ + --topo-float-bg: rgb(var(--nx-ink-rgb) / 0.92); + --topo-float-border: rgba(148, 163, 184, 0.22); + --topo-float-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + --topo-float-fg: var(--nx-text); + --topo-float-muted: var(--nx-text-muted); + --topo-float-btn-bg: var(--nx-field); + --topo-float-btn-border: rgba(148, 163, 184, 0.28); + --topo-float-btn-hover: var(--nx-surface-3); + --topo-float-active-bg: rgba(37, 99, 235, 0.28); + --topo-float-active-fg: var(--nx-accent-text); + --topo-float-active-border: var(--nx-accent-strong); + --topo-float-control-bg: var(--nx-surface-2); + --topo-float-control-hover: var(--nx-line); + --topo-float-control-fill: var(--nx-text-soft); + --topo-float-input-bg: var(--nx-field); + --topo-float-input-fg: var(--nx-text); + --topo-float-input-border: rgba(148, 163, 184, 0.28); +} + +.topo-canvas:fullscreen, +.topo-canvas.is-fullscreen:fullscreen { + width: 100vw; + height: 100vh; + background: var(--topo-canvas-bg, var(--nx-surface-2)); +} + +/* Place fullscreen above zoom-in (+); Controls renders custom children last. */ +.topo-canvas .react-flow__controls { + display: flex; + flex-direction: column; + box-shadow: var(--topo-float-shadow); + border: 0; + border-radius: 6px; + overflow: hidden; + background: var(--topo-float-bg); +} + +.topo-canvas .react-flow__controls-button { + background: transparent; + border: 0; + border-bottom: 1px solid var(--topo-float-border); + color: var(--topo-float-control-fill); + fill: var(--topo-float-control-fill); +} + +.topo-canvas .react-flow__controls-button:last-child { + border-bottom: 0; +} + +.topo-canvas .react-flow__controls-button:hover { + background: var(--topo-float-control-hover); +} + +.topo-canvas .react-flow__controls-button svg, +.topo-canvas .react-flow__controls-button svg path { + fill: currentColor; +} + +.topo-canvas .react-flow__minimap { + border: 0; + border-radius: 8px; + overflow: hidden; + box-shadow: var(--topo-float-shadow); +} + +.topo-canvas .react-flow__controls-button.topo-fs-control { + order: -1; +} + +.topo-canvas .react-flow__controls-button.topo-fs-control svg { + max-width: 14px; + max-height: 14px; + width: 14px; + height: 14px; + display: block; + color: inherit; +} + +.topo-toolbar__cluster .topo-toolbar__group--find { + margin-left: 0; + padding-left: 0; + border-left: 0; +} + +.topo-fs-toolbar { + position: absolute; + top: 12px; + left: 12px; + z-index: 20; + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px; + border-radius: 10px; + background: var(--topo-float-bg); + border: 1px solid var(--topo-float-border); + box-shadow: var(--topo-float-shadow); + color: var(--topo-float-fg); + pointer-events: auto; +} + +.topo-view-tools { + position: absolute; + top: 12px; + right: 12px; + z-index: 20; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 10px; + background: var(--topo-float-bg); + border: 1px solid var(--topo-float-border); + box-shadow: var(--topo-float-shadow); + color: var(--topo-float-fg); + pointer-events: auto; + max-width: min(520px, calc(100% - 24px)); +} + +.topo-view-tools .topo-toolbar__display { + position: relative; +} + +.topo-view-tools .topo-toolbar__display .topo-display-toggles { + right: 0; + left: auto; + max-height: min(70vh, 520px); + overflow: auto; +} + +.topo-view-tools .topo-toolbar__group--find { + margin-left: 0; + padding-left: 0; + border-left: 0; + position: relative; +} + +.topo-view-tools .topo-toolbar__find { + width: 140px; + min-width: 120px; +} + +.topo-canvas.is-fullscreen .topo-view-tools, +.topo-canvas:fullscreen .topo-view-tools { + top: 12px; + right: 12px; +} + +/* Beat `.app-main button { background:var(--nx-field) }` so inactive chips stay ghost. */ +.app-main .topo-fs-toolbar__btn, +.topo-canvas .topo-fs-toolbar__btn, +.topo-fs-toolbar__btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: auto; + padding: 6px 10px !important; + border-radius: 8px !important; + border: 1px solid transparent !important; + background: transparent !important; + color: var(--topo-float-muted) !important; + box-shadow: none !important; + font-size: 12px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; +} + +.app-main .topo-fs-toolbar__btn:hover, +.topo-canvas .topo-fs-toolbar__btn:hover, +.topo-fs-toolbar__btn:hover { + background: var(--topo-float-btn-hover) !important; + color: var(--topo-float-fg) !important; +} + +.app-main .topo-fs-toolbar__btn.is-active, +.topo-canvas .topo-fs-toolbar__btn.is-active, +.topo-fs-toolbar__btn.is-active { + border-color: var(--topo-float-active-border) !important; + background: var(--topo-float-active-bg) !important; + color: var(--topo-float-active-fg) !important; +} + +.app-main .topo-fs-toolbar__btn kbd, +.topo-canvas .topo-fs-toolbar__btn kbd, +.topo-fs-toolbar__btn kbd { + font-size: 10px; + opacity: 0.7; + border-color: currentColor; + background: transparent; + color: inherit; + padding: 0 3px; +} + +/* Beat global dark toolbar overrides when canvas bg is light. */ +.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__find, +.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__select, +.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__display > summary { + background: var(--topo-float-input-bg) !important; + border-color: var(--topo-float-input-border) !important; + color: var(--topo-float-input-fg) !important; +} + +.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__find::placeholder { + color: var(--topo-float-muted); +} + +.topo-canvas .topo-view-tools .topo-toolbar__find, +.topo-canvas .topo-view-tools .topo-toolbar__select, +.topo-canvas .topo-view-tools .topo-toolbar__display > summary { + background: var(--topo-float-input-bg); + border-color: var(--topo-float-input-border); + color: var(--topo-float-input-fg); +} + +.topo-canvas__empty { + display: grid; + place-items: center; + height: 100%; + color: var(--nm-muted, #5b6b7c); +} + +.topo-canvas__status { + gap: 12px; + justify-items: center; + text-align: center; +} + +.topo-canvas__status p { + margin: 0; + font-size: 13px; +} + +.topo-loading-spinner { + display: inline-block; + width: 18px; + height: 18px; + border: 2px solid rgba(148, 163, 184, 0.28); + border-top-color: #60a5fa; + border-radius: 50%; + animation: topo-spin 0.7s linear infinite; +} + +.topo-loading-spinner--lg { + width: 36px; + height: 36px; + border-width: 3px; +} + +@keyframes topo-spin { + to { + transform: rotate(360deg); + } +} + +.topo-canvas__overlay { + position: absolute; + inset: 0; + z-index: 15; + display: grid; + place-content: center; + justify-items: center; + gap: 8px; + padding: 24px; + text-align: center; + pointer-events: none; +} + +.topo-canvas__overlay--loading { + background: rgb(var(--nx-ink-rgb) / 0.55); + backdrop-filter: blur(2px); + pointer-events: auto; +} + +.topo-canvas__overlay--empty { + background: transparent; + pointer-events: none; +} + +.topo-canvas__overlay-title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--nx-text); +} + +.topo-canvas__overlay--empty .topo-canvas__overlay-title { + color: var(--nx-text-muted); +} + +.topo-canvas__overlay-hint { + margin: 0; + max-width: 320px; + font-size: 12px; + line-height: 1.45; + color: var(--nx-text-muted); +} + +.topo-canvas__refresh { + position: absolute; + top: 12px; + left: 50%; + z-index: 21; + display: inline-flex; + align-items: center; + gap: 8px; + transform: translateX(-50%); + padding: 6px 12px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgb(var(--nx-ink-rgb) / 0.92); + color: var(--nx-text-soft); + font-size: 12px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28); + pointer-events: none; +} + +.topo-browser__empty--loading { + gap: 12px; +} + +.topo-browser__empty--loading p { + margin: 0; +} + +.topo-sidebar .panel__hint { + margin: 0; + font-size: 12px; + color: var(--nm-muted); +} + +.topo-tree-status { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.topo-sidebar .input { + width: 100%; + height: 32px; + margin: 0; + padding: 0 10px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + color: var(--nx-text); + font-size: 12px; +} + +.topo-sidebar .input::placeholder { + color: var(--nm-muted); +} + +.topo-sidebar .btn.btn--sm { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + padding: 0 10px; + line-height: 1; + border-radius: 6px; + border-color: rgba(148, 163, 184, 0.28); + background: var(--nx-field); + color: var(--nx-text); + font-size: 12px; +} + +.topo-node { + position: relative; + width: 88px; + min-width: 0; + max-width: none; + height: 28px; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + font-size: 8px; + text-align: center; + --topo-icon-color: var(--topo-vendor-gray, var(--nx-text-muted)); +} + +/* Flat-world LOD: pixel / pin overview (Google-Earth style). */ +.topo-node--dot, +.topo-node--pin { + min-width: 0; + max-width: none; + width: 100%; + height: 100%; + display: grid; + place-items: center; + color: var(--topo-icon-color, var(--nx-text-muted)); + pointer-events: auto; +} + +.topo-world-hud { + position: absolute; + z-index: 6; + top: 10px; + left: 12px; + max-width: min(520px, calc(100% - 24px)); + padding: 4px 8px; + border-radius: 6px; + background: rgb(var(--nx-ink-rgb) / 0.72); + color: var(--nx-text-soft); + font-size: 11px; + line-height: 1.35; + pointer-events: none; + backdrop-filter: blur(2px); +} + +.topo-world-scatter { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + pointer-events: none; +} + +.topo-world-scatter-panel.react-flow__panel { + margin: 0 !important; +} + +.topo-canvas__overlay--empty.is-interactive { + pointer-events: auto; + background: rgb(var(--nx-ink-rgb) / 0.35); +} + +.topo-node__pixel { + display: block; + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 1px rgb(var(--nx-ink-rgb) / 0.35); +} + +.topo-node--dot.is-selected .topo-node__pixel { + width: 6px; + height: 6px; + box-shadow: 0 0 0 2px #38bdf8; +} + +.topo-node__pin { + display: block; + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + border: 1px solid rgb(var(--nx-ink-rgb) / 0.45); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); +} + +.topo-node--pin.is-selected .topo-node__pin { + box-shadow: 0 0 0 2px #38bdf8; +} + +.topo-node__handle--dot { + opacity: 0 !important; + width: 1px !important; + height: 1px !important; + min-width: 0 !important; + min-height: 0 !important; + border: 0 !important; + pointer-events: none !important; +} + +.topo-node.is-placeholder .topo-node__glyph { + outline: none; + opacity: 0.88; +} +.topo-node.is-placeholder .topo-node__icon { + filter: drop-shadow(0 0 1px rgba(148, 163, 184, 0.85)); + opacity: 0.92; +} + +.topo-node__badge { + position: absolute; + top: -4px; + right: -6px; + z-index: 2; + min-width: 16px; + padding: 1px 4px; + border-radius: 999px; + background: color-mix(in srgb, var(--nx-surface-2) 88%, #fbbf24 12%); + border: 1px solid rgba(251, 191, 36, 0.45); + color: #fde68a; + font-size: 7px; + font-weight: 700; + line-height: 1.25; + text-transform: uppercase; + letter-spacing: 0.04em; + pointer-events: none; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); +} + +.topo-node__conn { + position: absolute; + left: -2px; + bottom: -2px; + z-index: 2; + width: 8px; + height: 8px; + border-radius: 50%; + border: 1.5px solid rgb(var(--nx-ink-rgb) / 0.9); + pointer-events: none; + box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.25); +} +.topo-node__conn--pass { + background: #22c55e; +} +.topo-node__conn--fail { + background: #ef4444; +} +.topo-node__conn--testing { + background: #f59e0b; +} +.topo-node__conn--unknown { + background: var(--nx-text-faint); +} + +.topo-node__alarm { + position: absolute; + top: -5px; + right: -6px; + z-index: 3; + min-width: 16px; + padding: 1px 4px; + border-radius: 999px; + font-size: 8px; + font-weight: 700; + line-height: 1.35; + color: #fff; + pointer-events: none; + border: 1px solid rgb(var(--nx-ink-rgb) / 0.75); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.4); +} +.topo-node__alarm--critical { + background: #dc2626; +} +.topo-node__alarm--major { + background: #ea580c; +} +.topo-node__alarm--minor { + background: #ca8a04; +} +.topo-node__alarm--warning, +.topo-node__alarm--indeterminate, +.topo-node__alarm--unknown { + background: #d97706; +} + +.topo-node__unmatched { + position: absolute; + top: -3px; + right: -4px; + z-index: 2; + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(51, 65, 85, 0.95); + border: 1px solid rgba(148, 163, 184, 0.55); + color: var(--nx-text); + font-size: 8px; + font-weight: 700; + line-height: 1; + pointer-events: none; +} + +.topo-node.is-level-external .topo-node__glyph, +.topo-node.is-level-core .topo-node__glyph, +.topo-node.is-level-aggregation .topo-node__glyph, +.topo-node.is-level-access .topo-node__glyph, +.topo-node.is-level-edge .topo-node__glyph, +.topo-node.is-level-unmatched .topo-node__glyph { + box-shadow: none; + background: transparent; +} +/* Level tint on the icon art only — no rounded plate behind the glyph. */ +.topo-node.is-level-external { + --topo-level-ring: #38bdf8; +} +.topo-node.is-level-core { + --topo-level-ring: #60a5fa; +} +.topo-node.is-level-aggregation { + --topo-level-ring: #34d399; +} +.topo-node.is-level-access { + --topo-level-ring: #fbbf24; +} +.topo-node.is-level-edge { + --topo-level-ring: #fb7185; +} +.topo-node.is-level-unmatched { + --topo-level-ring: rgba(148, 163, 184, 0.7); +} +.topo-node.is-level-external .topo-node__icon, +.topo-node.is-level-core .topo-node__icon, +.topo-node.is-level-aggregation .topo-node__icon, +.topo-node.is-level-access .topo-node__icon, +.topo-node.is-level-edge .topo-node__icon, +.topo-node.is-level-unmatched .topo-node__icon { + filter: drop-shadow(0 0 1.5px var(--topo-level-ring)); +} + +.topo-node.has-alarm--critical .topo-node__glyph { + outline: 2px solid rgba(220, 38, 38, 0.85); + outline-offset: 1px; +} +.topo-node.has-alarm--major .topo-node__glyph { + outline: 2px solid rgba(234, 88, 12, 0.85); + outline-offset: 1px; +} + +.topo-truncate-banner { + position: absolute; + top: 8px; + left: 50%; + z-index: 5; + transform: translateX(-50%); + max-width: min(720px, calc(100% - 24px)); + padding: 8px 12px; + border-radius: 8px; + border: 1px solid rgba(251, 191, 36, 0.45); + background: rgb(var(--nx-ink-rgb) / 0.92); + color: #fde68a; + font-size: 12px; + line-height: 1.4; + text-align: center; + pointer-events: none; +} + +.topo-readonly-banner { + position: absolute; + top: 8px; + right: 12px; + z-index: 5; + max-width: min(360px, calc(100% - 24px)); + padding: 6px 10px; + border-radius: 8px; + border: 1px solid rgba(56, 189, 248, 0.45); + background: rgb(var(--nx-ink-rgb) / 0.92); + color: #bae6fd; + font-size: 12px; + line-height: 1.4; + pointer-events: none; +} + +.topo-canvas__overlay--error { + background: rgb(var(--nx-ink-rgb) / 0.88); +} + +.app-main .topo-toolbar__outside-peers, +.topo-toolbar__outside-peers { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; + width: auto; + height: 30px; + padding: 0 10px; + font-size: 12px; + font-weight: 600; + line-height: 1; + border-radius: 6px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: var(--nx-field); + color: var(--nx-text-muted); +} + +.app-main .topo-toolbar__outside-peers:hover, +.topo-toolbar__outside-peers:hover { + background: var(--nx-surface-3); + color: var(--nx-text); +} + +.topo-modal__panel--wide { + width: min(560px, calc(100vw - 32px)); +} + +.topo-modal__count { + font-weight: 500; + color: var(--nx-text-muted); +} + +.topo-node--cisco { + --topo-icon-color: var(--topo-vendor-cisco, #049fd9); +} +.topo-node--huawei { + --topo-icon-color: var(--topo-vendor-huawei, #cf0a2c); +} +.topo-node--zte { + --topo-icon-color: var(--topo-vendor-zte, #0091da); +} +.topo-node--juniper { + --topo-icon-color: var(--topo-vendor-juniper, #84b135); +} +.topo-node--nokia { + --topo-icon-color: var(--topo-vendor-nokia, #124191); +} +.topo-node--ericsson { + --topo-icon-color: var(--topo-vendor-ericsson, #1e3a5f); +} +.topo-node--h3c { + --topo-icon-color: var(--topo-vendor-h3c, #7ac143); +} +.topo-node--ruijie { + --topo-icon-color: var(--topo-vendor-ruijie, #7c3aed); +} +.topo-node--mikrotik { + --topo-icon-color: var(--topo-vendor-mikrotik, #ea580c); +} +.topo-node--gray, +.topo-node--other { + --topo-icon-color: var(--topo-vendor-gray, var(--nx-text-muted)); +} +.topo-node--region { + --topo-icon-color: #38bdf8; +} +.topo-region-list__chevron { + flex: 0 0 auto; + width: 24px; + height: 24px; + min-width: 24px; + opacity: 0.75; +} +.topo-region-list__chevron:disabled { + opacity: 0.25; + cursor: default; +} +.topo-region-list__chevron-spacer { + flex: 0 0 auto; + width: 24px; + min-width: 24px; + height: 24px; +} +.topo-region-list .topo-map-list__row { + display: flex; + align-items: center; + gap: 2px; +} + +.topo-node.is-region { + height: 17px; +} + +.topo-node__glyph { + position: relative; + width: 28px; + height: 28px; + margin: 0 auto; + border-radius: 0; + background: transparent; + box-shadow: none; + transition: filter 0.15s ease; +} + +.topo-node.is-region .topo-node__glyph { + width: 28px; + height: 19px; +} + +.topo-node__icon--region { + width: 28px; + height: 19px; +} + +.react-flow__node.is-search-hit { + z-index: 1000 !important; +} + +.react-flow__node.is-search-hit .topo-node { + --topo-icon-color: #fbbf24; + filter: drop-shadow(0 0 8px rgba(251, 191, 36, 0.95)); + animation: topo-search-flash 0.7s ease-in-out infinite; +} + +.react-flow__node.is-search-hit .topo-node__glyph, +.react-flow__node.is-search-hit .topo-node__pixel, +.react-flow__node.is-search-hit .topo-node__pin { + outline: none; + border-radius: 0; +} +.react-flow__node.is-search-hit .topo-node__icon { + filter: drop-shadow(0 0 6px rgba(251, 191, 36, 0.95)); +} + +.react-flow__node.is-search-hit .topo-node__pixel, +.react-flow__node.is-search-hit .topo-node__pin { + background: #fbbf24 !important; + color: #fbbf24; + box-shadow: 0 0 10px rgba(251, 191, 36, 0.95); +} + +.react-flow__node.is-search-hit .topo-node__icon-tint { + background-color: #fbbf24 !important; +} + +.react-flow__node.is-search-hit .topo-node__caption { + opacity: 1; + z-index: 6; +} + +.react-flow__node.is-search-hit .topo-node__caption-name { + color: #fde68a; + font-weight: 700; + font-size: 10px; + max-width: 140px; + text-shadow: + 0 0 4px rgba(0, 0, 0, 1), + 0 0 8px rgba(251, 191, 36, 0.85); +} + +@keyframes topo-search-flash { + 0%, + 100% { + filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.7)); + opacity: 1; + transform: scale(1); + } + 50% { + filter: drop-shadow(0 0 14px rgba(251, 191, 36, 1)); + opacity: 0.72; + transform: scale(1.18); + } +} + +.topo-node.is-selected .topo-node__glyph { + outline: none; + box-shadow: none; + background: transparent; +} + +.topo-node.is-selected .topo-node__icon { + transform: none; + filter: drop-shadow(0 0 3px rgba(56, 189, 248, 0.95)); +} + +.topo-node__handle { + width: 10px !important; + height: 10px !important; + min-width: 10px !important; + min-height: 10px !important; + background: transparent !important; + border: 0 !important; + opacity: 0 !important; + pointer-events: none; +} + +.topo-node.is-connect-mode .topo-node__handle { + pointer-events: all; +} + +.topo-node__handle--center { + left: 50% !important; + top: 50% !important; + right: auto !important; + bottom: auto !important; + transform: translate(-50%, -50%) !important; +} + +.topo-node.is-selected .topo-node__caption { + font-weight: 700; +} + +.topo-node.is-selected .topo-node__caption-name { + color: var(--topo-label-color, var(--nx-text-strong)); + filter: brightness(1.12); +} + +/* Selected link: dark green so it stands out from UME blue / LLDP sky. */ +.topo-canvas .react-flow__edge.selected .react-flow__edge-path { + stroke: #166534 !important; + stroke-width: 3px !important; +} + +.topo-node__icon { + position: relative; + display: block; + width: 28px; + height: 28px; + margin: 0; + pointer-events: none; + user-select: none; + isolation: isolate; + transition: transform 0.12s ease; +} + +.topo-node__icon-art { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +/* Recolor hue/sat from --topo-icon-color; keep luminosity so white textures remain. */ +.topo-node__icon-tint { + position: absolute; + inset: 0; + background-color: var(--topo-icon-color, var(--nx-accent-strong)); + mix-blend-mode: color; + -webkit-mask-image: url(/topo/ne-router.png); + mask-image: url(/topo/ne-router.png); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-mode: alpha; + mask-mode: alpha; + pointer-events: none; + transition: background-color 0.12s ease; +} + +.topo-node__caption { + position: absolute; + left: 50%; + top: calc(100% + 4px); + transform: translateX(-50%); + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + margin-top: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + backdrop-filter: none; + -webkit-backdrop-filter: none; + box-shadow: none; + color: var(--topo-label-color, var(--nx-text-strong)); + line-height: 1.2; + width: max-content; + max-width: 148px; + pointer-events: none; +} + +.topo-node__caption-name { + display: block; + max-width: 134px; + font-size: 10px; + font-weight: 650; + letter-spacing: 0.01em; + color: var(--topo-label-color, var(--nx-text-strong)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: + 0 1px 2px rgba(0, 0, 0, 0.9), + 0 0 6px rgba(0, 0, 0, 0.65); +} + +.topo-node__caption-meta { + display: block; + max-width: 134px; + font-size: 8px; + color: color-mix(in srgb, var(--topo-label-color, var(--nx-text-strong)) 55%, var(--nx-text-muted)); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + text-shadow: + 0 1px 2px rgba(0, 0, 0, 0.9), + 0 0 6px rgba(0, 0, 0, 0.55); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.topo-canvas .react-flow__edge-textbg { + fill: none !important; + display: none !important; +} + +.topo-canvas .react-flow__edge-text { + fill: var(--topo-edge-label-color, var(--nx-text)); + font-size: 11px; + paint-order: stroke; + stroke: rgb(var(--nx-ink-rgb) / 0.85); + stroke-width: 3px; +} + +.topo-edge-label { + font-size: 11px; + color: var(--topo-edge-label-color, var(--nx-text)); + background: transparent !important; + border: 0 !important; + border-radius: 0; + padding: 0 !important; + box-shadow: none !important; + white-space: nowrap; + text-shadow: + 0 0 3px rgb(var(--nx-ink-rgb) / 0.95), + 0 1px 2px rgb(var(--nx-ink-rgb) / 0.85); +} + +.topo-edge-label.is-selected { + color: var(--topo-edge-label-color, var(--nx-text)); + font-weight: 600; + filter: brightness(1.15); +} + +.topo-ctx__member-list { + margin: 0 0 8px; + padding: 0 0 0 16px; + font-size: 12px; + color: var(--nx-text-soft); + max-height: 96px; + overflow: auto; + overscroll-behavior: contain; +} + +.topo-ctx__style-members { + grid-column: 1 / -1; + display: grid; + gap: 6px; +} + +@media (max-width: 960px) { + .topo-page, + .topo-page.is-sidebar-collapsed { + grid-template-columns: 1fr; + height: auto; + } + + .topo-sidebar__resizer { + display: none; + } + + .topo-sidebar { + max-height: 40vh; + } + + .topo-page.is-sidebar-collapsed .topo-sidebar { + max-height: none; + min-height: 40px; + } + + .topo-sidebar__rail { + flex-direction: row; + justify-content: center; + padding: 6px 8px; + } + + .topo-sidebar__rail-label { + writing-mode: horizontal-tb; + } + + .topo-canvas { + min-height: 420px; + } +} diff --git a/web/src/styles/pages/webcrt.css b/web/src/styles/pages/webcrt.css new file mode 100644 index 0000000..bcfad85 --- /dev/null +++ b/web/src/styles/pages/webcrt.css @@ -0,0 +1,1705 @@ +/* WebCRT terminal */ + +.webcrt-page { + min-height: calc(100vh - 120px); +} + +.webcrt-session-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + font-size: 13px; + color: var(--nx-text-muted); +} + +.webcrt-term-panel { + padding: 0; + overflow: hidden; + min-height: 420px; + flex: 1; +} + +.webcrt-term { + width: 100%; + height: 100%; + min-height: 240px; + background: var(--nx-field); + padding: 8px; + box-sizing: border-box; +} + +.webcrt-term .xterm { + height: 100%; +} + +.webcrt-term .xterm-viewport { + overflow-y: auto !important; +} + +/* WebCRT — light sidebar + main (shared chrome tokens) */ +.webcrt-shell { + display: grid; + grid-template-columns: 280px 1fr; + height: calc(100vh - 48px); + min-height: 480px; + margin: -20px -28px -32px; + background: var(--nm-chrome); + border: 0; + overflow: hidden; +} + +.webcrt-sidebar { + display: flex; + flex-direction: column; + background: var(--nx-surface-1); + border-right: 1px solid var(--nm-ops-line, var(--nm-nav-border)); + min-width: 0; + min-height: 0; + overflow: hidden; + height: 100%; + color: var(--nm-text); + box-shadow: none; +} + +.webcrt-sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + background: transparent; + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + flex-shrink: 0; +} + +.webcrt-sidebar__title { + font-size: 13px; + font-weight: 650; + color: var(--nm-brand); + line-height: 1.2; + letter-spacing: -0.01em; +} + +.webcrt-sidebar__chrome { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 10px; + background: rgb(var(--nx-ink-rgb) / 0.55); + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + flex-shrink: 0; +} + +.webcrt-sidebar__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; + user-select: none; +} + +.webcrt-sidebar__new-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--nm-brand); + font-size: 12px; + font-weight: 650; + line-height: 1; + cursor: pointer; + box-shadow: none; +} + +.webcrt-sidebar__new-btn:hover { + background: transparent; + color: var(--nm-accent); + text-decoration: underline; +} + +.app-main .webcrt-sidebar__new-btn { + border: 0; + box-shadow: none; + height: 28px; + border-radius: 0; + background: transparent; +} + +.webcrt-menubar__item { + position: relative; + flex-shrink: 0; +} + +.webcrt-menubar__btn { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--nx-text); + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: pointer; + box-shadow: none; +} + +.webcrt-menubar__btn:hover, +.webcrt-menubar__btn.is-open { + background: transparent; + color: var(--nm-brand); + text-decoration: underline; +} + +.app-main .webcrt-menubar__btn { + border: 0; + box-shadow: none; + height: 28px; + border-radius: 0; + background: transparent; +} + +.webcrt-menubar__menu { + position: absolute; + top: calc(100% + 2px); + left: 0; + z-index: 40; + min-width: 180px; + padding: 4px 0; + background: var(--nx-surface-2); + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 4px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4); + color: var(--nx-text); +} + +.webcrt-menubar__menu--end { + left: auto; + right: 0; +} + +.webcrt-menubar__menu-item { + display: block; + width: 100%; + padding: 7px 14px; + border: 0; + background: transparent; + color: var(--nx-text); + font-size: 12px; + text-align: left; + cursor: pointer; + box-shadow: none; + border-radius: 0; +} + +.webcrt-menubar__menu-item:hover { + background: rgb(var(--nx-accent-rgb) / 0.2); + color: var(--nx-text-strong); +} + +.app-main .webcrt-menubar__menu-item { + border: 0; + box-shadow: none; + border-radius: 0; + height: auto; +} + +.webcrt-session-opts { + display: grid; + gap: 14px; +} + +.webcrt-session-opts__field { + display: grid; + gap: 6px; + font-size: 13px; + color: var(--nx-text-muted); + font-weight: 600; +} + +.webcrt-session-opts__field--check { + grid-template-columns: 1fr auto; + align-items: center; + gap: 12px; +} + +.webcrt-session-opts__field--check input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; +} + +.webcrt-session-opts__field select { + height: 34px; + padding: 0 10px; + border: 1px solid var(--nx-line-strong); + border-radius: 6px; + background: var(--nx-field); + font-size: 13px; + font-weight: 500; + color: var(--nx-text); +} + +.webcrt-session-opts__hint { + margin: 12px 0 0; +} + +.webcrt-session-opts__colors { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + align-items: end; +} + +.webcrt-session-opts__color input[type="color"] { + width: 100%; + height: 34px; + padding: 2px; + border: 1px solid var(--nx-line-strong); + border-radius: 6px; + background: var(--nx-field); + cursor: pointer; +} + +.webcrt-session-opts__preview { + grid-column: 1 / -1; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--nx-line); + border-radius: 6px; + font-family: Consolas, "Courier New", monospace; + font-size: 13px; + font-weight: 600; +} + +.webcrt-session-opts-modal { + width: min(460px, 100%); +} + +.webcrt-keyword-modal { + width: min(520px, 100%); +} + +.webcrt-keyword-add { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 8px; + align-items: end; + margin: 12px 0; +} + +.webcrt-keyword-add__regex { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--nx-text-muted); + padding-bottom: 8px; + white-space: nowrap; +} + +.webcrt-keyword-list-wrap { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto 1fr; + gap: 6px 8px; + margin-top: 4px; +} + +.webcrt-keyword-list-head { + grid-column: 1; + display: grid; + grid-template-columns: 1fr 48px; + gap: 8px; + padding: 0 8px; + font-size: 11px; + font-weight: 650; + color: var(--nx-text-muted); +} + +.webcrt-keyword-list { + grid-column: 1; + list-style: none; + margin: 0; + padding: 0; + min-height: 160px; + max-height: 220px; + overflow: auto; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + background: rgb(var(--nx-ink-deep-rgb) / 0.55); +} + +.webcrt-keyword-list__empty { + padding: 16px; + font-size: 12px; + color: var(--nx-text-muted); + text-align: center; +} + +.webcrt-keyword-list__row { + display: grid; + grid-template-columns: 1fr 48px; + gap: 8px; + width: 100%; + padding: 7px 8px; + border: 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.12); + background: transparent; + color: var(--nx-text); + font-size: 12px; + text-align: left; + cursor: pointer; + border-radius: 0; + box-shadow: none; +} + +.webcrt-keyword-list__row:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.75); +} + +.webcrt-keyword-list__row.is-selected { + background: rgb(var(--nx-accent-rgb) / 0.22); + color: var(--nx-accent-text); +} + +.app-main .webcrt-keyword-list__row { + border-radius: 0; + box-shadow: none; + height: auto; +} + +.webcrt-keyword-list__pattern { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.webcrt-keyword-list-actions { + grid-column: 2; + grid-row: 2; + display: flex; + flex-direction: column; + gap: 4px; +} + +.webcrt-keyword-list-actions button { + width: 32px; + height: 28px; + padding: 0; + font-size: 14px; + line-height: 1; +} + +.webcrt-keyword-footer { + display: grid; + grid-template-columns: 1fr auto; + gap: 16px; + align-items: end; + margin-top: 14px; +} + +.webcrt-keyword-color input[type="color"] { + width: 48px; + height: 32px; + padding: 2px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + cursor: pointer; +} + +.webcrt-sidebar__toolbar { + display: none; +} + +.webcrt-sidebar__chrome .webcrt-sidebar__search { + padding: 0; + border-bottom: 0; + flex-shrink: 0; +} + +.webcrt-sidebar__search input { + width: 100%; + box-sizing: border-box; + height: 30px; + padding: 0 10px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + color: var(--nx-text); + font-size: 12px; +} + +.webcrt-sidebar__search input::placeholder { + color: var(--nm-muted); +} + +.webcrt-sidebar__chrome .webcrt-source-tabs { + margin: 0; +} + +.webcrt-source-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2px; + margin: 0; + padding: 2px; + background: rgb(var(--nx-ink-deep-rgb) / 0.55); + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + flex-shrink: 0; +} + +.webcrt-source-tabs--4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.webcrt-new-session-modal, +.webcrt-auth-modal { + width: min(420px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + overflow: auto; +} + +.webcrt-auth-error { + color: var(--nx-bad); + margin-top: -4px; +} + +.webcrt-source-tabs__btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 0; + height: 26px; + padding: 0 2px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--nm-muted); + font-size: 11px; + font-weight: 600; + line-height: 1; + cursor: pointer; + box-shadow: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-main .webcrt-source-tabs__btn, +.app-main .webcrt-sidebar__footer button, +.app-main .webcrt-tree__item, +.app-main .webcrt-action-btn { + border-radius: 6px; +} + +.app-main .webcrt-source-tabs__btn, +.app-main .webcrt-sidebar__footer button { + border: 0; + background: transparent; + box-shadow: none; + height: 28px; + padding: 0 6px; +} + +.app-main .webcrt-source-tabs__btn.is-active, +.app-main .webcrt-sidebar__footer button.is-active { + background: rgb(var(--nx-surface-3-rgb) / 0.95); + color: var(--nx-accent-text); + box-shadow: inset 0 0 0 1px rgb(var(--nx-accent-rgb) / 0.4); +} + +.app-main .webcrt-tree__item { + border: 0; + border-left: 2px solid transparent; + border-radius: 6px; + background: transparent; + box-shadow: none; + padding: 7px 10px; +} + +.app-main .webcrt-tree__item.is-active, +.app-main .webcrt-tree__item.is-selected { + background: var(--nm-nav-active); + border-left-color: var(--nm-accent); +} + +.app-main .webcrt-action-btn { + border: 1px solid rgba(148, 163, 184, 0.28); + background: var(--nx-field); + color: var(--nx-text); + height: 28px; + padding: 0 10px; +} + +.app-main .webcrt-action-btn:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.75); + border-color: rgba(148, 163, 184, 0.4); + color: var(--nx-text-strong); +} + +.app-main .webcrt-action-btn.is-active { + background: rgb(var(--nx-accent-rgb) / 0.22); + border-color: rgb(var(--nx-accent-rgb) / 0.45); + color: var(--nx-accent-text); +} + +.app-main .webcrt-action-btn.is-warn { + background: rgb(var(--nx-bad-rgb) / 0.14); + border-color: rgba(248, 113, 113, 0.4); + color: var(--nx-bad-text); +} + +.webcrt-source-tabs__btn:last-child { + border-right: 0; +} + +.webcrt-source-tabs__btn.is-active { + background: rgb(var(--nx-surface-3-rgb) / 0.95); + color: var(--nx-accent-text); + box-shadow: inset 0 0 0 1px rgb(var(--nx-accent-rgb) / 0.4); + font-weight: 650; +} + +.webcrt-tree { + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; + background: transparent; + min-height: 0; + overscroll-behavior: contain; + padding: 8px 10px; +} + +.webcrt-tree__folder { + padding: 6px 8px; + font-size: 12px; + font-weight: 650; + color: var(--nm-brand); +} + +.webcrt-tree__empty { + padding: 10px 12px; + font-size: 12px; + color: var(--nm-muted); +} + +.webcrt-tree__list { + list-style: none; + margin: 0; + padding: 0 0 8px; +} + +.webcrt-tree__item { + width: 100%; + display: flex; + align-items: flex-start; + gap: 6px; + padding: 7px 10px 7px 10px; + margin-bottom: 2px; + border: 0; + border-radius: 6px; + border-left: 2px solid transparent; + background: transparent; + text-align: left; + cursor: pointer; + font-size: 12px; +} + +.webcrt-tree__item:hover { + background: var(--nm-nav-hover); +} + +.webcrt-tree__item.is-selected, +.webcrt-tree__item.is-active { + background: var(--nm-nav-active); + border-left-color: var(--nm-accent); +} + +.webcrt-tree__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 1px; + color: var(--nm-accent); + line-height: 1; +} + +.webcrt-tree__label { + display: flex; + flex-direction: column; + min-width: 0; +} + +.webcrt-tree__name { + color: var(--nm-brand); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.webcrt-tree__item.is-active .webcrt-tree__name { + color: var(--nm-accent); +} + +.webcrt-tree__meta { + color: var(--nm-muted); + font-size: 11px; +} + +.webcrt-sidebar__pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid rgba(148, 163, 184, 0.18); + background: rgb(var(--nx-ink-rgb) / 0.55); + font-size: 11px; + color: var(--nm-muted); + flex-shrink: 0; +} + +.webcrt-sidebar__pager-btns { + display: flex; + gap: 4px; +} + +.app-main .webcrt-sidebar__pager-btns button, +.webcrt-sidebar__pager-btns button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 24px; + padding: 0; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + color: var(--nx-text); + cursor: pointer; + line-height: 1; + font-size: 14px; +} + +.app-main .webcrt-sidebar__pager-btns button:disabled, +.webcrt-sidebar__pager-btns button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.webcrt-sidebar__footer { + display: flex; + gap: 2px; + margin: 0 10px 10px; + padding: 2px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + background: rgb(var(--nx-ink-deep-rgb) / 0.55); + flex-shrink: 0; +} + +.webcrt-sidebar__footer button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1; + height: 28px; + padding: 0 6px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--nm-muted); + font-size: 12px; + font-weight: 600; + line-height: 1; +} + +.webcrt-sidebar__footer button.is-active { + background: rgb(var(--nx-surface-3-rgb) / 0.95); + color: var(--nx-accent-text); + box-shadow: inset 0 0 0 1px rgb(var(--nx-accent-rgb) / 0.4); + font-weight: 650; +} + +.webcrt-main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + background: var(--nx-field); + border-left: 1px solid rgba(148, 163, 184, 0.18); + color: var(--nx-text); +} + +.webcrt-tabs { + display: flex; + gap: 4px; + overflow-x: auto; + padding: 6px 8px 0; + background: rgb(var(--nx-ink-rgb) / 0.92); + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + min-height: 34px; + flex-shrink: 0; +} + +.webcrt-tabs__item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid transparent; + border-bottom: 0; + border-radius: 6px 6px 0 0; + background: transparent; + color: var(--nx-text-muted); + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} + +.webcrt-tabs__item:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.55); + color: var(--nx-text); +} + +.webcrt-tabs__item.is-active { + background: var(--nx-field); + border-color: rgba(148, 163, 184, 0.22); + color: var(--nx-accent-text); + font-weight: 650; + box-shadow: 0 1px 0 var(--nx-field); +} + +.webcrt-tabs__item.is-dead { + color: #f87171; +} + +.webcrt-tabs__item.is-dead.is-active { + background: rgb(var(--nx-bad-rgb) / 0.14); + border-color: rgba(248, 113, 113, 0.35); + color: var(--nx-bad-text); +} + +.webcrt-tabs__item.is-connecting { + color: #fbbf24; +} + +/* span close mark — avoid global `button` border/padding chrome */ +.webcrt-tabs__close { + display: inline; + margin: 0; + padding: 0; + border: none; + background: none; + box-shadow: none; + outline: none; + cursor: pointer; + font: inherit; + font-weight: inherit; + line-height: inherit; + color: inherit; + opacity: 0.4; +} + +.webcrt-tabs__close::before { + content: " "; +} + +.webcrt-tabs__item:hover .webcrt-tabs__close, +.webcrt-tabs__close:hover { + opacity: 0.75; +} + +.webcrt-tabs__close:focus-visible { + opacity: 1; + text-decoration: underline; +} + +.webcrt-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 10px; + background: rgb(var(--nx-ink-rgb) / 0.92); + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + flex-shrink: 0; +} + +.webcrt-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + height: 28px; + padding: 0 10px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: var(--nx-field); + color: var(--nx-text); + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: pointer; +} + +.webcrt-action-btn:hover { + background: rgb(var(--nx-surface-3-rgb) / 0.75); + border-color: rgba(148, 163, 184, 0.4); + color: var(--nx-text-strong); +} + +.webcrt-action-btn.is-active { + background: rgb(var(--nx-accent-rgb) / 0.22); + border-color: rgb(var(--nx-accent-rgb) / 0.45); + color: var(--nx-accent-text); +} + +.webcrt-action-btn.is-warn { + background: rgb(var(--nx-bad-rgb) / 0.14); + border-color: rgba(248, 113, 113, 0.4); + color: var(--nx-bad-text); +} + +.webcrt-action-btn.is-warn:hover { + background: rgb(var(--nx-bad-rgb) / 0.24); + border-color: rgba(248, 113, 113, 0.55); +} + +.webcrt-connect-banner { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + padding: 8px 12px; + background: var(--nx-surface-3); + border-bottom: 1px solid var(--nx-line-strong); + color: var(--nx-accent-text); + z-index: 2; +} + +.webcrt-connect-banner__text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: 12px; +} + +.webcrt-connect-banner__text strong { + font-size: 13px; +} + +.webcrt-connect-banner__text span { + opacity: 0.85; +} + +.webcrt-disconnect-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; + padding: 8px 12px; + background: color-mix(in oklab, var(--nx-bad) 18%, var(--nx-surface-1)); + border-bottom: 1px solid rgb(var(--nx-bad-rgb) / 0.4); + color: var(--nx-bad-text); + z-index: 2; +} + +.webcrt-disconnect-banner__text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: 12px; +} + +.webcrt-disconnect-banner__text strong { + font-size: 13px; +} + +.webcrt-disconnect-banner__text span { + opacity: 0.85; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.webcrt-disconnect-banner .webcrt-action-btn.is-warn { + background: rgb(var(--nx-ink-rgb) / 0.85); + border-color: rgba(248, 113, 113, 0.45); + color: var(--nx-bad-text); + flex-shrink: 0; +} + +.webcrt-main__body { + flex: 1; + min-height: 0; + position: relative; + background: var(--nx-field); + display: flex; + flex-direction: row; + align-items: stretch; +} + +.webcrt-main__terms { + flex: 1; + min-width: 0; + min-height: 0; + position: relative; +} + +.webcrt-main__pane { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; +} + +.webcrt-main__pane[hidden] { + display: none !important; +} + +.webcrt-main__pane .webcrt-term, +.webcrt-main__pane .webcrt-term-wrap, +.webcrt-main__pane .webcrt-ssh-login { + flex: 1; + width: 100%; + height: auto !important; + min-height: 240px !important; +} + +.webcrt-ssh-login { + display: flex; + flex-direction: column; + min-height: 0; +} + +.webcrt-ssh-login__save { + flex: 0 0 auto; + padding: 6px 12px; + border-bottom: 1px solid rgba(148, 163, 184, 0.25); + color: var(--nx-text-soft); + background: var(--nx-field); +} + +.webcrt-ssh-login .ssh-login-term { + flex: 1; + min-height: 200px !important; +} + +.webcrt-main__empty, +.webcrt-main__placeholder { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--nx-text-soft); + background: var(--nx-field); + text-align: center; + padding: 24px; +} + +.webcrt-main__empty .panel__hint { + color: var(--nx-text-muted); +} + +.webcrt-main__placeholder--error { + color: var(--nx-bad-text); +} + +.webcrt-error-detail { + margin: 8px 0 0; + max-width: 80%; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + color: var(--nx-bad-text); + text-align: left; +} + +.webcrt-statusline { + display: none; +} + +.webcrt-term-wrap { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +.webcrt-term-wrap .webcrt-term { + flex: 1; + min-height: 0; +} + +.webcrt-paste-status { + position: absolute; + top: 8px; + right: 10px; + z-index: 4; + padding: 4px 10px; + font-size: 12px; + color: var(--nx-text); + background: rgb(var(--nx-ink-rgb) / 0.92); + border: 1px solid var(--nx-line-strong); + border-radius: 2px; + pointer-events: none; +} + +.webcrt-paste-bridge { + position: absolute; + inset: 0; + z-index: 8; + display: flex; + align-items: center; + justify-content: center; + background: rgb(var(--nx-ink-deep-rgb) / 0.55); + padding: 16px; +} + +.webcrt-paste-bridge__card { + width: min(420px, 100%); + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border-radius: 10px; + background: var(--nx-surface-2); + border: 1px solid var(--nx-line-strong); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); +} + +.webcrt-paste-bridge__hint { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: var(--nx-text-soft); +} + +.webcrt-paste-bridge__input { + width: 100%; + resize: vertical; + min-height: 88px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--nx-line-strong); + background: var(--nx-field); + color: var(--nx-text); + font: inherit; +} + +.webcrt-paste-bridge__actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.webcrt-paste-bridge__actions button { + padding: 6px 12px; + border-radius: 6px; + border: 1px solid var(--nx-line-strong); + background: var(--nx-line); + color: var(--nx-text); + cursor: pointer; +} + +.webcrt-paste-bridge__actions button:hover { + background: var(--nx-line-strong); +} + +.webcrt-find { + display: flex; + gap: 4px; + align-items: center; + padding: 4px 8px; + background: var(--nx-line); + border-bottom: 1px solid var(--nx-line-strong); + z-index: 2; +} + +.webcrt-find input { + flex: 1; + min-width: 0; + background: var(--nx-surface-2); + border: 1px solid var(--nx-line-strong); + color: var(--nx-text); + padding: 2px 8px; + font-size: 12px; +} + +.webcrt-find button { + background: var(--nx-line-strong); + border: 1px solid var(--nx-line-strong); + color: var(--nx-text); + padding: 2px 8px; + cursor: pointer; +} + +.webcrt-ctx { + position: fixed; + z-index: 50; + min-width: 120px; + background: var(--nx-line); + border: 1px solid var(--nx-line-strong); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + display: flex; + flex-direction: column; + padding: 4px 0; +} + +.webcrt-ctx button { + background: transparent; + border: 0; + color: var(--nx-text); + text-align: left; + padding: 6px 14px; + cursor: pointer; + font-size: 12px; +} + +.webcrt-ctx button:hover { + background: var(--nx-line-strong); +} + +.webcrt-ctx button:disabled { + opacity: 0.4; + cursor: default; +} + +.webcrt-ctx button:disabled:hover { + background: transparent; +} + +.webcrt-tab-ctx { + border-radius: 6px; + min-width: 140px; +} + +.webcrt-tree__row { + display: flex; + align-items: stretch; + gap: 0; +} + +.webcrt-tree__fav { + flex: 0 0 22px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 0; + color: var(--nx-text-muted); + cursor: pointer; + padding: 0; +} + +.webcrt-tree__fav:hover { + color: #fbbf24; +} + +.webcrt-tree__row .webcrt-tree__item { + flex: 1; + min-width: 0; +} + +.webcrt-sidebar__pins { + max-height: 28%; + overflow: auto; + border-bottom: 1px solid var(--nx-line-strong); + padding-bottom: 4px; +} + +.webcrt-sidebar__pin-title { + font-size: 11px; + color: var(--nx-text-muted); + padding: 6px 10px 2px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.webcrt-sidebar__prefs { + display: flex; + gap: 8px; + padding: 8px 10px; + border-top: 1px solid var(--nx-line-strong); + font-size: 12px; + color: var(--nx-text-soft); +} + +.webcrt-sidebar__prefs label { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.webcrt-sidebar__prefs select, +.webcrt-sidebar__prefs input { + background: var(--nx-surface-2); + border: 1px solid var(--nx-line-strong); + color: var(--nx-text); + padding: 2px 6px; + font-size: 12px; +} + +.webcrt-buttonbar { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + padding: 4px 8px; + background: var(--nx-line); + border-bottom: 1px solid var(--nx-line-strong); +} + +.webcrt-buttonbar__btn { + background: var(--nx-line-strong); + border: 1px solid var(--nx-line-strong); + color: var(--nx-text); + padding: 2px 8px; + font-size: 11px; + cursor: pointer; + border-radius: 2px; +} + +.webcrt-buttonbar__btn:hover { + background: var(--nx-line-strong); +} + +.webcrt-broadcast { + display: flex; + gap: 4px; + margin-left: auto; + align-items: center; +} + +.webcrt-broadcast input { + width: 160px; + background: var(--nx-surface-2); + border: 1px solid var(--nx-line-strong); + color: var(--nx-text); + padding: 2px 6px; + font-size: 11px; +} + +.webcrt-broadcast button { + background: var(--nx-accent); + border: 0; + color: var(--nx-surface-2); + padding: 2px 8px; + font-size: 11px; + cursor: pointer; +} + +.webcrt-buttonbar-edit { + padding: 6px 8px; + background: var(--nx-surface-2); + border-bottom: 1px solid var(--nx-line-strong); +} + +.webcrt-buttonbar-edit textarea { + width: 100%; + background: var(--nx-line); + border: 1px solid var(--nx-line-strong); + color: var(--nx-text); + font-family: Consolas, monospace; + font-size: 12px; + padding: 6px; +} + +.webcrt-sftp-split { + flex: 0 0 5px; + width: 5px; + cursor: col-resize; + background: var(--nx-line); + border-left: 1px solid var(--nx-line-strong); + border-right: 1px solid var(--nx-line-strong); + z-index: 4; + position: relative; +} + +.webcrt-sftp-split:hover, +.webcrt-sftp-split.is-dragging { + background: var(--nx-accent); +} + +.webcrt-sftp-split::after { + content: ""; + position: absolute; + inset: 0 -3px; +} + +.webcrt-sftp { + position: relative; + flex: 0 0 auto; + width: 420px; + max-width: 75%; + min-width: 280px; + background: var(--nx-surface-2); + border-left: 0; + z-index: 3; + display: flex; + flex-direction: column; + min-height: 0; +} + +.webcrt-sftp.is-busy { + cursor: progress; +} + +.webcrt-sftp.is-drop-target { + outline: 1px solid var(--nx-accent); + outline-offset: -1px; +} + +.webcrt-sftp__drop-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + text-align: center; + font-size: 12px; + font-weight: 600; + color: var(--nx-accent-text); + background: rgb(var(--nx-accent-rgb) / 0.18); + border: 1px dashed #38bdf8; + pointer-events: none; +} + +.webcrt-sftp__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 5px 6px; + border-bottom: 1px solid var(--nx-line-strong); + flex-shrink: 0; + background: var(--nx-surface-2); +} + +.webcrt-sftp__bar input { + flex: 1; + min-width: 0; + height: 24px; + background: var(--nx-field); + border: 1px solid var(--nx-line-strong); + border-radius: 4px; + color: var(--nx-text-soft); + padding: 0 6px; + font-size: 11px; + line-height: 1; +} + +.webcrt-sftp__bar button, +.webcrt-sftp__upload { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + background: var(--nx-line); + border: 1px solid var(--nx-line-strong); + border-radius: 4px; + color: var(--nx-text-soft); + padding: 0 8px; + font-size: 11px; + font-weight: 500; + line-height: 1; + cursor: pointer; +} + +.webcrt-sftp__bar button:hover:not(:disabled), +.webcrt-sftp__upload:hover:not(.is-disabled) { + background: var(--nx-surface-3); + border-color: var(--nx-line-strong); + color: var(--nx-text); +} + +.webcrt-sftp__bar button:disabled, +.webcrt-sftp__upload.is-disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.webcrt-sftp__status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 3px 8px; + font-size: 10px; + color: var(--nx-text-faint); + border-bottom: 1px solid var(--nx-line); + flex-shrink: 0; +} + +.webcrt-sftp__status--warn { + color: #fbbf24; + background: rgb(var(--nx-warn-rgb) / 0.08); +} + +.webcrt-sftp__cancel { + flex-shrink: 0; + height: 20px; + padding: 0 8px; + border: 1px solid var(--nx-line-strong); + border-radius: 4px; + background: var(--nx-line); + color: #f87171; + font-size: 10px; + font-weight: 600; + cursor: pointer; +} + +.webcrt-sftp__cancel:hover { + background: rgb(var(--nx-bad-rgb) / 0.2); + border-color: var(--nx-bad); + color: var(--nx-bad-text); +} + +.webcrt-sftp__table-wrap { + flex: 1; + min-height: 0; + overflow: auto; +} + +.webcrt-sftp__table { + border-collapse: collapse; + table-layout: fixed; + font-size: 11px; + line-height: 1.35; + color: var(--nx-text-soft); +} + +.webcrt-sftp__table thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--nx-surface-2); + color: var(--nx-text-faint); + font-size: 10px; + font-weight: 500; + text-align: left; + padding: 3px 6px 3px 4px; + border-bottom: 1px solid var(--nx-line); + white-space: nowrap; + user-select: none; + overflow: hidden; +} + +/* Beat .app-main button { white pill } — plain header text like the file rows. */ +.app-main .webcrt-sftp__th-label, +.webcrt-sftp__th-label { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + color: var(--nx-text-faint); + font-family: inherit; + font-size: 10px; + font-weight: 500; + line-height: 1.3; + letter-spacing: normal; + text-align: inherit; + text-transform: none; + cursor: pointer; +} + +.app-main .webcrt-sftp__th-label:hover, +.webcrt-sftp__th-label:hover { + color: var(--nx-text-muted); + background: transparent; + border: none; +} + +.app-main .webcrt-sftp__table thead th.is-sorted .webcrt-sftp__th-label, +.webcrt-sftp__table thead th.is-sorted .webcrt-sftp__th-label { + color: var(--nx-text-muted); +} + +.webcrt-sftp__col-resizer { + position: absolute; + top: 0; + right: 0; + width: 5px; + height: 100%; + cursor: col-resize; + z-index: 2; +} + +.webcrt-sftp__col-resizer::after { + content: ""; + position: absolute; + top: 2px; + bottom: 2px; + right: 1px; + width: 1px; + background: transparent; +} + +.webcrt-sftp__col-resizer:hover::after, +.webcrt-sftp__col-resizer:active::after { + background: var(--nx-accent); +} + +.webcrt-sftp__table tbody tr { + cursor: pointer; + user-select: none; +} + +.webcrt-sftp__table tbody tr:hover { + background: var(--nx-surface-2); +} + +.webcrt-sftp__table tbody tr.is-selected { + background: rgb(var(--nx-accent-rgb) / 0.2); +} + +.webcrt-sftp__table tbody tr.is-selected:hover { + background: rgb(var(--nx-accent-rgb) / 0.3); +} + +.webcrt-sftp__table tbody tr.is-busy { + opacity: 0.7; + cursor: progress; +} + +.webcrt-sftp__empty-row td { + color: var(--nx-text-faint); + text-align: center; + padding: 18px 8px; + cursor: default; +} + +.webcrt-sftp__table td { + padding: 3px 6px; + border-bottom: 1px solid var(--nx-line); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} + +.webcrt-sftp__col-name { + color: var(--nx-text); +} + +.webcrt-sftp__col-size { + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--nx-text-faint); +} + +.webcrt-sftp__table thead th.webcrt-sftp__col-size, +.webcrt-sftp__table thead th.webcrt-sftp__col-size .webcrt-sftp__th-label { + text-align: right; +} + +.webcrt-sftp__col-mtime { + font-variant-numeric: tabular-nums; + color: var(--nx-text-faint); +} + +.webcrt-sftp__col-owner, +.webcrt-sftp__col-group { + color: var(--nx-text-faint); +} + +.webcrt-sftp__col-mode { + font-family: Consolas, "Courier New", monospace; + font-size: 10px; + color: var(--nx-text-muted); +} + +.webcrt-sftp__name-cell { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.webcrt-sftp__icon { + display: inline-flex; + flex-shrink: 0; + color: var(--nx-text-faint); + line-height: 1; +} + +.webcrt-sftp__icon.is-dir { + color: var(--nx-warn); +} + +.webcrt-sftp__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +@media (max-width: 900px) { + .webcrt-shell { + grid-template-columns: 1fr; + height: auto; + min-height: calc(100vh - 64px); + } + + .webcrt-sidebar { + max-height: 42vh; + } + + .webcrt-main { + min-height: 50vh; + } +} + +@media (max-width: 1200px) { + .cards { + grid-template-columns: 1fr; + } + .filter-inline { + grid-template-columns: 1fr; + } + .actions-row--inline { + flex-wrap: wrap; + } + .split { + grid-template-columns: 1fr; + } + .form-grid { + grid-template-columns: 1fr; + } +} diff --git a/web/src/styles/pages/workbench.css b/web/src/styles/pages/workbench.css new file mode 100644 index 0000000..4b7cac0 --- /dev/null +++ b/web/src/styles/pages/workbench.css @@ -0,0 +1,1066 @@ +/* Workbench (home) — .wb-* */ + +.workbench { + width: 100%; + max-width: none; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.workbench--console { + position: relative; + --wb-corner: rgb(var(--nx-accent-rgb) / 0.45); + --wb-ink: var(--nx-text); + --wb-muted: var(--nm-ops-muted, var(--nx-text-muted)); + --wb-cyan: var(--nm-ops-accent, var(--nx-accent)); +} + +.app-main:has(> .workbench) { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + padding: 16px 20px 20px; + background: + linear-gradient(180deg, rgb(var(--nx-ink-deep-rgb) / 0.92), rgb(var(--nx-ink-deep-rgb) / 0.98)), + repeating-linear-gradient( + 0deg, + transparent, + transparent 23px, + rgba(148, 163, 184, 0.035) 23px, + rgba(148, 163, 184, 0.035) 24px + ), + repeating-linear-gradient( + 90deg, + transparent, + transparent 23px, + rgba(148, 163, 184, 0.03) 23px, + rgba(148, 163, 184, 0.03) 24px + ), + var(--nm-chrome, #070b14); +} + +.wb-panel { + position: relative; + border: 1px solid var(--nm-ops-line, var(--nx-line)); + border-radius: 6px; + background: + linear-gradient(var(--wb-corner, rgb(var(--nx-accent-rgb) / 0.45)) 0 0) 0 0 / 11px 2px no-repeat, + linear-gradient(var(--wb-corner, rgb(var(--nx-accent-rgb) / 0.45)) 0 0) 0 0 / 2px 11px no-repeat, + linear-gradient(var(--wb-corner, rgb(var(--nx-accent-rgb) / 0.45)) 0 0) 100% 0 / 11px 2px no-repeat, + linear-gradient(var(--wb-corner, rgb(var(--nx-accent-rgb) / 0.45)) 0 0) 100% 0 / 2px 11px no-repeat, + linear-gradient(var(--wb-corner, rgb(var(--nx-accent-rgb) / 0.45)) 0 0) 0 100% / 11px 2px no-repeat, + linear-gradient(var(--wb-corner, rgb(var(--nx-accent-rgb) / 0.45)) 0 0) 0 100% / 2px 11px no-repeat, + linear-gradient(var(--wb-corner, rgb(var(--nx-accent-rgb) / 0.45)) 0 0) 100% 100% / 11px 2px no-repeat, + linear-gradient(var(--wb-corner, rgb(var(--nx-accent-rgb) / 0.45)) 0 0) 100% 100% / 2px 11px no-repeat, + var(--nx-surface-1); +} + +.wb-panel::before, +.wb-panel::after { + content: none; +} + +.wb-head { + display: flex; + flex-direction: column; + gap: 0; +} + +.wb-head__title-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.wb-head__title { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.14em; + color: #38bdf8; + line-height: 1.2; +} + +.wb-head__health { + display: inline-flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 8px; + border-radius: 3px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; + border: 1px solid transparent; +} + +.wb-head__health-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 8px currentColor; +} + +.wb-head__health.is-ok { + color: #4ade80; + background: rgba(34, 197, 94, 0.08); + border-color: rgba(34, 197, 94, 0.28); +} + +.wb-head__health.is-busy { + color: #fbbf24; + background: rgba(245, 158, 11, 0.1); + border-color: rgba(245, 158, 11, 0.3); +} + +.wb-head__sub { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: var(--nm-muted, var(--nx-text-muted)); + max-width: 52ch; +} + +.wb-strip { + display: flex; + flex-direction: column; + gap: 8px; + padding: 11px 14px; +} + +.wb-strip__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 18px; + min-width: 0; +} + +.wb-strip__tag { + flex: 0 0 auto; + min-width: 3.5em; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #7dd3fc; +} + +.wb-strip__tag--soft { + color: var(--nx-text-muted); +} + +.wb-strip__divider { + flex: 0 0 auto; + width: 1px; + height: 28px; + margin: 0 2px; + background: linear-gradient( + 180deg, + transparent, + rgba(56, 189, 248, 0.35), + transparent + ); +} + +.wb-stat, +.app-main .wb-stat { + display: inline-grid; + grid-template-columns: auto auto; + grid-template-rows: auto auto; + column-gap: 8px; + row-gap: 3px; + align-items: baseline; + flex: 0 0 auto; + width: auto; + min-width: 0; + margin: 0; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + color: inherit; + text-align: left; + border-radius: 0; +} + +.wb-stat__label { + grid-column: 1; + grid-row: 1; + font-size: 12px; + font-weight: 500; + color: var(--nx-text-muted); +} + +.wb-stat__value { + grid-column: 2; + grid-row: 1; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--nx-text); +} + +.wb-stat__track { + grid-column: 1 / -1; + grid-row: 2; + display: block; + width: 84px; + height: 2px; + border-radius: 0; + background: rgba(148, 163, 184, 0.16); + overflow: hidden; +} + +.wb-stat__fill { + display: block; + height: 100%; + background: var(--nx-text-faint); +} + +.wb-stat.is-ok .wb-stat__fill { + background: #22c55e; +} + +.wb-stat.is-mid .wb-stat__fill { + background: #38bdf8; +} + +.wb-stat.is-high .wb-stat__fill { + background: #f59e0b; +} + +.wb-stat.is-muted { + opacity: 0.55; +} + +.wb-body { + display: grid; + grid-template-columns: minmax(0, 1fr) clamp(300px, 26vw, 380px); + gap: 14px; + align-items: stretch; + flex: 1 1 auto; + min-height: 0; +} + +.wb-main { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + height: 100%; +} + +.wb-modules { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px 16px 16px; + flex: 1 1 auto; + min-height: 0; + justify-content: center; +} + +.wb-section { + margin: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.wb-section__title { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #7dd3fc; +} + +.wb-tiles { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + align-items: stretch; + width: 100%; +} + +.wb-tile, +.app-main .wb-tile { + display: inline-flex; + align-items: center; + gap: 12px; + width: 100%; + max-width: none; + min-height: 64px; + margin: 0; + padding: 12px 14px; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 4px; + background: rgb(var(--nx-ink-rgb) / 0.5); + box-shadow: none; + cursor: pointer; + text-align: left; + color: inherit; + transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease; +} + +.wb-tile:hover, +.wb-tile:focus-visible { + border-color: rgba(56, 189, 248, 0.42); + background: rgb(var(--nx-surface-3-rgb) / 0.72); + box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.1); + outline: none; + transform: translateY(-1px); +} + +.wb-tile__label { + font-size: 13px; + font-weight: 600; + color: var(--nx-text); + line-height: 1.25; +} + +.wb-tile .wb-card__icon { + opacity: 0.9; +} + + +.wb-fleet { + margin-top: 0; + flex: 0 0 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px 12px; +} + +.wb-fleet__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-bottom: 2px; + border-bottom: 1px solid rgba(148, 163, 184, 0.1); +} + +.wb-fleet__title { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #7dd3fc; +} + +.wb-fleet__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + min-height: 0; + align-items: stretch; +} + +.wb-fleet__card { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 8px; + width: 100%; + max-width: 100%; + min-width: 0; + margin: 0; + padding: 10px 10px 8px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 4px; + background: rgb(var(--nx-ink-rgb) / 0.45); + color: inherit; + text-align: left; + cursor: default; + overflow: hidden; + transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease; +} + +button.wb-fleet__card { + cursor: pointer; + font: inherit; + appearance: none; + -webkit-appearance: none; +} + +button.wb-fleet__card:hover, +button.wb-fleet__card:focus-visible { + border-color: rgba(56, 189, 248, 0.4); + background: rgb(var(--nx-surface-3-rgb) / 0.72); + outline: none; + transform: translateY(-1px); +} + +button.wb-fleet__card:disabled { + cursor: default; + opacity: 0.72; +} + +button.wb-fleet__card:disabled:hover, +button.wb-fleet__card:disabled:focus-visible { + border-color: rgba(148, 163, 184, 0.2); + background: rgb(var(--nx-ink-rgb) / 0.45); +} + +.wb-fleet__card--sched { + cursor: pointer; +} + +.wb-fleet__card--sched:hover, +.wb-fleet__card--sched:focus-visible { + border-color: rgba(56, 189, 248, 0.4); + background: rgb(var(--nx-surface-3-rgb) / 0.72); + outline: none; + transform: translateY(-1px); +} + +.wb-fleet__card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 18px; +} + +.wb-fleet__card-label { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #7dd3fc; +} + +.wb-fleet__card-badge { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 10px; + font-weight: 650; + letter-spacing: 0.02em; + color: var(--nx-text-faint); + white-space: nowrap; +} + +.wb-fleet__card-badge.is-ok { + color: #4ade80; +} + +.wb-fleet__card-badge.is-warn { + color: #fbbf24; +} + +.wb-fleet__viz { + display: grid; + grid-template-columns: 84px minmax(0, 1fr); + gap: 6px 12px; + align-items: center; + min-height: 0; +} + +.wb-fleet__donut { + position: relative; + width: 84px; + height: 84px; + border-radius: 50%; + flex: 0 0 auto; + margin: 0; + box-shadow: + inset 0 0 0 1px rgba(56, 189, 248, 0.12), + 0 0 14px rgba(56, 189, 248, 0.05); +} + +.wb-fleet__donut--dart { + background: transparent; + box-shadow: none; +} + +.wb-fleet__dart { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} + +.wb-fleet__dart-slice { + cursor: pointer; + transform-origin: 50% 50%; + transition: filter 0.14s ease, opacity 0.14s ease; + outline: none; +} + +.wb-fleet__dart-slice:hover, +.wb-fleet__dart-slice:focus-visible { + filter: brightness(1.18) drop-shadow(0 0 6px rgba(56, 189, 248, 0.45)); +} + +.wb-fleet__dart-slice:not(.is-on) { + opacity: 0.55; +} + +.wb-fleet__dart-slice:not(.is-on):hover, +.wb-fleet__dart-slice:not(.is-on):focus-visible { + opacity: 0.85; +} + +.wb-fleet__donut-hole { + position: absolute; + inset: 20%; + border-radius: 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + background: + radial-gradient(circle at 35% 30%, rgba(56, 189, 248, 0.08), transparent 55%), + linear-gradient(165deg, rgb(var(--nx-ink-rgb) / 0.98), rgb(var(--nx-ink-deep-rgb) / 0.96)); + box-shadow: 0 0 0 1px rgb(var(--nx-ink-rgb) / 0.9); +} + +.wb-fleet__donut-hole--dart { + inset: 28%; + pointer-events: none; +} + +.wb-fleet__donut-value { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; + color: var(--nx-text); + letter-spacing: -0.02em; +} + +.wb-fleet__donut-unit { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 9px; + font-weight: 650; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--nx-text-faint); +} + +.wb-fleet__rows { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + margin-top: 0; + min-width: 0; + min-height: 84px; +} + +.wb-fleet__row, +button.wb-fleet__row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + width: 100%; + margin: 0; + padding: 1px 0; + border: 0; + background: transparent; + font: inherit; + font-size: 12px; + line-height: 1.35; + color: var(--nx-text-muted); + text-align: left; + cursor: default; +} + +button.wb-fleet__row { + cursor: pointer; + border-radius: 3px; + padding: 1px 4px; + margin: 0 -4px; +} + +button.wb-fleet__row:hover, +button.wb-fleet__row:focus-visible { + color: var(--nx-text); + background: rgba(56, 189, 248, 0.08); + outline: none; +} + +.wb-fleet__legend { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.wb-fleet__legend-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wb-fleet__swatch { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--nx-text-faint); + flex: 0 0 auto; +} + +.wb-fleet__swatch.is-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.45); } +.wb-fleet__swatch.is-mid { background: #38bdf8; box-shadow: 0 0 6px rgba(56, 189, 248, 0.4); } +.wb-fleet__swatch.is-warn { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.4); } +.wb-fleet__swatch.is-bad { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); } +.wb-fleet__swatch.is-muted { background: var(--nx-text-faint); } + +.wb-fleet__row strong { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--nx-text); + text-align: right; + white-space: nowrap; +} + +.wb-fleet__row strong.is-ok { color: #4ade80; } +.wb-fleet__row strong.is-mid { color: #7dd3fc; } +.wb-fleet__row strong.is-warn { color: #fbbf24; } +.wb-fleet__row strong.is-bad { color: #f87171; } +.wb-fleet__row strong.is-muted { color: var(--nx-text-faint); } + +.wb-fleet__foot { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 4px 10px; + margin-top: 0; + padding-top: 6px; + border-top: 1px solid rgba(148, 163, 184, 0.1); + min-height: 22px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 10px; + line-height: 1.3; + color: var(--nx-text-faint); +} + +.wb-fleet__foot em { + font-style: normal; + font-weight: 650; + color: var(--nx-text-muted); +} + +.wb-fleet__foot em.is-ok { + color: #4ade80; +} + +.wb-fleet__foot em.is-warn { + color: #fbbf24; +} + +.wb-fleet__row-age { + margin-left: 4px; + font-weight: 550; + color: var(--nx-text-faint); +} + +.wb-fleet__row.is-on .wb-fleet__legend-text { + color: var(--nx-text); +} + +.wb-fleet__row--sched, +button.wb-fleet__row--sched { + display: grid; + grid-template-columns: minmax(0, 1fr) 2.75em 2.75em; + align-items: center; + justify-content: stretch; + column-gap: 6px; +} + +.wb-fleet__sched-state { + display: block; + width: 100%; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-align: left; + color: var(--nx-text-faint); +} + +.wb-fleet__sched-state.is-ok { + color: #4ade80; +} + +.wb-fleet__sched-state.is-muted { + color: var(--nx-text-faint); +} + +.wb-fleet__sched-age { + display: block; + width: 100%; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-variant-numeric: tabular-nums; + font-weight: 550; + text-align: right; + color: var(--nx-text-faint); +} + +.wb-side { + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; + height: 100%; + align-self: stretch; +} + +.wb-rail { + min-width: 0; + padding: 12px 14px; + display: flex; + flex-direction: column; + min-height: 0; +} + +.wb-rail--tasks { + flex: 1 1 auto; + min-height: 0; + height: 100%; +} + +.wb-rail--channels { + display: none; +} + +.wb-rail__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.wb-rail__title { + margin: 0; + display: inline-flex; + align-items: center; + gap: 8px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #7dd3fc; +} + +.wb-rail__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 3px; + background: rgba(56, 189, 248, 0.14); + color: #e0f2fe; + font-size: 11px; + font-weight: 700; + letter-spacing: 0; + text-transform: none; +} + +.wb-rail__meta { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + color: var(--nx-text-faint); +} + +.wb-rail__all, +.app-main .wb-rail__all { + height: 26px; + min-height: 26px; + padding: 0 8px; + font-size: 12px; + color: #7dd3fc; + background: transparent; + border: 0; + box-shadow: none; + border-radius: 3px; +} + +.wb-rail__all:hover, +.wb-rail__all:focus-visible { + color: #e0f2fe; + background: rgba(56, 189, 248, 0.1); + outline: none; +} + +.wb-rail__empty { + margin: 0; + padding: 12px 0; + font-size: 12px; + line-height: 1.45; + color: var(--nx-text-faint); +} + +.wb-rail__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + overflow: auto; + min-height: 0; + flex: 1 1 auto; +} + +.wb-rail__item, +.app-main .wb-rail__item, +.app-main button.wb-rail__item { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 4px; + width: 100%; + height: auto; + min-height: 0; + margin: 0; + padding: 10px 10px; + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 4px; + background: rgb(var(--nx-ink-deep-rgb) / 0.35); + box-shadow: none; + color: inherit; + text-align: left; + cursor: pointer; + white-space: normal; + line-height: 1.4; + overflow: visible; +} + +.wb-rail__item:hover, +.wb-rail__item:focus-visible, +.app-main button.wb-rail__item:hover, +.app-main button.wb-rail__item:focus-visible { + background: rgba(56, 189, 248, 0.1); + border-color: rgba(56, 189, 248, 0.28); + outline: none; +} + +.wb-rail__item-title { + display: block; + width: 100%; + font-size: 12px; + font-weight: 600; + color: var(--nx-text); + line-height: 1.45; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.wb-rail__item-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + width: 100%; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + color: var(--nx-text-muted); + line-height: 1.4; +} + +.wb-rail__status { + display: inline-flex; + align-items: center; + gap: 5px; + font-weight: 650; +} + +.wb-rail__status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 6px currentColor; + flex-shrink: 0; +} + +.wb-rail__status--running { + color: #38bdf8; +} + +.wb-rail__status--ok { + color: #4ade80; +} + +.wb-rail__status--paused { + color: #fbbf24; +} + +.wb-rail__status--stopped { + color: #f87171; +} + +.wb-rail__status--other { + color: var(--nx-text-muted); +} + +.wb-rail__progress { + color: var(--nx-text-faint); + font-weight: 500; +} + +.wb-channels { + display: flex; + flex-direction: column; + gap: 10px; +} + +.wb-channel { + display: flex; + flex-direction: column; + gap: 5px; +} + +.wb-channel__top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.wb-channel__label { + font-size: 12px; + color: var(--nx-text-muted); +} + +.wb-channel__value { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; + font-weight: 700; + color: var(--nx-text); + font-variant-numeric: tabular-nums; +} + +.wb-channel__sep { + margin: 0 2px; + color: var(--nx-text-faint); + font-weight: 500; +} + +.wb-channel__track { + display: block; + height: 3px; + background: rgba(148, 163, 184, 0.14); + overflow: hidden; +} + +.wb-channel__fill { + display: block; + height: 100%; + background: #38bdf8; +} + +.wb-channel.is-ok .wb-channel__fill { + background: #22c55e; +} + +.wb-channel.is-mid .wb-channel__fill { + background: #38bdf8; +} + +.wb-channel.is-high .wb-channel__fill { + background: #f59e0b; +} + +.wb-channel.is-muted { + opacity: 0.55; +} + +@media (max-width: 960px) { + .wb-body { + grid-template-columns: 1fr; + } + + .wb-side { + min-height: 0; + } + + .wb-rail--tasks { + flex: 0 0 auto; + max-height: 280px; + } + + .wb-fleet { + margin-top: 0; + flex: 0 0 auto; + } + + .wb-fleet__grid { + grid-template-columns: 1fr; + } + + .wb-fleet__viz { + grid-template-columns: 72px minmax(0, 1fr); + } + + .wb-fleet__donut { + width: 72px; + height: 72px; + } + + .wb-fleet__rows { + min-height: 72px; + } + + .wb-modules { + justify-content: flex-start; + } + + .wb-tile, + .app-main .wb-tile { + min-height: 52px; + } + + .wb-strip__divider { + display: none; + } + + .wb-tiles { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .wb-tiles { + grid-template-columns: 1fr; + } + + .wb-strip__tag { + width: 100%; + } +} diff --git a/web/src/styles/theme/theme.css b/web/src/styles/theme/theme.css new file mode 100644 index 0000000..b970f0e --- /dev/null +++ b/web/src/styles/theme/theme.css @@ -0,0 +1,833 @@ +/* + * NetX design language — loaded last. Modern blue. + * + * Deep navy surfaces with soft gradients, 8/12px rounded shapes, a vivid blue + * accent for primary actions and focus, gentle glows, lift-on-hover. + * + * Palette comes from theme/tokens.css. This layer re-shapes primitives that + * legacy page CSS styled inconsistently. Prefer editing the owning + * component/page file; add here only for cross-cutting visual language. + * + * Sections: 1 base · 2 header · 3 workbench · 4 nav/panels · 5 buttons · + * 6 fields/forms · 7 tables · 8 tags · 9 modals · 10 login + */ + +/* ------------------------------------------------------------------ 1. Base */ +html { + scrollbar-color: var(--nx-line-strong) transparent; +} + +*::-webkit-scrollbar-track, +*::-webkit-scrollbar-corner { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: var(--nx-line-strong); + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 999px; +} + +*::-webkit-scrollbar-thumb:hover { + background: #64748b; + background-clip: padding-box; +} + +body { + background: var(--nx-bg); + color: var(--nx-text); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +::selection { + background: rgb(var(--nx-accent-rgb) / 0.35); + color: #fff; +} + +:where(a, button, [role="button"], summary, [tabindex]):focus-visible { + outline: none; + box-shadow: var(--nx-ring); +} + +input[type="checkbox"], +input[type="radio"] { + accent-color: var(--nx-accent-strong); +} + +table, +.wb-stat__value, +.wb-fleet__row strong, +.pt-list-kpi__value { + font-variant-numeric: tabular-nums; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ----------------------------------------------------------------- 2. Header */ +/* Height stays 48px: several pages use calc(100vh - 48px). */ +.app--shell .app-brand { + background: linear-gradient(180deg, var(--nx-surface-2) 0%, var(--nx-surface-1) 100%); + border-bottom: 1px solid var(--nx-line); + box-shadow: 0 1px 0 rgb(var(--nx-accent-rgb) / 0.06); +} + +.app--shell .app-brand__logo { + font-size: 16px; + font-weight: 800; + letter-spacing: 0.22em; + background: linear-gradient(90deg, #ffffff 0%, #9fcbff 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.app--shell .app-brand__sep { + background: var(--nx-line-strong); +} + +.app--shell .app-brand__module-title { + font-size: 14px; + font-weight: 600; + color: var(--nx-text); +} + +.app--shell .app-brand .app-brand__apps { + border-radius: var(--nx-radius-sm); + color: var(--nx-text-muted); +} + +.app--shell .app-brand .app-brand__apps:hover, +.app--shell .app-brand .app-brand__apps:focus-visible { + background: var(--nx-accent-soft); + color: #fff; +} + +.app--shell .app-brand .conn-pill.conn-pill--on-brand, +.app--shell .app-brand .header-menu__trigger.header-menu__trigger--on-brand { + height: 26px; + border-radius: 999px; + border-color: var(--nx-line-strong); + background: rgb(255 255 255 / 0.05); + color: var(--nx-text-muted); + font-size: 12px; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease; +} + +.app--shell .app-brand .conn-pill.conn-pill--on-brand.conn-pill--user { + color: var(--nx-text); + font-weight: 500; +} + +.app--shell .app-brand .conn-pill.conn-pill--on-brand:hover, +.app--shell .app-brand .header-menu__trigger.header-menu__trigger--on-brand:hover { + background: var(--nx-accent-soft); + border-color: rgb(var(--nx-accent-rgb) / 0.6); + color: #fff; +} + +/* -------------------------------------------------------------- 3. Workbench */ +.app--shell .app-main:has(> .workbench) { + background: + radial-gradient(900px 380px at 12% -8%, rgb(var(--nx-accent-rgb) / 0.1), transparent 65%), + radial-gradient(700px 320px at 100% 0%, rgb(52 211 153 / 0.05), transparent 60%), + var(--nx-bg); +} + +.app--shell .workbench { + gap: 16px; +} + +/* Soft gradient plate instead of the corner-bracket HUD frame. */ +.app--shell .wb-panel { + border: 1px solid var(--nx-line); + border-radius: var(--nx-radius-lg); + background: linear-gradient(180deg, var(--nx-surface-2) 0%, var(--nx-surface-1) 100%); + box-shadow: var(--nx-shadow-panel); +} + +.app--shell .wb-head__title { + font-family: var(--nx-font-sans); + font-size: 22px; + font-weight: 700; + letter-spacing: 0.01em; + color: var(--nx-text-strong); +} + +.app--shell .wb-head__health { + height: 24px; + padding: 0 10px; + border-radius: 999px; + font-family: var(--nx-font-sans); + font-size: 12px; + letter-spacing: 0; +} + +/* Section captions: sans, readable, with a small gradient bar. */ +.app--shell .wb-section__title, +.app--shell .wb-fleet__title, +.app--shell .wb-rail__title, +.app--shell .wb-fleet__card-label { + font-family: var(--nx-font-sans); + font-size: 13px; + font-weight: 650; + letter-spacing: 0.02em; + text-transform: none; + color: var(--nx-text); +} + +.app--shell .wb-section__title { + display: flex; + align-items: center; + gap: 8px; +} + +.app--shell .wb-section__title::before { + content: ""; + width: 3px; + height: 14px; + border-radius: 2px; + background: var(--nx-gradient-accent); +} + +.app--shell .wb-strip__tag, +.app--shell .wb-strip__tag--soft { + font-size: 11px; + color: var(--nx-text-faint); +} + +.app--shell .wb-stat__label { + font-size: 12px; + color: var(--nx-text-muted); +} + +.app--shell .wb-stat__value { + font-size: 13px; +} + +.app--shell .wb-stat__track { + height: 3px; + border-radius: 2px; +} + +/* Content-height layout: no stretched empty panel above/below the tiles. */ +.app--shell .wb-body { + flex: 0 0 auto; + grid-template-columns: minmax(0, 1fr) clamp(320px, 26vw, 400px); + gap: 16px; +} + +.app--shell .wb-main { + height: auto; + gap: 16px; +} + +.app--shell .wb-modules { + flex: 0 0 auto; + justify-content: flex-start; + gap: 22px; + padding: 20px 22px 24px; +} + +.app--shell .wb-tiles { + grid-template-columns: repeat(auto-fill, minmax(176px, 1fr)); + gap: 12px; +} + +.app--shell .app-main .wb-tile { + min-height: 72px; + padding: 14px 16px; + gap: 14px; + border: 1px solid var(--nx-line); + border-radius: 10px; + background: var(--nx-surface-2); + transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease; +} + +.app--shell .app-main .wb-tile:hover, +.app--shell .app-main .wb-tile:focus-visible { + border-color: rgb(var(--nx-accent-rgb) / 0.6); + background: linear-gradient(180deg, rgb(var(--nx-accent-rgb) / 0.16), var(--nx-surface-2)); + box-shadow: 0 6px 18px rgb(0 0 0 / 0.3); + transform: translateY(-2px); +} + +.app--shell .wb-tile__label { + font-size: 14px; + font-weight: 600; + color: var(--nx-text); +} + +.app--shell .wb-card__icon { + --wb-icon: var(--nx-text-muted); + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 10px; + color: var(--wb-icon); + background: color-mix(in oklab, var(--wb-icon) 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wb-icon) 28%, transparent); +} + +.app--shell .wb-card__icon svg { + width: 20px; + height: 20px; +} + +.app--shell .wb-card__icon--blue { + --wb-icon: var(--nx-accent); +} + +.app--shell .wb-card__icon--green { + --wb-icon: #34d399; +} + +.app--shell .wb-card__icon--amber { + --wb-icon: var(--nx-warn); +} + +.app--shell .wb-card__icon--slate { + --wb-icon: var(--nx-text-soft); +} + +.app--shell .wb-fleet { + padding: 14px 16px 16px; + gap: 12px; +} + +.app--shell .wb-fleet__head { + padding-bottom: 8px; + border-bottom-color: var(--nx-line-soft); +} + +.app--shell .wb-fleet__grid { + gap: 12px; +} + +.app--shell .wb-fleet__card { + padding: 12px 12px 10px; + border: 1px solid var(--nx-line); + border-radius: 10px; + background: rgb(var(--nx-ink-deep-rgb) / 0.45); +} + +.app--shell button.wb-fleet__card:hover, +.app--shell button.wb-fleet__card:focus-visible, +.app--shell .wb-fleet__card--sched:hover, +.app--shell .wb-fleet__card--sched:focus-visible { + border-color: rgb(var(--nx-accent-rgb) / 0.55); + background: rgb(var(--nx-accent-rgb) / 0.1); +} + +.app--shell .wb-fleet__card-badge, +.app--shell .wb-fleet__donut-unit, +.app--shell .wb-fleet__foot, +.app--shell .wb-fleet__row-age, +.app--shell .wb-fleet__sched-age, +.app--shell .wb-rail__meta, +.app--shell .wb-rail__progress, +.app--shell .wb-rail__empty { + color: var(--nx-text-faint); +} + +.app--shell .wb-fleet__card-badge, +.app--shell .wb-fleet__foot { + font-size: 11px; +} + +.app--shell .wb-rail { + padding: 16px 16px 14px; +} + +.app--shell .wb-rail__count { + border-radius: 999px; + min-width: 20px; + height: 20px; + background: var(--nx-accent-soft); + color: var(--nx-accent-text); +} + +.app--shell .wb-rail__list { + gap: 8px; +} + +.app--shell .app-main button.wb-rail__item, +.app--shell .wb-rail__item { + padding: 11px 12px; + border: 1px solid var(--nx-line); + border-radius: 10px; + background: rgb(var(--nx-ink-deep-rgb) / 0.45); +} + +.app--shell .app-main button.wb-rail__item:hover, +.app--shell .app-main button.wb-rail__item:focus-visible { + background: var(--nx-accent-soft); + border-color: rgb(var(--nx-accent-rgb) / 0.5); +} + +.app--shell .wb-rail__item-title { + font-size: 13px; +} + +.app--shell .wb-rail__item-meta { + font-size: 12px; + color: var(--nx-text-muted); +} + +/* ---------------------------------------------------------- 4. Nav + panels */ +.app--shell .network-nav { + width: 236px; + background: linear-gradient(180deg, var(--nx-surface-1) 0%, rgb(var(--nx-ink-deep-rgb) / 0.55) 100%); + border-right: 1px solid var(--nx-line); +} + +.app--shell .network-nav__scroll { + padding: 12px 10px 18px; +} + +.app--shell .network-nav__group-toggle, +.app--shell .app-main .network-nav__group-toggle, +.app--shell .network-nav .accordion__trigger.network-nav__group-toggle { + padding: 9px 10px; + border-radius: var(--nx-radius); + font-size: 12.5px; + font-weight: 650; + letter-spacing: 0.02em; + text-transform: none; + color: var(--nx-text-muted); + transition: background 0.14s ease, color 0.14s ease; +} + +.app--shell .network-nav__group-toggle:hover, +.app--shell .app-main .network-nav__group-toggle:hover, +.app--shell .network-nav .accordion__trigger.network-nav__group-toggle:hover { + background: var(--nx-surface-2); + color: var(--nx-text); +} + +.app--shell .network-nav__group.is-active-group .network-nav__group-toggle, +.app--shell .network-nav__group.is-active-group .accordion__trigger.network-nav__group-toggle { + color: var(--nx-accent); +} + +.app--shell .network-nav__list { + gap: 2px; + margin-left: 18px; + padding-left: 8px; + border-left-color: var(--nx-line); +} + +/* Single-level menus (audit) are not a tree: no indent, no guide line. */ +.app--shell .network-nav__list--flat { + margin-left: 0; + padding-left: 0; + border-left: 0; + gap: 4px; +} + +.app--shell .network-nav__link { + padding: 8px 12px; + border-radius: var(--nx-radius); + font-size: 13px; + color: var(--nx-text-muted); +} + +.app--shell .network-nav__link:hover { + background: var(--nx-surface-2); + color: var(--nx-text); +} + +.app--shell .network-nav__link.is-active { + background: rgb(var(--nx-accent-rgb) / 0.14); + color: #fff; + font-weight: 600; + box-shadow: inset 0 0 0 1px rgb(var(--nx-accent-rgb) / 0.3); +} + +.app--shell .network-main { + background: + radial-gradient(1100px 420px at 0% 0%, rgb(var(--nx-accent-rgb) / 0.08), transparent 60%), + var(--nx-bg); +} + +.app--shell .network-main > .panel, +.app--shell .network-main .page-stack > .panel, +.app--shell .network-main > .nm-page-panel, +.app--shell .app-main .card, +.app--shell .app-main .panel { + border: 1px solid var(--nx-line); + border-radius: var(--nx-radius-lg); + background: var(--nx-surface-1); + box-shadow: var(--nx-shadow-panel); +} + +.app--shell .network-main .panel__toolbar { + border-bottom-color: var(--nx-line-soft); +} + +.app--shell .network-main .panel__toolbar h2, +.app--shell .app-main .panel > h2 { + font-size: 18px; + font-weight: 700; + letter-spacing: 0; + color: var(--nx-text-strong); +} + +.app--shell .network-main .filter-inline, +.app--shell .app-main .filter-inline { + border-radius: 10px !important; + background: rgb(var(--nx-ink-deep-rgb) / 0.5) !important; + border-color: var(--nx-line) !important; +} + +.muted, +.app-main .muted { + color: var(--nx-text-muted); +} + +/* Links on dark surfaces. */ +a, +.link-btn, +.system-page button.linkish { + color: var(--nx-accent); +} + +.system-page button.linkish:hover, +.link-btn:hover { + color: var(--nx-accent-text); +} + +/* ---------------------------------------------------------------- 5. Buttons */ +/* :where() keeps specificity at (0,1,1): enough to beat `.app-main button`, + while any component class (.wb-tile, .wb-rail__all …) still wins. */ +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) button.button { + border-radius: var(--nx-radius); + font-weight: 550; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease, box-shadow 0.14s ease, + transform 0.1s ease; +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) button.button:where(:active:not(:disabled)) { + transform: translateY(1px); +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) button.button:where(:disabled, [data-disabled="true"]) { + opacity: 0.45; + cursor: not-allowed; +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) button.button--secondary { + background: var(--nx-surface-3); + border-color: var(--nx-line); + color: var(--nx-text); +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) button.button--secondary:where(:hover:not(:disabled)) { + background: #1f3250; + border-color: rgb(var(--nx-accent-rgb) / 0.55); +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) button.button--primary { + background: var(--nx-gradient-accent); + border-color: var(--nx-accent-strong); + color: #fff; + box-shadow: 0 1px 0 rgb(255 255 255 / 0.2) inset, 0 2px 10px rgb(37 99 235 / 0.3); +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) button.button--primary:where(:hover:not(:disabled)) { + background: linear-gradient(180deg, #5b9bf8 0%, #3a74eb 100%); +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) button.button--danger { + background: rgb(248 113 113 / 0.1); + border-color: rgb(248 113 113 / 0.38); + color: #fda4a4; +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) button.button--danger:where(:hover:not(:disabled)) { + background: rgb(248 113 113 / 0.2); + border-color: rgb(248 113 113 / 0.65); + color: #fecaca; +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) :is(button.button--tertiary, button.button--ghost) { + background: rgb(255 255 255 / 0.03); + border-color: var(--nx-line); + color: var(--nx-text); +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) + :is(button.button--tertiary, button.button--ghost):where(:hover:not(:disabled)) { + background: var(--nx-surface-3); + border-color: rgb(var(--nx-accent-rgb) / 0.45); +} + +/* ------------------------------------------------------------ 6. Fields/forms */ +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) + :is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]), select, textarea) { + border-color: var(--nx-line); + border-radius: var(--nx-radius); + transition: border-color 0.14s ease, box-shadow 0.14s ease; +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) + :is(input:not([type="checkbox"], [type="radio"], [type="range"]), select, textarea):where(:hover:not(:disabled)) { + border-color: #42597a; +} + +:is(.app-main, .app-heroui-modal, .topo-heroui-modal) + :is(input:not([type="checkbox"], [type="radio"], [type="range"]), select, textarea):focus { + outline: none; + border-color: var(--nx-accent) !important; + box-shadow: var(--nx-ring); +} + +:where(.app-main, .app-heroui-modal, .topo-heroui-modal) :is(input, textarea)::placeholder { + color: var(--nx-text-faint); +} + +.ui-field__label, +.ui-field__hint { + color: var(--nx-text-muted); +} + +.ui-field__select { + border-radius: var(--nx-radius); +} + +/* HeroUI TextField (.textfield) and native .ui-field share one grid. + `.form-grid label { flex-direction: column }` stacked the required-star + ::after under the label text; keep labels inline and give every control the + same label gap + height so both columns line up. */ +.form-grid { + align-items: start; + gap: 14px 16px; +} + +.form-grid > :is(.textfield, .ui-field) { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.form-grid label.label, +.form-grid .ui-field__label { + display: inline-flex; + flex-direction: row; + align-items: baseline; + gap: 4px; + font-size: 12.5px; + font-weight: 550; + color: var(--nx-text-muted); +} + +.form-grid .label--required::after { + color: var(--nx-bad); +} + +.form-grid :is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]), select) { + height: 38px; + min-height: 38px; + padding: 0 12px; + font-size: 14px; +} + +.form-field-hint, +.form-hint, +.help-hint__trigger, +.app-main .help-hint__trigger { + color: var(--nx-text-faint); +} + +.form-field-hint, +.form-hint { + font-size: 12px; +} + +.form-fieldset { + border-color: var(--nx-line); + border-radius: 10px; +} + +/* ----------------------------------------------------------------- 7. Tables */ +:is(.app-main, .app-heroui-modal) .pt-list-table-wrap { + border-radius: 10px; + overflow-x: auto; + background: rgb(var(--nx-ink-deep-rgb) / 0.35); +} + +:is(.app-main, .app-heroui-modal) .pt-list-table thead th, +:is(.app-main, .app-heroui-modal) .data-table thead th { + padding: 10px 12px; + background: var(--nx-surface-3) !important; + color: var(--nx-text-muted) !important; + font-size: 12px; + font-weight: 650; + letter-spacing: 0.02em; + text-transform: none; + border-bottom: 1px solid var(--nx-line-strong); + white-space: nowrap; +} + +:is(.app-main, .app-heroui-modal) .pt-list-table tbody td, +:is(.app-main, .app-heroui-modal) .data-table tbody td { + padding: 10px 12px; + font-size: 13px; + line-height: 1.45; + vertical-align: middle; + border-bottom: 1px solid var(--nx-line-soft); + color: var(--nx-text); +} + +:is(.app-main, .app-heroui-modal) .pt-list-table tbody tr:last-child td, +:is(.app-main, .app-heroui-modal) .data-table tbody tr:last-child td { + border-bottom: 0; +} + +:is(.app-main, .app-heroui-modal) .pt-list-table tbody tr:hover td, +:is(.app-main, .app-heroui-modal) .data-table tbody tr:hover td { + background: rgb(var(--nx-accent-rgb) / 0.09) !important; +} + +/* ------------------------------------------------------------------- 8. Tags */ +.conn-pill, +.ne-stats-pill, +.pt-list-status, +.pill { + border-radius: 999px; +} + +.chip { + font-weight: 600; + letter-spacing: 0; +} + +.ne-stats-pill { + font-weight: 600; +} + +/* ---------------------------------------------------------------- 9. Modals */ +.app-heroui-modal .modal__dialog, +.app-heroui-modal__dialog, +.topo-heroui-modal .modal__dialog, +.topo-heroui-modal__dialog { + background: var(--nx-surface-1); + border: 1px solid var(--nx-line-strong); + border-radius: 14px; + box-shadow: var(--nx-shadow-pop); +} + +.modal-backdrop, +.token-scopes-modal__backdrop, +.topo-modal__backdrop { + background: rgb(var(--nx-ink-deep-rgb) / 0.7) !important; + backdrop-filter: blur(3px); +} + +.modal, +.app-main .modal, +.token-scopes-modal__panel, +.topo-modal__panel { + background: var(--nx-surface-1) !important; + border-color: var(--nx-line-strong) !important; + border-radius: 14px !important; +} + +.toast, +.app-toast { + border-radius: 10px; + background: var(--nx-surface-2); + border-color: var(--nx-line-strong); +} + +/* ------------------------------------------------------------------ 10. Login */ +.login-page { + --login-accent: var(--nx-accent-strong); + --login-muted: var(--nx-text-muted); +} + +.login-card { + max-width: 372px; + gap: 18px; + padding: 32px 30px 30px; + border-radius: 20px; + border-color: rgb(255 255 255 / 0.12); + background: rgb(var(--nx-ink-rgb) / 0.78); + backdrop-filter: blur(14px) saturate(1.15); + -webkit-backdrop-filter: blur(14px) saturate(1.15); +} + +.login-card__head { + flex-direction: column-reverse; + align-items: flex-start; + gap: 4px; + margin-bottom: 4px; +} + +.login-card__brand { + font-size: 2rem; + font-weight: 800; + letter-spacing: 0.28em; + background: linear-gradient(90deg, #ffffff 0%, #8fc2ff 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + text-shadow: none; +} + +.login-card__title { + font-size: 14px; + font-weight: 500; + letter-spacing: 0.04em; + color: var(--nx-text-muted); +} + +/* The username TextField sits outside .login-card__label/__password, so it + rendered with HeroUI defaults and did not match the password field. */ +.login-card__form input { + width: 100%; + height: 46px; + padding: 0 14px; + border: 1px solid var(--login-field-border); + border-radius: 12px; + background: var(--login-field); + color: #fff; + font: inherit; + font-size: 15px; + box-sizing: border-box; + transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; +} + +.login-card__form input::placeholder { + color: rgb(255 255 255 / 0.45); +} + +.login-card__form input:focus { + outline: none; + border-color: rgb(var(--nx-accent-rgb) / 0.8); + box-shadow: 0 0 0 3px rgb(var(--nx-accent-rgb) / 0.25); + background: rgb(255 255 255 / 0.1); +} + +.login-card__form { + gap: 14px; +} + +.login-card__submit { + height: 46px; + border-radius: 12px; + letter-spacing: 0.08em; +} diff --git a/web/src/styles/theme/tokens.css b/web/src/styles/theme/tokens.css new file mode 100644 index 0000000..15a70bd --- /dev/null +++ b/web/src/styles/theme/tokens.css @@ -0,0 +1,109 @@ +/* + * NetX design tokens — the ONLY place raw palette values live. + * + * Direction: modern blue on the original NetX navy palette. Deep navy surfaces with soft gradients, 8/12px + * rounded shapes, the original blue accent used for primary actions and focus, + * gentle glows and lift-on-hover. Status colors stay semantic. + * + * Rules for new CSS: + * - Use var(--nx-*) — never raw hex / rgb() for surfaces, text, lines, accent. + * - Translucent surfaces: rgb(var(--nx-ink-rgb) / .6), not rgba(15,23,42,.6). + * - Status colors are semantic: --nx-ok / --nx-warn / --nx-bad. + */ +:root { + /* Surfaces — original navy, 4 elevation steps + inset field */ + --nx-bg: #070b14; + --nx-surface-1: #0c1521; + --nx-surface-2: #111c2a; + --nx-surface-3: #162232; + --nx-field: #0b1220; + + /* Channel triplets for translucent fills: rgb(var(--nx-ink-rgb) / .6) */ + --nx-ink-rgb: 15 23 42; /* = surface-1 */ + --nx-ink-deep-rgb: 2 8 23; /* darker than bg, for scrims / insets */ + --nx-surface-3-rgb: 30 41 59; + --nx-accent-rgb: 59 130 246; + + /* Lines */ + --nx-line: #2b3748; + --nx-line-strong: #3a4a60; + --nx-line-soft: rgb(148 163 184 / 0.14); + + /* Text — five tones, all >= 4.5:1 on surface-1 */ + --nx-text-strong: #f1f5f9; + --nx-text: #e2e8f0; + --nx-text-soft: #cbd5e1; + --nx-text-muted: #94a3b8; + --nx-text-faint: #74849b; + + /* Accent: blue. Primary actions, selection, focus, gradients. */ + --nx-accent: #6ba9ec; + --nx-accent-strong: #3b82f6; + --nx-accent-text: #93c5fd; + --nx-accent-soft: rgb(var(--nx-accent-rgb) / 0.14); + + /* Status — semantic only */ + --nx-ok: #4ade80; + --nx-warn: #fbbf24; + --nx-bad: #f87171; + --nx-bad-text: #fecaca; /* readable text on a red wash */ + --nx-ok-rgb: 74 222 128; + --nx-warn-rgb: 251 191 36; + --nx-bad-rgb: 248 113 113; + + /* Shape: soft rounded corners */ + --nx-radius-sm: 6px; + --nx-radius: 8px; + --nx-radius-lg: 12px; + + --nx-ring: 0 0 0 3px rgb(var(--nx-accent-rgb) / 0.35); + --nx-shadow-panel: 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 10px 28px rgb(0 0 0 / 0.28); + --nx-shadow-pop: 0 18px 48px rgb(0 0 0 / 0.55); + + --nx-font-sans: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + --nx-font-mono: ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace; + + --nx-gradient-accent: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); + --nx-header-h: 48px; /* pages size themselves with calc(100vh - 48px) */ +} + +/* Legacy --nm-* tokens (hundreds of rules still read them) → single source. */ +.app--shell { + --nm-accent: var(--nx-accent-strong); + --nm-header: var(--nx-bg); + --nm-chrome: var(--nx-bg); + --nm-main: var(--nx-bg); + --nm-nav: var(--nx-surface-1); + --nm-nav-border: var(--nx-line); + --nm-nav-hover: rgb(var(--nx-accent-rgb) / 0.08); + --nm-nav-active: rgb(var(--nx-accent-rgb) / 0.14); + --nm-nav-ink: var(--nx-text); + --nm-nav-muted: var(--nx-text-muted); + --nm-panel: var(--nx-surface-1); + --nm-panel-solid: var(--nx-surface-1); + --nm-panel-border: var(--nx-line); + --nm-brand: var(--nx-text-strong); + --nm-text: var(--nx-text); + --nm-muted: var(--nx-text-muted); + --nm-glass: rgb(var(--nx-ink-rgb) / 0.9); + --nm-glass-border: var(--nx-line); + --nm-ops-line: var(--nx-line); + --nm-ops-muted: var(--nx-text-muted); + --nm-ops-accent: var(--nx-accent); + --nm-ops-surface: var(--nx-surface-2); +} + +/* HeroUI v3 tokens (oklch in its defaults) aligned to the same palette. */ +.dark, +[data-theme="dark"] { + --background: var(--nx-bg); + --foreground: var(--nx-text); + --surface: var(--nx-surface-1); + --surface-secondary: var(--nx-surface-2); + --overlay: var(--nx-surface-1); + --accent: var(--nx-accent-strong); + --accent-foreground: #fff; + --muted: var(--nx-text-muted); + --border: var(--nx-line); + --field-background: var(--nx-field); +} diff --git a/web/src/types.ts b/web/src/types.ts deleted file mode 100644 index a97027f..0000000 --- a/web/src/types.ts +++ /dev/null @@ -1,1026 +0,0 @@ -export type DshAlarmHubConnection = { - id: string; - user: string; - remote?: string; - client?: string; - connected_at?: string; - last_seen_at?: string; -}; - -export type DshAlarmHubStatus = { - enabled: boolean; - path?: string; - subscribers: number; - published?: number; - deliver_ok?: number; - deliver_fail?: number; - connections?: DshAlarmHubConnection[]; -}; - -export type IntegrationStatus = { - netx_api: { status: "up" | "down" | "unknown"; [k: string]: unknown }; - db: { status: "up" | "down" | "unknown"; latency_ms?: number; error?: string; [k: string]: unknown }; - dsh_alarm_hub?: DshAlarmHubStatus; -}; - -export type UmeKeyAlertRuleItem = { - notification_id: string; - match_type: "notification_id" | "keyword"; - match_value: string; - enabled: boolean; - label: string; - ne_types?: string[]; - created_at: string; - updated_at: string; - forward_stats?: { - attempts: number; - published_ok: number; - last_forwarded_at: string; - }; -}; - -export type UmeInventoryNeTypeItem = { - ne_type: string; - ne_count: number; -}; - -export type UmeKeyAlertMonitorResponse = { - ok: boolean; - rules: UmeKeyAlertRuleItem[]; - total?: number; - page?: number; - page_size?: number; - config?: { - forward_on_clear: boolean; - }; - /** NetX hub ← netxops clients (multi-subscriber). */ - dsh_alarm_hub?: DshAlarmHubStatus; -}; - -export type UmeSyncJobItem = { - id: number; - domain: string; - status: string; - trigger_mode: string; - pulled_count: number; - inserted_count: number; - updated_count: number; - deleted?: number; - error_message?: string; - started_at: string; - ended_at?: string | null; -}; - -export type UmeWsLogEntry = { - ts: string; - level: string; - message: string; - subscription_id?: string; -}; - -export type UmeWsConnectionStatus = { - state: string; - label: string; - detail?: string; -}; - -export type UmeAlarmSubscriptionStatus = { - ok?: boolean; - created?: boolean; - already_exists?: boolean; - active: boolean; - subscription_id?: string; - wss_uri?: string; - topic?: string; - server_subscription_lost?: boolean; - server_subscription_lost_reason?: string; - current_alarms_mode?: "wss" | "rest"; - wss_active_for_current_alarms?: boolean; - scheduled_sync_skipped?: boolean; - needs_local_cleanup?: boolean; - ume_already_missing?: boolean; - cleared_local?: boolean; - message?: string; - ws_connection?: UmeWsConnectionStatus; - ws_consumer_status?: string; - ws_consumer_last_error?: string; - ws_consumer_last_run_at?: string | null; - ws_logs?: UmeWsLogEntry[]; -}; - -export type UmeTopologyFabricGap = { - dock_me_count?: number; - fabric_ume_count?: number; - world_exists?: boolean; - world_folder_id?: string; - latest_topology_status?: string; - latest_topology_error?: string; - partial_apply?: boolean; - needs_apply?: boolean; - error?: string; -}; - -export type UmeSyncStatusResponse = { - total?: number; - page?: number; - page_size?: number; - items: UmeSyncJobItem[]; - latest_by_domain?: Record; - alarm_subscription?: UmeAlarmSubscriptionStatus; - topology_fabric?: UmeTopologyFabricGap; - runtime_tasks?: Array<{ - task: string; - status: string; - paused?: boolean; - last_run_at?: string | null; - last_error?: string; - interval_s?: number | null; - interval_label?: string; - }>; -}; - -export type UmeNeItem = { - ne_id: string; - ne_name: string; - user_label: string; - ip_address: string; - ipv6_address?: string; - ne_type: string; - device_level?: string; - host_name?: string; - location?: string; - hardware_version?: string; - loopback?: string; - consistent_state?: string; - interface_version?: string; - mac?: string; - admin_status?: string; - address_type?: string; - connection_status?: string; - maintain_status?: string; - net_mask?: string; - create_time?: string; - creator?: string; - last_seen_at?: string; -}; - -export type UmeAlarmItem = { - alarm_key: string; - ne_id: string; - ne_name: string; - user_label: string; - host_name?: string; - ne_type?: string; - object_name: string; - event_type: string; - native_probable_cause: string; - perceived_severity: string; - is_cleared: string; - time_created: string; - last_seen_at?: string; -}; - -export type ConnectStatus = "unknown" | "testing" | "pass" | "fail"; - -export type ManagedNeItem = { - id: string; - name: string; - vendor: string; - device_type: string; - ip_address: string; - port: number; - protocol: string; - username: string; - /** True when a password is stored server-side (secret never returned). */ - has_password?: boolean; - connect_status: ConnectStatus; - connect_message: string; - connect_detail: string; - connect_tested_at: string | null; - tags: string; - remark: string; - source?: string; - source_ref?: string; - hop_enabled: boolean; - hop_vendor: string; - hop_host: string; - hop_port: number; - hop_protocol: string; - hop_username: string; - hop_command_template: string; - hop_vrf: string; - hop_target_auth_mode: string; - hop_enter_system_view?: boolean; - /** MCP/API CLI gate: readonly | linux_shell | unrestricted */ - exec_policy?: "readonly" | "linux_shell" | "unrestricted"; - created_at: string; - updated_at: string; -}; - -export type ManagedNeListResponse = { - total: number; - page: number; - page_size: number; - items: ManagedNeItem[]; -}; - -export type ManagedNeMeta = { - device_types: string[]; - vendors: string[]; - /** When false (default), exec_policy UI/API open policies are disabled. */ - exec_policy_enabled?: boolean; -}; - -export type ManagedNeImportResult = { - inserted: number; - updated: number; - failed: Array<{ row: number; reason: string }>; -}; - -export type CliConnectProfileItem = { - id: string; - name: string; - is_default: boolean; - username: string; - port: number; - protocol: string; - device_type_default: string; - vendor_default: string; - ne_type_rules: string; - hop_enabled: boolean; - hop_vendor: string; - hop_host: string; - hop_port: number; - hop_protocol: string; - hop_username: string; - hop_command_template: string; - hop_vrf: string; - hop_target_auth_mode: string; - hop_enter_system_view?: boolean; - created_at: string; - updated_at: string; -}; - -export type CliMeta = { - credentials_configured: boolean; - default_profile_configured: boolean; - cli_profile_ready: boolean; -}; - -export type CliTargetItem = { - source: string; - id: string; - ume_ne_id?: string | null; - name: string; - ip_address: string; - ne_type?: string; - vendor?: string; - device_type?: string; - protocol?: string; - port?: number; - username?: string; - has_password?: boolean; - hop_enabled?: boolean; - connect_status: string; - cli_profile_ready?: boolean; - /** ManagedNE.source (lldp / webcrt / ume_sync / …). */ - ne_source?: string; -}; - -export type CliTargetListResponse = { - total: number; - page: number; - page_size: number; - items: CliTargetItem[]; -}; - -export type UmeCliOverrideItem = { - ume_ne_id: string; - profile_id: string | null; - username_override: string; - device_type_override: string; - vendor_override: string; - connect_status: string; - connect_message: string; - connect_detail: string; - connect_tested_at: string | null; - updated_at: string; -}; - -export type EligibleNeItem = { - id: string; - source?: "managed" | "ume" | string; - name: string; - vendor: string; - device_type: string; - ip_address: string; - connect_status: string; - connect_tested_at: string | null; -}; - -export type CollectionJobItem = { - id: string; - title: string; - commands: string; - trigger_mode?: string; - status: string; - ne_count: number; - success_count: number; - fail_count: number; - output_count: number; - error_message: string; - created_at: string; - started_at: string | null; - ended_at: string | null; - last_run_at: string | null; -}; - -export type CollectionJobSummary = { - id: string; - title: string; - status: string; - trigger_mode?: string; - ne_count: number; - success_count: number; - fail_count: number; - created_at?: string | null; - started_at?: string | null; - ended_at?: string | null; - last_run_at?: string | null; -}; - -export type CollectionTargetRef = { - source: "managed" | "ume" | string; - id: string; -}; - -export type CollectionPolicy = { - enabled: boolean; - interval_days: number; - interval_hours: number; - scope_mode: "all" | "selected" | string; - selected_targets: CollectionTargetRef[]; - title: string; - commands: string; - history_keep: number; - updated_at?: string | null; -}; - -export type CollectionDashboard = { - job_count: number; - active_count: number; - running_job: CollectionJobSummary | null; - last_job: CollectionJobSummary | null; - next_due_at?: string | null; - policy?: CollectionPolicy | null; -}; - -export type CollectionRunItem = { - id: string; - job_id: string; - ne_id: string; - ne_source?: string; - ne_name: string; - ne_ip: string; - status: string; - message: string; - output_rel_path: string; - has_output: boolean; - started_at: string | null; - ended_at: string | null; -}; - -export type CollectionJobDetail = { - job: CollectionJobItem; -}; - -export type CollectionRunList = { - total: number; - page: number; - page_size: number; - items: CollectionRunItem[]; -}; - -export type UmeTokenStatus = { - ok?: boolean; - has_token: boolean; - expires_in_s: number; - expires_at_epoch_s: number; - auth_header: string; - token_preview?: string; - changed?: boolean; - error_kind?: string; - error?: string; -}; - -export type TopologyViewRole = "core" | "aggregation" | "access"; -export type TopologyViewKind = "physical" | "custom"; - -export type TopologyViewItem = { - id: string; - name: string; - remark: string; - folder_id?: string; - kind?: TopologyViewKind | string; - role?: TopologyViewRole | string; - sort_order?: number; - filter?: Record; - viewport?: Record; - node_count: number; - edge_count?: number; - created_at?: string | null; - updated_at?: string | null; -}; - -export type TopologyTreeViewItem = { - id: string; - name: string; - kind: TopologyViewKind | string; - role?: TopologyViewRole | string; - sort_order: number; - node_count: number; - updated_at?: string | null; -}; - -export type TopologyTreeFolderItem = { - id: string; - parent_id: string; - kind: "root" | "region" | string; - name: string; - sort_order: number; - is_system: boolean; - external_ref?: string; - /** Distinct fabric NE count in this folder subtree (API). Region icons excluded. */ - ne_count?: number; - views: TopologyTreeViewItem[]; - children: TopologyTreeFolderItem[]; -}; - -export type TopologyTree = { - root: TopologyTreeFolderItem | null; -}; - -export type ClassifyRuleScope = "role" | "region"; - -export type ClassifyRule = { - id: string; - scope: ClassifyRuleScope | string; - name: string; - pattern: string; - match_field: string; - priority: number; - enabled: boolean; - payload: Record; - remark?: string; - created_at?: string | null; - updated_at?: string | null; -}; - -export type ClassifyPreview = { - total_nodes: number; - role_matched: number; - role_unmatched: number; - role_conflicts: number; - region_matched: number; - region_unmatched: number; - region_conflicts: number; - role_samples: Array>; - region_samples: Array>; - unmatched_samples: Array>; -}; - -export type ClassifyApplyResult = { - role_updated: number; - region_updated: number; - skipped_manual: number; - total_nodes: number; -}; - -export type FabricNodeSearchHit = { - id: string; - name: string; - ip: string; - vendor: string; - managed_ne_id?: string; - ume_ne_id?: string; - /** Layout rank major.minor; null/undefined = unclassified */ - level?: number | null; - role?: string; - region_folder_id?: string | null; - world_x?: number | null; - world_y?: number | null; - link_status?: "managed" | "ume" | "both" | "orphaned" | string; - managed_alive?: boolean; - ume_alive?: boolean; - managed_source?: string; - deletable?: boolean; - views?: Array<{ - view_id: string; - view_name: string; - folder_id: string; - folder_name: string; - kind: string; - }>; -}; - -export type TopologyViewNodeItem = { - fabric_node_id: string; - managed_ne_id: string; - ume_ne_id: string; - label: string; - x: number; - y: number; - locked: boolean; - name: string; - ip: string; - vendor: string; - device_type: string; - connect_status: string; - managed_source?: string; - /** Fabric classify rank; smaller = closer to external/WAN. */ - level?: number | null; - /** Synced alias from floor(level): external|core|aggregation|access|edge|"" */ - role?: string; - /** ne | region — region drills into a child canvas */ - kind?: string; - folder_id?: string; - view_id?: string; - node_count?: number; -}; - -export type TopologyViewEdgeItem = { - id: string; - a_node_id: string; - b_node_id: string; - a_port: string; - b_port: string; - source: string; - status: string; - layer: string; - stroke_color?: string; - stroke_width?: number; - line_style?: string; - discovered_at?: string | null; - /** UME userLabel when no EQ+PTP ports. */ - display_label?: string; -}; - -export type TopologyOutsidePeer = { - fabric_node_id: string; - name: string; - ip: string; - via_node_id: string; -}; - -export type TopologyWorldTransform = { - origin_x: number; - origin_y: number; - scale: number; - full_min_x: number; - full_max_x: number; - full_min_y: number; - full_max_y: number; - total: number; - lod: string; - dock_me_count?: number; -}; - -export type TopologyWorldScatterPoint = { - x: number; - y: number; -}; - -export type TopologyViewGraph = { - view: TopologyViewItem; - nodes: TopologyViewNodeItem[]; - edges: TopologyViewEdgeItem[]; - truncated?: boolean; - truncate_reason?: string; - outside_peers?: TopologyOutsidePeer[]; - world_transform?: TopologyWorldTransform | null; - scatter?: TopologyWorldScatterPoint[]; -}; - -export type FabricSummary = { - node_count: number; - edge_count: number; - edge_active: number; - edge_stale: number; - edge_missing?: number; - last_discover_at?: string | null; - updated_at?: string | null; -}; - -export type FabricEdge = { - id: string; - layer: string; - a_node_id: string; - b_node_id: string; - a_port: string; - b_port: string; - a_name?: string; - b_name?: string; - a_ip?: string; - b_ip?: string; - source: string; - status: string; - attrs?: Record; - discovered_at?: string | null; - last_seen_at?: string | null; - updated_at?: string | null; -}; - -export type TopologyDiscoverUnmatched = { - remote_name: string; - remote_ip: string; - local_port: string; - remote_port: string; -}; - -export type TopologyDiscoverJobItem = { - id: string; - job_id: string; - ne_id: string; - ume_ne_id: string; - fabric_node_id: string; - ne_name: string; - ne_ip: string; - ok: boolean; - command: string; - neighbors: number; - edges_added: number; - edges_updated: number; - unmatched_count: number; - unmatched?: TopologyDiscoverUnmatched[]; - parser_key?: string; - parser_stub?: boolean; - error: string; - raw_preview: string; -}; - -export type TopologyDiscoverJob = { - id: string; - scope: string; - trigger_mode?: string; - status: string; - total: number; - done: number; - edges_added: number; - edges_updated: number; - edges_stale: number; - edges_missing?: number; - error: string; - started_at?: string | null; - ended_at?: string | null; - created_at?: string | null; - items: TopologyDiscoverJobItem[]; - items_total?: number; - items_page?: number; - items_page_size?: number; -}; - -/** @deprecated alias — prefer TopologyViewItem */ -export type TopologyMapItem = TopologyViewItem; - -/** UI-compat aliases for restored TopologyPage. */ -export type TopologyNodeItem = TopologyViewNodeItem & { id?: string; map_id?: string; ne_name?: string; ne_ip?: string; protocol?: string }; -export type TopologyEdgeItem = TopologyViewEdgeItem & { - source_node_id?: string; - target_node_id?: string; - map_id?: string; -}; -export type TopologyDiscoverNeResult = TopologyDiscoverJobItem & { - links?: Array>; -}; -export type TopologyDiscoverOut = { - map_id?: string; - protocol?: string; - job_id?: string; - scanned: number; - edges_added: number; - edges_updated: number; - edges_stale?: number; - results: TopologyDiscoverNeResult[]; - graph: TopologyViewGraph | null; -}; - -export type ConfigSyncTargetRef = { - source: "managed" | "ume"; - id: string; -}; - -export type LldpCollectPolicy = { - enabled: boolean; - interval_days: number; - interval_hours: number; - concurrency: number; - scope_mode: string; - selected_targets: ConfigSyncTargetRef[]; - auto_add_unmatched: boolean; - history_keep: number; - updated_at?: string | null; -}; - -export type LldpCollectJobSummary = { - id: string; - scope: string; - trigger_mode: string; - status: string; - total: number; - done: number; - success_count?: number; - fail_count?: number; - edges_added: number; - edges_updated: number; - edges_stale: number; - edges_missing?: number; - error: string; - started_at?: string | null; - ended_at?: string | null; - created_at?: string | null; -}; - -export type LldpCollectDashboard = { - policy: LldpCollectPolicy; - fabric_node_count: number; - fabric_edge_count: number; - fabric_edge_active: number; - fabric_edge_stale: number; - fabric_edge_missing?: number; - last_discover_at?: string | null; - running_job: LldpCollectJobSummary | null; - last_job: LldpCollectJobSummary | null; - next_due_at?: string | null; -}; - -export type ConfigSyncPolicy = { - enabled: boolean; - interval_days: number; - concurrency: number; - scope_mode: string; - selected_targets: ConfigSyncTargetRef[]; - history_keep: number; - cycle_keep: number; - updated_at?: string | null; -}; - -export type ConfigSyncCycle = { - id: string; - trigger_mode: string; - status: string; - concurrency: number; - planned_count: number; - success_count: number; - fail_count: number; - skip_count: number; - error_message: string; - started_at?: string | null; - ended_at?: string | null; - created_at?: string | null; -}; - -export type ConfigSyncTask = { - id: string; - cycle_id: string; - source: string; - target_id: string; - ne_name: string; - ne_ip: string; - vendor: string; - status: string; - message: string; - started_at?: string | null; - ended_at?: string | null; -}; - -export type ConfigSyncDashboard = { - policy: ConfigSyncPolicy; - snapshot_count: number; - last_cycle: ConfigSyncCycle | null; - running_cycle: ConfigSyncCycle | null; - next_due_at?: string | null; - fail_by_vendor: Record; -}; - -export type NeConfigSnapshotMeta = { - source: string; - target_id: string; - vendor: string; - device_type: string; - ne_name: string; - ne_ip: string; - config_sha256: string; - config_alt_sha256: string; - plain_size: number; - plain_alt_size: number; - zlib_size: number; - zlib_alt_size: number; - has_alt: boolean; - commands: string[]; - collected_at?: string | null; - last_cycle_id: string; -}; - -export type NeConfigSnapshotDetail = NeConfigSnapshotMeta & { - config_text: string; - config_alt_text: string; -}; - -export type PortTrafficDevice = { - id: string; - source: string; - ne_id: string; - ne_name: string; - ne_ip: string; - vendor: string; - note: string; - status: string; - interval_sec: number; - retention_days: number; - concurrency: number; - collect_running: boolean; - target_count: number; - active_target_count: number; - last_collect_started_at?: string | null; - last_collect_ended_at?: string | null; - last_error: string; - created_at?: string | null; - updated_at?: string | null; -}; - -/** @deprecated use PortTrafficDevice */ -export type PortTrafficTask = PortTrafficDevice; - -export type PortTrafficTarget = { - id: string; - device_id: string; - task_id?: string; - series_id?: string; - source: string; - target_id: string; - ne_name: string; - ne_ip: string; - vendor: string; - ifname: string; - if_description: string; - bw_bps: number; - status: string; - last_error: string; - last_sample_at?: string | null; - created_at?: string | null; -}; - -export type PortTrafficEvent = { - id: string; - device_id: string; - target_row_id: string; - ifname: string; - level: string; - message: string; - created_at?: string | null; -}; - -export type PortTrafficBoardPanel = { - id: string; - board_id: string; - title: string; - target_id: string; - range_hours: number; - baseline: "off" | "day" | "week" | "shift" | "custom" | string; - offset_hours: number; - ahead_hours: number; - baseline_target_id: string; - y_mode: "auto" | "current" | "util" | string; - ord: number; - col_span: number; - row_span: number; - stale: boolean; - target: PortTrafficTarget | null; - baseline_target: PortTrafficTarget | null; - created_at?: string | null; - updated_at?: string | null; -}; - -export type PortTrafficBoardPanelIn = { - id?: string; - title?: string; - target_id: string; - range_hours?: number; - baseline?: string; - offset_hours?: number; - ahead_hours?: number; - baseline_target_id?: string; - y_mode?: string; - ord?: number; - col_span?: number; - row_span?: number; -}; - -export type PortTrafficBoardSummary = { - id: string; - name: string; - remark: string; - cols: number; - panel_count: number; - created_by: string; - updated_by: string; - created_at?: string | null; - updated_at?: string | null; -}; - -export type PortTrafficBoard = PortTrafficBoardSummary & { - panels: PortTrafficBoardPanel[]; -}; - -export type PortTrafficSeries = { - id: string; - device_id: string; - task_id?: string; - title: string; - status: string; - active_target: PortTrafficTarget | null; - retired_target_count: number; - created_at?: string | null; -}; - -export type PortTrafficIfaceIn = { - ifname: string; - if_description?: string; - bw_bps?: number; -}; - -export type PortTrafficTargetIn = PortTrafficIfaceIn & { - source?: "managed" | "ume"; - target_id?: string; - ne_name?: string; - ne_ip?: string; - vendor?: string; -}; - -export type PortTrafficDiscoverPort = { - ifname: string; - attribute: string; - mode: string; - bw_raw: string; - bw_bps: number; - admin: string; - phy: string; - prot: string; - description: string; -}; - -export type PortTrafficDiscoverResponse = { - source: string; - id: string; - ne_name: string; - ne_ip: string; - vendor: string; - vendor_key: string; - ports: PortTrafficDiscoverPort[]; - ok?: boolean; - command?: string; - raw_preview?: string; - error?: string; -}; - -export type PortTrafficSamplePoint = { - ts: string; - in_bps: number; - out_bps: number; - in_util_pct: number; - out_util_pct: number; - bw_bps: number; - rate_period_sec: number; - ts_raw?: string | null; -}; - -export type PortTrafficSamples = { - target: PortTrafficTarget; - points: PortTrafficSamplePoint[]; -}; - -export type PortTrafficCompare = { - meta: { - target_id: string; - baseline: string; - offset_hours: number; - range_hours: number; - ahead_hours?: number; - current_target: PortTrafficTarget | null; - baseline_target: PortTrafficTarget | null; - baseline_target_id: string; - }; - current: PortTrafficSamplePoint[]; - baseline: PortTrafficSamplePoint[]; -}; - -export type PortTrafficDashboard = { - device_count: number; - running_device_count: number; - active_target_count: number; - sample_count_24h: number; - last_sample_at?: string | null; - task_count?: number; - running_task_count?: number; -}; - diff --git a/web/src/types/cli.ts b/web/src/types/cli.ts new file mode 100644 index 0000000..d8eda4f --- /dev/null +++ b/web/src/types/cli.ts @@ -0,0 +1,69 @@ +export type CliConnectProfileItem = { + id: string; + name: string; + is_default: boolean; + username: string; + port: number; + protocol: string; + device_type_default: string; + vendor_default: string; + ne_type_rules: string; + hop_enabled: boolean; + hop_vendor: string; + hop_host: string; + hop_port: number; + hop_protocol: string; + hop_username: string; + hop_command_template: string; + hop_vrf: string; + hop_target_auth_mode: string; + hop_enter_system_view?: boolean; + created_at: string; + updated_at: string; +}; + +export type CliMeta = { + credentials_configured: boolean; + default_profile_configured: boolean; + cli_profile_ready: boolean; +}; + +export type CliTargetItem = { + source: string; + id: string; + ume_ne_id?: string | null; + name: string; + ip_address: string; + ne_type?: string; + vendor?: string; + device_type?: string; + protocol?: string; + port?: number; + username?: string; + has_password?: boolean; + hop_enabled?: boolean; + connect_status: string; + cli_profile_ready?: boolean; + /** ManagedNE.source (lldp / webcrt / ume_sync / …). */ + ne_source?: string; +}; + +export type CliTargetListResponse = { + total: number; + page: number; + page_size: number; + items: CliTargetItem[]; +}; + +export type UmeCliOverrideItem = { + ume_ne_id: string; + profile_id: string | null; + username_override: string; + device_type_override: string; + vendor_override: string; + connect_status: string; + connect_message: string; + connect_detail: string; + connect_tested_at: string | null; + updated_at: string; +}; diff --git a/web/src/types/collection.ts b/web/src/types/collection.ts new file mode 100644 index 0000000..33c5d81 --- /dev/null +++ b/web/src/types/collection.ts @@ -0,0 +1,93 @@ +export type EligibleNeItem = { + id: string; + source?: "managed" | "ume" | string; + name: string; + vendor: string; + device_type: string; + ip_address: string; + connect_status: string; + connect_tested_at: string | null; +}; + +export type CollectionJobItem = { + id: string; + title: string; + commands: string; + trigger_mode?: string; + status: string; + ne_count: number; + success_count: number; + fail_count: number; + output_count: number; + error_message: string; + created_at: string; + started_at: string | null; + ended_at: string | null; + last_run_at: string | null; +}; + +export type CollectionJobSummary = { + id: string; + title: string; + status: string; + trigger_mode?: string; + ne_count: number; + success_count: number; + fail_count: number; + created_at?: string | null; + started_at?: string | null; + ended_at?: string | null; + last_run_at?: string | null; +}; + +export type CollectionTargetRef = { + source: "managed" | "ume" | string; + id: string; +}; + +export type CollectionPolicy = { + enabled: boolean; + interval_days: number; + interval_hours: number; + scope_mode: "all" | "selected" | string; + selected_targets: CollectionTargetRef[]; + title: string; + commands: string; + history_keep: number; + updated_at?: string | null; +}; + +export type CollectionDashboard = { + job_count: number; + active_count: number; + running_job: CollectionJobSummary | null; + last_job: CollectionJobSummary | null; + next_due_at?: string | null; + policy?: CollectionPolicy | null; +}; + +export type CollectionRunItem = { + id: string; + job_id: string; + ne_id: string; + ne_source?: string; + ne_name: string; + ne_ip: string; + status: string; + message: string; + output_rel_path: string; + has_output: boolean; + started_at: string | null; + ended_at: string | null; +}; + +export type CollectionJobDetail = { + job: CollectionJobItem; +}; + +export type CollectionRunList = { + total: number; + page: number; + page_size: number; + items: CollectionRunItem[]; +}; diff --git a/web/src/types/configSync.ts b/web/src/types/configSync.ts new file mode 100644 index 0000000..69e75b0 --- /dev/null +++ b/web/src/types/configSync.ts @@ -0,0 +1,77 @@ +export type ConfigSyncTargetRef = { + source: "managed" | "ume"; + id: string; +}; + +export type ConfigSyncPolicy = { + enabled: boolean; + interval_days: number; + concurrency: number; + scope_mode: string; + selected_targets: ConfigSyncTargetRef[]; + history_keep: number; + cycle_keep: number; + updated_at?: string | null; +}; + +export type ConfigSyncCycle = { + id: string; + trigger_mode: string; + status: string; + concurrency: number; + planned_count: number; + success_count: number; + fail_count: number; + skip_count: number; + error_message: string; + started_at?: string | null; + ended_at?: string | null; + created_at?: string | null; +}; + +export type ConfigSyncTask = { + id: string; + cycle_id: string; + source: string; + target_id: string; + ne_name: string; + ne_ip: string; + vendor: string; + status: string; + message: string; + started_at?: string | null; + ended_at?: string | null; +}; + +export type ConfigSyncDashboard = { + policy: ConfigSyncPolicy; + snapshot_count: number; + last_cycle: ConfigSyncCycle | null; + running_cycle: ConfigSyncCycle | null; + next_due_at?: string | null; + fail_by_vendor: Record; +}; + +export type NeConfigSnapshotMeta = { + source: string; + target_id: string; + vendor: string; + device_type: string; + ne_name: string; + ne_ip: string; + config_sha256: string; + config_alt_sha256: string; + plain_size: number; + plain_alt_size: number; + zlib_size: number; + zlib_alt_size: number; + has_alt: boolean; + commands: string[]; + collected_at?: string | null; + last_cycle_id: string; +}; + +export type NeConfigSnapshotDetail = NeConfigSnapshotMeta & { + config_text: string; + config_alt_text: string; +}; diff --git a/web/src/types/index.ts b/web/src/types/index.ts new file mode 100644 index 0000000..b173351 --- /dev/null +++ b/web/src/types/index.ts @@ -0,0 +1,9 @@ +// Barrel: keep `import type { … } from "…/types"` working. Add new types to the matching domain file. +export * from "./system"; +export * from "./ume"; +export * from "./managedNe"; +export * from "./cli"; +export * from "./collection"; +export * from "./topology"; +export * from "./configSync"; +export * from "./portTraffic"; diff --git a/web/src/types/managedNe.ts b/web/src/types/managedNe.ts new file mode 100644 index 0000000..6d6d556 --- /dev/null +++ b/web/src/types/managedNe.ts @@ -0,0 +1,56 @@ +export type ConnectStatus = "unknown" | "testing" | "pass" | "fail"; + +export type ManagedNeItem = { + id: string; + name: string; + vendor: string; + device_type: string; + ip_address: string; + port: number; + protocol: string; + username: string; + /** True when a password is stored server-side (secret never returned). */ + has_password?: boolean; + connect_status: ConnectStatus; + connect_message: string; + connect_detail: string; + connect_tested_at: string | null; + tags: string; + remark: string; + source?: string; + source_ref?: string; + hop_enabled: boolean; + hop_vendor: string; + hop_host: string; + hop_port: number; + hop_protocol: string; + hop_username: string; + hop_command_template: string; + hop_vrf: string; + hop_target_auth_mode: string; + hop_enter_system_view?: boolean; + /** MCP/API CLI gate: readonly | linux_shell | unrestricted */ + exec_policy?: "readonly" | "linux_shell" | "unrestricted"; + created_at: string; + updated_at: string; +}; + +export type ManagedNeListResponse = { + total: number; + page: number; + page_size: number; + items: ManagedNeItem[]; +}; + +export type ManagedNeMeta = { + device_types: string[]; + vendors: string[]; + /** When false (default), exec_policy UI/API open policies are disabled. */ + exec_policy_enabled?: boolean; +}; + +export type ManagedNeImportResult = { + inserted: number; + updated: number; + failed: Array<{ row: number; reason: string }>; +}; diff --git a/web/src/types/portTraffic.ts b/web/src/types/portTraffic.ts new file mode 100644 index 0000000..7635e79 --- /dev/null +++ b/web/src/types/portTraffic.ts @@ -0,0 +1,197 @@ +export type PortTrafficDevice = { + id: string; + source: string; + ne_id: string; + ne_name: string; + ne_ip: string; + vendor: string; + note: string; + status: string; + interval_sec: number; + retention_days: number; + concurrency: number; + collect_running: boolean; + target_count: number; + active_target_count: number; + last_collect_started_at?: string | null; + last_collect_ended_at?: string | null; + last_error: string; + created_at?: string | null; + updated_at?: string | null; +}; + +/** @deprecated use PortTrafficDevice */ +export type PortTrafficTask = PortTrafficDevice; + +export type PortTrafficTarget = { + id: string; + device_id: string; + task_id?: string; + series_id?: string; + source: string; + target_id: string; + ne_name: string; + ne_ip: string; + vendor: string; + ifname: string; + if_description: string; + bw_bps: number; + status: string; + last_error: string; + last_sample_at?: string | null; + created_at?: string | null; +}; + +export type PortTrafficEvent = { + id: string; + device_id: string; + target_row_id: string; + ifname: string; + level: string; + message: string; + created_at?: string | null; +}; + +export type PortTrafficBoardPanel = { + id: string; + board_id: string; + title: string; + target_id: string; + range_hours: number; + baseline: "off" | "day" | "week" | "shift" | "custom" | string; + offset_hours: number; + ahead_hours: number; + baseline_target_id: string; + y_mode: "auto" | "current" | "util" | string; + ord: number; + col_span: number; + row_span: number; + stale: boolean; + target: PortTrafficTarget | null; + baseline_target: PortTrafficTarget | null; + created_at?: string | null; + updated_at?: string | null; +}; + +export type PortTrafficBoardPanelIn = { + id?: string; + title?: string; + target_id: string; + range_hours?: number; + baseline?: string; + offset_hours?: number; + ahead_hours?: number; + baseline_target_id?: string; + y_mode?: string; + ord?: number; + col_span?: number; + row_span?: number; +}; + +export type PortTrafficBoardSummary = { + id: string; + name: string; + remark: string; + cols: number; + panel_count: number; + created_by: string; + updated_by: string; + created_at?: string | null; + updated_at?: string | null; +}; + +export type PortTrafficBoard = PortTrafficBoardSummary & { + panels: PortTrafficBoardPanel[]; +}; + +export type PortTrafficSeries = { + id: string; + device_id: string; + task_id?: string; + title: string; + status: string; + active_target: PortTrafficTarget | null; + retired_target_count: number; + created_at?: string | null; +}; + +export type PortTrafficIfaceIn = { + ifname: string; + if_description?: string; + bw_bps?: number; +}; + +export type PortTrafficTargetIn = PortTrafficIfaceIn & { + source?: "managed" | "ume"; + target_id?: string; + ne_name?: string; + ne_ip?: string; + vendor?: string; +}; + +export type PortTrafficDiscoverPort = { + ifname: string; + attribute: string; + mode: string; + bw_raw: string; + bw_bps: number; + admin: string; + phy: string; + prot: string; + description: string; +}; + +export type PortTrafficDiscoverResponse = { + source: string; + id: string; + ne_name: string; + ne_ip: string; + vendor: string; + vendor_key: string; + ports: PortTrafficDiscoverPort[]; + ok?: boolean; + command?: string; + raw_preview?: string; + error?: string; +}; + +export type PortTrafficSamplePoint = { + ts: string; + in_bps: number; + out_bps: number; + in_util_pct: number; + out_util_pct: number; + bw_bps: number; + rate_period_sec: number; + ts_raw?: string | null; +}; + +export type PortTrafficSamples = { + target: PortTrafficTarget; + points: PortTrafficSamplePoint[]; +}; + +export type PortTrafficCompare = { + meta: { + target_id: string; + baseline: string; + offset_hours: number; + range_hours: number; + ahead_hours?: number; + current_target: PortTrafficTarget | null; + baseline_target: PortTrafficTarget | null; + baseline_target_id: string; + }; + current: PortTrafficSamplePoint[]; + baseline: PortTrafficSamplePoint[]; +}; + +export type PortTrafficDashboard = { + device_count: number; + running_device_count: number; + active_target_count: number; + sample_count_24h: number; + last_sample_at?: string | null; + task_count?: number; + running_task_count?: number; +}; diff --git a/web/src/types/system.ts b/web/src/types/system.ts new file mode 100644 index 0000000..dc7dcda --- /dev/null +++ b/web/src/types/system.ts @@ -0,0 +1,24 @@ +export type DshAlarmHubConnection = { + id: string; + user: string; + remote?: string; + client?: string; + connected_at?: string; + last_seen_at?: string; +}; + +export type DshAlarmHubStatus = { + enabled: boolean; + path?: string; + subscribers: number; + published?: number; + deliver_ok?: number; + deliver_fail?: number; + connections?: DshAlarmHubConnection[]; +}; + +export type IntegrationStatus = { + netx_api: { status: "up" | "down" | "unknown"; [k: string]: unknown }; + db: { status: "up" | "down" | "unknown"; latency_ms?: number; error?: string; [k: string]: unknown }; + dsh_alarm_hub?: DshAlarmHubStatus; +}; diff --git a/web/src/types/topology.ts b/web/src/types/topology.ts new file mode 100644 index 0000000..ca76e76 --- /dev/null +++ b/web/src/types/topology.ts @@ -0,0 +1,336 @@ +import type { ConfigSyncTargetRef } from "./configSync"; + +export type TopologyViewRole = "core" | "aggregation" | "access"; +export type TopologyViewKind = "physical" | "custom"; + +export type TopologyViewItem = { + id: string; + name: string; + remark: string; + folder_id?: string; + kind?: TopologyViewKind | string; + role?: TopologyViewRole | string; + sort_order?: number; + filter?: Record; + viewport?: Record; + node_count: number; + edge_count?: number; + created_at?: string | null; + updated_at?: string | null; +}; + +export type TopologyTreeViewItem = { + id: string; + name: string; + kind: TopologyViewKind | string; + role?: TopologyViewRole | string; + sort_order: number; + node_count: number; + updated_at?: string | null; +}; + +export type TopologyTreeFolderItem = { + id: string; + parent_id: string; + kind: "root" | "region" | string; + name: string; + sort_order: number; + is_system: boolean; + external_ref?: string; + /** Distinct fabric NE count in this folder subtree (API). Region icons excluded. */ + ne_count?: number; + views: TopologyTreeViewItem[]; + children: TopologyTreeFolderItem[]; +}; + +export type TopologyTree = { + root: TopologyTreeFolderItem | null; +}; + +export type ClassifyRuleScope = "role" | "region"; + +export type ClassifyRule = { + id: string; + scope: ClassifyRuleScope | string; + name: string; + pattern: string; + match_field: string; + priority: number; + enabled: boolean; + payload: Record; + remark?: string; + created_at?: string | null; + updated_at?: string | null; +}; + +export type ClassifyPreview = { + total_nodes: number; + role_matched: number; + role_unmatched: number; + role_conflicts: number; + region_matched: number; + region_unmatched: number; + region_conflicts: number; + role_samples: Array>; + region_samples: Array>; + unmatched_samples: Array>; +}; + +export type ClassifyApplyResult = { + role_updated: number; + region_updated: number; + skipped_manual: number; + total_nodes: number; +}; + +export type FabricNodeSearchHit = { + id: string; + name: string; + ip: string; + vendor: string; + managed_ne_id?: string; + ume_ne_id?: string; + /** Layout rank major.minor; null/undefined = unclassified */ + level?: number | null; + role?: string; + region_folder_id?: string | null; + world_x?: number | null; + world_y?: number | null; + link_status?: "managed" | "ume" | "both" | "orphaned" | string; + managed_alive?: boolean; + ume_alive?: boolean; + managed_source?: string; + deletable?: boolean; + views?: Array<{ + view_id: string; + view_name: string; + folder_id: string; + folder_name: string; + kind: string; + }>; +}; + +export type TopologyViewNodeItem = { + fabric_node_id: string; + managed_ne_id: string; + ume_ne_id: string; + label: string; + x: number; + y: number; + locked: boolean; + name: string; + ip: string; + vendor: string; + device_type: string; + connect_status: string; + managed_source?: string; + /** Fabric classify rank; smaller = closer to external/WAN. */ + level?: number | null; + /** Synced alias from floor(level): external|core|aggregation|access|edge|"" */ + role?: string; + /** ne | region — region drills into a child canvas */ + kind?: string; + folder_id?: string; + view_id?: string; + node_count?: number; +}; + +export type TopologyViewEdgeItem = { + id: string; + a_node_id: string; + b_node_id: string; + a_port: string; + b_port: string; + source: string; + status: string; + layer: string; + stroke_color?: string; + stroke_width?: number; + line_style?: string; + discovered_at?: string | null; + /** UME userLabel when no EQ+PTP ports. */ + display_label?: string; +}; + +export type TopologyOutsidePeer = { + fabric_node_id: string; + name: string; + ip: string; + via_node_id: string; +}; + +export type TopologyWorldTransform = { + origin_x: number; + origin_y: number; + scale: number; + full_min_x: number; + full_max_x: number; + full_min_y: number; + full_max_y: number; + total: number; + lod: string; + dock_me_count?: number; +}; + +export type TopologyWorldScatterPoint = { + x: number; + y: number; +}; + +export type TopologyViewGraph = { + view: TopologyViewItem; + nodes: TopologyViewNodeItem[]; + edges: TopologyViewEdgeItem[]; + truncated?: boolean; + truncate_reason?: string; + outside_peers?: TopologyOutsidePeer[]; + world_transform?: TopologyWorldTransform | null; + scatter?: TopologyWorldScatterPoint[]; +}; + +export type FabricSummary = { + node_count: number; + edge_count: number; + edge_active: number; + edge_stale: number; + edge_missing?: number; + last_discover_at?: string | null; + updated_at?: string | null; +}; + +export type FabricEdge = { + id: string; + layer: string; + a_node_id: string; + b_node_id: string; + a_port: string; + b_port: string; + a_name?: string; + b_name?: string; + a_ip?: string; + b_ip?: string; + source: string; + status: string; + attrs?: Record; + discovered_at?: string | null; + last_seen_at?: string | null; + updated_at?: string | null; +}; + +export type TopologyDiscoverUnmatched = { + remote_name: string; + remote_ip: string; + local_port: string; + remote_port: string; +}; + +export type TopologyDiscoverJobItem = { + id: string; + job_id: string; + ne_id: string; + ume_ne_id: string; + fabric_node_id: string; + ne_name: string; + ne_ip: string; + ok: boolean; + command: string; + neighbors: number; + edges_added: number; + edges_updated: number; + unmatched_count: number; + unmatched?: TopologyDiscoverUnmatched[]; + parser_key?: string; + parser_stub?: boolean; + error: string; + raw_preview: string; +}; + +export type TopologyDiscoverJob = { + id: string; + scope: string; + trigger_mode?: string; + status: string; + total: number; + done: number; + edges_added: number; + edges_updated: number; + edges_stale: number; + edges_missing?: number; + error: string; + started_at?: string | null; + ended_at?: string | null; + created_at?: string | null; + items: TopologyDiscoverJobItem[]; + items_total?: number; + items_page?: number; + items_page_size?: number; +}; + +/** @deprecated alias — prefer TopologyViewItem */ +export type TopologyMapItem = TopologyViewItem; + +/** UI-compat aliases for restored TopologyPage. */ +export type TopologyNodeItem = TopologyViewNodeItem & { id?: string; map_id?: string; ne_name?: string; ne_ip?: string; protocol?: string }; +export type TopologyEdgeItem = TopologyViewEdgeItem & { + source_node_id?: string; + target_node_id?: string; + map_id?: string; +}; +export type TopologyDiscoverNeResult = TopologyDiscoverJobItem & { + links?: Array>; +}; +export type TopologyDiscoverOut = { + map_id?: string; + protocol?: string; + job_id?: string; + scanned: number; + edges_added: number; + edges_updated: number; + edges_stale?: number; + results: TopologyDiscoverNeResult[]; + graph: TopologyViewGraph | null; +}; + +export type LldpCollectPolicy = { + enabled: boolean; + interval_days: number; + interval_hours: number; + concurrency: number; + scope_mode: string; + selected_targets: ConfigSyncTargetRef[]; + auto_add_unmatched: boolean; + history_keep: number; + updated_at?: string | null; +}; + +export type LldpCollectJobSummary = { + id: string; + scope: string; + trigger_mode: string; + status: string; + total: number; + done: number; + success_count?: number; + fail_count?: number; + edges_added: number; + edges_updated: number; + edges_stale: number; + edges_missing?: number; + error: string; + started_at?: string | null; + ended_at?: string | null; + created_at?: string | null; +}; + +export type LldpCollectDashboard = { + policy: LldpCollectPolicy; + fabric_node_count: number; + fabric_edge_count: number; + fabric_edge_active: number; + fabric_edge_stale: number; + fabric_edge_missing?: number; + last_discover_at?: string | null; + running_job: LldpCollectJobSummary | null; + last_job: LldpCollectJobSummary | null; + next_due_at?: string | null; +}; diff --git a/web/src/types/ume.ts b/web/src/types/ume.ts new file mode 100644 index 0000000..91b0a2b --- /dev/null +++ b/web/src/types/ume.ts @@ -0,0 +1,170 @@ +import type { DshAlarmHubStatus } from "./system"; + +export type UmeKeyAlertRuleItem = { + notification_id: string; + match_type: "notification_id" | "keyword"; + match_value: string; + enabled: boolean; + label: string; + ne_types?: string[]; + created_at: string; + updated_at: string; + forward_stats?: { + attempts: number; + published_ok: number; + last_forwarded_at: string; + }; +}; + +export type UmeInventoryNeTypeItem = { + ne_type: string; + ne_count: number; +}; + +export type UmeKeyAlertMonitorResponse = { + ok: boolean; + rules: UmeKeyAlertRuleItem[]; + total?: number; + page?: number; + page_size?: number; + config?: { + forward_on_clear: boolean; + }; + /** NetX hub ← netxops clients (multi-subscriber). */ + dsh_alarm_hub?: DshAlarmHubStatus; +}; + +export type UmeSyncJobItem = { + id: number; + domain: string; + status: string; + trigger_mode: string; + pulled_count: number; + inserted_count: number; + updated_count: number; + deleted?: number; + error_message?: string; + started_at: string; + ended_at?: string | null; +}; + +export type UmeWsLogEntry = { + ts: string; + level: string; + message: string; + subscription_id?: string; +}; + +export type UmeWsConnectionStatus = { + state: string; + label: string; + detail?: string; +}; + +export type UmeAlarmSubscriptionStatus = { + ok?: boolean; + created?: boolean; + already_exists?: boolean; + active: boolean; + subscription_id?: string; + wss_uri?: string; + topic?: string; + server_subscription_lost?: boolean; + server_subscription_lost_reason?: string; + current_alarms_mode?: "wss" | "rest"; + wss_active_for_current_alarms?: boolean; + scheduled_sync_skipped?: boolean; + needs_local_cleanup?: boolean; + ume_already_missing?: boolean; + cleared_local?: boolean; + message?: string; + ws_connection?: UmeWsConnectionStatus; + ws_consumer_status?: string; + ws_consumer_last_error?: string; + ws_consumer_last_run_at?: string | null; + ws_logs?: UmeWsLogEntry[]; +}; + +export type UmeTopologyFabricGap = { + dock_me_count?: number; + fabric_ume_count?: number; + world_exists?: boolean; + world_folder_id?: string; + latest_topology_status?: string; + latest_topology_error?: string; + partial_apply?: boolean; + needs_apply?: boolean; + error?: string; +}; + +export type UmeSyncStatusResponse = { + total?: number; + page?: number; + page_size?: number; + items: UmeSyncJobItem[]; + latest_by_domain?: Record; + alarm_subscription?: UmeAlarmSubscriptionStatus; + topology_fabric?: UmeTopologyFabricGap; + runtime_tasks?: Array<{ + task: string; + status: string; + paused?: boolean; + last_run_at?: string | null; + last_error?: string; + interval_s?: number | null; + interval_label?: string; + }>; +}; + +export type UmeNeItem = { + ne_id: string; + ne_name: string; + user_label: string; + ip_address: string; + ipv6_address?: string; + ne_type: string; + device_level?: string; + host_name?: string; + location?: string; + hardware_version?: string; + loopback?: string; + consistent_state?: string; + interface_version?: string; + mac?: string; + admin_status?: string; + address_type?: string; + connection_status?: string; + maintain_status?: string; + net_mask?: string; + create_time?: string; + creator?: string; + last_seen_at?: string; +}; + +export type UmeAlarmItem = { + alarm_key: string; + ne_id: string; + ne_name: string; + user_label: string; + host_name?: string; + ne_type?: string; + object_name: string; + event_type: string; + native_probable_cause: string; + perceived_severity: string; + is_cleared: string; + time_created: string; + last_seen_at?: string; +}; + +export type UmeTokenStatus = { + ok?: boolean; + has_token: boolean; + expires_in_s: number; + expires_at_epoch_s: number; + auth_header: string; + token_preview?: string; + changed?: boolean; + error_kind?: string; + error?: string; +}; diff --git a/web/src/utils/runtimeMessages.ts b/web/src/utils/runtimeMessages.ts deleted file mode 100644 index add2bd5..0000000 --- a/web/src/utils/runtimeMessages.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** Map backend runtime status codes (rt:* / ws:*) and legacy Chinese to i18n keys. */ - -type TFunc = (key: string) => string; - -const LEGACY_ZH_RUNTIME_ERROR: Record = { - "启动:正在同步当前告警(完成后连接 WSS)…": "ume.tasks.runtimeError.startup_alarm_sync_before_ws", - "启动 REST 全量同步未完成,定时 REST 与 WSS 均待命": "ume.tasks.runtimeError.startup_gate_waiting", - "WSS 实时接收中,已跳过 REST 同步": "ume.tasks.runtimeError.wss_active_skip_rest", - "正在拉取 UME 当前告警…": "ume.tasks.runtimeError.pulling_alarms_current", - "另一条当前告警 REST 同步进行中,已跳过": "ume.tasks.runtimeError.alarms_sync_in_progress_skip", - "正在拉取 UME 网元清单…": "ume.tasks.runtimeError.pulling_inventory", - "正在拉取 UME 拓扑(节点/链路)…": "ume.tasks.runtimeError.pulling_topology", - "未启用或未配置 UME_BASE_URL": "ume.tasks.runtimeError.ume_ws_disabled_no_base_url", - "已恢复:将跳过本轮周期等待并尽快同步": "ume.tasks.runtimeError.resumed_sync_soon", - "已恢复:将尽快重连 WSS": "ume.tasks.runtimeError.resumed_wss_reconnect", - 已恢复: "ume.tasks.runtimeError.resumed", - 初始化: "ume.tasks.wsState.init", - 已连接: "ume.tasks.wsState.connected", - 连接中: "ume.tasks.wsState.connecting", - 已断开: "ume.tasks.wsState.disconnected", - 无订阅: "ume.tasks.wsState.no_subscription", - "等待 token": "ume.tasks.wsState.waiting_token", - 已暂停: "ume.tasks.wsState.paused", - 连接异常: "ume.tasks.wsState.error", - 重连等待: "ume.tasks.wsState.reconnecting", - UME订阅已丢失: "ume.tasks.wsState.subscription_lost", -}; - -function translatePrefixed(code: string, prefix: string, i18nSection: string, t: TFunc): string | null { - if (!code.startsWith(prefix)) return null; - const slug = code.slice(prefix.length); - if (!slug) return null; - const key = `${i18nSection}.${slug}`; - const tr = t(key); - return tr !== key ? tr : null; -} - -export function runtimeLastError(raw: string | null | undefined, t: TFunc): string { - const s = String(raw ?? "").trim(); - if (!s) return ""; - - const rt = translatePrefixed(s, "rt:", "ume.tasks.runtimeError", t); - if (rt) return rt; - const ws = translatePrefixed(s, "ws:", "ume.tasks.wsState", t); - if (ws) return ws; - - if (s === "keepalive_failed") { - const tr = t("ume.tasks.runtimeError.keepalive_failed"); - if (tr !== "ume.tasks.runtimeError.keepalive_failed") return tr; - } - - const legacyKey = LEGACY_ZH_RUNTIME_ERROR[s]; - if (legacyKey) { - const tr = t(legacyKey); - if (tr !== legacyKey) return tr; - } - - return s; -} - -export function wsConnectionLabel( - state: string | null | undefined, - label: string | null | undefined, - t: TFunc, -): string { - const fromLabel = runtimeLastError(label, t); - if (fromLabel && fromLabel !== String(label ?? "").trim()) return fromLabel; - const st = String(state ?? "").trim(); - if (st) { - const tr = runtimeLastError(`ws:${st}`, t); - if (tr) return tr; - } - return fromLabel || st || ""; -} diff --git a/web/tests/compareTemplateModel.test.mjs b/web/tests/compareTemplateModel.test.mjs index 422df11..757bdcd 100644 --- a/web/tests/compareTemplateModel.test.mjs +++ b/web/tests/compareTemplateModel.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { parseTemplateImport, parseIfaceNormText, toOrGroups, fromOrGroups, validateTemplateDraft } from "../src/pages/network/compareTemplateModel.ts"; +import { parseTemplateImport, parseIfaceNormText, toOrGroups, fromOrGroups, validateTemplateDraft } from "../src/features/cutover/lib/compareTemplateModel.ts"; const sheet = () => ({ metric_id: "custom", sheet_id: "s", key_fields: ["id"], iface_fields: ["port"], compare_fields: ["count"], field_rules: [], row_filters: [] }); const template = (metrics = [sheet()]) => ({ name: "验收", metrics }); diff --git a/web/tests/cutoverDataCache.test.mjs b/web/tests/cutoverDataCache.test.mjs index 8c12694..1e8e838 100644 --- a/web/tests/cutoverDataCache.test.mjs +++ b/web/tests/cutoverDataCache.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { beforeEach, test } from "node:test"; -import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "../src/pages/network/cutoverDataCache.ts"; +import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "../src/features/cutover/lib/cutoverDataCache.ts"; const deferred = () => { let resolve, reject;