netx/web/src/pages/UmePage.tsx
oliver 41fd5b62a4 Polish network shell and UME with HeroUI chips and denser layouts.
Refresh left-nav/main chrome, unify list status chips across network pages, and merge UME token with alarm subscription into one top card.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 00:13:24 +08:00

1773 lines
73 KiB
TypeScript

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<string[]>([]);
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<string[]>([]);
const [keyAlertEditRule, setKeyAlertEditRule] = useState<UmeKeyAlertRuleItem | null>(null);
const [keyAlertEditNeTypes, setKeyAlertEditNeTypes] = useState<string[]>([]);
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<string, string>();
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 (
<>
<div className="page-stack ume-page">
<section className="cards ume-cards">
<article className="panel nm-page-panel ume-conn-panel">
<div className="ume-conn-grid">
<div className="ume-conn-col">
<div className="panel__toolbar">
<h2>{t("ume.token.title")}</h2>
</div>
<div className="actions-row actions-row--inline ume-status-row">
<NmStatusChip color={tokenChipColor({ hasToken, needsRenewal: tokenNeedsRenewal })}>
<span title={tokenNeedsRenewal ? t("ume.token.renewHint") : undefined}>
token:{" "}
{!hasToken
? t("ume.token.disconnected")
: tokenNeedsRenewal
? t("ume.token.connectedRenew")
: t("ume.token.connected")}
</span>
</NmStatusChip>
<NmStatusChip color={pillLevelToChip(tokenLevel)}>
{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")}
</NmStatusChip>
{tokenStatusQuery.data?.token_preview ? (
<NmStatusChip color="default">
{t("ume.token.preview")}: {tokenStatusQuery.data.token_preview}
</NmStatusChip>
) : null}
</div>
<div className="actions-row actions-row--inline">
<Button
size="sm"
variant="ghost"
isDisabled={tokenStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus })}
>
{tokenStatusQuery.isFetching ? (
<>
<span className="inline-spinner" aria-hidden />
{t("common.refreshing")}
</>
) : (
t("ume.token.refreshStatus")
)}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
onPress={() => tokenRefreshMutation.mutate()}
>
{tokenRefreshMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.token.renewing")}
</>
) : (
t("ume.token.renewLogin")
)}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
onPress={() => tokenDisconnectMutation.mutate()}
>
{tokenDisconnectMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.token.disconnecting")}
</>
) : (
t("ume.token.disconnect")
)}
</Button>
</div>
{(tokenOpError || tokenRefreshMutation.error || tokenDisconnectMutation.error) && (
<div className="pill pill--high">
{t("common.opFailed")}: {tokenOpError || String(tokenRefreshMutation.error || tokenDisconnectMutation.error)}
</div>
)}
</div>
<div className="ume-conn-col">
<div className="panel__toolbar">
<h2 className="card-title-with-hint">
{t("ume.subscription.title")}
<HelpHint text={t("ume.subscription.help")} ariaLabel={t("common.help")} />
</h2>
</div>
<div className="actions-row actions-row--inline ume-status-row">
<NmStatusChip color={subscriptionActive ? "success" : "danger"}>
{t("ume.subscription.sub")}:{" "}
{subscriptionActive ? t("ume.subscription.subActive") : t("ume.subscription.subInactive")}
</NmStatusChip>
{subscriptionActive && alarmSub.subscription_id ? (
<NmStatusChip color="default" className="ume-chip-mono">
<span title={alarmSub.wss_uri || ""}>
id: {String(alarmSub.subscription_id).slice(0, 12)}…
</span>
</NmStatusChip>
) : null}
{subscriptionActive || wsConsumer ? (
<NmStatusChip color={pillLevelToChip(wsPillLevel)}>
<span
title={wsConn?.detail || runtimeLastError(wsConsumer?.last_error, t) || alarmSub.wss_uri || ""}
>
WSS: {wsLabel}
</span>
</NmStatusChip>
) : null}
</div>
{scheduledSyncSkipped && !serverSubLost ? (
<div className="pill pill--low">
{t("ume.subscription.wssModeBanner", { mode: currentAlarmsMode })}
</div>
) : null}
{serverSubLost ? (
<div className="pill pill--medium">
{t("ume.subscription.serverLostBanner")}
{serverSubLostReason ? (
<div className="muted" style={{ marginTop: 6, fontSize: 12, wordBreak: "break-word" }}>
{serverSubLostReason.slice(0, 280)}
</div>
) : null}
</div>
) : null}
<div className="actions-row actions-row--inline">
<Button
size="sm"
variant="secondary"
isDisabled={subPending || (!serverSubLost && subscriptionActive) || !hasToken}
onPress={() => {
if (serverSubLost && subscriptionActive) {
if (window.confirm(t("ume.subscription.confirmReestablish"))) {
subscriptionEstablishMutation.mutate({ forceReestablish: true });
}
return;
}
subscriptionEstablishMutation.mutate(undefined);
}}
>
{subscriptionEstablishMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{serverSubLost ? t("ume.subscription.reestablishing") : t("ume.subscription.establishing")}
</>
) : serverSubLost ? (
t("ume.subscription.reestablish")
) : (
t("ume.subscription.establish")
)}
</Button>
{serverSubLost && subscriptionActive ? (
<Button
size="sm"
variant="ghost"
isDisabled={subPending}
onPress={() => {
if (confirmClearLocalSubscription()) subscriptionClearLocalMutation.mutate();
}}
>
{subscriptionClearLocalMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.subscription.clearing")}
</>
) : (
t("ume.subscription.clearLocalOnly")
)}
</Button>
) : null}
<Button
size="sm"
variant="danger"
isDisabled={subPending || !subscriptionActive}
onPress={() => subscriptionCancelMutation.mutate(undefined)}
>
{subscriptionCancelMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.subscription.cancelling")}
</>
) : (
t("ume.subscription.cancel")
)}
</Button>
<Button
size="sm"
variant="ghost"
isDisabled={subscriptionStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription })}
>
{t("ume.subscription.refreshStatus")}
</Button>
</div>
{subscriptionOpError ? (
<div className="pill pill--high">
{t("ume.subscription.opFailed")}: {subscriptionOpError}
</div>
) : null}
</div>
</div>
<div className="ume-ws-log">
<div className="ume-ws-log__head">
<strong>{t("ume.subscription.wsLogs")}</strong>
<span className="muted">
{wsConsumer?.last_run_at
? t("ume.subscription.wsActivity", { time: formatSystemTime(String(wsConsumer.last_run_at)) })
: t("ume.subscription.wsAutoRefresh")}
</span>
</div>
<div className="ws-log-panel" role="log" aria-live="polite">
{wsLogs.length === 0 ? (
<div className="ws-log-line ws-log-line--info">{t("ume.subscription.wsLogsEmpty")}</div>
) : (
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 (
<div key={`${line.ts}-${idx}`} className={`ws-log-line ${cls}`}>
<span className="ws-log-ts">{formatSystemTime(line.ts)}</span>
<span>[{lvl}] </span>
<span>{line.message}</span>
{line.subscription_id ? (
<span className="muted"> · {String(line.subscription_id).slice(0, 8)}…</span>
) : null}
</div>
);
})
)}
</div>
</div>
</article>
<article className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("ume.tasks.currentTitle")}</h2>
<div className="panel__toolbar-end">
<NmStatusChip color={runningTasks.length > 0 ? "accent" : "success"}>
{t("ume.tasks.running")}: {runningTasks.length}
</NmStatusChip>
<Button
size="sm"
variant="ghost"
isDisabled={syncStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
>
{t("common.refresh")}
</Button>
</div>
</div>
{needsFabricApply ? (
<div className="pill pill--high" style={{ marginBottom: 10 }} role="status">
<div style={{ fontWeight: 600 }}>{t("ume.syncStatus.fabricGapTitle")}</div>
<div className="muted" style={{ marginTop: 4 }}>
{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 || "—"),
)}
</div>
<div className="btn-row" style={{ marginTop: 8 }}>
<Button
size="sm"
variant="secondary"
isDisabled={applyFabricMut.isPending}
onPress={() => applyFabricMut.mutate()}
>
{applyFabricMut.isPending
? t("ume.syncStatus.applyingFabric")
: t("ume.syncStatus.applyFabric")}
</Button>
</div>
</div>
) : null}
<div className="pt-list">
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>ID</th>
<th>domain</th>
<th>trigger_mode</th>
<th>status</th>
<th>started_at</th>
<th>error</th>
</tr>
</thead>
<tbody>
{runningTasks.map((x) => (
<tr key={`running-${x.id}`}>
<td>{x.id}</td>
<td>{x.domain}</td>
<td>{x.trigger_mode}</td>
<td>
<NmStatusChip color={jobChipColor(x.status)}>{x.status}</NmStatusChip>
</td>
<td>{formatSystemTime(x.started_at)}</td>
<td className={x.error_message ? "error-text" : undefined}>
{x.error_message || t("common.empty")}
</td>
</tr>
))}
{!syncStatusQuery.isLoading && runningTasks.length === 0 && (
<tr>
<td colSpan={6}>{t("ume.tasks.noRunning")}</td>
</tr>
)}
</tbody>
</table>
</div>
<h3 className="card__section-title">{t("ume.tasks.runtimeTitle")}</h3>
{runtimeTaskError ? (
<div className="pill pill--high" style={{ marginBottom: 8 }}>
{t("ume.tasks.runtimeOpFailed")}: {runtimeTaskError}
</div>
) : null}
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>task</th>
<th title={t("ume.tasks.intervalTitle")}>interval</th>
<th>status</th>
<th title={t("ume.tasks.lastRunTitle")}>last_run_at</th>
<th>{t("ume.tasks.lastErrorCol")}</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{runtimeTasks.map((x) => (
<tr key={`runtime-${x.task}`}>
<td className="pt-list-task-name" title={x.task}>
{runtimeTaskLabel(x.task)}
</td>
<td title={typeof x.interval_s === "number" ? `${x.interval_s}s` : undefined}>
{runtimeIntervalLabel(x.interval_label)}
</td>
<td>
<NmStatusChip color={jobChipColor(x.status)}>{x.status}</NmStatusChip>
</td>
<td>{x.last_run_at ? formatSystemTime(x.last_run_at) : t("common.empty")}</td>
<td className={x.last_error ? "error-text" : undefined}>
{runtimeLastError(x.last_error, t) || t("common.empty")}
</td>
<td>
{Boolean(x.paused) ? (
<Button
size="sm"
variant="ghost"
isDisabled={runtimeTaskMutation.isPending}
onPress={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })}
>
resume
</Button>
) : (
<Button
size="sm"
variant="ghost"
isDisabled={runtimeTaskMutation.isPending}
onPress={() => runtimeTaskMutation.mutate({ task: x.task, action: "pause" })}
>
pause
</Button>
)}
</td>
</tr>
))}
{!syncStatusQuery.isLoading && runtimeTasks.length === 0 && (
<tr>
<td colSpan={6}>{t("ume.tasks.noRuntime")}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</article>
</section>
<section className="ume-entry-grid">
<article className="panel ume-entry nm-page-panel">
<div className="panel__toolbar">
<h2 className="card-title-with-hint">
{t("ume.cli.title")}
<HelpHint text={t("ume.cli.hint")} ariaLabel={t("common.help")} />
</h2>
<Button size="sm" variant="ghost" onPress={() => setCliPanelOpen(true)}>
{t("ume.cli.showPanel")}
</Button>
</div>
<p className="ume-entry__summary muted">{t("ume.cli.hint")}</p>
</article>
<article className="panel ume-entry nm-page-panel">
<div className="panel__toolbar">
<h2 className="card-title-with-hint">
{t("ume.keyAlert.title")}
<HelpHint text={t("ume.keyAlert.help")} ariaLabel={t("common.help")} />
</h2>
<Button size="sm" variant="ghost" onPress={() => setKeyAlertPanelOpen(true)}>
{t("ume.keyAlert.showPanel")}
</Button>
</div>
<div className="ume-entry__pills actions-row actions-row--inline">
<NmStatusChip color={pillLevelToChip(hubWsPill)}>
{t("ume.keyAlert.hub")}:{" "}
{keyAlertHubSubscribers > 0
? t("ume.keyAlert.hubConnected")
: t("ume.keyAlert.hubEmpty")}
</NmStatusChip>
<NmStatusChip color="default">
{t("ume.keyAlert.hubSubscribers")}: {keyAlertHubSubscribers}
</NmStatusChip>
<NmStatusChip color="default">
{t("ume.keyAlert.hubPublished")}: {Number(keyAlertHub?.published || 0)}
</NmStatusChip>
<NmStatusChip color="default">
{t("ume.keyAlert.hubDeliverOk")}: {Number(keyAlertHub?.deliver_ok || 0)}
</NmStatusChip>
</div>
</article>
<article className="panel ume-entry nm-page-panel">
<div className="panel__toolbar">
<h2>{t("ume.syncStatus.title")}</h2>
<Button size="sm" variant="ghost" onPress={() => setSyncStatusPanelOpen(true)}>
{t("ume.syncStatus.showPanel")}
</Button>
</div>
<p className="ume-entry__summary muted">
{syncTotal
? t("ume.syncStatus.summary", {
total: String(syncTotal),
running: String(runningTasks.length),
})
: t("ume.syncStatus.empty")}
</p>
</article>
<article className="panel ume-entry nm-page-panel">
<div className="panel__toolbar">
<h2>{t("ume.ne.title")}</h2>
<Button size="sm" variant="ghost" onPress={() => setNePanelOpen(true)}>
{t("ume.ne.showPanel")}
</Button>
</div>
<p className="ume-entry__summary muted">
{t("ume.ne.summary", { total: String(neTotal) })}
</p>
</article>
<article className="panel ume-entry nm-page-panel">
<div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2>
</div>
<p className="ume-entry__summary muted">
{t("ume.alarms.movedHint")}{" "}
<a
className="ume-entry__link"
href="/network/alarms"
onClick={(e) => {
e.preventDefault();
openOrFocusModule({ moduleId: "network", path: "/network/alarms" });
}}
>
{t("network.nav.alarms")}
</a>
</p>
</article>
</section>
</div>
<AppModalShell
open={cliPanelOpen}
onClose={() => setCliPanelOpen(false)}
size="lg"
className="app-heroui-modal--xl"
>
<Modal.Header>
<Modal.Heading>{t("ume.cli.title")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted">{t("ume.cli.hint")}</p>
<UmeCliConnectPanel embedded enabled />
</Modal.Body>
</AppModalShell>
<AppModalShell
open={keyAlertPanelOpen}
onClose={() => setKeyAlertPanelOpen(false)}
size="lg"
className="app-heroui-modal--xl"
>
<Modal.Header>
<Modal.Heading>{t("ume.keyAlert.title")}</Modal.Heading>
<Button
size="sm"
variant="secondary"
isDisabled={keyAlertMonitorQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll })}
>
{keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</Button>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted">
{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}` : ""}
</p>
<div className="ume-modal-form">
<div className="muted ume-modal-form__label">{t("ume.keyAlert.hubConnections")}</div>
{keyAlertHubConnections.length === 0 ? (
<p className="muted">{t("ume.keyAlert.hubEmptyConnections")}</p>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("ume.keyAlert.hubColId")}</th>
<th>{t("ume.keyAlert.hubColUser")}</th>
<th>{t("ume.keyAlert.hubColRemote")}</th>
<th>{t("ume.keyAlert.hubColClient")}</th>
<th>{t("ume.keyAlert.hubColConnected")}</th>
<th>{t("ume.keyAlert.hubColLastSeen")}</th>
</tr>
</thead>
<tbody>
{keyAlertHubConnections.map((conn) => (
<tr key={conn.id}>
<td>
<code>{conn.id}</code>
</td>
<td>{conn.user || "-"}</td>
<td>{conn.remote || "-"}</td>
<td>{conn.client || "-"}</td>
<td>
{conn.connected_at
? formatSystemTime(conn.connected_at)
: "-"}
</td>
<td>
{conn.last_seen_at
? formatSystemTime(conn.last_seen_at)
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="ume-modal-form">
<div className="ops-detail-modal__toolbar filter-inline">
<label className="muted">{t("ume.keyAlert.matchType")}</label>
<select
className="input"
value={keyAlertMatchType}
onChange={(e) => {
setKeyAlertMatchType(e.target.value === "keyword" ? "keyword" : "notification_id");
setKeyAlertKeywordHints([]);
setKeyAlertIdHints([]);
}}
>
<option value="notification_id">{t("ume.keyAlert.matchNotificationId")}</option>
<option value="keyword">{t("ume.keyAlert.matchKeyword")}</option>
</select>
<input
className="input ume-modal-form__grow"
placeholder={
keyAlertMatchType === "keyword"
? t("ume.keyAlert.matchValuePhKeyword")
: t("ume.keyAlert.matchValuePhNotificationId")
}
value={keyAlertMatchValue}
onChange={(e) => setKeyAlertMatchValue(e.target.value)}
list="ume-key-alert-match-options"
/>
<input
className="input ume-modal-form__grow"
placeholder={t("ume.keyAlert.labelPh")}
value={keyAlertLabel}
onChange={(e) => setKeyAlertLabel(e.target.value)}
/>
<datalist id="ume-key-alert-match-options">
{keyAlertMatchType === "keyword"
? keyAlertKeywordHints.map((kw) => <option key={kw} value={kw} />)
: keyAlertIdHints.map((x) => (
<option key={x.notification_id} value={x.notification_id}>
{x.native_probable_cause_sample || x.notification_id}
</option>
))}
</datalist>
<Button
size="sm"
variant="secondary"
onPress={async () => {
try {
if (keyAlertMatchType === "keyword") {
const resp = await fetchUmeAlarmKeywords(100);
const hints = (resp.items || []).map((x) => x.keyword).filter(Boolean);
setKeyAlertKeywordHints(hints);
if (hints.length && !keyAlertMatchValue.trim()) {
setKeyAlertMatchValue(hints[0]);
}
} else {
const resp = await fetchUmeNotificationIds(100);
const items = resp.items || [];
setKeyAlertIdHints(items);
if (items.length && !keyAlertMatchValue.trim()) {
const first = items[0];
setKeyAlertMatchValue(first.notification_id);
if (!keyAlertLabel.trim() && first.native_probable_cause_sample) {
setKeyAlertLabel(first.native_probable_cause_sample);
}
}
}
} catch (err) {
showError(String(err));
}
}}
>
{t("ume.keyAlert.pickFromAlarms")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={
keyAlertAddMutation.isPending || !keyAlertMatchValue.trim() || !keyAlertLabel.trim()
}
onPress={() => keyAlertAddMutation.mutate()}
>
{keyAlertAddMutation.isPending ? t("ume.keyAlert.adding") : t("ume.keyAlert.add")}
</Button>
</div>
<div className="muted ume-modal-form__label">{t("ume.keyAlert.neTypesLabel")}</div>
{keyAlertNeTypesQuery.isLoading ? (
<span className="muted">{t("common.refreshing")}</span>
) : (keyAlertNeTypesQuery.data?.items || []).length === 0 ? (
<span className="muted">{t("ume.keyAlert.neTypesEmpty")}</span>
) : (
<div className="ume-ne-types">
{(keyAlertNeTypesQuery.data?.items || []).map((item) => (
<label key={item.ne_type} className="ume-ne-types__item">
<input
type="checkbox"
checked={keyAlertNeTypes.includes(item.ne_type)}
onChange={(e) => {
if (e.target.checked) {
setKeyAlertNeTypes((prev) => [...prev, item.ne_type]);
} else {
setKeyAlertNeTypes((prev) => prev.filter((x) => x !== item.ne_type));
}
}}
/>
{item.ne_type}
<span className="muted">({item.ne_count})</span>
</label>
))}
</div>
)}
<p className="muted ume-modal-form__hint">{t("ume.keyAlert.neTypesHint")}</p>
<div className="ops-detail-modal__toolbar ume-modal-form__opts">
<label className="ume-ne-types__item muted">
<input
type="checkbox"
checked={keyAlertForwardOnClear}
disabled={keyAlertConfigMutation.isPending || keyAlertMonitorQuery.isLoading}
onChange={(e) => keyAlertConfigMutation.mutate(e.target.checked)}
/>
{t("ume.keyAlert.forwardOnClear")}
<HelpHint
text={t("ume.keyAlert.forwardOnClearHelp")}
ariaLabel={t("common.help")}
align="start"
/>
</label>
</div>
{keyAlertOpError ? (
<p className="ops-detail-modal__error">
{t("common.opFailed")}: {keyAlertOpError}
</p>
) : null}
</div>
<div className="ops-detail-modal__toolbar filter-inline">
<input
type="search"
placeholder={t("ume.keyAlert.filterKeywordPh")}
value={keyAlertFilterKeyword}
onChange={(e) => {
setKeyAlertFilterKeyword(e.target.value);
setKeyAlertPage(1);
}}
/>
<select
value={keyAlertFilterMatchType}
onChange={(e) => {
setKeyAlertFilterMatchType(e.target.value as "" | "notification_id" | "keyword");
setKeyAlertPage(1);
}}
>
<option value="">{t("ume.keyAlert.filterMatchAll")}</option>
<option value="notification_id">{t("ume.keyAlert.matchNotificationId")}</option>
<option value="keyword">{t("ume.keyAlert.matchKeyword")}</option>
</select>
<select
value={keyAlertFilterEnabled}
onChange={(e) => {
setKeyAlertFilterEnabled(e.target.value as "" | "true" | "false");
setKeyAlertPage(1);
}}
>
<option value="">{t("ume.keyAlert.filterEnabledAll")}</option>
<option value="true">{t("ume.keyAlert.filterEnabledOn")}</option>
<option value="false">{t("ume.keyAlert.filterEnabledOff")}</option>
</select>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("ume.keyAlert.colMonitor")}</th>
<th>{t("ume.keyAlert.colType")}</th>
<th>{t("ume.keyAlert.colMatch")}</th>
<th>{t("ume.keyAlert.colLabel")}</th>
<th>{t("ume.keyAlert.colNeTypes")}</th>
<th>{t("ume.keyAlert.colPublished")}</th>
<th>{t("ume.keyAlert.colAttempts")}</th>
<th>{t("ume.keyAlert.colLast")}</th>
<th>{t("ume.keyAlert.colActions")}</th>
</tr>
</thead>
<tbody>
{keyAlertRules.map((rule) => (
<tr key={rule.notification_id} className={rule.enabled ? undefined : "row--muted"}>
<td className="nowrap">
<label className="ume-ne-types__item">
<input
type="checkbox"
checked={Boolean(rule.enabled)}
disabled={keyAlertToggleMutation.isPending}
onChange={(e) =>
keyAlertToggleMutation.mutate({
ruleKey: rule.notification_id,
enabled: e.target.checked,
})
}
/>
{rule.enabled ? t("ume.keyAlert.monitorOn") : t("ume.keyAlert.monitorOff")}
</label>
</td>
<td>
{rule.match_type === "keyword"
? t("ume.keyAlert.matchKeyword")
: t("ume.keyAlert.matchNotificationId")}
</td>
<td>{rule.match_value || rule.notification_id}</td>
<td>{rule.label || t("common.empty")}</td>
<td>
{rule.ne_types?.length ? rule.ne_types.join(", ") : t("ume.keyAlert.neTypesAll")}
</td>
<td>{Number(rule.forward_stats?.published_ok || 0)}</td>
<td>{Number(rule.forward_stats?.attempts || 0)}</td>
<td>
{rule.forward_stats?.last_forwarded_at
? formatSystemTime(rule.forward_stats.last_forwarded_at)
: t("common.empty")}
</td>
<td>
<div className="btn-row">
<Button
size="sm"
variant="secondary"
isDisabled={keyAlertEditMutation.isPending}
onPress={() => {
setKeyAlertEditRule(rule);
setKeyAlertEditNeTypes(rule.ne_types || []);
}}
>
{t("ume.keyAlert.edit")}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={keyAlertDeleteMutation.isPending}
onPress={() => {
if (window.confirm(t("ume.keyAlert.confirmDelete"))) {
keyAlertDeleteMutation.mutate(rule.notification_id);
}
}}
>
{t("ume.keyAlert.delete")}
</Button>
</div>
</td>
</tr>
))}
{!keyAlertMonitorQuery.isLoading && keyAlertRules.length === 0 ? (
<tr>
<td colSpan={9} className="muted">
{t("ume.keyAlert.emptyRules")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(keyAlertTotal),
page: String(keyAlertPage),
pages: String(keyAlertPages),
})}
</span>
<div className="btn-row">
<Button
size="sm"
variant="secondary"
isDisabled={keyAlertPage <= 1}
onPress={() => setKeyAlertPage(Math.max(1, keyAlertPage - 1))}
>
{t("common.prevPage")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={keyAlertPage >= keyAlertPages}
onPress={() => setKeyAlertPage(keyAlertPage + 1)}
>
{t("common.nextPage")}
</Button>
<select
className="pager__size"
value={String(keyAlertPageSize)}
onChange={(e) => {
setKeyAlertPageSize(Number(e.target.value) || 10);
setKeyAlertPage(1);
}}
>
<option value="10">{perPage(10)}</option>
<option value="20">{perPage(20)}</option>
<option value="50">{perPage(50)}</option>
</select>
</div>
</div>
</Modal.Body>
</AppModalShell>
<AppModalShell
open={syncStatusPanelOpen}
onClose={() => setSyncStatusPanelOpen(false)}
size="lg"
className="app-heroui-modal--xl"
>
<Modal.Header>
<Modal.Heading>{t("ume.syncStatus.title")}</Modal.Heading>
<Button
size="sm"
variant="secondary"
isDisabled={syncStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
>
{syncStatusQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</Button>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted">
{t("ume.syncStatus.summary", {
total: String(syncTotal),
running: String(runningTasks.length),
})}
</p>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>ID</th>
<th>domain</th>
<th>status</th>
<th>pulled</th>
<th>inserted</th>
<th>updated</th>
<th title={t("ume.tasks.deletedTitle")}>deleted</th>
<th>started_at</th>
<th>ended_at</th>
<th className="ume-col-error">error</th>
</tr>
</thead>
<tbody>
{(syncStatusQuery.data?.items || []).map((x) => (
<tr key={x.id}>
<td className="pt-list-num" title={String(x.id)}>
{String(x.id).slice(0, 8)}
</td>
<td>{x.domain}</td>
<td>
<NmStatusChip color={jobChipColor(x.status)}>{x.status}</NmStatusChip>
</td>
<td>{x.pulled_count}</td>
<td>{x.inserted_count}</td>
<td>{x.updated_count}</td>
<td>{Number(x.deleted ?? 0)}</td>
<td>{formatSystemTime(x.started_at)}</td>
<td>{x.ended_at ? formatSystemTime(x.ended_at) : t("common.empty")}</td>
<td className="ume-col-error" title={x.error_message || ""}>
{x.error_message || t("common.empty")}
</td>
</tr>
))}
{!syncStatusQuery.isLoading && (syncStatusQuery.data?.items || []).length === 0 ? (
<tr>
<td colSpan={10} className="muted">
{t("ume.syncStatus.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(syncTotal),
page: String(syncPage),
pages: String(syncPages),
})}
</span>
<div className="btn-row">
<Button
size="sm"
variant="secondary"
isDisabled={syncPage <= 1}
onPress={() => setSyncPage(Math.max(1, syncPage - 1))}
>
{t("common.prevPage")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={syncPage >= syncPages}
onPress={() => setSyncPage(syncPage + 1)}
>
{t("common.nextPage")}
</Button>
<select
className="pager__size"
value={String(syncPageSize)}
onChange={(e) => {
setSyncPageSize(Number(e.target.value) || 10);
setSyncPage(1);
}}
>
<option value="10">{perPage(10)}</option>
<option value="20">{perPage(20)}</option>
<option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option>
<option value="200">{perPage(200)}</option>
</select>
</div>
</div>
</Modal.Body>
</AppModalShell>
<AppModalShell
open={nePanelOpen}
onClose={() => {
setNePanelOpen(false);
setExpandedNeId("");
}}
size="lg"
className="app-heroui-modal--xl"
>
<Modal.Header>
<Modal.Heading>{t("ume.ne.title")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted">{t("ume.ne.summary", { total: String(neTotal) })}</p>
<div className="ops-detail-modal__toolbar filter-inline">
<Input
value={neKeyword}
placeholder={t("ume.ne.keywordPh")}
onChange={(e) => {
setNeKeyword(e.target.value);
setNePage(1);
}}
/>
<Button
size="sm"
variant="secondary"
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll })}
>
{t("common.query")}
</Button>
<Button
size="sm"
variant="tertiary"
isDisabled={!neKeyword.trim()}
onPress={() => {
setNeKeyword("");
setNePage(1);
}}
>
{t("common.clearFilters")}
</Button>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>ne_id</th>
<th>user_label</th>
<th>ip</th>
<th>type</th>
<th>device_level</th>
<th>host_name</th>
<th>{t("ume.ne.cliConnect")}</th>
<th>hw_ver</th>
<th>last_seen</th>
<th />
</tr>
</thead>
<tbody>
{(neQuery.data?.items || []).map((x) => (
<tr key={x.ne_id}>
<td className="pt-list-num" title={x.ne_id}>
{x.ne_id}
</td>
<td>{x.user_label}</td>
<td>{x.ip_address}</td>
<td>{x.ne_type}</td>
<td>{x.device_level || t("common.empty")}</td>
<td>{x.host_name || t("common.empty")}</td>
<td>{cliStatusByNeId.get(x.ne_id) || "unknown"}</td>
<td>{x.hardware_version || t("common.empty")}</td>
<td>{x.last_seen_at ? formatSystemTime(x.last_seen_at) : t("common.empty")}</td>
<td>
<Button size="sm" variant="ghost" onPress={() => setExpandedNeId(x.ne_id)}>
{t("ume.ne.expand")}
</Button>
</td>
</tr>
))}
{!neQuery.isLoading && !(neQuery.data?.items || []).length ? (
<tr>
<td colSpan={10} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(neTotal),
page: String(nePage),
pages: String(nePages),
})}
</span>
<div className="btn-row">
<Button
size="sm"
variant="secondary"
isDisabled={nePage <= 1}
onPress={() => setNePage(Math.max(1, nePage - 1))}
>
{t("common.prevPage")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={nePage >= nePages}
onPress={() => setNePage(nePage + 1)}
>
{t("common.nextPage")}
</Button>
<select
className="pager__size"
value={String(nePageSize)}
onChange={(e) => {
setNePageSize(Number(e.target.value) || 10);
setNePage(1);
}}
>
<option value="10">{perPage(10)}</option>
<option value="20">{perPage(20)}</option>
<option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option>
<option value="200">{perPage(200)}</option>
<option value="500">{perPage(500)}</option>
</select>
</div>
</div>
</Modal.Body>
</AppModalShell>
<AppModalShell
open={Boolean(expandedNe)}
onClose={() => setExpandedNeId("")}
size="lg"
>
<Modal.Header>
<Modal.Heading>{expandedNe?.user_label || expandedNe?.ne_id || t("ume.ne.expand")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{expandedNe ? (
<>
<p className="muted">
{expandedNe.ne_id}
{expandedNe.ip_address ? ` · ${expandedNe.ip_address}` : ""}
{expandedNe.ne_type ? ` · ${expandedNe.ne_type}` : ""}
</p>
<div className="ume-ne-detail-grid">
<div>
<span className="muted">consistent_state</span>
<div>{expandedNe.consistent_state || t("common.empty")}</div>
</div>
<div>
<span className="muted">admin_status</span>
<div>{expandedNe.admin_status || t("common.empty")}</div>
</div>
<div>
<span className="muted">connection_status</span>
<div>{expandedNe.connection_status || t("common.empty")}</div>
</div>
<div>
<span className="muted">maintain_status</span>
<div>{expandedNe.maintain_status || t("common.empty")}</div>
</div>
<div>
<span className="muted">address_type</span>
<div>{expandedNe.address_type || t("common.empty")}</div>
</div>
<div>
<span className="muted">location</span>
<div>{expandedNe.location || t("common.empty")}</div>
</div>
<div>
<span className="muted">loopback</span>
<div>{expandedNe.loopback || t("common.empty")}</div>
</div>
<div>
<span className="muted">net_mask</span>
<div>{expandedNe.net_mask || t("common.empty")}</div>
</div>
<div>
<span className="muted">mac</span>
<div>{expandedNe.mac || t("common.empty")}</div>
</div>
<div>
<span className="muted">interface_version</span>
<div>{expandedNe.interface_version || t("common.empty")}</div>
</div>
<div>
<span className="muted">create_time</span>
<div>{expandedNe.create_time || t("common.empty")}</div>
</div>
<div>
<span className="muted">creator</span>
<div>{expandedNe.creator || t("common.empty")}</div>
</div>
<div>
<span className="muted">host_name</span>
<div>{expandedNe.host_name || t("common.empty")}</div>
</div>
<div>
<span className="muted">hardware_version</span>
<div>{expandedNe.hardware_version || t("common.empty")}</div>
</div>
<div>
<span className="muted">{t("ume.ne.cliConnect")}</span>
<div>{cliStatusByNeId.get(expandedNe.ne_id) || "unknown"}</div>
</div>
<div>
<span className="muted">last_seen</span>
<div>
{expandedNe.last_seen_at
? formatSystemTime(expandedNe.last_seen_at)
: t("common.empty")}
</div>
</div>
</div>
</>
) : null}
</Modal.Body>
</AppModalShell>
<AppModalShell
open={Boolean(keyAlertEditRule)}
onClose={() => {
if (!keyAlertEditMutation.isPending) setKeyAlertEditRule(null);
}}
dismissible={!keyAlertEditMutation.isPending}
size="lg"
>
<Modal.Header>
<Modal.Heading>{t("ume.keyAlert.editTitle")}</Modal.Heading>
<Modal.CloseTrigger isDisabled={keyAlertEditMutation.isPending} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{keyAlertEditRule ? (
<>
<p className="muted">
{keyAlertEditRule.match_type === "keyword"
? t("ume.keyAlert.matchKeyword")
: t("ume.keyAlert.matchNotificationId")}
: {keyAlertEditRule.match_value || keyAlertEditRule.notification_id}
{keyAlertEditRule.label ? ` · ${keyAlertEditRule.label}` : ""}
</p>
<div className="muted ume-modal-form__label">{t("ume.keyAlert.neTypesLabel")}</div>
{keyAlertNeTypesQuery.isLoading ? (
<span className="muted">{t("common.refreshing")}</span>
) : (keyAlertNeTypesQuery.data?.items || []).length === 0 ? (
<span className="muted">{t("ume.keyAlert.neTypesEmpty")}</span>
) : (
<div className="ume-ne-types ume-ne-types--tall">
{(keyAlertNeTypesQuery.data?.items || []).map((item) => (
<label key={item.ne_type} className="ume-ne-types__item">
<input
type="checkbox"
checked={keyAlertEditNeTypes.includes(item.ne_type)}
onChange={(e) => {
if (e.target.checked) {
setKeyAlertEditNeTypes((prev) => [...prev, item.ne_type]);
} else {
setKeyAlertEditNeTypes((prev) => prev.filter((x) => x !== item.ne_type));
}
}}
/>
{item.ne_type}
<span className="muted">({item.ne_count})</span>
</label>
))}
</div>
)}
<p className="muted ume-modal-form__hint">{t("ume.keyAlert.neTypesHint")}</p>
</>
) : null}
</Modal.Body>
<Modal.Footer>
<Button
variant="tertiary"
isDisabled={keyAlertEditMutation.isPending}
onPress={() => setKeyAlertEditRule(null)}
>
{t("ume.keyAlert.cancel")}
</Button>
<Button
variant="primary"
isDisabled={keyAlertEditMutation.isPending || !keyAlertEditRule}
onPress={() => {
if (!keyAlertEditRule) return;
keyAlertEditMutation.mutate({
ruleKey: keyAlertEditRule.notification_id,
ne_types: keyAlertEditNeTypes,
});
}}
>
{keyAlertEditMutation.isPending ? t("ume.keyAlert.saving") : t("ume.keyAlert.save")}
</Button>
</Modal.Footer>
</AppModalShell>
</>
);
}