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()
st = client.token_status()
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()
_set_runtime_task("token_keepalive", status="running", last_run_at=datetime.now(timezone.utc), last_error="")
except Exception:
@ -445,6 +446,11 @@ def on_startup() -> None:
if _runtime_is_paused("alarms_current_auto_sync"):
time.sleep(1)
continue
_set_runtime_task(
"alarms_current_auto_sync",
status="running",
last_error="正在拉取 UME 当前告警…",
)
db = SessionLocal()
try:
client = _ume_client()
@ -482,6 +488,11 @@ def on_startup() -> None:
if _runtime_is_paused("inventory_auto_sync"):
time.sleep(1)
continue
_set_runtime_task(
"inventory_auto_sync",
status="running",
last_error="正在拉取 UME 网元清单…",
)
db = SessionLocal()
try:
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>
{toast && <div className={`toast toast--${toast.type}`}>{toast.text}</div>}
</AppLayout>

View file

@ -268,6 +268,29 @@ pre {
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 {
height: 32px;
border-radius: 8px;

View file

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

View file

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