UME: renew token when expires_in is 0; token UI loading + toasts; sync thread status hint

- keepalive: call renew when has_token and (expires_in<=0 or below renew_before) so 0s stuck state self-heals\n- alarms/inventory auto loops: set last_error while pulling so table is not stuck at init with no hint\n- UmePage: warn pills for connected+需续期, spinners on token actions, toast ok/err from App, invalidate sync status after token ops

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-12 09:50:13 +08:00
parent def534e0a6
commit 0afc77c06b
5 changed files with 126 additions and 22 deletions

View file

@ -423,7 +423,8 @@ def on_startup() -> None:
client = _ume_client() client = _ume_client()
st = client.token_status() st = client.token_status()
expires_in = int(st.get("expires_in_s") or 0) expires_in = int(st.get("expires_in_s") or 0)
if bool(st.get("has_token")) and expires_in > 0 and expires_in < renew_before_s: # Renew when missing/invalid TTL (0) or nearing expiry — previously 0 skipped renew forever.
if bool(st.get("has_token")) and (expires_in <= 0 or expires_in < renew_before_s):
client.renew_token() client.renew_token()
_set_runtime_task("token_keepalive", status="running", last_run_at=datetime.now(timezone.utc), last_error="") _set_runtime_task("token_keepalive", status="running", last_run_at=datetime.now(timezone.utc), last_error="")
except Exception: except Exception:
@ -445,6 +446,11 @@ def on_startup() -> None:
if _runtime_is_paused("alarms_current_auto_sync"): if _runtime_is_paused("alarms_current_auto_sync"):
time.sleep(1) time.sleep(1)
continue continue
_set_runtime_task(
"alarms_current_auto_sync",
status="running",
last_error="正在拉取 UME 当前告警…",
)
db = SessionLocal() db = SessionLocal()
try: try:
client = _ume_client() client = _ume_client()
@ -482,6 +488,11 @@ def on_startup() -> None:
if _runtime_is_paused("inventory_auto_sync"): if _runtime_is_paused("inventory_auto_sync"):
time.sleep(1) time.sleep(1)
continue continue
_set_runtime_task(
"inventory_auto_sync",
status="running",
last_error="正在拉取 UME 网元清单…",
)
db = SessionLocal() db = SessionLocal()
try: try:
client = _ume_client() client = _ume_client()

View file

@ -335,7 +335,7 @@ function App() {
/> />
} }
/> />
<Route path="/ume" element={<UmePage />} /> <Route path="/ume" element={<UmePage toastOk={showOk} toastError={showError} />} />
</Routes> </Routes>
{toast && <div className={`toast toast--${toast.type}`}>{toast.text}</div>} {toast && <div className={`toast toast--${toast.type}`}>{toast.text}</div>}
</AppLayout> </AppLayout>

View file

@ -268,6 +268,29 @@ pre {
border-color: #5f1f2a; border-color: #5f1f2a;
} }
.inline-spinner {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 8px;
border: 2px solid rgba(255, 255, 255, 0.22);
border-top-color: rgba(255, 255, 255, 0.85);
border-radius: 50%;
vertical-align: -2px;
animation: inline-spin 0.65s linear infinite;
}
@keyframes inline-spin {
to {
transform: rotate(360deg);
}
}
.conn-pill--warn {
border-color: #664813;
color: #e7c46d;
}
.skeleton-line { .skeleton-line {
height: 32px; height: 32px;
border-radius: 8px; border-radius: 8px;

View file

@ -11,7 +11,13 @@ import {
} from "../services/api"; } from "../services/api";
import { formatSystemTime } from "../utils/time"; import { formatSystemTime } from "../utils/time";
export function UmePage() { export type UmePageProps = {
/** Optional toasts from App shell (fixed bottom-right). */
toastOk?: (message: string) => void;
toastError?: (message: string) => void;
};
export function UmePage({ toastOk, toastError }: UmePageProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [tokenOpError, setTokenOpError] = useState(""); const [tokenOpError, setTokenOpError] = useState("");
const [runtimeTaskError, setRuntimeTaskError] = useState(""); const [runtimeTaskError, setRuntimeTaskError] = useState("");
@ -52,41 +58,63 @@ export function UmePage() {
}); });
const tokenRefreshMutation = useMutation({ const tokenRefreshMutation = useMutation({
mutationFn: refreshUmeToken, mutationFn: refreshUmeToken,
onMutate: () => {
setTokenOpError("");
},
onSuccess: async (res) => { onSuccess: async (res) => {
if (!res?.ok) { if (!res?.ok) {
setTokenOpError(String(res.error || res.error_kind || "token_refresh_failed")); const msg = String(res.error || res.error_kind || "token_refresh_failed");
setTokenOpError(msg);
toastError?.(msg);
} else { } else {
setTokenOpError(""); setTokenOpError("");
toastOk?.(res.changed ? "续期成功,token 已更新" : "续期完成(与此前一致)");
} }
await queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] }); await queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] });
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
}, },
onError: (err) => { onError: (err) => {
setTokenOpError(String(err)); const msg = String(err);
setTokenOpError(msg);
toastError?.(msg);
}, },
}); });
const tokenDisconnectMutation = useMutation({ const tokenDisconnectMutation = useMutation({
mutationFn: disconnectUmeToken, mutationFn: disconnectUmeToken,
onMutate: () => {
setTokenOpError("");
},
onSuccess: async (res) => { onSuccess: async (res) => {
if (!res?.ok) { if (!res?.ok) {
setTokenOpError(String(res.error || res.error_kind || "token_disconnect_failed")); const msg = String(res.error || res.error_kind || "token_disconnect_failed");
setTokenOpError(msg);
toastError?.(msg);
} else { } else {
setTokenOpError(""); setTokenOpError("");
toastOk?.("已断开 UME token");
} }
await queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] }); await queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] });
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
}, },
onError: (err) => { onError: (err) => {
setTokenOpError(String(err)); const msg = String(err);
setTokenOpError(msg);
toastError?.(msg);
}, },
}); });
const tokenExpiresIn = Number(tokenStatusQuery.data?.expires_in_s || 0); const tokenExpiresIn = Number(tokenStatusQuery.data?.expires_in_s || 0);
const tokenLevel = !tokenStatusQuery.data?.has_token const hasToken = Boolean(tokenStatusQuery.data?.has_token);
const tokenNeedsRenewal = hasToken && tokenExpiresIn <= 0;
const tokenLevel = !hasToken
? "down" ? "down"
: tokenExpiresIn < 15 : tokenNeedsRenewal
? "down" ? "warn"
: tokenExpiresIn < 60 : tokenExpiresIn < 15
? "unknown" ? "down"
: "up"; : tokenExpiresIn < 60
? "unknown"
: "up";
const neQuery = useQuery({ const neQuery = useQuery({
queryKey: ["umeNE", neKeyword, nePage, nePageSize], queryKey: ["umeNE", neKeyword, nePage, nePageSize],
queryFn: () => fetchUmeNe({ keyword: neKeyword, page: nePage, pageSize: nePageSize }), queryFn: () => fetchUmeNe({ keyword: neKeyword, page: nePage, pageSize: nePageSize }),
@ -131,11 +159,23 @@ export function UmePage() {
<article className="card card--full"> <article className="card card--full">
<h3>UME Token 状态</h3> <h3>UME Token 状态</h3>
<div className="actions-row actions-row--inline"> <div className="actions-row actions-row--inline">
<span className={`conn-pill conn-pill--${tokenStatusQuery.data?.has_token ? "up" : "down"}`}> <span
token: {tokenStatusQuery.data?.has_token ? "connected" : "disconnected"} className={`conn-pill conn-pill--${!hasToken ? "down" : tokenNeedsRenewal ? "warn" : "up"}`}
title={
tokenNeedsRenewal
? "库中 token 缺少有效过期时间或已过期,请点「手动续期/登录」或等待后台自动续期"
: undefined
}
>
token: {!hasToken ? "disconnected" : tokenNeedsRenewal ? "connected (需续期)" : "connected"}
</span> </span>
<span className={`conn-pill conn-pill--${tokenLevel}`}> <span className={`conn-pill conn-pill--${tokenLevel}`}>
expires_in: {typeof tokenStatusQuery.data?.expires_in_s === "number" ? `${tokenStatusQuery.data.expires_in_s}s` : "-"} expires_in:{" "}
{typeof tokenStatusQuery.data?.expires_in_s === "number"
? tokenNeedsRenewal
? "需续期 / 0s"
: `${tokenStatusQuery.data.expires_in_s}s`
: "-"}
</span> </span>
{tokenStatusQuery.data?.token_preview ? <span className="conn-pill">preview: {tokenStatusQuery.data.token_preview}</span> : null} {tokenStatusQuery.data?.token_preview ? <span className="conn-pill">preview: {tokenStatusQuery.data.token_preview}</span> : null}
</div> </div>
@ -144,13 +184,42 @@ export function UmePage() {
onClick={() => queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] })} onClick={() => queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] })}
disabled={tokenStatusQuery.isFetching} disabled={tokenStatusQuery.isFetching}
> >
刷新状态 {tokenStatusQuery.isFetching ? (
<>
<span className="inline-spinner" aria-hidden />
刷新中…
</>
) : (
"刷新状态"
)}
</button> </button>
<button onClick={() => tokenRefreshMutation.mutate()} disabled={tokenRefreshMutation.isPending}> <button
手动续期/登录 type="button"
onClick={() => tokenRefreshMutation.mutate()}
disabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
>
{tokenRefreshMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
续期中…
</>
) : (
"手动续期/登录"
)}
</button> </button>
<button onClick={() => tokenDisconnectMutation.mutate()} disabled={tokenDisconnectMutation.isPending}> <button
断开 token type="button"
onClick={() => tokenDisconnectMutation.mutate()}
disabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
>
{tokenDisconnectMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
断开中…
</>
) : (
"断开 token"
)}
</button> </button>
</div> </div>
{(tokenOpError || tokenRefreshMutation.error || tokenDisconnectMutation.error) && ( {(tokenOpError || tokenRefreshMutation.error || tokenDisconnectMutation.error) && (

View file

@ -148,12 +148,13 @@ export type UmeAlarmItem = {
}; };
export type UmeTokenStatus = { export type UmeTokenStatus = {
ok: boolean; ok?: boolean;
has_token: boolean; has_token: boolean;
expires_in_s: number; expires_in_s: number;
expires_at_epoch_s: number; expires_at_epoch_s: number;
auth_header: string; auth_header: string;
token_preview?: string; token_preview?: string;
changed?: boolean;
error_kind?: string; error_kind?: string;
error?: string; error?: string;
}; };