From 2b45aa33e13494d3a677a2a466b9a882016b95ea Mon Sep 17 00:00:00 2001
From: Kristian Wind
Date: Tue, 18 Aug 2026 12:18:35 +0200
Subject: [PATCH] feat(admin): ask the panel to restart, from verdande's own
settings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
README.md | 3 +
docs/configuration.md | 12 ++
docs/openapi.yaml | 61 ++++++++
internal/config/config.go | 19 +++
internal/httpapi/panel_handlers.go | 105 ++++++++++++++
internal/httpapi/panel_test.go | 131 ++++++++++++++++++
internal/httpapi/server.go | 5 +
rune/verdande.yaml | 18 +++
web/src/lib/api.js | 2 +
web/src/lib/locales/da.js | 13 +-
web/src/lib/locales/en.js | 13 +-
.../indstillinger/notifikationer/+page.svelte | 63 +++++++++
12 files changed, 443 insertions(+), 2 deletions(-)
create mode 100644 internal/httpapi/panel_handlers.go
create mode 100644 internal/httpapi/panel_test.go
diff --git a/README.md b/README.md
index abe4979..7227ca7 100644
--- a/README.md
+++ b/README.md
@@ -197,6 +197,9 @@ to put one that the operator would ever see.
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
+| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings. |
+| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
+| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
## What lives in `/data`
diff --git a/docs/configuration.md b/docs/configuration.md
index 88ea829..8fb19b3 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -60,6 +60,18 @@ own mailbox from **Settings → Gmail**. The redirect URI is derived from
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
+| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
+| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
+| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
+
+All three together let **Indstillinger → Notifikationer** restart this instance,
+which is also how it picks up a new version: a container cannot replace its own
+image, so the panel recreates it and pulls `:latest` on the way. Any one of the
+three alone does nothing, and the page names the ones that are missing.
+
+The token is a panel credential with control over that server. Anybody who can
+read this environment can restart it — which is true of anybody with the host
+anyway — and it is never sent to a browser.
Off unless you turn it on. When on, verdande asks the public GitHub releases API
at most every six hours and sends nothing about your instance — no identifier, no
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index f313ebf..bc0e33e 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1337,6 +1337,67 @@ paths:
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
+ /api/v1/panel:
+ get:
+ tags: [auth]
+ summary: Whether this instance can restart itself. Administrators only.
+ description: |
+ 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 says whether the three settings are present. The token is never sent:
+ the page needs to know whether the button can work, not what the credential
+ is. When it cannot, the missing variables are named — an operator reading
+ "not configured" should not have to go and find out which of three.
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ configured: { type: boolean }
+ panel_url: { type: string }
+ missing:
+ type: array
+ items: { type: string }
+ description: The environment variables that are not set.
+ "403": { $ref: "#/components/responses/Forbidden" }
+
+ /api/v1/panel/restart:
+ post:
+ tags: [auth]
+ summary: Ask the panel to recreate this container. Administrators only.
+ description: |
+ The panel does the work and records who asked; this removes the second
+ browser tab, not the panel's authority.
+
+ Answers 202 rather than waiting: the panel stops this container as part of
+ answering, so the reply may never arrive. A request cut off mid-flight is
+ the successful case wearing a failure's clothes — the interface polls
+ `/healthz` rather than believing either answer.
+
+ Sessions only, like the rest of this group. A leaked API token that could
+ restart the server is a denial of service in one request.
+ responses:
+ "202":
+ description: The panel accepted, or was already restarting this container.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ restarting: { type: boolean }
+ note: { type: string }
+ "403": { $ref: "#/components/responses/Forbidden" }
+ "502":
+ description: |
+ The panel refused. Usually a token without control of this server.
+ "503":
+ description: The three settings are not all present.
+
/api/v1/errors:
get:
tags: [auth]
diff --git a/internal/config/config.go b/internal/config/config.go
index ab0ad56..c6e1ec1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -38,6 +38,22 @@ type Config struct {
// the deal its operator made by self-hosting.
UpdateCheck bool
+ // The Yggdrasil panel this instance runs under, so it can ask to be restarted
+ // from its own interface rather than from another browser tab.
+ //
+ // A container cannot replace its own image from the inside. Restarting is the
+ // panel's job — it recreates the container and pulls `:latest` on the way — so
+ // what this needs is a way to ask. All three are required together; any one
+ // alone cannot do anything.
+ //
+ // The token is a panel credential with control over that server. Anybody who
+ // can read this environment can restart it, which is true of anybody with the
+ // host anyway. It is never sent to a browser: the settings page is told only
+ // whether it is set.
+ PanelURL string
+ PanelToken string
+ PanelServerID string
+
// TrashRetention is how long a soft-deleted task or project stays recoverable.
TrashRetention time.Duration
@@ -69,6 +85,9 @@ func Load() (*Config, error) {
Dev: envBool("VERDANDE_DEV", false),
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
+ PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
+ PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
+ PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
}
diff --git a/internal/httpapi/panel_handlers.go b/internal/httpapi/panel_handlers.go
new file mode 100644
index 0000000..06166f5
--- /dev/null
+++ b/internal/httpapi/panel_handlers.go
@@ -0,0 +1,105 @@
+package httpapi
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+// Asking the panel to restart this instance.
+//
+// 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.
+//
+// The alternative was a second browser tab: read the version here, go to the
+// panel, find the server, press Restart. This removes the tab, not the panel's
+// authority — the panel still does the work and still records who asked.
+//
+// Administrators only, and sessions only. A leaked API token that could restart
+// the server is a denial of service in one request, and the whole point of the
+// session rule elsewhere in this file is that a stolen token must not be able to
+// do things only a person at a keyboard should.
+
+type panelStatusJSON struct {
+ // Configured is all three settings being present. The token itself is never
+ // sent: the page needs to know whether the button can work, not what the
+ // credential is.
+ Configured bool `json:"configured"`
+ PanelURL string `json:"panel_url,omitempty"`
+ // Why it cannot work, when it cannot. An operator reading "not configured"
+ // should not have to go and find out which of three things is missing.
+ Missing []string `json:"missing,omitempty"`
+}
+
+func (s *Server) panelStatus() panelStatusJSON {
+ var missing []string
+ if s.cfg.PanelURL == "" {
+ missing = append(missing, "VERDANDE_PANEL_URL")
+ }
+ if s.cfg.PanelToken == "" {
+ missing = append(missing, "VERDANDE_PANEL_TOKEN")
+ }
+ if s.cfg.PanelServerID == "" {
+ missing = append(missing, "VERDANDE_PANEL_SERVER_ID")
+ }
+ return panelStatusJSON{
+ Configured: len(missing) == 0,
+ PanelURL: s.cfg.PanelURL,
+ Missing: missing,
+ }
+}
+
+func (s *Server) handlePanelStatus(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, s.panelStatus())
+}
+
+func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request) {
+ status := s.panelStatus()
+ if !status.Configured {
+ writeError(w, http.StatusServiceUnavailable, CodeInternal,
+ fmt.Sprintf("this instance cannot restart itself: %v are not set", status.Missing))
+ return
+ }
+
+ user := userFrom(r.Context())
+ s.log.Info("restart requested from the interface", "user", user.ID, "server", s.cfg.PanelServerID)
+
+ // A short timeout of its own. The panel stops this container as part of
+ // answering, so the reply may never arrive — that is not a failure, and a
+ // request that hung waiting for it would look like one.
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
+ defer cancel()
+
+ url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
+ if err != nil {
+ s.internal(w, r, "build panel request", err)
+ return
+ }
+ req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
+
+ resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
+ if err != nil {
+ // Including "the panel killed us mid-reply", which is the successful case
+ // wearing a failure's clothes. The interface polls /healthz rather than
+ // believing either answer, so this only has to be honest about what it saw.
+ s.log.Warn("panel restart call did not complete", "err", err)
+ writeJSON(w, http.StatusAccepted, map[string]any{
+ "restarting": true,
+ "note": "the panel did not answer; it may already be restarting this container",
+ })
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 {
+ s.log.Error("panel refused the restart", "status", resp.StatusCode)
+ writeError(w, http.StatusBadGateway, CodeInternal,
+ fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
+ return
+ }
+
+ writeJSON(w, http.StatusAccepted, map[string]any{"restarting": true})
+}
diff --git a/internal/httpapi/panel_test.go b/internal/httpapi/panel_test.go
new file mode 100644
index 0000000..fd5ac42
--- /dev/null
+++ b/internal/httpapi/panel_test.go
@@ -0,0 +1,131 @@
+package httpapi
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/kristianwind/verdande/internal/config"
+)
+
+// Restarting is administrators only, and sessions only. A leaked API token that
+// could restart the server is a denial of service in one request.
+func TestRestartingIsAdminsOnlyAndSessionsOnly(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+ other := ts.newUser(t, "anden@example.dk", "Anden")
+
+ for _, path := range []string{"/api/v1/panel", "/api/v1/panel/restart"} {
+ method := "GET"
+ if path != "/api/v1/panel" {
+ method = "POST"
+ }
+ if resp, _ := other.do(t, method, path, nil); resp.StatusCode != http.StatusForbidden {
+ t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
+ }
+ }
+
+ admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := ts.apiToken(t, admin.ID)
+ req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := (&http.Client{}).Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusForbidden {
+ t.Errorf("restart with an admin's API token: status %d, want 403", resp.StatusCode)
+ }
+}
+
+// Unconfigured, it says which of the three settings are missing rather than
+// "not configured" — an operator should not have to go and find out which.
+func TestAnUnconfiguredPanelSaysWhatIsMissing(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ _, status := ts.do(t, "GET", "/api/v1/panel", nil)
+ if status["configured"] != false {
+ t.Fatalf("configured = %v on an instance with no panel settings", status["configured"])
+ }
+ missing, _ := status["missing"].([]any)
+ if len(missing) != 3 {
+ t.Errorf("missing = %v, want all three settings named", missing)
+ }
+
+ resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Errorf("restart with nothing configured: status %d, want 503", resp.StatusCode)
+ }
+ if body["error"] == nil {
+ t.Error("the refusal says nothing about why")
+ }
+}
+
+// Configured, it calls the panel with a bearer token and reports what came back.
+func TestARestartCallsThePanelWithItsToken(t *testing.T) {
+ var gotPath, gotAuth string
+ panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer panel.Close()
+
+ ts := newTestServerWith(t, func(cfg *config.Config) {
+ cfg.PanelURL = panel.URL
+ cfg.PanelToken = "hemmelig"
+ cfg.PanelServerID = "abc-123"
+ })
+ ts.bootstrap(t)
+
+ _, status := ts.do(t, "GET", "/api/v1/panel", nil)
+ if status["configured"] != true {
+ t.Fatalf("configured = %v with all three set", status["configured"])
+ }
+ // The token is a credential and must never reach a browser.
+ for key, value := range status {
+ if s, ok := value.(string); ok && s == "hemmelig" {
+ t.Errorf("the panel token was sent to the client in %q", key)
+ }
+ }
+
+ resp, _ := ts.do(t, "POST", "/api/v1/panel/restart", nil)
+ if resp.StatusCode != http.StatusAccepted {
+ t.Fatalf("restart: status %d, want 202", resp.StatusCode)
+ }
+ if gotPath != "/api/servers/abc-123/restart" {
+ t.Errorf("called %q", gotPath)
+ }
+ if gotAuth != "Bearer hemmelig" {
+ t.Errorf("Authorization = %q", gotAuth)
+ }
+}
+
+// A panel that refuses is reported as the panel refusing, not as this server
+// breaking: the fix is a token with control of that server, and saying "internal
+// error" would send somebody looking in the wrong place.
+func TestAPanelThatRefusesIsReportedAsSuch(t *testing.T) {
+ panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ }))
+ defer panel.Close()
+
+ ts := newTestServerWith(t, func(cfg *config.Config) {
+ cfg.PanelURL = panel.URL
+ cfg.PanelToken = "forkert"
+ cfg.PanelServerID = "abc-123"
+ })
+ ts.bootstrap(t)
+
+ resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
+ if resp.StatusCode != http.StatusBadGateway {
+ t.Errorf("a refusing panel: status %d, want 502", resp.StatusCode)
+ }
+ if msg, _ := body["error"].(string); msg == "" {
+ t.Error("the refusal does not say what to check")
+ }
+}
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index 1050c0a..3be111d 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -347,6 +347,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin)
r.Get("/errors", s.handleListErrors)
+ // Restarting this instance from its own interface. The panel does
+ // the work — a container cannot replace its own image — and pulls
+ // `:latest` on the way, which is what makes a restart an update.
+ r.Get("/panel", s.handlePanelStatus)
+ r.Post("/panel/restart", s.handleRestartFromPanel)
// A backup file is a complete copy of the database, so this whole
// group is sessions-only as well as administrators-only: a leaked
// token must not be able to download everybody's data.
diff --git a/rune/verdande.yaml b/rune/verdande.yaml
index 699b192..94a1b1d 100644
--- a/rune/verdande.yaml
+++ b/rune/verdande.yaml
@@ -78,6 +78,24 @@ gameskill:
type: bool
default: false
+ # Restarting from verdande's own settings, which is also how it picks up a new
+ # version: a container cannot replace its own image, so the panel recreates it
+ # and pulls :latest on the way. All three are needed together; the page names
+ # whichever are missing.
+ - key: VERDANDE_PANEL_URL
+ name: "Panel address, so verdande can ask to be restarted from its own settings"
+ type: string
+ default: ""
+ - key: VERDANDE_PANEL_TOKEN
+ name: "Panel API token — from an account with control of this server"
+ type: string
+ secret: true
+ default: ""
+ - key: VERDANDE_PANEL_SERVER_ID
+ name: "This server's id in the panel (the one in its URL)"
+ type: string
+ default: ""
+
install:
# The image has no shell, so the install step borrows one. It only has to make
# the directory layout exist; verdande creates and migrates its own database on
diff --git a/web/src/lib/api.js b/web/src/lib/api.js
index fb2bf57..36dfc2b 100644
--- a/web/src/lib/api.js
+++ b/web/src/lib/api.js
@@ -199,6 +199,8 @@ export const api = {
deleteUser: (id) => del(`/users/${id}`),
revokeInvite: (id) => del(`/invites/${id}`),
listErrors: () => get('/errors'),
+ panelStatus: () => get('/panel'),
+ restartFromPanel: () => post('/panel/restart'),
// The nightly backup, which ran since the beginning with nothing to show it.
// The OAuth client itself, which is the instance's registration with Google
diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js
index 743aff0..e6352ad 100644
--- a/web/src/lib/locales/da.js
+++ b/web/src/lib/locales/da.js
@@ -635,5 +635,16 @@ export const da = {
'passkey.signingIn': 'Venter på enheden…',
'passkey.unavailable':
- 'Passkeys kan ikke bruges her — enten understøtter browseren dem ikke, eller også er serverens adresse en IP frem for et domænenavn, hvilket ingen authenticator accepterer.'
+ 'Passkeys kan ikke bruges her — enten understøtter browseren dem ikke, eller også er serverens adresse en IP frem for et domænenavn, hvilket ingen authenticator accepterer.',
+
+ // --- restarting from the interface ---------------------------------------------
+ 'update.restart': 'Genstart og hent nyeste version',
+ 'update.restarting': 'Genstarter…',
+ 'update.restartQuestion':
+ 'Genstart nu? Serveren er nede i omkring et halvt minut, og henter den nyeste version på vejen op.',
+ 'update.asking': 'Beder panelet om at genstarte…',
+ 'update.waiting': 'Venter på at serveren kommer op igen…',
+ 'update.stillDown': 'Serveren er ikke kommet op endnu. Kig i panelet.',
+ 'update.notConfigured':
+ 'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.'
};
diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js
index c1fa9b9..21276b9 100644
--- a/web/src/lib/locales/en.js
+++ b/web/src/lib/locales/en.js
@@ -630,5 +630,16 @@ export const en = {
'passkey.signingIn': 'Waiting for the device…',
'passkey.unavailable':
- 'Passkeys cannot be used here — either this browser does not support them, or the server’s address is an IP rather than a domain name, which no authenticator will accept.'
+ 'Passkeys cannot be used here — either this browser does not support them, or the server’s address is an IP rather than a domain name, which no authenticator will accept.',
+
+ // --- restarting from the interface ---------------------------------------------
+ 'update.restart': 'Restart and pull the latest version',
+ 'update.restarting': 'Restarting…',
+ 'update.restartQuestion':
+ 'Restart now? The server is down for about half a minute, and pulls the newest version on the way back up.',
+ 'update.asking': 'Asking the panel to restart…',
+ 'update.waiting': 'Waiting for the server to come back…',
+ 'update.stillDown': 'The server has not come back yet. Have a look in the panel.',
+ 'update.notConfigured':
+ 'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.'
};
diff --git a/web/src/routes/indstillinger/notifikationer/+page.svelte b/web/src/routes/indstillinger/notifikationer/+page.svelte
index 6459eba..05f0c81 100644
--- a/web/src/routes/indstillinger/notifikationer/+page.svelte
+++ b/web/src/routes/indstillinger/notifikationer/+page.svelte
@@ -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}