fix: refresh dashboard update runs live
Container-Image veröffentlichen / container (push) Multi-Arch-Image veröffentlicht und verifiziert
Container-Image veröffentlichen / container (push) Multi-Arch-Image veröffentlicht und verifiziert
This commit is contained in:
1 parent
bb60c06c0e
commit
fc59092030
11 files changed
+241
-29
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
ARG VERSION=v1.4.0
|
ARG VERSION=v1.4.1
|
||||||
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.4.0
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 20s
|
stop_grace_period: 20s
|
||||||
ports: ["9090:8080"]
|
ports: ["9090:8080"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Domain Control aktualisieren
|
# 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
|
verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei
|
||||||
den folgenden Verfahren erhalten.
|
den folgenden Verfahren erhalten.
|
||||||
|
|
||||||
@@ -34,7 +34,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.4.0
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1
|
||||||
update-agent:
|
update-agent:
|
||||||
image: *image
|
image: *image
|
||||||
```
|
```
|
||||||
@@ -68,7 +68,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.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.
|
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.4.0
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 20s
|
stop_grace_period: 20s
|
||||||
ports: ["9090:8080"]
|
ports: ["9090:8080"]
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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()
|
defer api.Close()
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
state := manager.State()
|
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)
|
t.Fatalf("new release was not detected: %#v", state)
|
||||||
}
|
}
|
||||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||||
@@ -82,7 +82,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
|||||||
t.Fatal("unavailable Gitea endpoint was reported as successful")
|
t.Fatal("unavailable Gitea endpoint was reported as successful")
|
||||||
}
|
}
|
||||||
state = manager.State()
|
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)
|
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.4.0"
|
var Version = "v1.4.1"
|
||||||
+44
-7
@@ -96,6 +96,23 @@ type accessZone struct {
|
|||||||
ManagedTargetIDs map[int64]bool
|
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) {
|
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()
|
templates, err := loadTemplates()
|
||||||
if err != nil {
|
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())
|
settings, err := s.store.GetSettings(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.serverError(w, err)
|
s.serverError(w, err)
|
||||||
return
|
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("Content-Type", "application/json; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(response)
|
||||||
"next_run_unix": unixSeconds(s.scheduler.NextRunAt()),
|
|
||||||
"running": s.scheduler.IsRunning(),
|
|
||||||
"enabled": settings.UpdaterEnabled,
|
|
||||||
"last_ipv4": settings.LastIPv4,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"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/config"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
"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/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/secure"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
"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) {
|
func TestLoginLimiterPrunesExpiredClientsAndBoundsMemory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
limiter := newLoginLimiter()
|
limiter := newLoginLimiter()
|
||||||
|
|||||||
@@ -348,12 +348,18 @@
|
|||||||
|
|
||||||
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
||||||
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
|
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 nextRun = Number(countdowns[0]?.dataset.countdown || 0);
|
||||||
let running = countdowns[0]?.dataset.running === 'true';
|
let running = countdowns[0]?.dataset.running === 'true';
|
||||||
let enabled = countdowns[0]?.dataset.enabled !== 'false';
|
let enabled = countdowns[0]?.dataset.enabled !== 'false';
|
||||||
let lastRefresh = 0;
|
let lastRefresh = 0;
|
||||||
|
let refreshing = false;
|
||||||
|
let recentRunsSignature = '';
|
||||||
|
|
||||||
const formatRemaining = (seconds) => {
|
const formatRemaining = (seconds) => {
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
@@ -363,23 +369,100 @@
|
|||||||
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
|
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 () => {
|
const refreshSchedule = async () => {
|
||||||
if (document.hidden) return;
|
if (document.hidden || refreshing) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastRefresh < 2500) return;
|
if (now - lastRefresh < 2500) return;
|
||||||
lastRefresh = now;
|
lastRefresh = now;
|
||||||
|
refreshing = true;
|
||||||
try {
|
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;
|
if (!response.ok) return;
|
||||||
const state = await response.json();
|
const state = await response.json();
|
||||||
nextRun = Number(state.next_run_unix || 0);
|
nextRun = Number(state.next_run_unix || 0);
|
||||||
running = Boolean(state.running);
|
running = Boolean(state.running);
|
||||||
enabled = state.enabled !== false;
|
enabled = state.enabled !== false;
|
||||||
if (typeof state.last_ipv4 === 'string' && state.last_ipv4.trim()) {
|
renderScheduleState(state);
|
||||||
for (const field of currentIPv4Fields) field.placeholder = state.last_ipv4.trim();
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Visible scheduler data keeps its last known state when the request fails.
|
// Visible scheduler data keeps its last known state when the request fails.
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="heading-actions">
|
<div class="heading-actions">
|
||||||
<form action="/run" method="post">
|
<form action="/run" method="post">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<button class="button button-primary" type="submit" {{if not .Dashboard.Settings.UpdaterEnabled}}disabled title="Updater ist deaktiviert"{{end}}>Update jetzt ausführen</button>
|
<button class="button button-primary" type="submit" data-run-now-button {{if not .Dashboard.Settings.UpdaterEnabled}}disabled title="Updater ist deaktiviert"{{else if .Dashboard.SchedulerRunning}}disabled{{end}}>{{if .Dashboard.SchedulerRunning}}Update läuft{{else}}Update jetzt ausführen{{end}}</button>
|
||||||
</form>
|
</form>
|
||||||
{{if not .Dashboard.Settings.UpdaterEnabled}}<a class="button button-quiet" href="/settings">Dienst aktivieren</a>{{end}}
|
{{if not .Dashboard.Settings.UpdaterEnabled}}<a class="button button-quiet" href="/settings">Dienst aktivieren</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
|
|
||||||
<section class="metric-grid" aria-label="DynDNS-Status">
|
<section class="metric-grid" aria-label="DynDNS-Status">
|
||||||
<article class="metric-card"><span class="metric-label">Aktive Ziele</span><strong>{{.Dashboard.ActiveTargets}}</strong><small>{{len .Dashboard.Zones}} sichtbare DNS-Zone(n)</small></article>
|
<article class="metric-card"><span class="metric-label">Aktive Ziele</span><strong>{{.Dashboard.ActiveTargets}}</strong><small>{{len .Dashboard.Zones}} sichtbare DNS-Zone(n)</small></article>
|
||||||
<article class="metric-card"><span class="metric-label">Öffentliche IPv4</span><strong class="mono address-value">{{if .Dashboard.Settings.LastIPv4}}{{.Dashboard.Settings.LastIPv4}}{{else}}Nicht ermittelt{{end}}</strong><small>Quelle: externer IP-Resolver</small></article>
|
<article class="metric-card"><span class="metric-label">Öffentliche IPv4</span><strong class="mono address-value" data-dashboard-ipv4>{{if .Dashboard.Settings.LastIPv4}}{{.Dashboard.Settings.LastIPv4}}{{else}}Nicht ermittelt{{end}}</strong><small>Quelle: externer IP-Resolver</small></article>
|
||||||
<article class="metric-card {{if not .Dashboard.Settings.IPv6Enabled}}metric-card-disabled{{end}}"><span class="metric-label">Öffentliche IPv6</span><strong class="mono address-value compact">{{if not .Dashboard.Settings.IPv6Enabled}}Deaktiviert{{else if .Dashboard.Settings.LastIPv6}}{{.Dashboard.Settings.LastIPv6}}{{else}}Nicht ermittelt{{end}}</strong><small>{{if .Dashboard.Settings.IPv6Enabled}}Quelle: externer IP-Resolver{{else}}AAAA-Updates pausiert{{end}}</small></article>
|
<article class="metric-card {{if not .Dashboard.Settings.IPv6Enabled}}metric-card-disabled{{end}}"><span class="metric-label">Öffentliche IPv6</span><strong class="mono address-value compact" data-dashboard-ipv6>{{if not .Dashboard.Settings.IPv6Enabled}}Deaktiviert{{else if .Dashboard.Settings.LastIPv6}}{{.Dashboard.Settings.LastIPv6}}{{else}}Nicht ermittelt{{end}}</strong><small>{{if .Dashboard.Settings.IPv6Enabled}}Quelle: externer IP-Resolver{{else}}AAAA-Updates pausiert{{end}}</small></article>
|
||||||
<article class="metric-card countdown-card {{if not .Dashboard.Settings.UpdaterEnabled}}metric-card-disabled{{end}}"><span class="metric-label">Nächste Aktualisierung</span><strong class="mono countdown" data-countdown="{{unixSeconds .Dashboard.NextRunAt}}" data-running="{{.Dashboard.SchedulerRunning}}" data-enabled="{{.Dashboard.Settings.UpdaterEnabled}}">{{if .Dashboard.Settings.UpdaterEnabled}}--:--{{else}}Inaktiv{{end}}</strong><small>{{if .Dashboard.Settings.UpdaterEnabled}}Intervall: {{.Dashboard.Settings.UpdateIntervalSeconds}} Sekunden{{else}}Dienst in den Einstellungen deaktiviert{{end}}</small></article>
|
<article class="metric-card countdown-card {{if not .Dashboard.Settings.UpdaterEnabled}}metric-card-disabled{{end}}"><span class="metric-label">Nächste Aktualisierung</span><strong class="mono countdown" data-countdown="{{unixSeconds .Dashboard.NextRunAt}}" data-running="{{.Dashboard.SchedulerRunning}}" data-enabled="{{.Dashboard.Settings.UpdaterEnabled}}">{{if .Dashboard.Settings.UpdaterEnabled}}--:--{{else}}Inaktiv{{end}}</strong><small>{{if .Dashboard.Settings.UpdaterEnabled}}Intervall: {{.Dashboard.Settings.UpdateIntervalSeconds}} Sekunden{{else}}Dienst in den Einstellungen deaktiviert{{end}}</small></article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -28,13 +28,15 @@
|
|||||||
{{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>
|
||||||
|
<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">
|
||||||
{{range .Dashboard.RecentRuns}}
|
{{range .Dashboard.RecentRuns}}
|
||||||
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}}</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>{{formatTime .StartedAt}}</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 kein Update-Lauf protokolliert.</div>{{end}}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
|
|||||||
@@ -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-23">
|
<link rel="stylesheet" href="/static/styles.css?v=features-24">
|
||||||
<script src="/static/app.js?v=features-23" defer></script>
|
<script src="/static/app.js?v=features-24" 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>
|
||||||
|
|||||||
Reference in new issue
Block a user