mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 11:20:48 +08:00
Align NE, UME, system, topology, and WebCRT with shared list styling, and set the browser tab title to NetX. Co-authored-by: Cursor <cursoragent@cursor.com>
1444 lines
59 KiB
TypeScript
1444 lines
59 KiB
TypeScript
import { Fragment, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
apiPost,
|
|
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 { 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";
|
|
|
|
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(20);
|
|
const [neKeyword, setNeKeyword] = useState("");
|
|
const [nePage, setNePage] = useState(1);
|
|
const [nePageSize, setNePageSize] = useState(50);
|
|
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(20);
|
|
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: 5000,
|
|
refetchInterval: 5000,
|
|
});
|
|
const tokenStatusQuery = useQuery({
|
|
queryKey: queryKeys.umeTokenStatus,
|
|
queryFn: fetchUmeTokenStatus,
|
|
staleTime: 3000,
|
|
refetchInterval: 5000,
|
|
});
|
|
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: 3000,
|
|
refetchInterval: 5000,
|
|
});
|
|
const keyAlertMonitorQuery = useQuery({
|
|
queryKey: queryKeys.umeKeyAlertMonitor(
|
|
keyAlertPage,
|
|
keyAlertPageSize,
|
|
keyAlertFilterKeyword,
|
|
keyAlertFilterEnabled,
|
|
keyAlertFilterMatchType,
|
|
),
|
|
queryFn: () =>
|
|
fetchUmeKeyAlertMonitor({
|
|
page: keyAlertPage,
|
|
pageSize: keyAlertPageSize,
|
|
keyword: keyAlertFilterKeyword,
|
|
enabled: keyAlertFilterEnabled,
|
|
matchType: keyAlertFilterMatchType,
|
|
}),
|
|
staleTime: 3000,
|
|
refetchInterval: 5000,
|
|
enabled: keyAlertPanelOpen,
|
|
});
|
|
const keyAlertNeTypesQuery = useQuery({
|
|
queryKey: queryKeys.umeInventoryNeTypes,
|
|
queryFn: () => fetchUmeInventoryNeTypes(),
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
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 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: queryKeys.integrationsStatus });
|
|
},
|
|
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: queryKeys.integrationsStatus });
|
|
},
|
|
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 keyAlertForwarder = keyAlertMonitorQuery.data?.forwarder;
|
|
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 oclawWsPill =
|
|
!keyAlertForwarder?.enabled
|
|
? "unknown"
|
|
: keyAlertForwarder.paused
|
|
? "warn"
|
|
: keyAlertForwarder.connected
|
|
? "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">
|
|
<div className="panel__toolbar">
|
|
<h2>{t("ume.token.title")}</h2>
|
|
</div>
|
|
<div className="actions-row actions-row--inline">
|
|
<span
|
|
className={`conn-pill conn-pill--${!hasToken ? "down" : tokenNeedsRenewal ? "warn" : "up"}`}
|
|
title={tokenNeedsRenewal ? t("ume.token.renewHint") : undefined}
|
|
>
|
|
token:{" "}
|
|
{!hasToken
|
|
? t("ume.token.disconnected")
|
|
: tokenNeedsRenewal
|
|
? t("ume.token.connectedRenew")
|
|
: t("ume.token.connected")}
|
|
</span>
|
|
<span className={`conn-pill conn-pill--${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")}
|
|
</span>
|
|
{tokenStatusQuery.data?.token_preview ? (
|
|
<span className="conn-pill">
|
|
{t("ume.token.preview")}: {tokenStatusQuery.data.token_preview}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="actions-row actions-row--inline">
|
|
<button
|
|
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus })}
|
|
disabled={tokenStatusQuery.isFetching}
|
|
>
|
|
{tokenStatusQuery.isFetching ? (
|
|
<>
|
|
<span className="inline-spinner" aria-hidden />
|
|
{t("common.refreshing")}
|
|
</>
|
|
) : (
|
|
t("ume.token.refreshStatus")
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => tokenRefreshMutation.mutate()}
|
|
disabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
|
|
>
|
|
{tokenRefreshMutation.isPending ? (
|
|
<>
|
|
<span className="inline-spinner" aria-hidden />
|
|
{t("ume.token.renewing")}
|
|
</>
|
|
) : (
|
|
t("ume.token.renewLogin")
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => tokenDisconnectMutation.mutate()}
|
|
disabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
|
|
>
|
|
{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>
|
|
)}
|
|
</article>
|
|
|
|
<article className="panel">
|
|
<div className="panel__toolbar">
|
|
<h2>{t("ume.tasks.currentTitle")}</h2>
|
|
<div className="panel__toolbar-end">
|
|
<span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}>
|
|
{t("ume.tasks.running")}: {runningTasks.length}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
|
|
disabled={syncStatusQuery.isFetching}
|
|
>
|
|
{t("common.refresh")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<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>{x.status}</td>
|
|
<td>{formatSystemTime(x.started_at)}</td>
|
|
<td>{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 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>{x.status}</td>
|
|
<td>{x.last_run_at ? formatSystemTime(x.last_run_at) : t("common.empty")}</td>
|
|
<td>{runtimeLastError(x.last_error, t) || t("common.empty")}</td>
|
|
<td>
|
|
{Boolean(x.paused) ? (
|
|
<button
|
|
type="button"
|
|
className="link-btn"
|
|
disabled={runtimeTaskMutation.isPending}
|
|
onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })}
|
|
>
|
|
resume
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="link-btn"
|
|
disabled={runtimeTaskMutation.isPending}
|
|
onClick={() => 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>
|
|
|
|
<article className="panel">
|
|
<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">
|
|
<span className={`conn-pill conn-pill--${subscriptionActive ? "up" : "down"}`}>
|
|
{t("ume.subscription.sub")}: {subscriptionActive ? t("ume.subscription.subActive") : t("ume.subscription.subInactive")}
|
|
</span>
|
|
{subscriptionActive && alarmSub.subscription_id ? (
|
|
<span className="conn-pill" title={alarmSub.wss_uri || ""}>
|
|
id: {String(alarmSub.subscription_id).slice(0, 12)}…
|
|
</span>
|
|
) : null}
|
|
{subscriptionActive || wsConsumer ? (
|
|
<span
|
|
className={`conn-pill conn-pill--${wsPillLevel}`}
|
|
title={wsConn?.detail || runtimeLastError(wsConsumer?.last_error, t) || alarmSub.wss_uri || ""}
|
|
>
|
|
WSS: {wsLabel}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{scheduledSyncSkipped && !serverSubLost ? (
|
|
<div className="pill pill--low" style={{ marginTop: 8 }}>
|
|
{t("ume.subscription.wssModeBanner", { mode: currentAlarmsMode })}
|
|
</div>
|
|
) : null}
|
|
{serverSubLost ? (
|
|
<div className="pill pill--medium" style={{ marginTop: 8 }}>
|
|
{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
|
|
type="button"
|
|
onClick={() => {
|
|
if (serverSubLost && subscriptionActive) {
|
|
if (window.confirm(t("ume.subscription.confirmReestablish"))) {
|
|
subscriptionEstablishMutation.mutate({ forceReestablish: true });
|
|
}
|
|
return;
|
|
}
|
|
subscriptionEstablishMutation.mutate(undefined);
|
|
}}
|
|
disabled={subPending || (!serverSubLost && subscriptionActive) || !hasToken}
|
|
title={
|
|
!hasToken
|
|
? t("ume.subscription.titleLoginFirst")
|
|
: serverSubLost
|
|
? t("ume.subscription.titleReestablish")
|
|
: 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
|
|
type="button"
|
|
onClick={() => {
|
|
if (confirmClearLocalSubscription()) subscriptionClearLocalMutation.mutate();
|
|
}}
|
|
disabled={subPending}
|
|
>
|
|
{subscriptionClearLocalMutation.isPending ? (
|
|
<>
|
|
<span className="inline-spinner" aria-hidden />
|
|
{t("ume.subscription.clearing")}
|
|
</>
|
|
) : (
|
|
t("ume.subscription.clearLocalOnly")
|
|
)}
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={() => subscriptionCancelMutation.mutate(undefined)}
|
|
disabled={subPending || !subscriptionActive}
|
|
>
|
|
{subscriptionCancelMutation.isPending ? (
|
|
<>
|
|
<span className="inline-spinner" aria-hidden />
|
|
{t("ume.subscription.cancelling")}
|
|
</>
|
|
) : (
|
|
t("ume.subscription.cancel")
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription })}
|
|
disabled={subscriptionStatusQuery.isFetching}
|
|
>
|
|
{t("ume.subscription.refreshStatus")}
|
|
</button>
|
|
</div>
|
|
{subscriptionOpError ? (
|
|
<div className="pill pill--high">
|
|
{t("ume.subscription.opFailed")}: {subscriptionOpError}
|
|
</div>
|
|
) : null}
|
|
<div style={{ marginTop: 12 }}>
|
|
<div className="actions-row actions-row--inline" style={{ marginTop: 0 }}>
|
|
<strong style={{ fontSize: 13 }}>{t("ume.subscription.wsLogs")}</strong>
|
|
<span className="muted" style={{ fontSize: 12 }}>
|
|
{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">
|
|
<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 type="button" className="link-btn" onClick={() => setCliPanelOpen((x) => !x)}>
|
|
{cliPanelOpen ? t("ume.cli.hidePanel") : t("ume.cli.showPanel")}
|
|
</button>
|
|
</div>
|
|
{cliPanelOpen ? <UmeCliConnectPanel embedded enabled /> : null}
|
|
</article>
|
|
|
|
<article className="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 type="button" className="link-btn" onClick={() => setKeyAlertPanelOpen((x) => !x)}>
|
|
{keyAlertPanelOpen ? t("ume.keyAlert.hidePanel") : t("ume.keyAlert.showPanel")}
|
|
</button>
|
|
</div>
|
|
{keyAlertPanelOpen ? (
|
|
<>
|
|
<div className="actions-row actions-row--inline">
|
|
<span className={`conn-pill conn-pill--${oclawWsPill}`}>
|
|
{t("ume.keyAlert.ws")}:{" "}
|
|
{!keyAlertForwarder?.enabled
|
|
? t("ume.keyAlert.wsDisabled")
|
|
: keyAlertForwarder.paused
|
|
? t("ume.keyAlert.wsPaused")
|
|
: keyAlertForwarder.connected
|
|
? t("ume.keyAlert.wsConnected")
|
|
: t("ume.keyAlert.wsDisconnected")}
|
|
</span>
|
|
{keyAlertForwarder?.enabled ? (
|
|
<>
|
|
<span className="conn-pill">
|
|
{t("ume.keyAlert.publishedOk")}: {Number(keyAlertForwarder.published_ok || 0)}
|
|
</span>
|
|
<span className="conn-pill">
|
|
{t("ume.keyAlert.publishedFail")}: {Number(keyAlertForwarder.published_fail || 0)}
|
|
</span>
|
|
<span className="conn-pill">
|
|
{t("ume.keyAlert.queue")}: {Number(keyAlertForwarder.queue_size || 0)}
|
|
</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
<div style={{ marginTop: 10 }}>
|
|
<div
|
|
className="actions-row actions-row--inline"
|
|
style={{ flexWrap: "wrap", alignItems: "center", gap: 8 }}
|
|
>
|
|
<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"
|
|
style={{ flex: "1 1 180px", minWidth: 160 }}
|
|
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"
|
|
style={{ flex: "1 1 140px", minWidth: 120 }}
|
|
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
|
|
type="button"
|
|
onClick={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
|
|
type="button"
|
|
onClick={() => keyAlertAddMutation.mutate()}
|
|
disabled={
|
|
keyAlertAddMutation.isPending || !keyAlertMatchValue.trim() || !keyAlertLabel.trim()
|
|
}
|
|
>
|
|
{keyAlertAddMutation.isPending ? t("ume.keyAlert.adding") : t("ume.keyAlert.add")}
|
|
</button>
|
|
</div>
|
|
<div style={{ marginTop: 8 }}>
|
|
<div className="muted" style={{ marginBottom: 4 }}>
|
|
{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
|
|
style={{
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: "8px 16px",
|
|
maxHeight: 120,
|
|
overflowY: "auto",
|
|
padding: "6px 8px",
|
|
border: "1px solid var(--border, #ddd)",
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
{(keyAlertNeTypesQuery.data?.items || []).map((item) => (
|
|
<label
|
|
key={item.ne_type}
|
|
style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}
|
|
>
|
|
<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>
|
|
)}
|
|
<div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
|
|
{t("ume.keyAlert.neTypesHint")}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="actions-row actions-row--inline"
|
|
style={{ marginTop: 8, flexWrap: "wrap", alignItems: "center", justifyContent: "space-between" }}
|
|
>
|
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}>
|
|
<label className="muted" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={keyAlertForwardOnClear}
|
|
disabled={keyAlertConfigMutation.isPending || keyAlertMonitorQuery.isLoading}
|
|
onChange={(e) => keyAlertConfigMutation.mutate(e.target.checked)}
|
|
/>
|
|
{t("ume.keyAlert.forwardOnClear")}
|
|
</label>
|
|
<HelpHint
|
|
text={t("ume.keyAlert.forwardOnClearHelp")}
|
|
ariaLabel={t("common.help")}
|
|
align="start"
|
|
/>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll })}
|
|
disabled={keyAlertMonitorQuery.isFetching}
|
|
>
|
|
{keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{keyAlertOpError ? (
|
|
<div className="pill pill--high" style={{ marginTop: 8 }}>
|
|
{t("common.opFailed")}: {keyAlertOpError}
|
|
</div>
|
|
) : null}
|
|
<div className="filter-inline" style={{ marginTop: 12 }}>
|
|
<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 style={{ whiteSpace: "nowrap" }}>
|
|
<label style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
|
<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 style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setKeyAlertEditRule(rule);
|
|
setKeyAlertEditNeTypes(rule.ne_types || []);
|
|
}}
|
|
disabled={keyAlertEditMutation.isPending}
|
|
>
|
|
{t("ume.keyAlert.edit")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (window.confirm(t("ume.keyAlert.confirmDelete"))) {
|
|
keyAlertDeleteMutation.mutate(rule.notification_id);
|
|
}
|
|
}}
|
|
disabled={keyAlertDeleteMutation.isPending}
|
|
>
|
|
{t("ume.keyAlert.delete")}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{!keyAlertMonitorQuery.isLoading && keyAlertRules.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={9}>{t("ume.keyAlert.emptyRules")}</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="pager pt-list-pager">
|
|
<div className="muted">
|
|
{t("common.pagerMeta", { total: keyAlertTotal, page: keyAlertPage, pages: keyAlertPages })}
|
|
</div>
|
|
<div className="btn-row">
|
|
<button
|
|
className="pager__btn"
|
|
onClick={() => setKeyAlertPage(Math.max(1, keyAlertPage - 1))}
|
|
disabled={keyAlertPage <= 1}
|
|
>
|
|
{t("common.prevPage")}
|
|
</button>
|
|
<button
|
|
className="pager__btn"
|
|
onClick={() => setKeyAlertPage(keyAlertPage + 1)}
|
|
disabled={keyAlertPage >= keyAlertPages}
|
|
>
|
|
{t("common.nextPage")}
|
|
</button>
|
|
<select
|
|
className="pager__size"
|
|
value={String(keyAlertPageSize)}
|
|
onChange={(e) => {
|
|
setKeyAlertPageSize(Number(e.target.value) || 20);
|
|
setKeyAlertPage(1);
|
|
}}
|
|
>
|
|
<option value="10">10 / page</option>
|
|
<option value="20">20 / page</option>
|
|
<option value="50">50 / page</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</article>
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<div className="panel__toolbar">
|
|
<h2>{t("ume.syncStatus.title")}</h2>
|
|
<button type="button" className="link-btn" onClick={() => setSyncStatusPanelOpen((x) => !x)}>
|
|
{syncStatusPanelOpen ? t("ume.syncStatus.hidePanel") : t("ume.syncStatus.showPanel")}
|
|
</button>
|
|
</div>
|
|
{syncStatusPanelOpen ? (
|
|
<div className="pt-list">
|
|
<div className="actions-row actions-row--inline">
|
|
<button type="button" onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} disabled={syncStatusQuery.isFetching}>
|
|
{t("common.refresh")}
|
|
</button>
|
|
</div>
|
|
<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>error</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(syncStatusQuery.data?.items || []).map((x) => (
|
|
<tr key={x.id}>
|
|
<td>{x.id}</td>
|
|
<td>{x.domain}</td>
|
|
<td>{x.status}</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>{x.error_message || t("common.empty")}</td>
|
|
</tr>
|
|
))}
|
|
{!syncStatusQuery.isLoading && (syncStatusQuery.data?.items || []).length === 0 && (
|
|
<tr>
|
|
<td colSpan={10}>{t("ume.syncStatus.empty")}</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="pager pt-list-pager">
|
|
<div className="muted">{t("common.pagerMeta", { total: syncTotal, page: syncPage, pages: syncPages })}</div>
|
|
<div className="btn-row">
|
|
<button className="pager__btn" onClick={() => setSyncPage(Math.max(1, syncPage - 1))} disabled={syncPage <= 1}>
|
|
{t("common.prevPage")}
|
|
</button>
|
|
<button className="pager__btn" onClick={() => setSyncPage(syncPage + 1)} disabled={syncPage >= syncPages}>
|
|
{t("common.nextPage")}
|
|
</button>
|
|
<select
|
|
className="pager__size"
|
|
value={String(syncPageSize)}
|
|
onChange={(e) => {
|
|
setSyncPageSize(Number(e.target.value) || 20);
|
|
setSyncPage(1);
|
|
}}
|
|
>
|
|
<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>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<div className="panel__toolbar">
|
|
<h2>{t("ume.ne.title")}</h2>
|
|
<button type="button" className="link-btn" onClick={() => setNePanelOpen((x) => !x)}>
|
|
{nePanelOpen ? t("ume.ne.hidePanel") : t("ume.ne.showPanel")}
|
|
</button>
|
|
</div>
|
|
{nePanelOpen ? (
|
|
<div className="pt-list">
|
|
<div className="filter-inline">
|
|
<input value={neKeyword} placeholder={t("ume.ne.keywordPh")} onChange={(e) => setNeKeyword(e.target.value)} />
|
|
<button type="button" onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll })}>
|
|
{t("common.query")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
title={t("ume.ne.clearTitle")}
|
|
onClick={() => {
|
|
setNeKeyword("");
|
|
setNePage(1);
|
|
}}
|
|
disabled={!neKeyword.trim()}
|
|
>
|
|
{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>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(neQuery.data?.items || []).map((x) => (
|
|
<Fragment key={x.ne_id}>
|
|
<tr>
|
|
<td>
|
|
<button
|
|
className="link-btn"
|
|
onClick={() => setExpandedNeId(expandedNeId === x.ne_id ? "" : x.ne_id)}
|
|
title={expandedNeId === x.ne_id ? t("ume.ne.collapse") : t("ume.ne.expand")}
|
|
>
|
|
{x.ne_id}
|
|
</button>
|
|
</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>
|
|
</tr>
|
|
{expandedNeId === x.ne_id ? (
|
|
<tr>
|
|
<td colSpan={9}>
|
|
<div style={{ fontSize: 12, display: "grid", gridTemplateColumns: "repeat(3, minmax(180px, 1fr))", gap: 8 }}>
|
|
<div>consistent_state: {x.consistent_state || t("common.empty")}</div>
|
|
<div>admin_status: {x.admin_status || t("common.empty")}</div>
|
|
<div>connection_status: {x.connection_status || t("common.empty")}</div>
|
|
<div>maintain_status: {x.maintain_status || t("common.empty")}</div>
|
|
<div>address_type: {x.address_type || t("common.empty")}</div>
|
|
<div>location: {x.location || t("common.empty")}</div>
|
|
<div>loopback: {x.loopback || t("common.empty")}</div>
|
|
<div>net_mask: {x.net_mask || t("common.empty")}</div>
|
|
<div>mac: {x.mac || t("common.empty")}</div>
|
|
<div>interface_version: {x.interface_version || t("common.empty")}</div>
|
|
<div>create_time: {x.create_time || t("common.empty")}</div>
|
|
<div>creator: {x.creator || t("common.empty")}</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="pager pt-list-pager">
|
|
<div className="muted">{t("common.pagerMeta", { total: neTotal, page: nePage, pages: nePages })}</div>
|
|
<div className="btn-row">
|
|
<button className="pager__btn" onClick={() => setNePage(Math.max(1, nePage - 1))} disabled={nePage <= 1}>
|
|
{t("common.prevPage")}
|
|
</button>
|
|
<button className="pager__btn" onClick={() => setNePage(nePage + 1)} disabled={nePage >= nePages}>
|
|
{t("common.nextPage")}
|
|
</button>
|
|
<select
|
|
className="pager__size"
|
|
value={String(nePageSize)}
|
|
onChange={(e) => {
|
|
setNePageSize(Number(e.target.value) || 50);
|
|
setNePage(1);
|
|
}}
|
|
>
|
|
<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>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<div className="panel__toolbar">
|
|
<h2>{t("ume.alarms.title")}</h2>
|
|
</div>
|
|
<p className="muted">
|
|
{t("ume.alarms.movedHint")}{" "}
|
|
<a href="/network/alarms" onClick={(e) => {
|
|
e.preventDefault();
|
|
openOrFocusModule({ moduleId: "network", path: "/network/alarms" });
|
|
}}>
|
|
{t("network.nav.alarms")}
|
|
</a>
|
|
</p>
|
|
</section>
|
|
</div>
|
|
|
|
{keyAlertEditRule ? (
|
|
<div className="modal-backdrop" role="presentation" onClick={() => setKeyAlertEditRule(null)}>
|
|
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
|
<h3>{t("ume.keyAlert.editTitle")}</h3>
|
|
<p className="form-hint 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" style={{ marginBottom: 4 }}>
|
|
{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
|
|
style={{
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: "8px 16px",
|
|
maxHeight: 240,
|
|
overflowY: "auto",
|
|
padding: "6px 8px",
|
|
border: "1px solid var(--border, #ddd)",
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
{(keyAlertNeTypesQuery.data?.items || []).map((item) => (
|
|
<label
|
|
key={item.ne_type}
|
|
style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}
|
|
>
|
|
<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>
|
|
)}
|
|
<div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
|
|
{t("ume.keyAlert.neTypesHint")}
|
|
</div>
|
|
<div className="actions-row actions-row--inline" style={{ marginTop: 16 }}>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
keyAlertEditMutation.mutate({
|
|
ruleKey: keyAlertEditRule.notification_id,
|
|
ne_types: keyAlertEditNeTypes,
|
|
})
|
|
}
|
|
disabled={keyAlertEditMutation.isPending}
|
|
>
|
|
{keyAlertEditMutation.isPending ? t("ume.keyAlert.saving") : t("ume.keyAlert.save")}
|
|
</button>
|
|
<button type="button" onClick={() => setKeyAlertEditRule(null)} disabled={keyAlertEditMutation.isPending}>
|
|
{t("ume.keyAlert.cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|