mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 12:40:44 +08:00
fix(ume): global clear-push setting, schema migration, and help hints
Make forward-on-clear a global monitor toggle, run key-alert DDL in an isolated startup transaction, and fix HelpHint popovers plus API errors when the server returns non-JSON responses. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
34db62c8d2
commit
b54a6e5ad4
12 changed files with 266 additions and 68 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
|
|
@ -7,14 +8,63 @@ type Props = {
|
|||
nowrap?: boolean;
|
||||
};
|
||||
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
const POPOVER_MAX_WIDTH = 360;
|
||||
|
||||
export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
|
||||
const rootRef = useRef<HTMLSpanElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updatePopoverPosition = () => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const popover = popoverRef.current;
|
||||
const maxWidth = Math.min(POPOVER_MAX_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2);
|
||||
const popWidth = popover?.offsetWidth || maxWidth;
|
||||
const popHeight = popover?.offsetHeight || 0;
|
||||
|
||||
let left = align === "end" ? rect.right - popWidth : rect.left;
|
||||
left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - popWidth - VIEWPORT_MARGIN));
|
||||
|
||||
let top = rect.bottom + 6;
|
||||
if (popHeight > 0 && top + popHeight > window.innerHeight - VIEWPORT_MARGIN) {
|
||||
top = Math.max(VIEWPORT_MARGIN, rect.top - popHeight - 6);
|
||||
}
|
||||
|
||||
setPopoverStyle({
|
||||
position: "fixed",
|
||||
top,
|
||||
left,
|
||||
right: "auto",
|
||||
maxWidth,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePopoverPosition();
|
||||
const raf = window.requestAnimationFrame(updatePopoverPosition);
|
||||
const onReflow = () => updatePopoverPosition();
|
||||
window.addEventListener("resize", onReflow);
|
||||
window.addEventListener("scroll", onReflow, true);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", onReflow);
|
||||
window.removeEventListener("scroll", onReflow, true);
|
||||
};
|
||||
}, [open, align, text, nowrap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (ev: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(ev.target as Node)) setOpen(false);
|
||||
const root = rootRef.current;
|
||||
const target = ev.target as Node;
|
||||
if (root?.contains(target)) return;
|
||||
if (target instanceof Element && target.closest(".help-hint__popover")) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Escape") setOpen(false);
|
||||
|
|
@ -34,18 +84,29 @@ export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: P
|
|||
className="help-hint__trigger"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className={`help-hint__popover${nowrap ? " help-hint__popover--nowrap" : ""}`}
|
||||
role="tooltip"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
) : null}
|
||||
{open
|
||||
? createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={`help-hint__popover help-hint__popover--portal${
|
||||
nowrap ? " help-hint__popover--nowrap" : ""
|
||||
}`}
|
||||
style={popoverStyle}
|
||||
role="tooltip"
|
||||
>
|
||||
{text}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -353,7 +353,7 @@ const en = {
|
|||
label: "Rule description",
|
||||
labelPh: "Required, e.g. BGP Down",
|
||||
forwardOnClear: "Push on alarm clear",
|
||||
forwardOnClearHelp: "By default only new/updated alarms are pushed. When enabled, a clear notification is also sent when UME reports the alarm cleared.",
|
||||
forwardOnClearHelp: "Global switch for all monitor rules. By default only new/updated alarms are pushed; when enabled, clear notifications are also sent when UME reports alarms cleared.",
|
||||
add: "Add rules",
|
||||
adding: "Adding…",
|
||||
delete: "Delete",
|
||||
|
|
|
|||
|
|
@ -351,7 +351,7 @@ const zh = {
|
|||
label: "规则描述",
|
||||
labelPh: "必填,如 BGP Down",
|
||||
forwardOnClear: "告警清除时也推送",
|
||||
forwardOnClearHelp: "默认只在告警新增/更新时推送。勾选后,当 UME 上报告警已清除(从当前告警表删除)时,也会向 WhatsApp 发送一条清除通知。",
|
||||
forwardOnClearHelp: "全局开关:对所有监控规则生效。默认只在告警新增/更新时推送;勾选后,UME 上报告警已清除时也会向 WhatsApp 发送清除通知。",
|
||||
add: "添加规则",
|
||||
adding: "添加中…",
|
||||
delete: "删除",
|
||||
|
|
|
|||
|
|
@ -708,6 +708,11 @@ pre {
|
|||
right: 0;
|
||||
}
|
||||
|
||||
.help-hint__popover--portal {
|
||||
z-index: 2000;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.help-hint__popover--nowrap {
|
||||
width: max-content;
|
||||
max-width: min(90vw, 720px);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
refreshUmeToken,
|
||||
upsertUmeKeyAlertRule,
|
||||
deleteUmeKeyAlertRule,
|
||||
updateUmeKeyAlertMonitorConfig,
|
||||
} from "../services/api";
|
||||
import { HelpHint } from "../components/HelpHint";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
|
|
@ -50,7 +51,6 @@ export function UmePage() {
|
|||
const [keyAlertMatchType, setKeyAlertMatchType] = useState<"notification_id" | "keyword">("keyword");
|
||||
const [keyAlertMatchValue, setKeyAlertMatchValue] = useState("");
|
||||
const [keyAlertLabel, setKeyAlertLabel] = useState("");
|
||||
const [keyAlertForwardOnClear, setKeyAlertForwardOnClear] = useState(false);
|
||||
const [keyAlertOpError, setKeyAlertOpError] = useState("");
|
||||
const [keyAlertKeywordHints, setKeyAlertKeywordHints] = useState<string[]>([]);
|
||||
const [keyAlertIdHints, setKeyAlertIdHints] = useState<
|
||||
|
|
@ -300,7 +300,6 @@ export function UmePage() {
|
|||
match_value: keyAlertMatchValue.trim(),
|
||||
label: keyAlertLabel.trim(),
|
||||
enabled: true,
|
||||
forward_on_clear: keyAlertForwardOnClear,
|
||||
}),
|
||||
onMutate: () => setKeyAlertOpError(""),
|
||||
onSuccess: async () => {
|
||||
|
|
@ -332,6 +331,19 @@ export function UmePage() {
|
|||
},
|
||||
});
|
||||
|
||||
const keyAlertConfigMutation = useMutation({
|
||||
mutationFn: (forwardOnClear: boolean) => updateUmeKeyAlertMonitorConfig({ forward_on_clear: forwardOnClear }),
|
||||
onMutate: () => setKeyAlertOpError(""),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor });
|
||||
},
|
||||
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);
|
||||
|
|
@ -341,6 +353,7 @@ export function UmePage() {
|
|||
|
||||
const keyAlertForwarder = keyAlertMonitorQuery.data?.forwarder;
|
||||
const keyAlertRules = keyAlertMonitorQuery.data?.rules || [];
|
||||
const keyAlertForwardOnClear = Boolean(keyAlertMonitorQuery.data?.config?.forward_on_clear);
|
||||
const oclawWsPill =
|
||||
!keyAlertForwarder?.enabled
|
||||
? "unknown"
|
||||
|
|
@ -697,23 +710,22 @@ export function UmePage() {
|
|||
className="actions-row actions-row--inline"
|
||||
style={{ marginTop: 8, flexWrap: "wrap", alignItems: "center", justifyContent: "space-between" }}
|
||||
>
|
||||
<label
|
||||
className="muted"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keyAlertForwardOnClear}
|
||||
onChange={(e) => setKeyAlertForwardOnClear(e.target.checked)}
|
||||
/>
|
||||
{t("ume.keyAlert.forwardOnClear")}
|
||||
<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="end"
|
||||
nowrap
|
||||
align="start"
|
||||
/>
|
||||
</label>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor })}
|
||||
|
|
@ -734,7 +746,6 @@ export function UmePage() {
|
|||
<th>{t("ume.keyAlert.colType")}</th>
|
||||
<th>{t("ume.keyAlert.colMatch")}</th>
|
||||
<th>{t("ume.keyAlert.colLabel")}</th>
|
||||
<th>{t("ume.keyAlert.colForwardClear")}</th>
|
||||
<th>{t("ume.keyAlert.colPublished")}</th>
|
||||
<th>{t("ume.keyAlert.colAttempts")}</th>
|
||||
<th>{t("ume.keyAlert.colLast")}</th>
|
||||
|
|
@ -751,7 +762,6 @@ export function UmePage() {
|
|||
</td>
|
||||
<td>{rule.match_value || rule.notification_id}</td>
|
||||
<td>{rule.label || t("common.empty")}</td>
|
||||
<td>{rule.forward_on_clear ? t("ume.keyAlert.yes") : t("ume.keyAlert.no")}</td>
|
||||
<td>{Number(rule.forward_stats?.published_ok || 0)}</td>
|
||||
<td>{Number(rule.forward_stats?.attempts || 0)}</td>
|
||||
<td>
|
||||
|
|
@ -776,7 +786,7 @@ export function UmePage() {
|
|||
))}
|
||||
{!keyAlertMonitorQuery.isLoading && keyAlertRules.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8}>{t("ume.keyAlert.emptyRules")}</td>
|
||||
<td colSpan={7}>{t("ume.keyAlert.emptyRules")}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ import type {
|
|||
UmeTokenStatus,
|
||||
} from "../types";
|
||||
|
||||
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
|
||||
const text = await res.text();
|
||||
if (!text) return {};
|
||||
try {
|
||||
return JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
const snippet = text.replace(/\s+/g, " ").trim().slice(0, 160);
|
||||
throw new Error(res.ok ? "invalid_json_response" : `${res.status} ${snippet || res.statusText}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const apiGet = async <T,>(path: string): Promise<T> => {
|
||||
const res = await fetch(path, { headers: { accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`${res.status} ${path}`);
|
||||
|
|
@ -28,9 +39,8 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
|
||||
const data = await parseApiResponse(res);
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
return data as T;
|
||||
};
|
||||
|
||||
|
|
@ -40,17 +50,15 @@ export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
|
||||
const data = await parseApiResponse(res);
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
return data as T;
|
||||
};
|
||||
|
||||
export const apiDelete = async <T,>(path: string): Promise<T> => {
|
||||
const res = await fetch(path, { method: "DELETE", headers: { accept: "application/json" } });
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
|
||||
const data = await parseApiResponse(res);
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
return data as T;
|
||||
};
|
||||
|
||||
|
|
@ -79,9 +87,11 @@ export const upsertUmeKeyAlertRule = (payload: {
|
|||
match_value: string;
|
||||
label: string;
|
||||
enabled?: boolean;
|
||||
forward_on_clear?: boolean;
|
||||
}) => apiPost<{ ok: boolean; item?: unknown }>("/v1/ume/key-alert-rules", payload);
|
||||
|
||||
export const updateUmeKeyAlertMonitorConfig = (payload: { forward_on_clear: boolean }) =>
|
||||
apiPatch<{ ok: boolean; config: { forward_on_clear: boolean } }>("/v1/ume/key-alert-monitor/config", payload);
|
||||
|
||||
export const deleteUmeKeyAlertRule = (ruleKey: string) =>
|
||||
apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ export type UmeKeyAlertRuleItem = {
|
|||
match_type: "notification_id" | "keyword";
|
||||
match_value: string;
|
||||
enabled: boolean;
|
||||
forward_on_clear: boolean;
|
||||
label: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
@ -45,6 +44,9 @@ export type UmeKeyAlertForwarderStatus = {
|
|||
export type UmeKeyAlertMonitorResponse = {
|
||||
ok: boolean;
|
||||
rules: UmeKeyAlertRuleItem[];
|
||||
config?: {
|
||||
forward_on_clear: boolean;
|
||||
};
|
||||
forwarder: UmeKeyAlertForwarderStatus;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue