v0.1.3 — update flow: detached restart, version-aware status, auto-reload UI, banner polling

This commit is contained in:
Hendrik 2026-05-01 18:34:15 +02:00
parent 3549c7cc9c
commit c710d874b1
5 changed files with 65 additions and 19 deletions

View file

@ -95,16 +95,38 @@ export default function SettingsPage() {
setChecking(false);
}
async function waitForServerBack() {
for (let i = 0; i < 60; i++) {
try {
const r = await fetch("/api/v1/health", { cache: "no-store" });
if (r.ok) return true;
} catch {}
await new Promise((res) => setTimeout(res, 1000));
}
return false;
}
async function applyNow() {
if (!confirm(`Update auf ${status?.latest} jetzt installieren?\n\nDer Server wird neu gestartet (kurze Downtime der Admin-UI). Redirects bleiben über Caddy aktiv.`)) return;
setApplying(true);
setMsg("");
setMsg("Update läuft — bitte warten...");
try {
const r = await fetch("/api/update/apply", { method: "POST" });
const d = await r.json();
setMsg(d.ok ? `Update erfolgreich: ${d.from}${d.to}` : `Fehler: ${d.error}`);
load();
} finally {
let d: { ok?: boolean; from?: string; to?: string; error?: string } = {};
try { d = await r.json(); } catch {}
if (!r.ok || d.error) {
setMsg(`Fehler: ${d.error || r.statusText}`);
setApplying(false);
return;
}
setMsg(`Update gezogen (${d.from}${d.to}). Server startet neu...`);
// Wait for restart, then hard-reload to drop all client cache
await new Promise((res) => setTimeout(res, 3000));
const back = await waitForServerBack();
setMsg(back ? "Server zurück. Lade Seite neu..." : "Restart dauert ungewöhnlich lang. Trotzdem neu laden.");
window.location.reload();
} catch (e) {
setMsg(`Fehler: ${e instanceof Error ? e.message : String(e)}`);
setApplying(false);
}
}
@ -130,18 +152,27 @@ export default function SettingsPage() {
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Button onClick={check} variant="outline" size="sm" disabled={checking}>
<Button onClick={check} variant="outline" size="sm" disabled={checking || applying}>
{checking ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <RefreshCcw className="mr-2 h-3 w-3" />}
Jetzt prüfen
</Button>
{status.update_available && (
{status.update_available && !applying && (
<Button onClick={applyNow} size="sm" disabled={applying}>
{applying ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <ArrowUpCircle className="mr-2 h-3 w-3" />}
<ArrowUpCircle className="mr-2 h-3 w-3" />
Update {status.latest} installieren
</Button>
)}
{!status.update_available && !applying && (
<span className="text-xs text-green-400"> Aktuelle Version</span>
)}
{status.release_url && <a href={status.release_url} target="_blank" rel="noreferrer" className="text-xs text-cyan-400 hover:underline">Release-Notes </a>}
</div>
{applying && (
<div className="flex items-center gap-2 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-xs text-cyan-200">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{msg || "Update läuft..."}</span>
</div>
)}
<div className="space-y-2 pt-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={settings.update_auto === "true"} onChange={(e) => save({ update_auto: e.target.checked ? "true" : "false" })} disabled={saving} />
@ -152,7 +183,7 @@ export default function SettingsPage() {
Pre-Releases einbeziehen
</label>
</div>
{msg && <p className="text-xs text-muted-foreground">{msg}</p>}
{msg && !applying && <p className="text-xs text-muted-foreground">{msg}</p>}
{status.last_check && <p className="text-xs text-muted-foreground">Letzte Prüfung: {new Date(status.last_check).toLocaleString("de-DE")}</p>}
</CardContent>
</Card>

View file

@ -15,10 +15,16 @@ export function UpdateBanner() {
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
fetch("/api/update/check")
.then((r) => r.json())
.then(setInfo)
.catch(() => {});
let cancelled = false;
function load() {
fetch("/api/update/check", { cache: "no-store" })
.then((r) => r.json())
.then((d) => { if (!cancelled) setInfo(d); })
.catch(() => {});
}
load();
const id = setInterval(load, 60_000);
return () => { cancelled = true; clearInterval(id); };
}, []);
if (!info?.update_available || dismissed) return null;

View file

@ -83,10 +83,13 @@ export async function checkForUpdate(): Promise<UpdateStatus> {
}
export function getUpdateStatus(): UpdateStatus {
const current = pkg.version;
const latest = getSetting("latest_version") || null;
const update_available = !!latest && cmpVersions(current, latest) < 0;
return {
current: pkg.version,
latest: getSetting("latest_version") || null,
update_available: getSetting("update_available") === "true",
current,
latest,
update_available,
release_url: getSetting("update_release_url") || undefined,
last_check: Number(getSetting("update_last_check") || 0) || undefined,
auto_update: getSetting("update_auto") === "true",

View file

@ -1,6 +1,6 @@
{
"name": "corex-nexredirect",
"version": "0.1.2",
"version": "0.1.3",
"license": "MIT",
"scripts": {
"dev": "tsx watch server.ts",

View file

@ -50,5 +50,11 @@ fi
ln -sf "$INSTALL_DIR/bin/nexredirect" /usr/local/bin/nexredirect
chmod +x "$INSTALL_DIR/bin/nexredirect" 2>/dev/null || true
systemctl restart corex-nexredirect
echo "Update auf $(sudo -u "$SERVICE_USER" -H bash -c "cd '$INSTALL_DIR' && git describe --tags --always") abgeschlossen"
VERSION=$(sudo -u "$SERVICE_USER" -H bash -c "cd '$INSTALL_DIR' && git describe --tags --always")
echo "Update auf $VERSION abgeschlossen — Restart wird in 2s ausgelöst"
# Detach restart so this process can return cleanly to the API caller
# (the API can then respond before its own service gets killed).
( sleep 2 && systemctl restart corex-nexredirect ) >/dev/null 2>&1 &
disown
exit 0