mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 02:10:45 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
6701aee3c7
commit
664775927b
3 changed files with 152 additions and 25 deletions
|
|
@ -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)."""
|
"""Effective loop interval as configured at process start (matches startup clamps)."""
|
||||||
if task_id == "token_keepalive":
|
if task_id == "token_keepalive":
|
||||||
if not bool(getattr(settings, "ume_keepalive_enabled", True)):
|
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)
|
interval_s = int(getattr(settings, "ume_keepalive_interval_s", 600) or 600)
|
||||||
eff = max(30, min(interval_s, 3600))
|
eff = max(30, min(interval_s, 3600))
|
||||||
return eff, _format_runtime_interval_label(eff)
|
return eff, _format_runtime_interval_label(eff)
|
||||||
if task_id == "alarms_current_auto_sync":
|
if task_id == "alarms_current_auto_sync":
|
||||||
if not bool(getattr(settings, "ume_sync_alarms_current_enabled", True)):
|
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)
|
interval_s = int(getattr(settings, "ume_sync_alarms_current_interval_s", 18000) or 18000)
|
||||||
eff = max(30, min(interval_s, 86400))
|
eff = max(30, min(interval_s, 86400))
|
||||||
return eff, _format_runtime_interval_label(eff)
|
return eff, _format_runtime_interval_label(eff)
|
||||||
if task_id == "alarms_current_ws_consumer":
|
if task_id == "alarms_current_ws_consumer":
|
||||||
if not bool(getattr(settings, "ume_alarm_ws_enabled", True)):
|
if not bool(getattr(settings, "ume_alarm_ws_enabled", True)):
|
||||||
return None, "未启用"
|
return None, "disabled"
|
||||||
return None, "实时"
|
return None, "realtime"
|
||||||
if task_id == "inventory_auto_sync":
|
if task_id == "inventory_auto_sync":
|
||||||
if not bool(getattr(settings, "ume_sync_inventory_auto_enabled", True)):
|
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 = int(getattr(settings, "ume_sync_inventory_every_hours", 48) or 48)
|
||||||
hours = max(1, min(hours, 168))
|
hours = max(1, min(hours, 168))
|
||||||
eff = int(hours * 3600)
|
eff = int(hours * 3600)
|
||||||
|
|
@ -346,8 +346,8 @@ def _sleep_or_until_paused(task_id: str, total_s: float) -> None:
|
||||||
ev.clear()
|
ev.clear()
|
||||||
|
|
||||||
|
|
||||||
def _seconds_since_last_finished_job(db: Session, domain: str) -> float | None:
|
def _last_finished_job_ended_at(db: Session, domain: str) -> datetime | None:
|
||||||
"""Seconds since latest job with ended_at for domain (done or failed). None if none."""
|
"""Latest finished sync job end time for domain (success or failed)."""
|
||||||
row = (
|
row = (
|
||||||
db.query(UmeSyncJob)
|
db.query(UmeSyncJob)
|
||||||
.filter(
|
.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:
|
if not row or row.ended_at is None:
|
||||||
return 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:
|
if end is None:
|
||||||
return None
|
return None
|
||||||
return max(0.0, (datetime.now(timezone.utc) - end).total_seconds())
|
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(
|
def _maybe_wait_for_sync_interval(
|
||||||
*,
|
*,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|
@ -384,6 +406,7 @@ def _maybe_wait_for_sync_interval(
|
||||||
elapsed = _seconds_since_last_finished_job(db, domain)
|
elapsed = _seconds_since_last_finished_job(db, domain)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
_refresh_runtime_task_idle(task_id, domain)
|
||||||
if elapsed is None:
|
if elapsed is None:
|
||||||
_schedule_log.info("%s: no prior finished job for %s, sync now", label, domain)
|
_schedule_log.info("%s: no prior finished job for %s, sync now", label, domain)
|
||||||
return
|
return
|
||||||
|
|
@ -392,8 +415,6 @@ def _maybe_wait_for_sync_interval(
|
||||||
return
|
return
|
||||||
wait_s = float(interval_s) - elapsed
|
wait_s = float(interval_s) - elapsed
|
||||||
_schedule_log.info("%s: last finished %.0fs ago, wait %.0fs before sync", label, elapsed, wait_s)
|
_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)
|
_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))
|
alarms_interval_s = max(30, min(alarms_interval_s, 86400))
|
||||||
|
|
||||||
def _alarms_current_sync_loop() -> None:
|
def _alarms_current_sync_loop() -> None:
|
||||||
|
_refresh_runtime_task_idle("alarms_current_auto_sync", "alarms_current")
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
_schedule_log.info(
|
_schedule_log.info(
|
||||||
|
|
@ -790,10 +812,9 @@ def on_startup() -> None:
|
||||||
bool(getattr(settings, "ume_sync_alarms_current_skip_when_ws", True))
|
bool(getattr(settings, "ume_sync_alarms_current_skip_when_ws", True))
|
||||||
and is_wss_active_for_current_alarms()
|
and is_wss_active_for_current_alarms()
|
||||||
):
|
):
|
||||||
_set_runtime_task(
|
_refresh_runtime_task_idle(
|
||||||
"alarms_current_auto_sync",
|
"alarms_current_auto_sync",
|
||||||
status="running",
|
"alarms_current",
|
||||||
last_run_at=datetime.now(timezone.utc),
|
|
||||||
last_error="WSS 实时接收中,已跳过 REST 同步",
|
last_error="WSS 实时接收中,已跳过 REST 同步",
|
||||||
)
|
)
|
||||||
time.sleep(max(30, min(alarms_interval_s, 300)))
|
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 = int(getattr(settings, "ume_sync_inventory_every_hours", 48) or 48)
|
||||||
hours = max(1, min(hours, 168))
|
hours = max(1, min(hours, 168))
|
||||||
inventory_interval_s = int(hours * 3600)
|
inventory_interval_s = int(hours * 3600)
|
||||||
|
_refresh_runtime_task_idle("inventory_auto_sync", "inventory")
|
||||||
|
|
||||||
def _inventory_auto_sync_loop() -> None:
|
def _inventory_auto_sync_loop() -> None:
|
||||||
|
_refresh_runtime_task_idle("inventory_auto_sync", "inventory")
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
_schedule_log.info(
|
_schedule_log.info(
|
||||||
|
|
|
||||||
|
|
@ -358,6 +358,57 @@ pre {
|
||||||
color: #a8bad5;
|
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 {
|
.mini-chart__bar-wrap {
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
|
||||||
|
|
@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
apiPost,
|
apiPost,
|
||||||
|
|
@ -21,6 +21,60 @@ export type UmePageProps = {
|
||||||
toastError?: (message: string) => void;
|
toastError?: (message: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UME_ALARM_SUBSCRIPTION_HELP =
|
||||||
|
"订阅需手动建立/取消;建立后(或重启后若库中仍有有效订阅)后台会自动连接 WSS 接收实时告警。后台任务 alarms_current_ws_consumer 的 pause/resume 会停止或恢复 WSS 连接(不会取消 UME 订阅)。";
|
||||||
|
|
||||||
|
const RUNTIME_INTERVAL_LABEL_EN: Record<string, string> = {
|
||||||
|
未启用: "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<HTMLSpanElement>(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 (
|
||||||
|
<span className="help-hint" ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="help-hint__trigger"
|
||||||
|
aria-label="帮助说明"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="help-hint__popover" role="tooltip">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function UmePage({ toastOk, toastError }: UmePageProps) {
|
export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [tokenOpError, setTokenOpError] = useState("");
|
const [tokenOpError, setTokenOpError] = useState("");
|
||||||
|
|
@ -366,11 +420,10 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
<article className="card card--full">
|
<article className="card card--full">
|
||||||
<h3>UME 告警订阅(WebSocket)</h3>
|
<h3 className="card-title-with-hint">
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
UME 告警订阅(WebSocket)
|
||||||
订阅需手动建立/取消;建立后(或重启后若库中仍有有效订阅)后台会自动连接 WSS 接收实时告警。
|
<HelpHint text={UME_ALARM_SUBSCRIPTION_HELP} />
|
||||||
后台任务 <code>alarms_current_ws_consumer</code> 的「暂停/开始」会停止或恢复 WSS 连接(不会取消 UME 订阅)。
|
</h3>
|
||||||
</p>
|
|
||||||
<div className="actions-row actions-row--inline">
|
<div className="actions-row actions-row--inline">
|
||||||
<span className={`conn-pill conn-pill--${subscriptionActive ? "up" : "down"}`}>
|
<span className={`conn-pill conn-pill--${subscriptionActive ? "up" : "down"}`}>
|
||||||
订阅: {subscriptionActive ? "已建立" : "未建立"}
|
订阅: {subscriptionActive ? "已建立" : "未建立"}
|
||||||
|
|
@ -583,13 +636,13 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>task</th>
|
<th>task</th>
|
||||||
<th title="相邻两次循环 sleep 的配置间隔(与服务启动时 clamp 一致)">间隔</th>
|
<th title="Configured sleep between loop iterations (clamped at process start)">interval</th>
|
||||||
<th>status</th>
|
<th>status</th>
|
||||||
<th title="每次周期开始时刷新;若单次同步耗时很长,间隔列仍为周期 sleep,实际两轮完成间隔 ≈ 同步耗时 + 间隔">
|
<th title="Last finished sync for scheduled tasks; refreshed at each loop tick">
|
||||||
last_run_at
|
last_run_at
|
||||||
</th>
|
</th>
|
||||||
<th>last_error</th>
|
<th>last_error</th>
|
||||||
<th>操作</th>
|
<th>actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -597,7 +650,7 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
<tr key={`runtime-${x.task}`}>
|
<tr key={`runtime-${x.task}`}>
|
||||||
<td>{x.task}</td>
|
<td>{x.task}</td>
|
||||||
<td title={typeof x.interval_s === "number" ? `${x.interval_s}s` : undefined}>
|
<td title={typeof x.interval_s === "number" ? `${x.interval_s}s` : undefined}>
|
||||||
{x.interval_label ?? "—"}
|
{runtimeIntervalLabel(x.interval_label)}
|
||||||
</td>
|
</td>
|
||||||
<td>{x.status}</td>
|
<td>{x.status}</td>
|
||||||
<td>{x.last_run_at ? formatSystemTime(x.last_run_at) : "-"}</td>
|
<td>{x.last_run_at ? formatSystemTime(x.last_run_at) : "-"}</td>
|
||||||
|
|
@ -610,7 +663,7 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
disabled={runtimeTaskMutation.isPending}
|
disabled={runtimeTaskMutation.isPending}
|
||||||
onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })}
|
onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })}
|
||||||
>
|
>
|
||||||
开始
|
resume
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|
@ -619,7 +672,7 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
|
||||||
disabled={runtimeTaskMutation.isPending}
|
disabled={runtimeTaskMutation.isPending}
|
||||||
onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "pause" })}
|
onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "pause" })}
|
||||||
>
|
>
|
||||||
暂停
|
pause
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue