From fc590920302ceb9f02e58829f28931635f5fc5d4 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 29 Aug 2026 12:45:58 +0200 Subject: [PATCH] fix: refresh dashboard update runs live --- Dockerfile | 2 +- README.md | 2 +- UPDATE.md | 6 +- compose.yml | 2 +- internal/appupdate/manager_test.go | 6 +- internal/buildinfo/version.go | 2 +- internal/web/server.go | 51 ++++++++++++-- internal/web/server_test.go | 90 +++++++++++++++++++++++++ internal/web/static/app.js | 95 +++++++++++++++++++++++++-- internal/web/templates/dashboard.html | 10 +-- internal/web/templates/layout.html | 4 +- 11 files changed, 241 insertions(+), 29 deletions(-) diff --git a/Dockerfile b/Dockerfile index 18364a0..f6745fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.7 -ARG VERSION=v1.4.0 +ARG VERSION=v1.4.1 ARG MINIMUM_UPDATE_VERSION=v1.4.0 ARG MINIMUM_AGENT_PROTOCOL=2 ARG RUNTIME_CONTRACT_VERSION=2 diff --git a/README.md b/README.md index 0175d5c..c5a4b00 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i ```yaml services: dyndns: - image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.0 + image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1 restart: unless-stopped stop_grace_period: 20s ports: ["9090:8080"] diff --git a/UPDATE.md b/UPDATE.md index 4203201..ecf9d0c 100644 --- a/UPDATE.md +++ b/UPDATE.md @@ -1,6 +1,6 @@ # Domain Control aktualisieren -Die aktuelle Compose-Version ist `v1.4.0`. Datenbank, Einstellungen und +Die aktuelle Compose-Version ist `v1.4.1`. Datenbank, Einstellungen und verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei den folgenden Verfahren erhalten. @@ -34,7 +34,7 @@ In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert: ```yaml services: dyndns: - image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.0 + image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1 update-agent: image: *image ``` @@ -68,7 +68,7 @@ Die erwartete Antwort lautet `ok`. ## Portainer oder Dockge 1. Den bestehenden Stack bearbeiten. -2. Nur den Tag der zentralen Image-Zeile auf `v1.4.0` setzen. +2. Nur den Tag der zentralen Image-Zeile auf `v1.4.1` setzen. 3. Den Stack mit vorherigem Image-Pull erneut bereitstellen. 4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist. diff --git a/compose.yml b/compose.yml index 6c34f4c..9ae6f5d 100644 --- a/compose.yml +++ b/compose.yml @@ -1,6 +1,6 @@ services: dyndns: - image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.0 + image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1 restart: unless-stopped stop_grace_period: 20s ports: ["9090:8080"] diff --git a/internal/appupdate/manager_test.go b/internal/appupdate/manager_test.go index b606cb6..16b8bc7 100644 --- a/internal/appupdate/manager_test.go +++ b/internal/appupdate/manager_test.go @@ -33,7 +33,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"tag_name":"v1.4.1","html_url":"https://example.invalid/releases/v1.4.1","draft":false,"prerelease":false}`)) + _, _ = w.Write([]byte(`{"tag_name":"v1.4.2","html_url":"https://example.invalid/releases/v1.4.2","draft":false,"prerelease":false}`)) })) defer api.Close() @@ -66,7 +66,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { t.Fatal(err) } state := manager.State() - if !state.UpdateAvailable || state.LatestVersion != "v1.4.1" || state.LastError != "" { + if !state.UpdateAvailable || state.LatestVersion != "v1.4.2" || state.LastError != "" { t.Fatalf("new release was not detected: %#v", state) } settings, err := database.GetSoftwareUpdateSettings(ctx) @@ -82,7 +82,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { t.Fatal("unavailable Gitea endpoint was reported as successful") } state = manager.State() - if !state.UpdateAvailable || state.LatestVersion != "v1.4.1" || state.LastError == "" { + if !state.UpdateAvailable || state.LatestVersion != "v1.4.2" || state.LastError == "" { t.Fatalf("temporary outage removed the last known update reminder: %#v", state) } } diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 2decde0..d5fa39b 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -18,4 +18,4 @@ const ( // Version is replaced through -ldflags for release images. The source fallback // keeps local builds and tests honest about the currently checked-out release. -var Version = "v1.4.0" +var Version = "v1.4.1" diff --git a/internal/web/server.go b/internal/web/server.go index 7a06bda..e7fde57 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -96,6 +96,23 @@ type accessZone struct { ManagedTargetIDs map[int64]bool } +type schedulerRunResponse struct { + ID int64 `json:"id"` + StartedAtUnix int64 `json:"started_at_unix"` + Status string `json:"status"` + Summary string `json:"summary"` +} + +type schedulerResponse struct { + NextRunUnix int64 `json:"next_run_unix"` + Running bool `json:"running"` + Enabled bool `json:"enabled"` + IPv6Enabled bool `json:"ipv6_enabled"` + LastIPv4 string `json:"last_ipv4"` + LastIPv6 string `json:"last_ipv6"` + RecentRuns *[]schedulerRunResponse `json:"recent_runs,omitempty"` +} + func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger) (*Server, error) { templates, err := loadTemplates() if err != nil { @@ -1488,20 +1505,40 @@ func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Sess }) } -func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) { +func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, session *model.Session) { settings, err := s.store.GetSettings(r.Context()) if err != nil { s.serverError(w, err) return } + response := schedulerResponse{ + NextRunUnix: unixSeconds(s.scheduler.NextRunAt()), + Running: s.scheduler.IsRunning(), + Enabled: settings.UpdaterEnabled, + IPv6Enabled: settings.IPv6Enabled, + LastIPv4: settings.LastIPv4, + LastIPv6: settings.LastIPv6, + } + if session.Role == "admin" && r.URL.Query().Get("runs") == "1" { + runs, err := s.store.RecentRuns(r.Context(), 6) + if err != nil { + s.serverError(w, err) + return + } + recentRuns := make([]schedulerRunResponse, 0, len(runs)) + for _, run := range runs { + recentRuns = append(recentRuns, schedulerRunResponse{ + ID: run.ID, + StartedAtUnix: unixSeconds(run.StartedAt), + Status: run.Status, + Summary: run.Summary, + }) + } + response.RecentRuns = &recentRuns + } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(map[string]any{ - "next_run_unix": unixSeconds(s.scheduler.NextRunAt()), - "running": s.scheduler.IsRunning(), - "enabled": settings.UpdaterEnabled, - "last_ipv4": settings.LastIPv4, - }) + _ = json.NewEncoder(w).Encode(response) } func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *model.Session) { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index e3867c5..70dfd57 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" + "log/slog" "net/http" "net/http/httptest" "net/url" @@ -18,6 +20,7 @@ import ( "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" ) @@ -232,6 +235,93 @@ func TestLoginErrorRendersAsToast(t *testing.T) { } } +func TestDashboardRendersLiveUpdateHooks(t *testing.T) { + t.Parallel() + templates, err := loadTemplates() + if err != nil { + t.Fatalf("load templates: %v", err) + } + var rendered bytes.Buffer + data := viewData{ + Title: "Übersicht", + Page: "dashboard", + CSRF: "csrf-token", + User: model.User{DisplayName: "Admin", Username: "admin"}, + IsAdmin: true, + Dashboard: model.Dashboard{ + Settings: model.Settings{UpdaterEnabled: true, IPv6Enabled: true}, + SchedulerRunning: true, + RecentRuns: []model.UpdateRun{{ID: 1, StartedAt: time.Now(), Status: "running"}}, + }, + } + if err := templates["dashboard"].ExecuteTemplate(&rendered, "dashboard", data); err != nil { + t.Fatalf("render dashboard: %v", err) + } + body := rendered.String() + for _, expected := range []string{ + `data-run-now-button disabled`, + `data-dashboard-ipv4`, + `data-dashboard-ipv6`, + `data-recent-runs aria-live="polite" aria-busy="true"`, + `Aktualisierung läuft`, + `features-24`, + } { + if !strings.Contains(body, expected) { + t.Errorf("rendered dashboard is missing %q", expected) + } + } +} + +func TestSchedulerAPIIncludesRecentRunsOnlyForAdministrators(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + runID, err := database.CreateUpdateRun(ctx) + if err != nil { + t.Fatal(err) + } + if err := database.FinishUpdateRun(ctx, runID, "ok", "198.51.100.10", "", "1 Ziel aktualisiert"); err != nil { + t.Fatal(err) + } + schedule := scheduler.New(database, nil, nil, nil, slog.Default()) + server := &Server{store: database, scheduler: schedule} + + adminResponse := httptest.NewRecorder() + adminRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil) + server.schedulerAPI(adminResponse, adminRequest, &model.Session{Role: "admin"}) + if adminResponse.Code != http.StatusOK { + t.Fatalf("unexpected admin response: status=%d body=%s", adminResponse.Code, adminResponse.Body.String()) + } + if got := adminResponse.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("unexpected cache policy: %q", got) + } + var adminState schedulerResponse + if err := json.NewDecoder(adminResponse.Body).Decode(&adminState); err != nil { + t.Fatal(err) + } + if adminState.RecentRuns == nil || len(*adminState.RecentRuns) != 1 { + t.Fatalf("administrator did not receive recent runs: %#v", adminState.RecentRuns) + } + if run := (*adminState.RecentRuns)[0]; run.ID != runID || run.Status != "ok" || run.Summary != "1 Ziel aktualisiert" { + t.Fatalf("unexpected recent run: %#v", run) + } + + memberResponse := httptest.NewRecorder() + memberRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil) + server.schedulerAPI(memberResponse, memberRequest, &model.Session{Role: "member"}) + var memberState schedulerResponse + if err := json.NewDecoder(memberResponse.Body).Decode(&memberState); err != nil { + t.Fatal(err) + } + if memberState.RecentRuns != nil { + t.Fatal("member received administrator-only update runs") + } +} + func TestLoginLimiterPrunesExpiredClientsAndBoundsMemory(t *testing.T) { t.Parallel() limiter := newLoginLimiter() diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 88c0910..f0dfe6f 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -348,12 +348,18 @@ const countdowns = [...document.querySelectorAll('[data-countdown]')]; const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')]; - if (countdowns.length === 0 && currentIPv4Fields.length === 0) return; + const dashboardIPv4 = document.querySelector('[data-dashboard-ipv4]'); + const dashboardIPv6 = document.querySelector('[data-dashboard-ipv6]'); + const recentRuns = document.querySelector('[data-recent-runs]'); + const runNowButton = document.querySelector('[data-run-now-button]'); + if (countdowns.length === 0 && currentIPv4Fields.length === 0 && !dashboardIPv4 && !dashboardIPv6 && !recentRuns && !runNowButton) return; let nextRun = Number(countdowns[0]?.dataset.countdown || 0); let running = countdowns[0]?.dataset.running === 'true'; let enabled = countdowns[0]?.dataset.enabled !== 'false'; let lastRefresh = 0; + let refreshing = false; + let recentRunsSignature = ''; const formatRemaining = (seconds) => { const hours = Math.floor(seconds / 3600); @@ -363,23 +369,100 @@ return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`; }; + const runStatusClass = (value) => { + const status = String(value || '').toLowerCase(); + if (status.includes('ok') || status.includes('unverändert') || status.includes('aktualisiert') || status.includes('angelegt')) return 'status-good'; + if (status.includes('error') || status.includes('fehler') || status.includes('partial')) return 'status-bad'; + return 'status-warn'; + }; + + const formatRunTime = (unixTime) => { + const date = new Date(Number(unixTime || 0) * 1000); + if (Number.isNaN(date.getTime())) return '–'; + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${day}.${month}.${date.getFullYear()} · ${hours}:${minutes}`; + }; + + const renderRecentRuns = (runs) => { + if (!recentRuns || !Array.isArray(runs)) return; + const signature = JSON.stringify(runs.map((run) => [run.id, run.started_at_unix, run.status, run.summary])); + if (signature === recentRunsSignature) return; + recentRunsSignature = signature; + + if (runs.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = 'Noch kein Update-Lauf protokolliert.'; + recentRuns.replaceChildren(empty); + return; + } + + const list = document.createElement('div'); + list.className = 'data-list'; + for (const run of runs) { + const status = String(run.status || 'unbekannt'); + const statusClass = runStatusClass(status); + const summary = String(run.summary || '').trim() || (status === 'running' ? 'Aktualisierung läuft' : 'Update-Lauf'); + const row = document.createElement('div'); + row.className = 'data-row'; + + const dot = document.createElement('span'); + dot.className = `state-dot ${statusClass}`; + const details = document.createElement('span'); + const title = document.createElement('strong'); + title.textContent = summary; + const time = document.createElement('small'); + time.textContent = formatRunTime(run.started_at_unix); + details.append(title, time); + const pill = document.createElement('span'); + pill.className = `status-pill ${statusClass}`; + pill.textContent = status; + row.append(dot, details, pill); + list.append(row); + } + recentRuns.replaceChildren(list); + }; + + const renderScheduleState = (state) => { + const lastIPv4 = typeof state.last_ipv4 === 'string' ? state.last_ipv4.trim() : ''; + const lastIPv6 = typeof state.last_ipv6 === 'string' ? state.last_ipv6.trim() : ''; + if (lastIPv4) { + for (const field of currentIPv4Fields) field.placeholder = lastIPv4; + } + if (dashboardIPv4) dashboardIPv4.textContent = lastIPv4 || 'Nicht ermittelt'; + if (dashboardIPv6) dashboardIPv6.textContent = state.ipv6_enabled === false ? 'Deaktiviert' : (lastIPv6 || 'Nicht ermittelt'); + if (runNowButton) { + runNowButton.disabled = !enabled || running; + runNowButton.textContent = running ? 'Update läuft' : 'Update jetzt ausführen'; + if (!enabled) runNowButton.title = 'Updater ist deaktiviert'; + else runNowButton.removeAttribute('title'); + } + if (recentRuns) recentRuns.setAttribute('aria-busy', String(running)); + renderRecentRuns(state.recent_runs); + }; + const refreshSchedule = async () => { - if (document.hidden) return; + if (document.hidden || refreshing) return; const now = Date.now(); if (now - lastRefresh < 2500) return; lastRefresh = now; + refreshing = true; try { - const response = await fetch('/api/v1/scheduler', {headers: {'Accept': 'application/json'}, cache: 'no-store'}); + const endpoint = recentRuns ? '/api/v1/scheduler?runs=1' : '/api/v1/scheduler'; + const response = await fetch(endpoint, {headers: {'Accept': 'application/json'}, cache: 'no-store'}); if (!response.ok) return; const state = await response.json(); nextRun = Number(state.next_run_unix || 0); running = Boolean(state.running); enabled = state.enabled !== false; - if (typeof state.last_ipv4 === 'string' && state.last_ipv4.trim()) { - for (const field of currentIPv4Fields) field.placeholder = state.last_ipv4.trim(); - } + renderScheduleState(state); } catch { // Visible scheduler data keeps its last known state when the request fails. + } finally { + refreshing = false; } }; diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 656b0a5..fbbd3b5 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -10,7 +10,7 @@
- +
{{if not .Dashboard.Settings.UpdaterEnabled}}Dienst aktivieren{{end}}
@@ -19,8 +19,8 @@
Aktive Ziele{{.Dashboard.ActiveTargets}}{{len .Dashboard.Zones}} sichtbare DNS-Zone(n)
-
Öffentliche IPv4{{if .Dashboard.Settings.LastIPv4}}{{.Dashboard.Settings.LastIPv4}}{{else}}Nicht ermittelt{{end}}Quelle: externer IP-Resolver
-
Öffentliche IPv6{{if not .Dashboard.Settings.IPv6Enabled}}Deaktiviert{{else if .Dashboard.Settings.LastIPv6}}{{.Dashboard.Settings.LastIPv6}}{{else}}Nicht ermittelt{{end}}{{if .Dashboard.Settings.IPv6Enabled}}Quelle: externer IP-Resolver{{else}}AAAA-Updates pausiert{{end}}
+
Öffentliche IPv4{{if .Dashboard.Settings.LastIPv4}}{{.Dashboard.Settings.LastIPv4}}{{else}}Nicht ermittelt{{end}}Quelle: externer IP-Resolver
+
Öffentliche IPv6{{if not .Dashboard.Settings.IPv6Enabled}}Deaktiviert{{else if .Dashboard.Settings.LastIPv6}}{{.Dashboard.Settings.LastIPv6}}{{else}}Nicht ermittelt{{end}}{{if .Dashboard.Settings.IPv6Enabled}}Quelle: externer IP-Resolver{{else}}AAAA-Updates pausiert{{end}}
Nächste Aktualisierung{{if .Dashboard.Settings.UpdaterEnabled}}--:--{{else}}Inaktiv{{end}}{{if .Dashboard.Settings.UpdaterEnabled}}Intervall: {{.Dashboard.Settings.UpdateIntervalSeconds}} Sekunden{{else}}Dienst in den Einstellungen deaktiviert{{end}}
@@ -28,13 +28,15 @@ {{if .IsAdmin}}
+
{{if .Dashboard.RecentRuns}}
{{range .Dashboard.RecentRuns}} -
{{.Summary}}{{formatTime .StartedAt}}{{.Status}}
+
{{if .Summary}}{{.Summary}}{{else if eq .Status "running"}}Aktualisierung läuft{{else}}Update-Lauf{{end}}{{formatTime .StartedAt}}{{.Status}}
{{end}}
{{else}}
Noch kein Update-Lauf protokolliert.
{{end}} +
{{end}}
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index fc4d827..1fd5f1f 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -6,8 +6,8 @@ {{.Title}} · DNS Control - - + +