diff --git a/.gitignore b/.gitignore index 77311c8..925aed9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ scripts/archive/ packages/netx-topology-mcp/tests/_* _tmp_* .idea/ -ume/ +/ume/ data/ne_collections/ data/webcrt/ data/auth/ diff --git a/web/src/features/ume/UmePage.tsx b/web/src/features/ume/UmePage.tsx new file mode 100644 index 0000000..6142c47 --- /dev/null +++ b/web/src/features/ume/UmePage.tsx @@ -0,0 +1,1771 @@ +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 "./lib/runtimeMessages"; +import { formatSystemTime } from "../../utils/time"; +import { openOrFocusModule } from "../../utils/moduleWindows"; +import { jobChipColor, NmStatusChip, type NmChipColor } from "../../components/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/features/ume/components/UmeCliConnectPanel.tsx b/web/src/features/ume/components/UmeCliConnectPanel.tsx new file mode 100644 index 0000000..d8195df --- /dev/null +++ b/web/src/features/ume/components/UmeCliConnectPanel.tsx @@ -0,0 +1,520 @@ +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 "../../managed-ne/components/HopProxyFields"; +import { HelpHint } from "../../../components/HelpHint"; +import { FieldSelect } from "../../../components/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 "../../managed-ne/lib/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/features/ume/lib/runtimeMessages.ts b/web/src/features/ume/lib/runtimeMessages.ts new file mode 100644 index 0000000..add2bd5 --- /dev/null +++ b/web/src/features/ume/lib/runtimeMessages.ts @@ -0,0 +1,74 @@ +/** 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 || ""; +}