From 664775927b56472188c2a751587c6e2612b4fa58 Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 27 May 2026 10:15:22 +0800 Subject: [PATCH] fix(ume): idle runtime tasks show running with last sync time; English labels and help hint Show inventory/alarms schedulers as running with DB last_run_at during debounce; English interval labels; subscription help on ? click. Co-authored-by: Cursor --- netx_api/main.py | 49 ++++++++++++++++++------- web/src/index.css | 51 ++++++++++++++++++++++++++ web/src/pages/UmePage.tsx | 77 +++++++++++++++++++++++++++++++++------ 3 files changed, 152 insertions(+), 25 deletions(-) diff --git a/netx_api/main.py b/netx_api/main.py index 42f434f..58fde86 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -185,23 +185,23 @@ def _runtime_task_interval_fields(task_id: str) -> tuple[int | None, str]: """Effective loop interval as configured at process start (matches startup clamps).""" if task_id == "token_keepalive": if not bool(getattr(settings, "ume_keepalive_enabled", True)): - return None, "未启用" + return None, "disabled" interval_s = int(getattr(settings, "ume_keepalive_interval_s", 600) or 600) eff = max(30, min(interval_s, 3600)) return eff, _format_runtime_interval_label(eff) if task_id == "alarms_current_auto_sync": if not bool(getattr(settings, "ume_sync_alarms_current_enabled", True)): - return None, "未启用" + return None, "disabled" interval_s = int(getattr(settings, "ume_sync_alarms_current_interval_s", 18000) or 18000) eff = max(30, min(interval_s, 86400)) return eff, _format_runtime_interval_label(eff) if task_id == "alarms_current_ws_consumer": if not bool(getattr(settings, "ume_alarm_ws_enabled", True)): - return None, "未启用" - return None, "实时" + return None, "disabled" + return None, "realtime" if task_id == "inventory_auto_sync": if not bool(getattr(settings, "ume_sync_inventory_auto_enabled", True)): - return None, "未启用" + return None, "disabled" hours = int(getattr(settings, "ume_sync_inventory_every_hours", 48) or 48) hours = max(1, min(hours, 168)) eff = int(hours * 3600) @@ -346,8 +346,8 @@ def _sleep_or_until_paused(task_id: str, total_s: float) -> None: ev.clear() -def _seconds_since_last_finished_job(db: Session, domain: str) -> float | None: - """Seconds since latest job with ended_at for domain (done or failed). None if none.""" +def _last_finished_job_ended_at(db: Session, domain: str) -> datetime | None: + """Latest finished sync job end time for domain (success or failed).""" row = ( db.query(UmeSyncJob) .filter( @@ -360,12 +360,34 @@ def _seconds_since_last_finished_job(db: Session, domain: str) -> float | None: ) if not row or row.ended_at is None: return None - end = _ensure_utc(row.ended_at) + return _ensure_utc(row.ended_at) + + +def _seconds_since_last_finished_job(db: Session, domain: str) -> float | None: + """Seconds since latest job with ended_at for domain (done or failed). None if none.""" + end = _last_finished_job_ended_at(db, domain) if end is None: return None return max(0.0, (datetime.now(timezone.utc) - end).total_seconds()) +def _refresh_runtime_task_idle(task_id: str, domain: str, *, last_error: str | None = None) -> None: + """Mark scheduled sync task running; last_run_at = last finished job time (idle / debounce).""" + with _UME_RUNTIME_LOCK: + prev_error = str((_UME_RUNTIME_TASKS.get(task_id) or {}).get("last_error") or "") + db = SessionLocal() + try: + ended = _last_finished_job_ended_at(db, domain) + finally: + db.close() + _set_runtime_task( + task_id, + status="running", + last_run_at=ended, + last_error=prev_error if last_error is None else last_error, + ) + + def _maybe_wait_for_sync_interval( *, task_id: str, @@ -384,6 +406,7 @@ def _maybe_wait_for_sync_interval( elapsed = _seconds_since_last_finished_job(db, domain) finally: db.close() + _refresh_runtime_task_idle(task_id, domain) if elapsed is None: _schedule_log.info("%s: no prior finished job for %s, sync now", label, domain) return @@ -392,8 +415,6 @@ def _maybe_wait_for_sync_interval( return wait_s = float(interval_s) - elapsed _schedule_log.info("%s: last finished %.0fs ago, wait %.0fs before sync", label, elapsed, wait_s) - # Do not write debounce/wait text to last_error — it is not an error and would - # overwrite the cleared state right after a successful sync on the next loop tick. _sleep_or_until_paused(task_id, wait_s) @@ -777,6 +798,7 @@ def on_startup() -> None: alarms_interval_s = max(30, min(alarms_interval_s, 86400)) def _alarms_current_sync_loop() -> None: + _refresh_runtime_task_idle("alarms_current_auto_sync", "alarms_current") while True: try: _schedule_log.info( @@ -790,10 +812,9 @@ def on_startup() -> None: bool(getattr(settings, "ume_sync_alarms_current_skip_when_ws", True)) and is_wss_active_for_current_alarms() ): - _set_runtime_task( + _refresh_runtime_task_idle( "alarms_current_auto_sync", - status="running", - last_run_at=datetime.now(timezone.utc), + "alarms_current", last_error="WSS 实时接收中,已跳过 REST 同步", ) time.sleep(max(30, min(alarms_interval_s, 300))) @@ -854,8 +875,10 @@ def on_startup() -> None: hours = int(getattr(settings, "ume_sync_inventory_every_hours", 48) or 48) hours = max(1, min(hours, 168)) inventory_interval_s = int(hours * 3600) + _refresh_runtime_task_idle("inventory_auto_sync", "inventory") def _inventory_auto_sync_loop() -> None: + _refresh_runtime_task_idle("inventory_auto_sync", "inventory") while True: try: _schedule_log.info( diff --git a/web/src/index.css b/web/src/index.css index fd99f46..6b5e5bc 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -358,6 +358,57 @@ pre { color: #a8bad5; } +.card-title-with-hint { + display: flex; + align-items: center; + gap: 8px; + margin: 0; +} + +.help-hint { + position: relative; + display: inline-flex; + vertical-align: middle; +} + +.help-hint__trigger { + width: 18px; + height: 18px; + padding: 0; + border: 1px solid #4a5e7e; + border-radius: 50%; + background: #14253e; + color: #a8bad5; + font-size: 12px; + font-weight: 700; + line-height: 1; + cursor: pointer; +} + +.help-hint__trigger:hover, +.help-hint__trigger:focus-visible { + border-color: #3d82f7; + color: #dce8ff; + outline: none; +} + +.help-hint__popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 40; + width: min(360px, 80vw); + padding: 10px 12px; + border: 1px solid #2c405f; + border-radius: 8px; + background: #0f1e34; + color: #c1cde0; + font-size: 13px; + font-weight: 400; + line-height: 1.45; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + .mini-chart__bar-wrap { height: 10px; border-radius: 999px; diff --git a/web/src/pages/UmePage.tsx b/web/src/pages/UmePage.tsx index 67d2c43..42258db 100644 --- a/web/src/pages/UmePage.tsx +++ b/web/src/pages/UmePage.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiPost, @@ -21,6 +21,60 @@ export type UmePageProps = { toastError?: (message: string) => void; }; +const UME_ALARM_SUBSCRIPTION_HELP = + "订阅需手动建立/取消;建立后(或重启后若库中仍有有效订阅)后台会自动连接 WSS 接收实时告警。后台任务 alarms_current_ws_consumer 的 pause/resume 会停止或恢复 WSS 连接(不会取消 UME 订阅)。"; + +const RUNTIME_INTERVAL_LABEL_EN: Record = { + 未启用: "disabled", + 实时: "realtime", +}; + +function runtimeIntervalLabel(label?: string | null): string { + const raw = String(label ?? "").trim(); + if (!raw) return "—"; + return RUNTIME_INTERVAL_LABEL_EN[raw] ?? raw; +} + +function HelpHint({ text }: { text: string }) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDocClick = (ev: MouseEvent) => { + if (!rootRef.current?.contains(ev.target as Node)) setOpen(false); + }; + const onKey = (ev: KeyboardEvent) => { + if (ev.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( + + + {open ? ( +
+ {text} +
+ ) : null} +
+ ); +} + export function UmePage({ toastOk, toastError }: UmePageProps) { const queryClient = useQueryClient(); const [tokenOpError, setTokenOpError] = useState(""); @@ -366,11 +420,10 @@ export function UmePage({ toastOk, toastError }: UmePageProps) { )}
-

UME 告警订阅(WebSocket)

-

- 订阅需手动建立/取消;建立后(或重启后若库中仍有有效订阅)后台会自动连接 WSS 接收实时告警。 - 后台任务 alarms_current_ws_consumer 的「暂停/开始」会停止或恢复 WSS 连接(不会取消 UME 订阅)。 -

+

+ UME 告警订阅(WebSocket) + +

订阅: {subscriptionActive ? "已建立" : "未建立"} @@ -583,13 +636,13 @@ export function UmePage({ toastOk, toastError }: UmePageProps) { task - 间隔 + interval status - + last_run_at last_error - 操作 + actions @@ -597,7 +650,7 @@ export function UmePage({ toastOk, toastError }: UmePageProps) { {x.task} - {x.interval_label ?? "—"} + {runtimeIntervalLabel(x.interval_label)} {x.status} {x.last_run_at ? formatSystemTime(x.last_run_at) : "-"} @@ -610,7 +663,7 @@ export function UmePage({ toastOk, toastError }: UmePageProps) { disabled={runtimeTaskMutation.isPending} onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })} > - 开始 + resume ) : ( )}