codex-proxy / shared /hooks /use-update-status.ts
icebear0828
feat: SSE update progress + reliable restart on Windows
236dd56
raw
history blame
6.82 kB
import { useState, useEffect, useCallback, useRef } from "preact/hooks";
export interface UpdateStatus {
proxy: {
version: string;
commit: string | null;
can_self_update: boolean;
mode: "git" | "docker" | "electron";
commits_behind: number | null;
commits: { hash: string; message: string }[];
release: { version: string; body: string; url: string } | null;
update_available: boolean;
update_in_progress: boolean;
};
codex: {
current_version: string | null;
current_build: string | null;
latest_version: string | null;
latest_build: string | null;
update_available: boolean;
update_in_progress: boolean;
last_check: string | null;
};
}
export interface CheckResult {
proxy?: {
commits_behind: number;
current_commit: string | null;
latest_commit: string | null;
commits: { hash: string; message: string }[];
release: { version: string; body: string; url: string } | null;
update_available: boolean;
mode: "git" | "docker" | "electron";
error?: string;
};
codex?: {
update_available: boolean;
current_version: string;
latest_version: string | null;
version_changed?: boolean;
error?: string;
};
proxy_update_in_progress: boolean;
codex_update_in_progress: boolean;
}
const RESTART_POLL_INTERVAL = 2000;
const RESTART_TIMEOUT = 120000;
export interface UpdateStep {
step: string;
status: "running" | "done" | "error";
detail?: string;
}
export function useUpdateStatus() {
const [status, setStatus] = useState<UpdateStatus | null>(null);
const [checking, setChecking] = useState(false);
const [result, setResult] = useState<CheckResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false);
const [restartFailed, setRestartFailed] = useState(false);
const [updateSteps, setUpdateSteps] = useState<UpdateStep[]>([]);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
// Cleanup on unmount
useEffect(() => clearPolling, [clearPolling]);
const startRestartPolling = useCallback(() => {
setRestarting(true);
setRestartFailed(false);
clearPolling();
// Wait a bit before first poll (server needs time to shut down)
const initialDelay = setTimeout(() => {
pollTimerRef.current = setInterval(async () => {
try {
const resp = await fetch("/health", { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
clearPolling();
location.reload();
}
} catch {
// Server still down, keep polling
}
}, RESTART_POLL_INTERVAL);
// Timeout fallback
timeoutRef.current = setTimeout(() => {
clearPolling();
setRestarting(false);
setRestartFailed(true);
}, RESTART_TIMEOUT);
}, 2000);
// Store the initial delay timer for cleanup
timeoutRef.current = initialDelay;
}, [clearPolling]);
const load = useCallback(async () => {
try {
const resp = await fetch("/admin/update-status");
if (resp.ok) {
setStatus(await resp.json() as UpdateStatus);
}
} catch { /* ignore */ }
}, []);
useEffect(() => { load(); }, [load]);
const checkForUpdate = useCallback(async () => {
setChecking(true);
setError(null);
setResult(null);
try {
const resp = await fetch("/admin/check-update", { method: "POST" });
const data = await resp.json() as CheckResult;
if (!resp.ok) {
setError("Check failed");
} else {
setResult(data);
await load();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Network error");
} finally {
setChecking(false);
}
}, [load]);
const applyUpdate = useCallback(async () => {
setApplying(true);
setError(null);
setUpdateSteps([]);
try {
const resp = await fetch("/admin/apply-update", { method: "POST" });
if (!resp.ok) {
const text = await resp.text();
try {
const data = JSON.parse(text) as { error?: string };
setError(data.error ?? "Apply failed");
} catch {
setError(text || "Apply failed");
}
setApplying(false);
return;
}
// Parse SSE stream
const reader = resp.body?.getReader();
if (!reader) {
setError("No response body");
setApplying(false);
return;
}
const decoder = new TextDecoder();
let buffer = "";
let shouldRestart = false;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE lines
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const data = JSON.parse(line.slice(6)) as Record<string, unknown>;
if (data.step && data.status) {
setUpdateSteps((prev) => {
const existing = prev.findIndex((s) => s.step === data.step);
const entry: UpdateStep = {
step: data.step as string,
status: data.status as UpdateStep["status"],
detail: data.detail as string | undefined,
};
if (existing >= 0) {
const next = [...prev];
next[existing] = entry;
return next;
}
return [...prev, entry];
});
}
if (data.done) {
shouldRestart = !!(data.restarting);
if (!data.started) {
setError((data.error as string) ?? "Apply failed");
}
}
} catch { /* skip malformed SSE */ }
}
}
setApplying(false);
if (shouldRestart) {
startRestartPolling();
} else if (!error) {
await load();
}
} catch (err) {
// Connection lost during update — server may be restarting
setApplying(false);
startRestartPolling();
}
}, [load, startRestartPolling, error]);
return { status, checking, result, error, checkForUpdate, applyUpdate, applying, restarting, restartFailed, updateSteps };
}