fix: show meaningful update runs and tidy update access layout
This commit is contained in:
1 parent
81def77e3c
commit
706b5f5f59
12 files changed
+85
-20
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
ARG VERSION=v1.5.1
|
ARG VERSION=v1.5.2
|
||||||
ARG MINIMUM_UPDATE_VERSION=v1.4.0
|
ARG MINIMUM_UPDATE_VERSION=v1.4.0
|
||||||
ARG MINIMUM_AGENT_PROTOCOL=2
|
ARG MINIMUM_AGENT_PROTOCOL=2
|
||||||
ARG RUNTIME_CONTRACT_VERSION=2
|
ARG RUNTIME_CONTRACT_VERSION=2
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
dyndns:
|
dyndns:
|
||||||
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.1
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 20s
|
stop_grace_period: 20s
|
||||||
ports: ["9090:8080"]
|
ports: ["9090:8080"]
|
||||||
@@ -102,6 +102,10 @@ Port 9090 verwendet zunächst unverschlüsseltes HTTP. Den Port daher nur in ein
|
|||||||
|
|
||||||
## Netzwerkausfälle und Diagnose
|
## Netzwerkausfälle und Diagnose
|
||||||
|
|
||||||
|
Die Übersicht zeigt unter **Letzte Update-Läufe** die letzten sechs Läufe mit
|
||||||
|
Änderungen, Fehlern oder Teilerfolgen. Erfolgreiche Prüfungen ohne Änderungen
|
||||||
|
werden ausgeblendet; die vollständige Laufhistorie bleibt für die Diagnose erhalten.
|
||||||
|
|
||||||
Der Build und die Container-Prüfungen verwenden Go 1.26.9. Damit sind auch
|
Der Build und die Container-Prüfungen verwenden Go 1.26.9. Damit sind auch
|
||||||
die vom Sicherheitscheck gemeldeten HTTP-/Template-Probleme der bisherigen
|
die vom Sicherheitscheck gemeldeten HTTP-/Template-Probleme der bisherigen
|
||||||
Go-1.25.13-Basis behoben (siehe etwa [GO-2026-6617](https://pkg.go.dev/vuln/GO-2026-6617)).
|
Go-1.25.13-Basis behoben (siehe etwa [GO-2026-6617](https://pkg.go.dev/vuln/GO-2026-6617)).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Domain Control aktualisieren
|
# Domain Control aktualisieren
|
||||||
|
|
||||||
Die aktuelle Compose-Version ist `v1.5.1`. Datenbank, Einstellungen und
|
Die aktuelle Compose-Version ist `v1.5.2`. Datenbank, Einstellungen und
|
||||||
verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei
|
verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei
|
||||||
den folgenden Verfahren erhalten.
|
den folgenden Verfahren erhalten.
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
dyndns:
|
dyndns:
|
||||||
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.1
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.2
|
||||||
update-agent:
|
update-agent:
|
||||||
image: *image
|
image: *image
|
||||||
```
|
```
|
||||||
@@ -97,7 +97,7 @@ Die erwartete Antwort lautet `ok`.
|
|||||||
## Portainer oder Dockge
|
## Portainer oder Dockge
|
||||||
|
|
||||||
1. Den bestehenden Stack bearbeiten.
|
1. Den bestehenden Stack bearbeiten.
|
||||||
2. Nur den Tag der zentralen Image-Zeile auf `v1.5.1` setzen.
|
2. Nur den Tag der zentralen Image-Zeile auf `v1.5.2` setzen.
|
||||||
3. Den Stack mit vorherigem Image-Pull erneut bereitstellen.
|
3. Den Stack mit vorherigem Image-Pull erneut bereitstellen.
|
||||||
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
|
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
dyndns:
|
dyndns:
|
||||||
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.1
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 20s
|
stop_grace_period: 20s
|
||||||
ports: ["9090:8080"]
|
ports: ["9090:8080"]
|
||||||
|
|||||||
@@ -18,4 +18,4 @@ const (
|
|||||||
|
|
||||||
// Version is replaced through -ldflags for release images. The source fallback
|
// Version is replaced through -ldflags for release images. The source fallback
|
||||||
// keeps local builds and tests honest about the currently checked-out release.
|
// keeps local builds and tests honest about the currently checked-out release.
|
||||||
var Version = "v1.5.1"
|
var Version = "v1.5.2"
|
||||||
+18
-1
@@ -1480,7 +1480,24 @@ func (s *Store) RecoverInterruptedRuns(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) RecentRuns(ctx context.Context, limit int) ([]model.UpdateRun, error) {
|
func (s *Store) RecentRuns(ctx context.Context, limit int) ([]model.UpdateRun, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT id, started_at, finished_at, status, ipv4, ipv6, summary FROM update_runs ORDER BY id DESC LIMIT ?`, limit)
|
return s.recentRuns(ctx, limit, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentSignificantRuns keeps changes and failures visible even after many
|
||||||
|
// successful checks without changes. The existing history stores counts only
|
||||||
|
// in its summary, so exclude the scheduler's two known successful no-op results.
|
||||||
|
// Unknown summaries and all failures are retained rather than silently hidden.
|
||||||
|
func (s *Store) RecentSignificantRuns(ctx context.Context, limit int) ([]model.UpdateRun, error) {
|
||||||
|
return s.recentRuns(ctx, limit, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) recentRuns(ctx context.Context, limit int, significantOnly bool) ([]model.UpdateRun, error) {
|
||||||
|
query := `SELECT id, started_at, finished_at, status, ipv4, ipv6, summary FROM update_runs`
|
||||||
|
if significantOnly {
|
||||||
|
query += ` WHERE status IN ('error', 'partial') OR (status = 'ok' AND summary NOT IN ('0 Ziel(e) aktualisiert, 0 Fehler', 'Keine aktiven DynDNS-Ziele'))`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY id DESC LIMIT ?`
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, session *mode
|
|||||||
var events []model.AuditEvent
|
var events []model.AuditEvent
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
if session.Role == "admin" {
|
if session.Role == "admin" {
|
||||||
runs, _ = s.store.RecentRuns(r.Context(), 6)
|
runs, _ = s.store.RecentSignificantRuns(r.Context(), 6)
|
||||||
events, _ = s.store.RecentAudit(r.Context(), 6)
|
events, _ = s.store.RecentAudit(r.Context(), 6)
|
||||||
errorCount, _ = s.store.CountErrors(r.Context())
|
errorCount, _ = s.store.CountErrors(r.Context())
|
||||||
}
|
}
|
||||||
@@ -1597,7 +1597,7 @@ func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, session *m
|
|||||||
LastIPv6: settings.LastIPv6,
|
LastIPv6: settings.LastIPv6,
|
||||||
}
|
}
|
||||||
if session.Role == "admin" && r.URL.Query().Get("runs") == "1" {
|
if session.Role == "admin" && r.URL.Query().Get("runs") == "1" {
|
||||||
runs, err := s.store.RecentRuns(r.Context(), 6)
|
runs, err := s.store.RecentSignificantRuns(r.Context(), 6)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.serverError(w, err)
|
s.serverError(w, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
|
|||||||
`data-dashboard-ipv6`,
|
`data-dashboard-ipv6`,
|
||||||
`data-recent-runs aria-live="polite" aria-busy="true"`,
|
`data-recent-runs aria-live="polite" aria-busy="true"`,
|
||||||
`Aktualisierung läuft`,
|
`Aktualisierung läuft`,
|
||||||
`features-30`,
|
`features-31`,
|
||||||
`data-local-time="1788001920"`,
|
`data-local-time="1788001920"`,
|
||||||
`dashboard-zone-item`,
|
`dashboard-zone-item`,
|
||||||
`DNS-Zone · example.de`,
|
`DNS-Zone · example.de`,
|
||||||
@@ -375,7 +375,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSchedulerAPIIncludesRecentRunsOnlyForAdministrators(t *testing.T) {
|
func TestRecentRunsShowChangesAndErrorsOnlyForAdministrators(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
@@ -390,8 +390,38 @@ func TestSchedulerAPIIncludesRecentRunsOnlyForAdministrators(t *testing.T) {
|
|||||||
if err := database.FinishUpdateRun(ctx, runID, "ok", "198.51.100.10", "", "1 Ziel aktualisiert"); err != nil {
|
if err := database.FinishUpdateRun(ctx, runID, "ok", "198.51.100.10", "", "1 Ziel aktualisiert"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// More no-op runs than the display limit must not push out older changes.
|
||||||
|
for index := 0; index < 12; index++ {
|
||||||
|
id, err := database.CreateUpdateRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
summary := "0 Ziel(e) aktualisiert, 0 Fehler"
|
||||||
|
if index%2 == 0 {
|
||||||
|
summary = "Keine aktiven DynDNS-Ziele"
|
||||||
|
}
|
||||||
|
if err := database.FinishUpdateRun(ctx, id, "ok", "", "", summary); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, status := range []string{"partial", "error"} {
|
||||||
|
id, err := database.CreateUpdateRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.FinishUpdateRun(ctx, id, status, "", "", "0 Ziel(e) aktualisiert, 0 Fehler"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := database.CreateUpdateRun(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
schedule := scheduler.New(database, nil, nil, nil, slog.Default())
|
schedule := scheduler.New(database, nil, nil, nil, slog.Default())
|
||||||
server := &Server{store: database, scheduler: schedule}
|
templates, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := &Server{store: database, scheduler: schedule, templates: templates, logger: slog.Default()}
|
||||||
|
|
||||||
adminResponse := httptest.NewRecorder()
|
adminResponse := httptest.NewRecorder()
|
||||||
adminRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil)
|
adminRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil)
|
||||||
@@ -406,13 +436,26 @@ func TestSchedulerAPIIncludesRecentRunsOnlyForAdministrators(t *testing.T) {
|
|||||||
if err := json.NewDecoder(adminResponse.Body).Decode(&adminState); err != nil {
|
if err := json.NewDecoder(adminResponse.Body).Decode(&adminState); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if adminState.RecentRuns == nil || len(*adminState.RecentRuns) != 1 {
|
if adminState.RecentRuns == nil || len(*adminState.RecentRuns) != 3 {
|
||||||
t.Fatalf("administrator did not receive recent runs: %#v", adminState.RecentRuns)
|
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" {
|
if run := (*adminState.RecentRuns)[2]; run.ID != runID || run.Status != "ok" || run.Summary != "1 Ziel aktualisiert" {
|
||||||
t.Fatalf("unexpected recent run: %#v", run)
|
t.Fatalf("unexpected recent run: %#v", run)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (*adminState.RecentRuns)[0].Status != "error" || (*adminState.RecentRuns)[1].Status != "partial" {
|
||||||
|
t.Fatal("zero-change errors or partial runs were hidden")
|
||||||
|
}
|
||||||
|
allRuns, err := database.RecentRuns(ctx, 100)
|
||||||
|
if err != nil || len(allRuns) != 16 {
|
||||||
|
t.Fatalf("filter changed diagnostic history: count=%d err=%v", len(allRuns), err)
|
||||||
|
}
|
||||||
|
pageResponse := httptest.NewRecorder()
|
||||||
|
server.dashboard(pageResponse, httptest.NewRequest(http.MethodGet, "/", nil), &model.Session{Role: "admin"})
|
||||||
|
if pageResponse.Code != http.StatusOK || !strings.Contains(pageResponse.Body.String(), "1 Ziel aktualisiert") || strings.Contains(pageResponse.Body.String(), "Keine aktiven DynDNS-Ziele") || strings.Count(pageResponse.Body.String(), "0 Ziel(e) aktualisiert, 0 Fehler") != 2 {
|
||||||
|
t.Fatal("initial dashboard differs from the filtered live history")
|
||||||
|
}
|
||||||
|
|
||||||
memberResponse := httptest.NewRecorder()
|
memberResponse := httptest.NewRecorder()
|
||||||
memberRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil)
|
memberRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil)
|
||||||
server.schedulerAPI(memberResponse, memberRequest, &model.Session{Role: "member"})
|
server.schedulerAPI(memberResponse, memberRequest, &model.Session{Role: "member"})
|
||||||
|
|||||||
@@ -636,7 +636,7 @@
|
|||||||
if (runs.length === 0) {
|
if (runs.length === 0) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'empty-state';
|
empty.className = 'empty-state';
|
||||||
empty.textContent = 'Noch kein Update-Lauf protokolliert.';
|
empty.textContent = 'Noch keine Änderungen oder Fehler protokolliert.';
|
||||||
recentRuns.replaceChildren(empty);
|
recentRuns.replaceChildren(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-heading"><div><span class="section-label">Scheduler</span><h2>Letzte Update-Läufe</h2></div><div class="panel-links"><a href="/errors" class="{{if .Dashboard.ErrorCount}}text-danger{{end}}">Alle Fehler{{if .Dashboard.ErrorCount}} · {{.Dashboard.ErrorCount}}{{end}}</a><a href="/settings">Konfiguration</a></div></div>
|
<div class="panel-heading"><div><span class="section-label">Scheduler</span><h2>Letzte Update-Läufe</h2></div><div class="panel-links"><a href="/errors" class="{{if .Dashboard.ErrorCount}}text-danger{{end}}">Alle Fehler{{if .Dashboard.ErrorCount}} · {{.Dashboard.ErrorCount}}{{end}}</a><a href="/settings">Konfiguration</a></div></div>
|
||||||
|
<p class="panel-description">Die letzten Änderungen, Fehler und Teilerfolge.</p>
|
||||||
<div data-recent-runs aria-live="polite" aria-busy="{{.Dashboard.SchedulerRunning}}">
|
<div data-recent-runs aria-live="polite" aria-busy="{{.Dashboard.SchedulerRunning}}">
|
||||||
{{if .Dashboard.RecentRuns}}
|
{{if .Dashboard.RecentRuns}}
|
||||||
<div class="data-list">
|
<div class="data-list">
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{if .Summary}}{{.Summary}}{{else if eq .Status "running"}}Aktualisierung läuft{{else}}Update-Lauf{{end}}</strong><small><time data-local-time="{{unixSeconds .StartedAt}}">{{formatTime .StartedAt}}</time></small></span><span class="status-pill {{statusClass .Status}}">{{.Status}}</span></div>
|
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{if .Summary}}{{.Summary}}{{else if eq .Status "running"}}Aktualisierung läuft{{else}}Update-Lauf{{end}}</strong><small><time data-local-time="{{unixSeconds .StartedAt}}">{{formatTime .StartedAt}}</time></small></span><span class="status-pill {{statusClass .Status}}">{{.Status}}</span></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}<div class="empty-state">Noch kein Update-Lauf protokolliert.</div>{{end}}
|
{{else}}<div class="empty-state">Noch keine Änderungen oder Fehler protokolliert.</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<title>{{.Title}} · DNS Control</title>
|
<title>{{.Title}} · DNS Control</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=features-30">
|
<link rel="stylesheet" href="/static/styles.css?v=features-31">
|
||||||
<script src="/static/app.js?v=features-30" defer></script>
|
<script src="/static/app.js?v=features-31" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="page-{{.Page}}">
|
<body class="page-{{.Page}}">
|
||||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||||
|
|||||||
@@ -43,7 +43,6 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
|
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
|
||||||
<p>Öffentliche Releases und Container-Images werden ohne Anmeldung geladen. Für private Repositories oder Images kannst du einen Zugang hinterlegen; er wird nur bei Bedarf verwendet.</p>
|
|
||||||
{{if .SoftwareUpdate.CredentialConfigured}}
|
{{if .SoftwareUpdate.CredentialConfigured}}
|
||||||
<div class="update-access-state">
|
<div class="update-access-state">
|
||||||
<span class="update-access-state-icon" aria-hidden="true">✓</span>
|
<span class="update-access-state-icon" aria-hidden="true">✓</span>
|
||||||
@@ -57,6 +56,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<form action="/system/update-credential" method="post" class="stacked-form">
|
<form action="/system/update-credential" method="post" class="stacked-form">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<p class="field-note">Öffentliche Updates benötigen keine Anmeldung. Dieser Zugang wird nur für private Updates verwendet.</p>
|
||||||
<div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>Passwort/Token <input name="registry_token" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></label></div>
|
<div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>Passwort/Token <input name="registry_token" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></label></div>
|
||||||
<label>Admin-Passwort bestätigen <input name="current_password" type="password" autocomplete="current-password" maxlength="128" required></label>
|
<label>Admin-Passwort bestätigen <input name="current_password" type="password" autocomplete="current-password" maxlength="128" required></label>
|
||||||
<div class="form-actions"><button class="button button-primary" type="submit">{{if .SoftwareUpdate.CredentialConfigured}}Zugang aktualisieren{{else}}Zugang sicher speichern{{end}}</button></div>
|
<div class="form-actions"><button class="button button-primary" type="submit">{{if .SoftwareUpdate.CredentialConfigured}}Zugang aktualisieren{{else}}Zugang sicher speichern{{end}}</button></div>
|
||||||
|
|||||||
Reference in new issue
Block a user