feat(admin): ask the panel to restart, from verdande's own settings
Release / Build and publish image (push) Failing after 10m2s

A container cannot replace its own image from the inside, so "update" is really
"ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on the
way, which is what makes a restart an update at all. This removes the second
browser tab, not the panel's authority: the panel still does the work and still
records who asked.

Three settings, needed together, and the page names whichever are missing — an
operator reading "not configured" should not have to go and find out which of
three. The token is never sent to a browser; the page is told only whether it is
set.

Answers 202 rather than waiting. The panel stops this container as part of
answering, so the reply may never arrive, and a request cut off mid-flight is the
successful case wearing a failure's clothes. The interface believes neither answer
and polls /healthz until it responds.

Administrators only, and sessions only. A leaked API token that could restart the
server is a denial of service in one request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 12:18:35 +02:00
co-authored by Claude Opus 5
parent 19aa02e80c
commit 2b45aa33e1
12 changed files with 443 additions and 2 deletions
@@ -89,6 +89,55 @@
let version = $state(null);
// --- restarting from here ---------------------------------------------------
let panel = $state(null);
let restarting = $state(false);
let restartNote = $state('');
$effect(() => {
api
.panelStatus()
.then((p) => (panel = p))
.catch(() => {});
});
/**
* Asks the panel to recreate this container, then waits for it to come back.
*
* The reply may never arrive — the panel stops this container as part of
* answering — so the answer is not believed either way. `/healthz` is polled
* until it responds, which is the only thing that actually says "it is up".
*/
async function restart() {
if (!confirm(t('update.restartQuestion'))) return;
restarting = true;
restartNote = t('update.asking');
try {
await api.restartFromPanel();
} catch (e) {
// A request cut off mid-flight is the successful case wearing a
// failure's clothes. The poll below decides.
restartNote = humanMessage(e);
}
restartNote = t('update.waiting');
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 2000));
try {
const response = await fetch('/healthz', { cache: 'no-store' });
if (response.ok) {
location.reload();
return;
}
} catch {
// Still down, which is expected for the first few seconds.
}
}
restarting = false;
restartNote = t('update.stillDown');
}
$effect(() => {
api.version().then((v) => (version = v)).catch(() => {});
});
@@ -182,6 +231,20 @@
{/if}
</p>
<!-- Restarting is what makes a new version arrive: a container cannot replace
its own image, and the panel pulls `:latest` when it recreates one. The
button is here rather than in another browser tab. -->
{#if panel?.configured}
<div class="row">
<button class="secondary" onclick={restart} disabled={restarting}>
{restarting ? t('update.restarting') : t('update.restart')}
</button>
{#if restartNote}<span class="saved">{restartNote}</span>{/if}
</div>
{:else if panel}
<p class="hint">{t('update.notConfigured', { missing: panel.missing.join(', ') })}</p>
{/if}
{#if version.update_available}
{#if version.notes}
<p class="notes">{version.notes}</p>