Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bbb14470c | ||
|
|
26a960c6bd |
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.1
|
ARG VERSION=v1.4.3
|
||||||
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.1
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 20s
|
stop_grace_period: 20s
|
||||||
ports: ["9090:8080"]
|
ports: ["9090:8080"]
|
||||||
@@ -61,7 +61,7 @@ Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden
|
|||||||
Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose,
|
Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose,
|
||||||
Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md).
|
Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md).
|
||||||
|
|
||||||
Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. Der Gitea-Benutzername und Token werden einmalig in der Weboberfläche hinterlegt; der Token wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben.
|
Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. Gitea-Benutzername und wahlweise Passwort oder Token werden einmalig in der Weboberfläche hinterlegt; das Geheimnis wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben.
|
||||||
|
|
||||||
`v1.4.0` ist die sichere Basis für diesen Update-Weg. Eine ältere Installation wird einmalig mit Docker Compose auf `v1.4.0` gebracht; danach kann der Agent auch spätere kompatible Releases direkt installieren. Web-App und Agent dürfen dabei unterschiedliche Versionsnummern haben: Maßgeblich ist das ausdrücklich ausgewiesene Protokoll. Verlangt ein Ziel-Image einen neueren Vertrag, bleibt der laufende Container unverändert und die Oberfläche fordert ein Compose-Update an.
|
`v1.4.0` ist die sichere Basis für diesen Update-Weg. Eine ältere Installation wird einmalig mit Docker Compose auf `v1.4.0` gebracht; danach kann der Agent auch spätere kompatible Releases direkt installieren. Web-App und Agent dürfen dabei unterschiedliche Versionsnummern haben: Maßgeblich ist das ausdrücklich ausgewiesene Protokoll. Verlangt ein Ziel-Image einen neueren Vertrag, bleibt der laufende Container unverändert und die Oberfläche fordert ein Compose-Update an.
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Domain Control aktualisieren
|
# Domain Control aktualisieren
|
||||||
|
|
||||||
Die aktuelle Compose-Version ist `v1.4.1`. Datenbank, Einstellungen und
|
Die aktuelle Compose-Version ist `v1.4.3`. 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.
|
||||||
|
|
||||||
## Direkt in der Weboberfläche
|
## Direkt in der Weboberfläche
|
||||||
|
|
||||||
1. Als Administrator anmelden und **Update** öffnen.
|
1. Als Administrator anmelden und **Update** öffnen.
|
||||||
2. Falls noch nicht geschehen, Gitea-Benutzername und einen Token mit
|
2. Falls noch nicht geschehen, Gitea-Benutzername und das Passwort oder einen
|
||||||
Lesezugriff auf Repository und Container-Paket hinterlegen.
|
Token mit Lesezugriff auf Repository und Container-Paket hinterlegen.
|
||||||
3. **Nach Update suchen** und anschließend **Jetzt aktualisieren** wählen.
|
3. **Nach Update suchen** und anschließend **Jetzt aktualisieren** wählen.
|
||||||
4. Warten, bis Image-Download, Containerwechsel und Healthcheck abgeschlossen
|
4. Warten, bis Image-Download, Containerwechsel und Healthcheck abgeschlossen
|
||||||
sind. Bei einem Fehler versucht der Update-Agent automatisch, den vorherigen
|
sind. Bei einem Fehler versucht der Update-Agent automatisch, den vorherigen
|
||||||
@@ -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.1
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.3
|
||||||
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.1` setzen.
|
2. Nur den Tag der zentralen Image-Zeile auf `v1.4.3` 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.1
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 20s
|
stop_grace_period: 20s
|
||||||
ports: ["9090:8080"]
|
ports: ["9090:8080"]
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type State struct {
|
|||||||
LastCheckedAt time.Time
|
LastCheckedAt time.Time
|
||||||
LastError string
|
LastError string
|
||||||
CredentialConfigured bool
|
CredentialConfigured bool
|
||||||
|
CredentialUsername string
|
||||||
UpdateAvailable bool
|
UpdateAvailable bool
|
||||||
AgentAvailable bool
|
AgentAvailable bool
|
||||||
AgentCompatible bool
|
AgentCompatible bool
|
||||||
@@ -136,15 +137,15 @@ func (m *Manager) State() State {
|
|||||||
return m.state
|
return m.state
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SaveCredential(ctx context.Context, username, token string) error {
|
func (m *Manager) SaveCredential(ctx context.Context, username, credential string) error {
|
||||||
username, token = strings.TrimSpace(username), strings.TrimSpace(token)
|
username = strings.TrimSpace(username)
|
||||||
if username == "" || len(username) > 128 {
|
if username == "" || len(username) > 128 {
|
||||||
return errors.New("Gitea-Benutzername fehlt oder ist zu lang")
|
return errors.New("Gitea-Benutzername fehlt oder ist zu lang")
|
||||||
}
|
}
|
||||||
if len(token) < 8 || len(token) > 1024 {
|
if len(credential) < 8 || len(credential) > 1024 {
|
||||||
return errors.New("Gitea-Token fehlt oder hat eine ungültige Länge")
|
return errors.New("Gitea-Passwort oder -Token fehlt oder hat eine ungültige Länge")
|
||||||
}
|
}
|
||||||
ciphertext, nonce, err := m.cipher.Encrypt(token)
|
ciphertext, nonce, err := m.cipher.Encrypt(credential)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Update-Zugang verschlüsseln: %w", err)
|
return fmt.Errorf("Update-Zugang verschlüsseln: %w", err)
|
||||||
}
|
}
|
||||||
@@ -169,11 +170,11 @@ func (m *Manager) CheckNow(ctx context.Context) error {
|
|||||||
m.setStoredState(settings)
|
m.setStoredState(settings)
|
||||||
return ErrCredentialMissing
|
return ErrCredentialMissing
|
||||||
}
|
}
|
||||||
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
|
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
|
||||||
}
|
}
|
||||||
release, err := m.fetchLatest(ctx, token)
|
release, err := m.fetchLatest(ctx, settings.RegistryUsername, credential)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m.recordFailure(ctx, err)
|
return m.recordFailure(ctx, err)
|
||||||
}
|
}
|
||||||
@@ -207,14 +208,14 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||||
if err != nil || settings.RegistryUsername == "" {
|
if err != nil || settings.RegistryUsername == "" {
|
||||||
return ErrCredentialMissing
|
return ErrCredentialMissing
|
||||||
}
|
}
|
||||||
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
|
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: token}
|
request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: credential}
|
||||||
if err := m.agent.Start(ctx, request); err != nil {
|
if err := m.agent.Start(ctx, request); err != nil {
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
m.clearDesiredVersion(cleanupCtx, version)
|
m.clearDesiredVersion(cleanupCtx, version)
|
||||||
@@ -254,17 +255,17 @@ func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, erro
|
|||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseResponse, error) {
|
func (m *Manager) fetchLatest(ctx context.Context, username, credential string) (releaseResponse, error) {
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
|
response, err := m.fetchLatestWithAuth(ctx, username, credential, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return releaseResponse{}, errors.New("Update-Endpunkt ist ungültig")
|
return releaseResponse{}, err
|
||||||
}
|
}
|
||||||
request.Header.Set("Accept", "application/json")
|
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
||||||
request.Header.Set("Authorization", "token "+token)
|
response.Body.Close()
|
||||||
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
|
response, err = m.fetchLatestWithAuth(ctx, username, credential, false)
|
||||||
response, err := m.httpClient.Do(request)
|
if err != nil {
|
||||||
if err != nil {
|
return releaseResponse{}, err
|
||||||
return releaseResponse{}, errors.New("Git-Server ist momentan nicht erreichbar")
|
}
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
||||||
@@ -284,6 +285,25 @@ func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseRespons
|
|||||||
return release, nil
|
return release, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchLatestWithAuth(ctx context.Context, username, credential string, basic bool) (*http.Response, error) {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("Update-Endpunkt ist ungültig")
|
||||||
|
}
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
if basic {
|
||||||
|
request.SetBasicAuth(username, credential)
|
||||||
|
} else {
|
||||||
|
request.Header.Set("Authorization", "token "+credential)
|
||||||
|
}
|
||||||
|
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
|
||||||
|
response, err := m.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("Git-Server ist momentan nicht erreichbar")
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) safeReleaseURL(raw string) string {
|
func (m *Manager) safeReleaseURL(raw string) string {
|
||||||
apiURL, apiErr := url.Parse(m.apiURL)
|
apiURL, apiErr := url.Parse(m.apiURL)
|
||||||
releaseURL, releaseErr := url.Parse(raw)
|
releaseURL, releaseErr := url.Parse(raw)
|
||||||
@@ -313,6 +333,7 @@ func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
|
|||||||
LastCheckedAt: settings.LastCheckedAt,
|
LastCheckedAt: settings.LastCheckedAt,
|
||||||
LastError: settings.LastError,
|
LastError: settings.LastError,
|
||||||
CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0,
|
CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0,
|
||||||
|
CredentialUsername: settings.RegistryUsername,
|
||||||
}
|
}
|
||||||
if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil {
|
if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil {
|
||||||
state.UpdateAvailable = comparison < 0
|
state.UpdateAvailable = comparison < 0
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ import (
|
|||||||
|
|
||||||
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
const token = "test-token-that-must-stay-secret"
|
const credential = " test-password-or-token-that-must-stay-secret "
|
||||||
var unavailable atomic.Bool
|
var unavailable atomic.Bool
|
||||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get("Authorization") != "token "+token {
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok || username != "registry-user" || password != credential {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -33,7 +34,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.2","html_url":"https://example.invalid/releases/v1.4.2","draft":false,"prerelease":false}`))
|
_, _ = w.Write([]byte(`{"tag_name":"v1.4.4","html_url":"https://example.invalid/releases/v1.4.4","draft":false,"prerelease":false}`))
|
||||||
}))
|
}))
|
||||||
defer api.Close()
|
defer api.Close()
|
||||||
|
|
||||||
@@ -59,22 +60,22 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := manager.SaveCredential(ctx, "registry-user", token); err != nil {
|
if err := manager.SaveCredential(ctx, "registry-user", credential); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := manager.CheckNow(ctx); err != nil {
|
if err := manager.CheckNow(ctx); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
state := manager.State()
|
state := manager.State()
|
||||||
if !state.UpdateAvailable || state.LatestVersion != "v1.4.2" || state.LastError != "" {
|
if !state.UpdateAvailable || state.LatestVersion != "v1.4.4" || state.LastError != "" || state.CredentialUsername != "registry-user" {
|
||||||
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if strings.Contains(string(settings.TokenCiphertext), token) {
|
if strings.Contains(string(settings.TokenCiphertext), credential) {
|
||||||
t.Fatal("registry token was stored as plaintext")
|
t.Fatal("registry credential was stored as plaintext")
|
||||||
}
|
}
|
||||||
|
|
||||||
unavailable.Store(true)
|
unavailable.Store(true)
|
||||||
@@ -82,11 +83,36 @@ 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.2" || state.LastError == "" {
|
if !state.UpdateAvailable || state.LatestVersion != "v1.4.4" || 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManagerFallsBackToTokenAuthentication(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const credential = "fallback-api-token"
|
||||||
|
var requests atomic.Int32
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
if r.Header.Get("Authorization") != "token "+credential {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"tag_name":"v2.0.0","html_url":"https://example.invalid/releases/v2.0.0","draft":false,"prerelease":false}`))
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
|
||||||
|
manager := &Manager{apiURL: api.URL, httpClient: api.Client()}
|
||||||
|
release, err := manager.fetchLatest(context.Background(), "registry-user", credential)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if release.TagName != "v2.0.0" || requests.Load() != 2 {
|
||||||
|
t.Fatalf("token fallback was not used: release=%#v requests=%d", release, requests.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) {
|
func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@@ -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.1"
|
var Version = "v1.4.3"
|
||||||
@@ -91,7 +91,7 @@ type PiHoleCredential struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SoftwareUpdateSettings contains the locally persisted update state. The
|
// SoftwareUpdateSettings contains the locally persisted update state. The
|
||||||
// encrypted registry token is returned only to trusted backend components and
|
// encrypted registry credential is returned only to trusted backend components and
|
||||||
// is never passed to templates.
|
// is never passed to templates.
|
||||||
type SoftwareUpdateSettings struct {
|
type SoftwareUpdateSettings struct {
|
||||||
RegistryUsername string
|
RegistryUsername string
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
request.Version = strings.TrimSpace(request.Version)
|
request.Version = strings.TrimSpace(request.Version)
|
||||||
request.CurrentVersion = strings.TrimSpace(request.CurrentVersion)
|
request.CurrentVersion = strings.TrimSpace(request.CurrentVersion)
|
||||||
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
|
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
|
||||||
request.RegistryToken = strings.TrimSpace(request.RegistryToken)
|
|
||||||
if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
|
if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
|
||||||
http.Error(w, "invalid update request", http.StatusBadRequest)
|
http.Error(w, "invalid update request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -99,12 +98,12 @@ func (c *Controller) run(request updateprotocol.Request) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
err := c.runner.Update(ctx, request, c.setPhase)
|
err := c.runner.Update(ctx, request, c.setPhase)
|
||||||
registryToken := request.RegistryToken
|
registryCredential := request.RegistryToken
|
||||||
request.RegistryToken = ""
|
request.RegistryToken = ""
|
||||||
if err != nil {
|
if err != nil {
|
||||||
message := publicAgentError(err)
|
message := publicAgentError(err)
|
||||||
c.setPhase(updateprotocol.PhaseFailed, message)
|
c.setPhase(updateprotocol.PhaseFailed, message)
|
||||||
c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryToken))
|
c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryCredential))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert")
|
c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert")
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
|
|||||||
t.Fatalf("request without current version accepted: status=%d", missingCurrent.Code)
|
t.Fatalf("request without current version accepted: status=%d", missingCurrent.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
const secret = "registry-token-must-not-leak"
|
const secret = " registry-password-must-not-be-trimmed "
|
||||||
body := []byte(`{"version":"v2.0.0","current_version":"v1.4.0","registry_username":"user","registry_token":"` + secret + `"}`)
|
body := []byte(`{"version":"v2.0.0","current_version":"v1.4.0","registry_username":"user","registry_token":"` + secret + `"}`)
|
||||||
accepted := httptest.NewRecorder()
|
accepted := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
|
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
|
||||||
|
|||||||
@@ -304,8 +304,8 @@ func (c *dockerClient) findTarget(ctx context.Context, label string) (containerS
|
|||||||
return running[0], nil
|
return running[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, token string) error {
|
func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, credential string) error {
|
||||||
authJSON, _ := json.Marshal(map[string]string{"username": username, "password": token, "serveraddress": strings.Split(repository, "/")[0]})
|
authJSON, _ := json.Marshal(map[string]string{"username": username, "password": credential, "serveraddress": strings.Split(repository, "/")[0]})
|
||||||
headers := http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}}
|
headers := http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}}
|
||||||
query := url.Values{"fromImage": {repository}, "tag": {tag}}
|
query := url.Values{"fromImage": {repository}, "tag": {tag}}
|
||||||
response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true)
|
response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true)
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ type Request struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
CurrentVersion string `json:"current_version"`
|
CurrentVersion string `json:"current_version"`
|
||||||
RegistryUsername string `json:"registry_username"`
|
RegistryUsername string `json:"registry_username"`
|
||||||
RegistryToken string `json:"registry_token"`
|
// RegistryToken keeps the v2 wire name for compatibility and carries either
|
||||||
|
// a registry password or an API token.
|
||||||
|
RegistryToken string `json:"registry_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
|
|||||||
@@ -1411,6 +1411,8 @@ func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request,
|
|||||||
"phase": status.Phase,
|
"phase": status.Phase,
|
||||||
"message": status.Message,
|
"message": status.Message,
|
||||||
"target_version": status.TargetVersion,
|
"target_version": status.TargetVersion,
|
||||||
|
"started_at": status.StartedAt,
|
||||||
|
"updated_at": status.UpdatedAt,
|
||||||
"current_version": state.CurrentVersion,
|
"current_version": state.CurrentVersion,
|
||||||
"latest_version": state.LatestVersion,
|
"latest_version": state.LatestVersion,
|
||||||
})
|
})
|
||||||
@@ -1888,7 +1890,13 @@ func (s *Server) validSessionCSRF(r *http.Request, session *model.Session) bool
|
|||||||
|
|
||||||
func (s *Server) parseForm(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) parseForm(w http.ResponseWriter, r *http.Request) error {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||||
if err := r.ParseForm(); err != nil {
|
var err error
|
||||||
|
if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") {
|
||||||
|
err = r.ParseMultipartForm(64 << 10)
|
||||||
|
} else {
|
||||||
|
err = r.ParseForm()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
|
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -204,6 +205,31 @@ func TestRedirectNoticePreservesQueryAndAddsKind(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseFormAcceptsMultipartUpdateFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
if err := writer.WriteField("csrf", "csrf-token"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writer.WriteField("version", "v1.4.2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/system/update", &body)
|
||||||
|
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
server := &Server{}
|
||||||
|
if err := server.parseForm(response, request); err != nil {
|
||||||
|
t.Fatalf("multipart update form was rejected: status=%d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if request.FormValue("csrf") != "csrf-token" || request.FormValue("version") != "v1.4.2" {
|
||||||
|
t.Fatalf("multipart fields were not parsed: %#v", request.Form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginErrorRendersAsToast(t *testing.T) {
|
func TestLoginErrorRendersAsToast(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
templates, err := loadTemplates()
|
templates, err := loadTemplates()
|
||||||
@@ -242,6 +268,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
|
|||||||
t.Fatalf("load templates: %v", err)
|
t.Fatalf("load templates: %v", err)
|
||||||
}
|
}
|
||||||
var rendered bytes.Buffer
|
var rendered bytes.Buffer
|
||||||
|
now := time.Date(2026, time.August, 29, 11, 12, 0, 0, time.UTC)
|
||||||
data := viewData{
|
data := viewData{
|
||||||
Title: "Übersicht",
|
Title: "Übersicht",
|
||||||
Page: "dashboard",
|
Page: "dashboard",
|
||||||
@@ -251,7 +278,8 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
|
|||||||
Dashboard: model.Dashboard{
|
Dashboard: model.Dashboard{
|
||||||
Settings: model.Settings{UpdaterEnabled: true, IPv6Enabled: true},
|
Settings: model.Settings{UpdaterEnabled: true, IPv6Enabled: true},
|
||||||
SchedulerRunning: true,
|
SchedulerRunning: true,
|
||||||
RecentRuns: []model.UpdateRun{{ID: 1, StartedAt: time.Now(), Status: "running"}},
|
RecentRuns: []model.UpdateRun{{ID: 1, StartedAt: now, Status: "running"}},
|
||||||
|
Zones: []model.Zone{{ID: 7, Name: "example.de", LastSyncAt: now}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := templates["dashboard"].ExecuteTemplate(&rendered, "dashboard", data); err != nil {
|
if err := templates["dashboard"].ExecuteTemplate(&rendered, "dashboard", data); err != nil {
|
||||||
@@ -264,7 +292,10 @@ 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-24`,
|
`features-28`,
|
||||||
|
`data-local-time="1788001920"`,
|
||||||
|
`dashboard-zone-item`,
|
||||||
|
`DNS-Zone · example.de`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, expected) {
|
if !strings.Contains(body, expected) {
|
||||||
t.Errorf("rendered dashboard is missing %q", expected)
|
t.Errorf("rendered dashboard is missing %q", expected)
|
||||||
@@ -480,13 +511,14 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||||
updateState := appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, AgentCompatible: true, AgentVersion: "v1.4.0", AgentProtocol: 2, CredentialConfigured: true}
|
checkedAt := time.Date(2026, time.August, 29, 11, 12, 0, 0, time.UTC)
|
||||||
|
updateState := appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", LastCheckedAt: checkedAt, UpdateAvailable: true, AgentAvailable: true, AgentCompatible: true, AgentVersion: "v1.4.0", AgentProtocol: 2, CredentialConfigured: true, CredentialUsername: "registry-user"}
|
||||||
var notification bytes.Buffer
|
var notification bytes.Buffer
|
||||||
accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
|
accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
|
||||||
if err := templates["security"].ExecuteTemplate(¬ification, "security", accountData); err != nil {
|
if err := templates["security"].ExecuteTemplate(¬ification, "security", accountData); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, expected := range []string{"Neue Version verfügbar", "v2.0.0 ist bereit", `data-software-update-form`, `data-software-update-overlay`} {
|
for _, expected := range []string{"Neue Version verfügbar", "v2.0.0 ist bereit", `data-software-update-banner="v2.0.0"`, `data-software-update-form`, `data-software-update-overlay`} {
|
||||||
if !strings.Contains(notification.String(), expected) {
|
if !strings.Contains(notification.String(), expected) {
|
||||||
t.Errorf("global update notification is missing %q", expected)
|
t.Errorf("global update notification is missing %q", expected)
|
||||||
}
|
}
|
||||||
@@ -497,7 +529,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
|||||||
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
|
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Update-Zugang ersetzen"} {
|
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Mit Gitea verbunden", "@registry-user", "Zugang ändern", "Passwort/Token", `<details class="update-credential-editor">`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`} {
|
||||||
if !strings.Contains(page.String(), expected) {
|
if !strings.Contains(page.String(), expected) {
|
||||||
t.Errorf("software update page is missing %q", expected)
|
t.Errorf("software update page is missing %q", expected)
|
||||||
}
|
}
|
||||||
@@ -507,7 +539,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
|||||||
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
|
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, expected := range []string{"Nach Update suchen", "Installiert", "Verfügbar", "Zugang fehlt", "Update-Zugang einrichten"} {
|
for _, expected := range []string{"Nach Update suchen", "Installiert", "Status", "Zugang fehlt", "Update-Zugang einrichten"} {
|
||||||
if !strings.Contains(freshPage.String(), expected) {
|
if !strings.Contains(freshPage.String(), expected) {
|
||||||
t.Errorf("fresh software update page is missing %q", expected)
|
t.Errorf("fresh software update page is missing %q", expected)
|
||||||
}
|
}
|
||||||
@@ -515,6 +547,14 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
|||||||
if strings.Contains(freshPage.String(), "Update-Kanal") {
|
if strings.Contains(freshPage.String(), "Update-Kanal") {
|
||||||
t.Error("confusing legacy update channel box is still rendered")
|
t.Error("confusing legacy update channel box is still rendered")
|
||||||
}
|
}
|
||||||
|
var currentPage bytes.Buffer
|
||||||
|
currentData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.1", LatestVersion: "v1.4.1", LastCheckedAt: checkedAt, AgentAvailable: true, AgentCompatible: true, CredentialConfigured: true}}
|
||||||
|
if err := templates["system"].ExecuteTemplate(¤tPage, "system", currentData); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(currentPage.String(), `Status</span><strong>Aktuell</strong>`) || strings.Count(currentPage.String(), `>v1.4.1<`) != 1 {
|
||||||
|
t.Fatal("an installed current release is still rendered as a second available version")
|
||||||
|
}
|
||||||
var syncPage bytes.Buffer
|
var syncPage bytes.Buffer
|
||||||
syncData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, ComposeSyncRequired: true, CredentialConfigured: true}}
|
syncData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, ComposeSyncRequired: true, CredentialConfigured: true}}
|
||||||
if err := templates["system"].ExecuteTemplate(&syncPage, "system", syncData); err != nil {
|
if err := templates["system"].ExecuteTemplate(&syncPage, "system", syncData); err != nil {
|
||||||
@@ -608,7 +648,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
|
|||||||
}
|
}
|
||||||
body := rendered.String()
|
body := rendered.String()
|
||||||
statusAt := strings.Index(body, `A unverändert`)
|
statusAt := strings.Index(body, `A unverändert`)
|
||||||
timeAt := strings.Index(body, `class="target-meta"><time>`)
|
timeAt := strings.Index(body, `class="target-meta"><time data-local-time=`)
|
||||||
if timeAt < 0 || statusAt < timeAt {
|
if timeAt < 0 || statusAt < timeAt {
|
||||||
t.Fatalf("target time must be rendered before the status pill: %s", body)
|
t.Fatalf("target time must be rendered before the status pill: %s", body)
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-9
@@ -1,4 +1,32 @@
|
|||||||
(() => {
|
(() => {
|
||||||
|
const localTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
for (const node of document.querySelectorAll('[data-local-time]')) {
|
||||||
|
const unixTime = Number(node.dataset.localTime || 0);
|
||||||
|
if (!Number.isFinite(unixTime) || unixTime <= 0) continue;
|
||||||
|
node.textContent = localTimeFormatter.format(new Date(unixTime * 1000)).replace(',', ' ·');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBanner = document.querySelector('[data-software-update-banner]');
|
||||||
|
if (updateBanner) {
|
||||||
|
const version = updateBanner.dataset.softwareUpdateBanner || 'unknown';
|
||||||
|
const storageKey = 'domain-control:update-banner-seen-version';
|
||||||
|
try {
|
||||||
|
if (window.localStorage.getItem(storageKey) !== version) {
|
||||||
|
window.localStorage.setItem(storageKey, version);
|
||||||
|
window.requestAnimationFrame(() => updateBanner.classList.add('should-animate'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A blocked storage API must not make the banner flicker on every page.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toast = document.querySelector('[data-toast]');
|
const toast = document.querySelector('[data-toast]');
|
||||||
if (toast) {
|
if (toast) {
|
||||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
@@ -255,22 +283,101 @@
|
|||||||
const softwareUpdateForms = [...document.querySelectorAll('[data-software-update-form]')];
|
const softwareUpdateForms = [...document.querySelectorAll('[data-software-update-form]')];
|
||||||
if (softwareUpdateForms.length > 0) {
|
if (softwareUpdateForms.length > 0) {
|
||||||
const overlay = document.querySelector('[data-software-update-overlay]');
|
const overlay = document.querySelector('[data-software-update-overlay]');
|
||||||
|
const title = overlay?.querySelector('[data-software-update-title]');
|
||||||
const message = overlay?.querySelector('[data-software-update-message]');
|
const message = overlay?.querySelector('[data-software-update-message]');
|
||||||
|
const progress = overlay?.querySelector('[data-software-update-progress]');
|
||||||
|
const progressTrack = overlay?.querySelector('[data-software-update-progress-track]');
|
||||||
|
const progressLabel = overlay?.querySelector('[data-software-update-progress-label]');
|
||||||
|
const percent = overlay?.querySelector('[data-software-update-percent]');
|
||||||
const close = overlay?.querySelector('[data-software-update-close]');
|
const close = overlay?.querySelector('[data-software-update-close]');
|
||||||
const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])];
|
const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])];
|
||||||
const phaseOrder = ['pulling', 'restarting', 'verifying'];
|
const phaseOrder = ['pulling', 'restarting', 'verifying'];
|
||||||
|
const phasePresentation = {
|
||||||
|
queued: {title: 'Update wird vorbereitet', label: 'Vorbereitung', start: 4, end: 9, timeConstant: 10000},
|
||||||
|
pulling: {title: 'Image wird sicher geladen', label: 'Image-Download', start: 12, end: 42, timeConstant: 22000},
|
||||||
|
restarting: {title: 'Container wird gewechselt', label: 'Containerwechsel', start: 44, end: 51, timeConstant: 6000},
|
||||||
|
reconnecting: {title: 'Verbindung wird aufgebaut', label: 'Neustart', start: 52, end: 57, timeConstant: 15000},
|
||||||
|
verifying: {title: 'Neue Instanz wird geprüft', label: 'Healthcheck', start: 58, end: 98, timeConstant: 35000},
|
||||||
|
complete: {title: 'Update erfolgreich', label: 'Abgeschlossen', start: 100, end: 100, timeConstant: 1},
|
||||||
|
failed: {title: 'Update nicht abgeschlossen', label: 'Angehalten'},
|
||||||
|
};
|
||||||
let polling = false;
|
let polling = false;
|
||||||
let targetVersion = '';
|
let targetVersion = '';
|
||||||
let startedAt = 0;
|
let startedAt = 0;
|
||||||
|
let lastKnownPhase = 'pulling';
|
||||||
|
let currentPhase = '';
|
||||||
|
let phaseStartedAt = 0;
|
||||||
|
let displayedProgress = 0;
|
||||||
|
let progressTimer;
|
||||||
|
const progressTickInterval = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 1000 : 500;
|
||||||
|
|
||||||
const setUpdatePhase = (phase, text) => {
|
const formatElapsed = (milliseconds) => {
|
||||||
|
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
||||||
|
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopProgressTimer = () => {
|
||||||
|
if (progressTimer) window.clearTimeout(progressTimer);
|
||||||
|
progressTimer = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProgress = (value) => {
|
||||||
|
const bounded = Math.max(0, Math.min(100, value));
|
||||||
|
displayedProgress = Math.max(displayedProgress, bounded);
|
||||||
|
if (progress) progress.style.width = `${displayedProgress.toFixed(2)}%`;
|
||||||
|
if (percent) percent.textContent = `${Math.floor(displayedProgress)}%`;
|
||||||
|
progressTrack?.setAttribute('aria-valuenow', String(Math.floor(displayedProgress)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaseTime = (raw) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const parsed = Date.parse(String(raw || ''));
|
||||||
|
if (Number.isFinite(parsed) && parsed >= now - (12 * 60 * 1000) && parsed <= now + 60000) {
|
||||||
|
return Math.min(parsed, now);
|
||||||
|
}
|
||||||
|
return now;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tickProgress = () => {
|
||||||
|
progressTimer = undefined;
|
||||||
|
const presentation = phasePresentation[currentPhase];
|
||||||
|
if (!presentation || typeof presentation.end !== 'number' || presentation.end <= presentation.start) return;
|
||||||
|
const elapsed = Math.max(0, Date.now() - phaseStartedAt);
|
||||||
|
const distance = presentation.end - presentation.start;
|
||||||
|
const next = presentation.start + distance * (1 - Math.exp(-elapsed / presentation.timeConstant));
|
||||||
|
renderProgress(next);
|
||||||
|
if (progressLabel) {
|
||||||
|
progressLabel.textContent = currentPhase === 'verifying' || currentPhase === 'reconnecting'
|
||||||
|
? `${presentation.label} · ${formatElapsed(elapsed)}`
|
||||||
|
: presentation.label;
|
||||||
|
}
|
||||||
|
progressTimer = window.setTimeout(tickProgress, progressTickInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setUpdatePhase = (phase, text, updatedAt = '') => {
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
|
const phaseChanged = currentPhase !== phase;
|
||||||
|
if (phaseChanged) {
|
||||||
|
currentPhase = phase;
|
||||||
|
phaseStartedAt = phaseTime(updatedAt);
|
||||||
|
stopProgressTimer();
|
||||||
|
} else if (updatedAt) {
|
||||||
|
phaseStartedAt = Math.min(phaseStartedAt, phaseTime(updatedAt));
|
||||||
|
}
|
||||||
overlay.dataset.updateState = phase;
|
overlay.dataset.updateState = phase;
|
||||||
|
overlay.setAttribute('aria-busy', String(phase !== 'complete' && phase !== 'failed'));
|
||||||
|
const presentation = phasePresentation[phase] || phasePresentation.queued;
|
||||||
|
if (title) title.textContent = presentation.title;
|
||||||
|
if (progressLabel) progressLabel.textContent = presentation.label;
|
||||||
|
if (typeof presentation.start === 'number') renderProgress(presentation.start);
|
||||||
|
if (phaseChanged && presentation.end > presentation.start) tickProgress();
|
||||||
if (message && text) message.textContent = text;
|
if (message && text) message.textContent = text;
|
||||||
const currentIndex = phaseOrder.indexOf(phase);
|
if (phaseOrder.includes(phase)) lastKnownPhase = phase;
|
||||||
|
const visualPhase = phase === 'reconnecting' || phase === 'failed' ? lastKnownPhase : phase;
|
||||||
|
const currentIndex = phaseOrder.indexOf(visualPhase);
|
||||||
for (const node of phaseNodes) {
|
for (const node of phaseNodes) {
|
||||||
const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase);
|
const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase);
|
||||||
node.classList.toggle('is-active', nodeIndex === currentIndex);
|
node.classList.toggle('is-active', nodeIndex === currentIndex && phase !== 'complete' && phase !== 'failed');
|
||||||
node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete');
|
node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -292,11 +399,11 @@
|
|||||||
queued: 'Update ist eingereiht.',
|
queued: 'Update ist eingereiht.',
|
||||||
pulling: 'Das neue Container-Image wird geladen. Der laufende Dienst bleibt dabei online.',
|
pulling: 'Das neue Container-Image wird geladen. Der laufende Dienst bleibt dabei online.',
|
||||||
restarting: 'Der Dienst wechselt kontrolliert auf die neue Version.',
|
restarting: 'Der Dienst wechselt kontrolliert auf die neue Version.',
|
||||||
verifying: 'Die neue Instanz durchläuft den Healthcheck.',
|
verifying: 'Die neue Instanz durchläuft den Healthcheck. Dieser Schritt kann etwas dauern.',
|
||||||
complete: `Version ${state.target_version || targetVersion} ist bereit.`,
|
complete: `Version ${state.target_version || targetVersion} ist bereit.`,
|
||||||
failed: state.message || 'Update fehlgeschlagen. Die bisherige Version bleibt aktiv.',
|
failed: state.message || 'Update fehlgeschlagen. Die bisherige Version bleibt aktiv.',
|
||||||
};
|
};
|
||||||
setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.');
|
setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.', state.updated_at);
|
||||||
if (phase === 'complete') {
|
if (phase === 'complete') {
|
||||||
polling = false;
|
polling = false;
|
||||||
window.setTimeout(() => window.location.assign('/system'), 1400);
|
window.setTimeout(() => window.location.assign('/system'), 1400);
|
||||||
@@ -308,7 +415,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setUpdatePhase('reconnecting', 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.');
|
const recoveryPhase = currentPhase === 'verifying' ? 'verifying' : 'reconnecting';
|
||||||
|
const recoveryMessage = recoveryPhase === 'verifying'
|
||||||
|
? 'Der Healthcheck läuft weiter – die Statusverbindung wird automatisch wiederhergestellt.'
|
||||||
|
: 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.';
|
||||||
|
setUpdatePhase(recoveryPhase, recoveryMessage);
|
||||||
}
|
}
|
||||||
window.setTimeout(pollSoftwareUpdate, 1500);
|
window.setTimeout(pollSoftwareUpdate, 1500);
|
||||||
};
|
};
|
||||||
@@ -318,17 +429,37 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (polling || !overlay) return;
|
if (polling || !overlay) return;
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
|
const requestBody = new URLSearchParams();
|
||||||
|
for (const [name, value] of formData) {
|
||||||
|
if (typeof value === 'string') requestBody.append(name, value);
|
||||||
|
}
|
||||||
targetVersion = String(formData.get('version') || '');
|
targetVersion = String(formData.get('version') || '');
|
||||||
|
stopProgressTimer();
|
||||||
|
currentPhase = '';
|
||||||
|
phaseStartedAt = 0;
|
||||||
|
displayedProgress = 0;
|
||||||
|
lastKnownPhase = 'pulling';
|
||||||
|
if (progress) progress.style.width = '0%';
|
||||||
|
if (percent) percent.textContent = '0%';
|
||||||
|
progressTrack?.setAttribute('aria-valuenow', '0');
|
||||||
|
startedAt = Date.now();
|
||||||
overlay.hidden = false;
|
overlay.hidden = false;
|
||||||
|
if (close) close.hidden = true;
|
||||||
document.body.classList.add('software-update-open');
|
document.body.classList.add('software-update-open');
|
||||||
setUpdatePhase('queued', `Update auf ${targetVersion} wird vorbereitet.`);
|
setUpdatePhase('queued', `Update auf ${targetVersion} wird vorbereitet.`);
|
||||||
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.setAttribute('disabled', '');
|
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.setAttribute('disabled', '');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(form.action, {method: 'POST', body: formData, headers: {'Accept': 'application/json'}});
|
const response = await fetch(form.action, {method: 'POST', body: requestBody, headers: {'Accept': 'application/json'}, credentials: 'same-origin'});
|
||||||
const result = await response.json().catch(() => ({}));
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
let result = {};
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
result = await response.json().catch(() => ({}));
|
||||||
|
} else {
|
||||||
|
const responseText = (await response.text()).trim();
|
||||||
|
if (responseText && !responseText.startsWith('<')) result.message = responseText.slice(0, 240);
|
||||||
|
}
|
||||||
if (!response.ok || !result.accepted) throw new Error(result.message || 'Update konnte nicht gestartet werden.');
|
if (!response.ok || !result.accepted) throw new Error(result.message || 'Update konnte nicht gestartet werden.');
|
||||||
polling = true;
|
polling = true;
|
||||||
startedAt = Date.now();
|
|
||||||
window.setTimeout(pollSoftwareUpdate, 400);
|
window.setTimeout(pollSoftwareUpdate, 400);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUpdatePhase('failed', error instanceof Error ? error.message : 'Update konnte nicht gestartet werden.');
|
setUpdatePhase('failed', error instanceof Error ? error.message : 'Update konnte nicht gestartet werden.');
|
||||||
@@ -342,6 +473,7 @@
|
|||||||
overlay.hidden = true;
|
overlay.hidden = true;
|
||||||
document.body.classList.remove('software-update-open');
|
document.body.classList.remove('software-update-open');
|
||||||
close.hidden = true;
|
close.hidden = true;
|
||||||
|
stopProgressTimer();
|
||||||
for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled');
|
for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+129
-33
@@ -76,7 +76,8 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
|||||||
.workspace-bar { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 clamp(18px, 3vw, 36px); border-bottom: 1px solid var(--line); background: rgba(255,255,255,.9); backdrop-filter: blur(12px); }
|
.workspace-bar { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 clamp(18px, 3vw, 36px); border-bottom: 1px solid var(--line); background: rgba(255,255,255,.9); backdrop-filter: blur(12px); }
|
||||||
.workspace-bar div { display: flex; gap: 6px; color: var(--muted); font-family: var(--mono); font-size: 10px; text-transform: uppercase; }
|
.workspace-bar div { display: flex; gap: 6px; color: var(--muted); font-family: var(--mono); font-size: 10px; text-transform: uppercase; }
|
||||||
.workspace-bar strong { color: var(--text); font-weight: 700; }
|
.workspace-bar strong { color: var(--text); font-weight: 700; }
|
||||||
.software-update-banner { display: grid; width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); grid-template-columns: 38px minmax(0,1fr) auto; gap: 12px; align-items: center; margin: 14px auto -8px; padding: 11px 12px; border: 1px solid #274975; border-radius: 12px; background: radial-gradient(circle at 12% 0, rgba(59,130,246,.24), transparent 38%), linear-gradient(110deg, #122038, #172b48); color: #edf5ff; box-shadow: 0 14px 30px rgba(15,23,42,.12); animation: software-banner-in .45s cubic-bezier(.2,.8,.3,1) both; }
|
.software-update-banner { display: grid; width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); grid-template-columns: 38px minmax(0,1fr) auto; gap: 12px; align-items: center; margin: 14px auto -8px; padding: 11px 12px; border: 1px solid #274975; border-radius: 12px; background: radial-gradient(circle at 12% 0, rgba(59,130,246,.24), transparent 38%), linear-gradient(110deg, #122038, #172b48); color: #edf5ff; box-shadow: 0 14px 30px rgba(15,23,42,.12); }
|
||||||
|
.software-update-banner.should-animate { animation: software-banner-in .45s cubic-bezier(.2,.8,.3,1) both; }
|
||||||
.software-update-banner-icon { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid rgba(147,197,253,.25); border-radius: 10px; background: rgba(37,99,235,.18); color: #93c5fd; font-size: 18px; }
|
.software-update-banner-icon { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid rgba(147,197,253,.25); border-radius: 10px; background: rgba(37,99,235,.18); color: #93c5fd; font-size: 18px; }
|
||||||
.software-update-banner div > span, .software-update-banner div > strong { display: block; }
|
.software-update-banner div > span, .software-update-banner div > strong { display: block; }
|
||||||
.software-update-banner div > span { color: #8fa6c2; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
|
.software-update-banner div > span { color: #8fa6c2; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
|
||||||
@@ -171,13 +172,32 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
|||||||
.status-pill.status-warn { background: var(--warning-soft); }
|
.status-pill.status-warn { background: var(--warning-soft); }
|
||||||
.status-pill.status-bad { background: var(--danger-soft); }
|
.status-pill.status-bad { background: var(--danger-soft); }
|
||||||
|
|
||||||
.domain-list { display: grid; }
|
.dashboard-zones-panel { overflow: hidden; background: radial-gradient(circle at 100% 0,rgba(37,99,235,.055),transparent 35%),var(--surface); }
|
||||||
.domain-list > a { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 0; border-top: 1px solid #edf0f3; }
|
.dashboard-zone-count { color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
.domain-list > a:first-child { border-top: 0; }
|
.dashboard-zone-list { display: grid; gap: 8px; }
|
||||||
.domain-list strong, .domain-list small { display: block; }
|
.dashboard-zone-item { display: grid; min-width: 0; grid-template-columns: 44px minmax(0,1fr) auto; gap: 11px; align-items: center; padding: 11px; border: 1px solid #e1e7ee; border-radius: 10px; background: linear-gradient(135deg,#fff,#f8faff); transition: border-color .18s, box-shadow .18s, transform .18s; }
|
||||||
.domain-list strong { font-size: 12px; }
|
.dashboard-zone-item:hover { border-color: #b8cae4; color: inherit; box-shadow: 0 10px 24px rgba(31,64,111,.08); transform: translateY(-1px); }
|
||||||
.domain-list small { margin-top: 2px; color: var(--muted); font-size: 10px; }
|
.dashboard-zone-glyph { position: relative; display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid #bfd0ea; border-radius: 12px; background: linear-gradient(145deg,#edf4ff,#dfeaff); color: #315eaa; font-family: var(--mono); }
|
||||||
.zone-glyph, .empty-icon { display: grid; width: 34px; height: 28px; place-items: center; border: 1px solid #bfd0ea; border-radius: 5px; background: var(--accent-soft); color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
.dashboard-zone-glyph::before, .dashboard-zone-glyph::after { position: absolute; border: 1px solid rgba(49,94,170,.2); border-radius: 50%; content: ""; }
|
||||||
|
.dashboard-zone-glyph::before { width: 26px; height: 12px; }
|
||||||
|
.dashboard-zone-glyph::after { width: 12px; height: 26px; }
|
||||||
|
.dashboard-zone-glyph b { position: relative; z-index: 1; padding: 2px 3px; border-radius: 3px; background: #e7effd; font-size: 7px; }
|
||||||
|
.dashboard-zone-glyph i { position: absolute; right: 5px; bottom: 5px; width: 5px; height: 5px; border: 1px solid #fff; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 2px rgba(15,118,110,.12); }
|
||||||
|
.dashboard-zone-glyph.is-error { border-color: #ecc3ca; background: linear-gradient(145deg,#fff5f6,#ffe9ed); color: var(--danger); }
|
||||||
|
.dashboard-zone-glyph.is-error i { background: var(--danger); box-shadow: 0 0 0 2px rgba(180,35,60,.11); }
|
||||||
|
.dashboard-zone-copy, .dashboard-zone-copy > small, .dashboard-zone-copy > strong { display: block; min-width: 0; }
|
||||||
|
.dashboard-domain-label { margin-bottom: 1px; color: #7a899b; font-family: var(--mono); font-size: 7px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.dashboard-zone-copy > strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dashboard-zone-branch { position: relative; display: grid; grid-template-columns: 10px minmax(0,1fr); gap: 5px; align-items: center; margin-top: 6px; padding-top: 5px; border-top: 1px solid #ebeff5; }
|
||||||
|
.dashboard-zone-branch > i { position: relative; width: 8px; height: 8px; border: 1px solid #8eacd4; border-radius: 3px; background: #edf4ff; }
|
||||||
|
.dashboard-zone-branch > i::before { position: absolute; bottom: 6px; left: 3px; width: 1px; height: 9px; background: #c8d6e8; content: ""; }
|
||||||
|
.dashboard-zone-branch span, .dashboard-zone-branch b, .dashboard-zone-branch small { display: block; min-width: 0; }
|
||||||
|
.dashboard-zone-branch b { color: #536780; font-size: 8px; }
|
||||||
|
.dashboard-zone-branch small { overflow: hidden; margin-top: 1px; color: #8795a6; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dashboard-zone-state { display: grid; gap: 8px; justify-items: end; }
|
||||||
|
.dashboard-zone-state > i { color: #8799af; font-size: 14px; font-style: normal; transition: color .18s, transform .18s; }
|
||||||
|
.dashboard-zone-item:hover .dashboard-zone-state > i { color: var(--accent); transform: translateX(3px); }
|
||||||
|
.empty-icon { display: grid; width: 34px; height: 28px; place-items: center; border: 1px solid #bfd0ea; border-radius: 5px; background: var(--accent-soft); color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||||
.audit-panel { margin-top: 12px; }
|
.audit-panel { margin-top: 12px; }
|
||||||
.audit-list { display: grid; margin: 0; padding: 0; list-style: none; }
|
.audit-list { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||||
.audit-list li { display: grid; grid-template-columns: 140px minmax(0,1fr) auto; gap: 12px; align-items: center; padding: 9px 0; border-top: 1px solid #edf0f3; }
|
.audit-list li { display: grid; grid-template-columns: 140px minmax(0,1fr) auto; gap: 12px; align-items: center; padding: 9px 0; border-top: 1px solid #edf0f3; }
|
||||||
@@ -189,6 +209,7 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
|||||||
.zone-card { display: flex; min-height: 220px; flex-direction: column; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
.zone-card { display: flex; min-height: 220px; flex-direction: column; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||||
.zone-card:hover { border-color: #b6c4d4; }
|
.zone-card:hover { border-color: #b6c4d4; }
|
||||||
.zone-card-top { display: flex; align-items: center; justify-content: space-between; }
|
.zone-card-top { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.domain-service-label { display: inline-flex; min-height: 22px; align-items: center; padding: 0 7px; border: 1px solid #c7d6e8; border-radius: 5px; background: #f1f6fd; color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; letter-spacing: .03em; }
|
||||||
.zone-card h2 { margin: 26px 0 12px; overflow-wrap: anywhere; font-size: 17px; }
|
.zone-card h2 { margin: 26px 0 12px; overflow-wrap: anywhere; font-size: 17px; }
|
||||||
.zone-card dl { display: grid; gap: 5px; margin-bottom: 14px; }
|
.zone-card dl { display: grid; gap: 5px; margin-bottom: 14px; }
|
||||||
.zone-card dl div { display: flex; justify-content: space-between; gap: 12px; }
|
.zone-card dl div { display: flex; justify-content: space-between; gap: 12px; }
|
||||||
@@ -493,41 +514,105 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.software-update-button i { font-style: normal; transition: transform .2s ease; }
|
.software-update-button i { font-style: normal; transition: transform .2s ease; }
|
||||||
.software-update-button:hover i { transform: translate(2px,-2px); }
|
.software-update-button:hover i { transform: translate(2px,-2px); }
|
||||||
.update-credential-panel { width: min(930px,100%); }
|
.update-credential-panel { width: min(930px,100%); }
|
||||||
|
.update-credential-panel.is-connected { overflow: hidden; padding: 0; border-color: #b8dcd5; background: linear-gradient(120deg,#fff 62%,#f0faf7); }
|
||||||
|
.update-access-state { display: grid; grid-template-columns: 44px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 18px; }
|
||||||
|
.update-access-state-icon { display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid #a9d8ce; border-radius: 12px; background: linear-gradient(145deg,#e9faf6,#d9f3ed); color: var(--success); box-shadow: 0 8px 18px rgba(15,118,110,.08); font-family: var(--mono); font-size: 15px; font-weight: 900; }
|
||||||
|
.update-access-state .section-label { margin-bottom: 2px; color: #598b82; }
|
||||||
|
.update-access-state h2 { color: #174f49; }
|
||||||
|
.update-access-state p { margin: 3px 0 0; color: var(--muted); font-size: 9px; }
|
||||||
|
.update-access-state p strong { color: #315f5a; font-family: var(--mono); }
|
||||||
|
.update-access-state .status-pill { gap: 6px; }
|
||||||
|
.update-access-state .status-pill i { width: 6px; height: 6px; border-radius: 50%; background: #1d9b87; box-shadow: 0 0 0 3px rgba(29,155,135,.11); }
|
||||||
|
.update-credential-editor { border-top: 1px solid #d8e9e5; background: rgba(255,255,255,.72); }
|
||||||
|
.update-credential-editor summary { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 14px; padding: 9px 18px; color: #315f5a; list-style: none; }
|
||||||
|
.update-credential-editor summary::-webkit-details-marker { display: none; }
|
||||||
|
.update-credential-editor summary span, .update-credential-editor summary strong, .update-credential-editor summary small { display: block; }
|
||||||
|
.update-credential-editor summary strong { font-size: 10px; }
|
||||||
|
.update-credential-editor summary small { margin-top: 1px; color: var(--muted); font-size: 8px; font-weight: 500; }
|
||||||
|
.update-credential-editor summary > i { color: #6d918b; font-size: 16px; font-style: normal; transition: transform .18s; }
|
||||||
|
.update-credential-editor[open] summary > i { transform: rotate(180deg); }
|
||||||
|
.update-credential-editor[open] summary { border-bottom: 1px solid #e4eeec; }
|
||||||
|
.update-credential-editor .stacked-form { padding: 18px; }
|
||||||
.update-inline-warning { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; align-items: center; margin-top: 12px; padding: 9px; border: 1px solid #ead18e; border-radius: 8px; background: var(--warning-soft); }
|
.update-inline-warning { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; align-items: center; margin-top: 12px; padding: 9px; border: 1px solid #ead18e; border-radius: 8px; background: var(--warning-soft); }
|
||||||
.update-inline-warning > span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: rgba(154,103,0,.1); color: var(--warning); font-weight: 900; }
|
.update-inline-warning > span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: rgba(154,103,0,.1); color: var(--warning); font-weight: 900; }
|
||||||
.update-inline-warning p { margin: 0; }
|
.update-inline-warning p { margin: 0; }
|
||||||
.update-inline-warning strong, .update-inline-warning small { display: block; }
|
.update-inline-warning strong, .update-inline-warning small { display: block; }
|
||||||
.update-inline-warning strong { color: #74540b; font-size: 9px; }
|
.update-inline-warning strong { color: #74540b; font-size: 9px; }
|
||||||
.update-inline-warning small { margin-top: 2px; color: #887347; font-size: 8px; overflow-wrap: anywhere; }
|
.update-inline-warning small { margin-top: 2px; color: #887347; font-size: 8px; overflow-wrap: anywhere; }
|
||||||
.software-update-overlay { position: fixed; z-index: 2200; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(8,15,28,.72); backdrop-filter: blur(10px); animation: update-overlay-in .25s ease both; }
|
.software-update-overlay { position: fixed; z-index: 2200; inset: 0; display: grid; place-items: center; padding: 20px; background: radial-gradient(circle at 50% 35%, rgba(37,99,235,.18), transparent 38%), rgba(5,10,20,.82); backdrop-filter: blur(14px) saturate(1.1); animation: update-overlay-in .3s ease both; }
|
||||||
.software-update-dialog { width: min(520px,100%); padding: 30px; border: 1px solid rgba(255,255,255,.14); border-radius: 18px; background: #111c2e; color: #f8fafc; box-shadow: 0 30px 80px rgba(0,0,0,.32); text-align: center; }
|
.software-update-dialog { position: relative; isolation: isolate; width: min(560px,100%); overflow: hidden; padding: 28px 32px 30px; border: 1px solid rgba(147,197,253,.2); border-radius: 24px; background: radial-gradient(circle at 50% -18%, rgba(59,130,246,.26), transparent 42%), linear-gradient(155deg,#111d31 0%,#0d1727 100%); color: #f8fafc; box-shadow: 0 38px 100px rgba(0,0,0,.46), 0 0 0 1px rgba(255,255,255,.025) inset; text-align: center; animation: update-dialog-in .46s cubic-bezier(.2,.9,.25,1) both; }
|
||||||
.software-update-dialog .section-label { color: #7f94af; }
|
.software-update-dialog::before { position: absolute; z-index: -2; inset: 0; background-image: linear-gradient(rgba(147,197,253,.035) 1px, transparent 1px), linear-gradient(90deg,rgba(147,197,253,.035) 1px,transparent 1px); background-size: 28px 28px; mask-image: linear-gradient(to bottom,rgba(0,0,0,.8),transparent 72%); content: ""; animation: update-grid-drift 10s linear infinite; }
|
||||||
.software-update-dialog h2 { margin: 8px 0 9px; color: #fff; font-size: 24px; }
|
.software-update-dialog::after { position: absolute; z-index: -1; top: -105px; left: 50%; width: 330px; height: 250px; border-radius: 50%; background: rgba(37,99,235,.19); filter: blur(58px); content: ""; transform: translateX(-50%); transition: background .35s; }
|
||||||
.software-update-dialog p { min-height: 38px; margin: 0 auto 20px; color: #9babc0; font-size: 11px; line-height: 1.55; }
|
.software-update-dialog .section-label { position: relative; color: #7f94af; }
|
||||||
.software-update-orbit { position: relative; display: grid; width: 62px; height: 62px; place-items: center; margin: 0 auto 20px; border: 1px solid #344862; border-radius: 18px; background: #18283f; color: #b7d1ff; font-family: var(--mono); font-size: 11px; font-weight: 900; }
|
.software-update-dialog h2 { position: relative; margin: 7px 0 8px; color: #fff; font-size: 25px; letter-spacing: -.025em; }
|
||||||
.software-update-orbit i { position: absolute; inset: -8px; border: 1px solid transparent; border-top-color: #60a5fa; border-right-color: rgba(96,165,250,.25); border-radius: 22px; animation: update-orbit 1.15s linear infinite; }
|
.software-update-dialog p { position: relative; min-height: 36px; margin: 0 auto 17px; color: #a8b8cc; font-size: 11px; line-height: 1.55; }
|
||||||
.software-update-progress { height: 5px; overflow: hidden; border-radius: 99px; background: #24344b; }
|
.software-update-visual { position: relative; display: grid; width: 166px; height: 112px; place-items: center; margin: -2px auto 13px; }
|
||||||
.software-update-progress i { display: block; width: 8%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#3b82f6,#60a5fa,#2dd4bf); box-shadow: 0 0 14px rgba(96,165,250,.45); transition: width .55s cubic-bezier(.2,.8,.3,1), background .2s; }
|
.software-update-visual::before, .software-update-visual::after { position: absolute; border: 1px solid rgba(96,165,250,.18); border-radius: 50%; content: ""; }
|
||||||
.software-update-overlay[data-update-state="pulling"] .software-update-progress i { width: 38%; }
|
.software-update-visual::before { width: 136px; height: 78px; transform: rotate(-9deg); animation: update-ring-left 4.8s linear infinite; }
|
||||||
.software-update-overlay[data-update-state="restarting"] .software-update-progress i { width: 65%; }
|
.software-update-visual::after { width: 104px; height: 104px; border-color: rgba(45,212,191,.13); transform: rotate(21deg); animation: update-ring-right 3.8s linear infinite reverse; }
|
||||||
.software-update-overlay[data-update-state="reconnecting"] .software-update-progress i { width: 73%; }
|
.software-update-scan { position: absolute; z-index: 0; width: 150px; height: 1px; overflow: visible; background: linear-gradient(90deg,transparent,#60a5fa,transparent); box-shadow: 0 0 16px rgba(96,165,250,.6); opacity: .7; animation: update-scan 2.2s ease-in-out infinite; }
|
||||||
.software-update-overlay[data-update-state="verifying"] .software-update-progress i { width: 88%; }
|
.software-update-orbit { position: relative; z-index: 2; display: grid; width: 68px; height: 68px; place-items: center; border: 1px solid #3c5f8c; border-radius: 21px; background: linear-gradient(145deg,#1c3455,#14243b); color: #c7dcff; box-shadow: 0 16px 34px rgba(0,0,0,.3), 0 0 30px rgba(59,130,246,.15), inset 0 1px rgba(255,255,255,.08); font-family: var(--mono); font-size: 11px; font-weight: 900; animation: update-core-float 2.8s ease-in-out infinite; }
|
||||||
.software-update-overlay[data-update-state="complete"] .software-update-progress i { width: 100%; }
|
.software-update-orbit::before { position: absolute; top: 7px; right: 7px; width: 5px; height: 5px; border-radius: 50%; background: #60a5fa; box-shadow: 0 0 9px #60a5fa; content: ""; animation: update-status-blink 1.1s ease-in-out infinite; }
|
||||||
.software-update-overlay[data-update-state="failed"] .software-update-progress i { width: 100%; background: #e45c70; }
|
.software-update-orbit span::after { content: ""; }
|
||||||
.software-update-overlay[data-update-state="complete"] .software-update-orbit i { border-color: #34d399; animation: none; }
|
.software-update-orbit i { position: absolute; inset: -10px; border: 1px solid transparent; border-top-color: #60a5fa; border-right-color: rgba(96,165,250,.28); border-radius: 26px; animation: update-orbit 1.25s linear infinite; }
|
||||||
.software-update-overlay[data-update-state="failed"] .software-update-orbit i { border-color: #fb7185; animation: none; }
|
.update-signal { position: absolute; z-index: 1; width: 5px; height: 5px; border-radius: 50%; background: #93c5fd; box-shadow: 0 0 10px rgba(147,197,253,.85); }
|
||||||
.software-update-phases { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; margin: 18px 0 0; padding: 0; list-style: none; }
|
.signal-a { animation: update-signal-a 2.4s ease-in-out infinite; }
|
||||||
.software-update-phases li { display: grid; gap: 4px; justify-items: center; color: #61738b; font-size: 8px; font-weight: 700; }
|
.signal-b { animation: update-signal-b 3s ease-in-out -.7s infinite; }
|
||||||
.software-update-phases li span { display: grid; width: 24px; height: 24px; place-items: center; border: 1px solid #2c3e57; border-radius: 8px; font-family: var(--mono); font-size: 7px; }
|
.signal-c { width: 4px; height: 4px; background: #5eead4; animation: update-signal-c 2.7s ease-in-out -1.3s infinite; }
|
||||||
.software-update-phases li.is-active { color: #b9d4ff; }
|
.software-update-progress-meta { display: flex; align-items: center; justify-content: space-between; margin-bottom: 7px; color: #71839a; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
.software-update-phases li.is-active span { border-color: #4b83d5; background: rgba(59,130,246,.16); color: #9ec5ff; }
|
.software-update-progress-meta strong { color: #a9c9ff; font-size: 9px; font-variant-numeric: tabular-nums; transition: color .25s; }
|
||||||
.software-update-phases li.is-complete { color: #6fc5b6; }
|
.software-update-progress { position: relative; height: 7px; overflow: hidden; border: 1px solid rgba(147,197,253,.08); border-radius: 99px; background: #202f44; box-shadow: inset 0 1px 2px rgba(0,0,0,.2); }
|
||||||
.software-update-phases li.is-complete span { border-color: #327e73; background: rgba(15,118,110,.16); color: #75d4c4; }
|
.software-update-progress i { display: block; width: 4%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#2563eb,#60a5fa,#2dd4bf); box-shadow: 0 0 17px rgba(96,165,250,.52); transition: width 1s linear, background .25s; }
|
||||||
|
.software-update-progress > span { position: absolute; inset: 0; background: linear-gradient(105deg,transparent 34%,rgba(255,255,255,.3) 48%,transparent 62%); transform: translateX(-100%); animation: update-progress-shine 1.65s ease-in-out infinite; }
|
||||||
|
.software-update-overlay[data-update-state="verifying"] .software-update-progress > span { animation-duration: 3.2s; }
|
||||||
|
.software-update-overlay[data-update-state="failed"] .software-update-progress i { background: #e45c70; }
|
||||||
|
.software-update-overlay[data-update-state="complete"] .software-update-dialog { border-color: rgba(52,211,153,.35); }
|
||||||
|
.software-update-overlay[data-update-state="complete"] .software-update-dialog::after { background: rgba(16,185,129,.2); }
|
||||||
|
.software-update-overlay[data-update-state="failed"] .software-update-dialog::after { background: rgba(225,29,72,.18); }
|
||||||
|
.software-update-overlay[data-update-state="complete"] .software-update-orbit, .software-update-overlay[data-update-state="failed"] .software-update-orbit { animation: update-result-pop .5s cubic-bezier(.2,.9,.25,1) both; }
|
||||||
|
.software-update-overlay[data-update-state="complete"] .software-update-orbit { border-color: #34d399; color: #6ee7b7; box-shadow: 0 0 36px rgba(52,211,153,.22); }
|
||||||
|
.software-update-overlay[data-update-state="failed"] .software-update-orbit { border-color: #fb7185; color: #fda4af; box-shadow: 0 0 36px rgba(251,113,133,.18); }
|
||||||
|
.software-update-overlay[data-update-state="complete"] .software-update-orbit i, .software-update-overlay[data-update-state="failed"] .software-update-orbit i { border-color: currentColor; animation: none; }
|
||||||
|
.software-update-overlay[data-update-state="complete"] .software-update-orbit span, .software-update-overlay[data-update-state="failed"] .software-update-orbit span { font-size: 0; }
|
||||||
|
.software-update-overlay[data-update-state="complete"] .software-update-orbit span::after { content: "✓"; font-family: inherit; font-size: 22px; }
|
||||||
|
.software-update-overlay[data-update-state="failed"] .software-update-orbit span::after { content: "!"; font-family: inherit; font-size: 22px; }
|
||||||
|
.software-update-overlay[data-update-state="complete"] .software-update-orbit::before { background: #34d399; box-shadow: 0 0 9px #34d399; animation: none; }
|
||||||
|
.software-update-overlay[data-update-state="failed"] .software-update-orbit::before { background: #fb7185; box-shadow: 0 0 9px #fb7185; animation: none; }
|
||||||
|
.software-update-overlay[data-update-state="complete"] .software-update-progress-meta strong { color: #6ee7b7; }
|
||||||
|
.software-update-overlay[data-update-state="failed"] .software-update-progress-meta strong { color: #fda4af; }
|
||||||
|
.software-update-phases { position: relative; display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; margin: 20px 0 0; padding: 0; list-style: none; }
|
||||||
|
.software-update-phases::before { position: absolute; top: 16px; right: 16%; left: 16%; height: 1px; background: #263a55; content: ""; }
|
||||||
|
.software-update-phases li { position: relative; z-index: 1; display: grid; gap: 2px; justify-items: center; color: #61738b; }
|
||||||
|
.software-update-phases li > span { display: grid; width: 32px; height: 32px; place-items: center; margin-bottom: 3px; border: 1px solid #2c3e57; border-radius: 10px; background: #111d2e; font-family: var(--mono); }
|
||||||
|
.software-update-phases li > span b { font-size: 7px; }
|
||||||
|
.software-update-phases li > strong { color: inherit; font-size: 8px; font-weight: 750; }
|
||||||
|
.software-update-phases li > small { color: #4d5e73; font-family: var(--mono); font-size: 7px; text-transform: uppercase; }
|
||||||
|
.software-update-phases li.is-active { color: #c5dcff; }
|
||||||
|
.software-update-phases li.is-active > span { border-color: #4b83d5; background: #172a46; color: #a9ccff; box-shadow: 0 0 0 4px rgba(59,130,246,.08), 0 8px 18px rgba(0,0,0,.18); animation: update-phase-pulse 1.4s ease-in-out infinite; }
|
||||||
|
.software-update-phases li.is-active > small { color: #7094c7; }
|
||||||
|
.software-update-phases li.is-complete { color: #76cbbb; }
|
||||||
|
.software-update-phases li.is-complete > span { border-color: #327e73; background: #12302f; color: #75d4c4; }
|
||||||
|
.software-update-phases li.is-complete > span b { display: none; }
|
||||||
|
.software-update-phases li.is-complete > span::after { content: "✓"; font-family: var(--mono); font-size: 10px; font-weight: 900; }
|
||||||
|
.software-update-phases li.is-complete > small { color: #527f78; }
|
||||||
.software-update-dialog [data-software-update-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; }
|
.software-update-dialog [data-software-update-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; }
|
||||||
body.software-update-open { overflow: hidden; }
|
body.software-update-open { overflow: hidden; }
|
||||||
@keyframes software-banner-in { from { opacity: 0; transform: translateY(-9px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes software-banner-in { from { opacity: 0; transform: translateY(-9px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
@keyframes update-overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes update-overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes update-dialog-in { from { opacity: 0; transform: translateY(16px) scale(.965); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||||
|
@keyframes update-grid-drift { to { background-position: 28px 28px, 28px 28px; } }
|
||||||
@keyframes update-orbit { to { transform: rotate(360deg); } }
|
@keyframes update-orbit { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes update-ring-left { to { transform: rotate(351deg); } }
|
||||||
|
@keyframes update-ring-right { to { transform: rotate(381deg); } }
|
||||||
|
@keyframes update-core-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
|
||||||
|
@keyframes update-status-blink { 0%,100% { opacity: .45; } 50% { opacity: 1; } }
|
||||||
|
@keyframes update-scan { 0%,100% { opacity: .12; transform: translateY(-34px) scaleX(.55); } 50% { opacity: .75; transform: translateY(34px) scaleX(1); } }
|
||||||
|
@keyframes update-signal-a { 0%,100% { opacity: 0; transform: translate(-68px,23px) scale(.5); } 45% { opacity: 1; } 70% { opacity: .65; transform: translate(50px,-34px) scale(1); } }
|
||||||
|
@keyframes update-signal-b { 0%,100% { opacity: 0; transform: translate(60px,28px) scale(.5); } 48% { opacity: .9; } 72% { opacity: .5; transform: translate(-55px,-28px) scale(1); } }
|
||||||
|
@keyframes update-signal-c { 0%,100% { opacity: 0; transform: translate(-44px,-42px); } 50% { opacity: 1; } 75% { opacity: .4; transform: translate(65px,18px); } }
|
||||||
|
@keyframes update-progress-shine { 0%,25% { transform: translateX(-110%); } 75%,100% { transform: translateX(110%); } }
|
||||||
|
@keyframes update-phase-pulse { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
||||||
|
@keyframes update-result-pop { 0% { transform: scale(.82) rotate(-5deg); } 70% { transform: scale(1.08) rotate(2deg); } 100% { transform: scale(1) rotate(0); } }
|
||||||
|
|
||||||
.error-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
.error-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
||||||
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
|
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
|
||||||
@@ -624,6 +709,8 @@ body.software-update-open { overflow: hidden; }
|
|||||||
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
|
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
|
||||||
.software-version-action { grid-column: 1 / -1; }
|
.software-version-action { grid-column: 1 / -1; }
|
||||||
.software-version-action form, .software-version-action .button { width: 100%; }
|
.software-version-action form, .software-version-action .button { width: 100%; }
|
||||||
|
.update-access-state { grid-template-columns: 42px minmax(0,1fr); }
|
||||||
|
.update-access-state > .status-pill { grid-column: 2; justify-self: start; }
|
||||||
.admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); }
|
.admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); }
|
||||||
.admin-full-access-card > .status-pill { grid-column: 2; }
|
.admin-full-access-card > .status-pill { grid-column: 2; }
|
||||||
.auth-shell { align-items: start; padding: 12px; }
|
.auth-shell { align-items: start; padding: 12px; }
|
||||||
@@ -645,7 +732,11 @@ body.software-update-open { overflow: hidden; }
|
|||||||
.software-version-cell { min-width: 0; padding-right: 12px; }
|
.software-version-cell { min-width: 0; padding-right: 12px; }
|
||||||
.software-version-cell + .software-version-cell { padding-left: 12px; }
|
.software-version-cell + .software-version-cell { padding-left: 12px; }
|
||||||
.software-version-cell strong { font-size: 21px; }
|
.software-version-cell strong { font-size: 21px; }
|
||||||
.software-update-dialog { padding: 24px 18px; }
|
.software-update-dialog { padding: 22px 18px 24px; border-radius: 19px; }
|
||||||
|
.software-update-visual { width: 148px; height: 96px; margin-bottom: 10px; transform: scale(.9); }
|
||||||
|
.software-update-dialog h2 { font-size: 22px; }
|
||||||
|
.software-update-phases li > strong { font-size: 7px; }
|
||||||
|
.software-update-phases li > small { display: none; }
|
||||||
.onboarding-card { padding: 18px; }
|
.onboarding-card { padding: 18px; }
|
||||||
.onboarding-card-meta { margin-bottom: 18px; }
|
.onboarding-card-meta { margin-bottom: 18px; }
|
||||||
.onboarding-form-heading h2 { font-size: 28px; }
|
.onboarding-form-heading h2 { font-size: 28px; }
|
||||||
@@ -659,6 +750,11 @@ body.software-update-open { overflow: hidden; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-height: 700px) {
|
@media (max-height: 700px) {
|
||||||
|
.software-update-dialog { padding-block: 20px 22px; }
|
||||||
|
.software-update-visual { height: 78px; margin-bottom: 8px; transform: scale(.82); }
|
||||||
|
.software-update-dialog p { min-height: 30px; margin-bottom: 12px; }
|
||||||
|
.software-update-phases { margin-top: 14px; }
|
||||||
|
.software-update-dialog [data-software-update-close] { margin-top: 14px; }
|
||||||
.onboarding-intro { padding-block: 30px; }
|
.onboarding-intro { padding-block: 30px; }
|
||||||
.onboarding-copy { margin-top: 28px; }
|
.onboarding-copy { margin-top: 28px; }
|
||||||
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
|
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
|
||||||
|
|||||||
@@ -32,19 +32,26 @@
|
|||||||
{{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>{{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>
|
<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 kein Update-Lauf protokolliert.</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
<article class="panel">
|
<article class="panel dashboard-zones-panel">
|
||||||
<div class="panel-heading"><div><span class="section-label">DNS-Zonen</span><h2>Verbundene Domains</h2></div><a href="/domains">Verwalten</a></div>
|
<div class="panel-heading"><div><span class="section-label">DNS-Landschaft</span><h2>Domains & Zonen</h2></div><div class="panel-links"><span class="dashboard-zone-count">{{len .Dashboard.Zones}} Zonen</span><a href="/domains">Verwalten</a></div></div>
|
||||||
{{if .Dashboard.Zones}}
|
{{if .Dashboard.Zones}}
|
||||||
<div class="domain-list">
|
<div class="dashboard-zone-list">
|
||||||
{{range .Dashboard.Zones}}
|
{{range .Dashboard.Zones}}
|
||||||
<a href="/domains/{{.ID}}"><span class="zone-glyph">DNS</span><span><strong>{{.Name}}</strong><small>Synchronisiert: {{formatTime .LastSyncAt}}</small></span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Fehler{{else}}Bereit{{end}}</span></a>
|
<a class="dashboard-zone-item" href="/domains/{{.ID}}">
|
||||||
|
<span class="dashboard-zone-glyph {{if .LastError}}is-error{{end}}" aria-hidden="true"><b>DNS</b><i></i></span>
|
||||||
|
<span class="dashboard-zone-copy">
|
||||||
|
<small class="dashboard-domain-label">Domain</small><strong>{{.Name}}</strong>
|
||||||
|
<span class="dashboard-zone-branch"><i aria-hidden="true"></i><span><b>DNS-Zone · {{.Name}}</b><small>{{if .LastError}}Synchronisierung prüfen{{else}}Hetzner · synchronisiert <time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time>{{end}}</small></span></span>
|
||||||
|
</span>
|
||||||
|
<span class="dashboard-zone-state"><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Fehler{{else}}Bereit{{end}}</span><i aria-hidden="true">→</i></span>
|
||||||
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}<div class="empty-state">{{if .IsAdmin}}Keine Domain verbunden. <a href="/domains/new">Domain hinzufügen</a>{{else}}Keine Domain zugewiesen.{{end}}</div>{{end}}
|
{{else}}<div class="empty-state">{{if .IsAdmin}}Keine Domain verbunden. <a href="/domains/new">Domain hinzufügen</a>{{else}}Keine Domain zugewiesen.{{end}}</div>{{end}}
|
||||||
@@ -54,7 +61,7 @@
|
|||||||
{{if and .IsAdmin .Dashboard.RecentEvents}}
|
{{if and .IsAdmin .Dashboard.RecentEvents}}
|
||||||
<section class="panel audit-panel">
|
<section class="panel audit-panel">
|
||||||
<div class="panel-heading"><div><span class="section-label">Audit</span><h2>Systemereignisse</h2></div></div>
|
<div class="panel-heading"><div><span class="section-label">Audit</span><h2>Systemereignisse</h2></div></div>
|
||||||
<ul class="audit-list">{{range .Dashboard.RecentEvents}}<li><time>{{formatTime .CreatedAt}}</time><strong>{{.Message}}</strong><code>{{.Kind}}</code></li>{{end}}</ul>
|
<ul class="audit-list">{{range .Dashboard.RecentEvents}}<li><time data-local-time="{{unixSeconds .CreatedAt}}">{{formatTime .CreatedAt}}</time><strong>{{.Message}}</strong><code>{{.Kind}}</code></li>{{end}}</ul>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<section class="zone-summary" aria-label="Zonenstatus">
|
<section class="zone-summary" aria-label="Zonenstatus">
|
||||||
<div><span>Provider</span><strong>Hetzner Cloud DNS</strong></div>
|
<div><span>Provider</span><strong>Hetzner Cloud DNS</strong></div>
|
||||||
<div><span>Letzter Import</span><strong>{{formatTime .Zone.LastSyncAt}}</strong></div>
|
<div><span>Letzter Import</span><strong><time data-local-time="{{unixSeconds .Zone.LastSyncAt}}">{{formatTime .Zone.LastSyncAt}}</time></strong></div>
|
||||||
<div><span>Verknüpfte Ziele</span><strong>{{len .Targets}}</strong></div>
|
<div><span>Verknüpfte Ziele</span><strong>{{len .Targets}}</strong></div>
|
||||||
{{if .IsAdmin}}<div><span>Getrennte Ziele</span><strong>{{len .ArchivedTargets}}</strong></div>{{end}}
|
{{if .IsAdmin}}<div><span>Getrennte Ziele</span><strong>{{len .ArchivedTargets}}</strong></div>{{end}}
|
||||||
</section>
|
</section>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
{{$canManage := or $.IsAdmin (index $.ManageTargetIDs .ID)}}
|
{{$canManage := or $.IsAdmin (index $.ManageTargetIDs .ID)}}
|
||||||
<article class="target-row {{if .Active}}is-active{{end}}">
|
<article class="target-row {{if .Active}}is-active{{end}}">
|
||||||
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">{{families .}}</span><span>{{if .Description}}{{.Description}}{{else}}Keine Beschreibung{{end}}</span></p></div></div>
|
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">{{families .}}</span><span>{{if .Description}}{{.Description}}{{else}}Keine Beschreibung{{end}}</span></p></div></div>
|
||||||
<div class="target-meta"><time>{{formatTime .LastUpdatedAt}}</time><span class="status-pill {{statusClass .LastStatus}}">{{if .LastStatus}}{{.LastStatus}}{{else}}Nicht ausgeführt{{end}}</span></div>
|
<div class="target-meta"><time data-local-time="{{unixSeconds .LastUpdatedAt}}">{{formatTime .LastUpdatedAt}}</time><span class="status-pill {{statusClass .LastStatus}}">{{if .LastStatus}}{{.LastStatus}}{{else}}Nicht ausgeführt{{end}}</span></div>
|
||||||
{{if $canManage}}<details class="target-edit"><summary>Einstellungen <span>⌄</span></summary><div class="edit-inner">
|
{{if $canManage}}<details class="target-edit"><summary>Einstellungen <span>⌄</span></summary><div class="edit-inner">
|
||||||
<form action="/targets/{{.ID}}/save" method="post" class="target-form" data-target-form data-ipv6-enabled="{{$.Settings.IPv6Enabled}}" data-update-ipv4="{{.UpdateIPv4}}" data-update-ipv6="{{.UpdateIPv6}}">
|
<form action="/targets/{{.ID}}/save" method="post" class="target-form" data-target-form data-ipv6-enabled="{{$.Settings.IPv6Enabled}}" data-update-ipv4="{{.UpdateIPv4}}" data-update-ipv6="{{.UpdateIPv6}}">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
<p class="panel-description">Diese Einträge sind nicht mehr mit dem Updater verknüpft. Die zugehörigen Hetzner-DNS-Records wurden beim Trennen nicht gelöscht.</p>
|
<p class="panel-description">Diese Einträge sind nicht mehr mit dem Updater verknüpft. Die zugehörigen Hetzner-DNS-Records wurden beim Trennen nicht gelöscht.</p>
|
||||||
<div class="archive-list">
|
<div class="archive-list">
|
||||||
{{range .ArchivedTargets}}
|
{{range .ArchivedTargets}}
|
||||||
<div class="archive-row"><span><strong>{{.FQDN}}</strong><small>{{families .}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/targets/{{.ID}}/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
<div class="archive-row"><span><strong>{{.FQDN}}</strong><small>{{families .}} · getrennt <time data-local-time="{{unixSeconds .ArchivedAt}}">{{formatTime .ArchivedAt}}</time></small></span><form action="/targets/{{.ID}}/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<section class="form-panel compact-panel">
|
<section class="form-panel compact-panel">
|
||||||
<form action="/domains" method="post" class="stacked-form wide-form">
|
<form action="/domains" method="post" class="stacked-form wide-form">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<div class="form-grid"><label>Interne Bezeichnung <input name="label" type="text" value="{{index .Form "label"}}" placeholder="Privates DNS-Projekt"></label><label>DNS-Zone <input name="zone" type="text" value="{{index .Form "zone"}}" placeholder="example.de" required autocomplete="off"><small>Muss im Hetzner-Projekt des Tokens vorhanden sein.</small></label></div>
|
<div class="form-grid domain-create-identity-grid"><label>Interne Bezeichnung <input name="label" type="text" value="{{index .Form "label"}}" placeholder="Privates DNS-Projekt"><small class="field-spacer" aria-hidden="true"> </small></label><label>DNS-Zone <input name="zone" type="text" value="{{index .Form "zone"}}" placeholder="example.de" required autocomplete="off"><small>Muss im Hetzner-Projekt des Tokens vorhanden sein.</small></label></div>
|
||||||
<label>Hetzner API-Token <input name="token" type="password" autocomplete="off" spellcheck="false" required><small>Wird validiert und anschließend verschlüsselt gespeichert.</small></label>
|
<label>Hetzner API-Token <input name="token" type="password" autocomplete="off" spellcheck="false" required><small>Wird validiert und anschließend verschlüsselt gespeichert.</small></label>
|
||||||
<div class="info-box"><strong>Automatischer Import</strong><span>Nach dem Verbinden liest die Anwendung alle A-/AAAA-RRsets ein. Kein importierter Record wird automatisch für DynDNS aktiviert.</span></div>
|
<div class="info-box"><strong>Automatischer Import</strong><span>Nach dem Verbinden liest die Anwendung alle A-/AAAA-RRsets ein. Kein importierter Record wird automatisch für DynDNS aktiviert.</span></div>
|
||||||
<div class="form-actions"><button class="button button-primary" type="submit">Verbindung prüfen und importieren</button><a class="button button-quiet" href="/domains">Abbrechen</a></div>
|
<div class="form-actions"><button class="button button-primary" type="submit">Verbindung prüfen und importieren</button><a class="button button-quiet" href="/domains">Abbrechen</a></div>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
{{if .Zones}}
|
{{if .Zones}}
|
||||||
{{range .Zones}}
|
{{range .Zones}}
|
||||||
<article class="zone-card">
|
<article class="zone-card">
|
||||||
<div class="zone-card-top"><span class="zone-glyph">DNS</span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Verbindungsfehler{{else}}Verbunden{{end}}</span></div>
|
<div class="zone-card-top"><span class="domain-service-label">DynDNS</span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Verbindungsfehler{{else}}Verbunden{{end}}</span></div>
|
||||||
<h2>{{.Name}}</h2>
|
<h2>{{.Name}}</h2>
|
||||||
<dl><div><dt>Letzter Import</dt><dd>{{formatTime .LastSyncAt}}</dd></div><div><dt>Provider</dt><dd>Hetzner Cloud DNS</dd></div></dl>
|
<dl><div><dt>Letzter Import</dt><dd><time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time></dd></div><div><dt>Provider</dt><dd>Hetzner Cloud DNS</dd></div></dl>
|
||||||
{{if .LastError}}<p class="inline-error">{{.LastError}}</p>{{end}}
|
{{if .LastError}}<p class="inline-error">{{.LastError}}</p>{{end}}
|
||||||
<a href="/domains/{{.ID}}" class="card-link">Ziele öffnen <span>→</span></a>
|
<a href="/domains/{{.ID}}" class="card-link">Ziele öffnen <span>→</span></a>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="panel-heading"><div><span class="section-label">Aktuell</span><h2>Zielprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorTargets}}</span></div>
|
<div class="panel-heading"><div><span class="section-label">Aktuell</span><h2>Zielprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorTargets}}</span></div>
|
||||||
<div class="error-list">
|
<div class="error-list">
|
||||||
{{range .ErrorTargets}}
|
{{range .ErrorTargets}}
|
||||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.FQDN}}</strong><p>{{.LastError}}</p><small>{{if .LastStatus}}{{.LastStatus}}{{else}}Fehler{{end}} · letzter erfolgreicher Abgleich {{formatTime .LastUpdatedAt}}</small></div><a href="/domains/{{.ZoneID}}">Öffnen →</a></article>
|
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.FQDN}}</strong><p>{{.LastError}}</p><small>{{if .LastStatus}}{{.LastStatus}}{{else}}Fehler{{end}} · letzter erfolgreicher Abgleich <time data-local-time="{{unixSeconds .LastUpdatedAt}}">{{formatTime .LastUpdatedAt}}</time></small></div><a href="/domains/{{.ZoneID}}">Öffnen →</a></article>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<div class="panel-heading"><div><span class="section-label">Lokales Netzwerk</span><h2>Pi-hole-Probleme</h2></div><span class="status-pill status-bad">{{len .ErrorPiHoles}}</span></div>
|
<div class="panel-heading"><div><span class="section-label">Lokales Netzwerk</span><h2>Pi-hole-Probleme</h2></div><span class="status-pill status-bad">{{len .ErrorPiHoles}}</span></div>
|
||||||
<div class="error-list">
|
<div class="error-list">
|
||||||
{{range .ErrorPiHoles}}
|
{{range .ErrorPiHoles}}
|
||||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>{{.BaseURL}} · letzte Prüfung {{formatTime .LastSyncAt}}</small></div><a href="/pihole#local-dns">Öffnen →</a></article>
|
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>{{.BaseURL}} · letzte Prüfung <time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time></small></div><a href="/pihole#local-dns">Öffnen →</a></article>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
|
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
|
||||||
<div class="error-list">
|
<div class="error-list">
|
||||||
{{range .ErrorZones}}
|
{{range .ErrorZones}}
|
||||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>Letzter Synchronisationsversuch {{formatTime .LastSyncAt}}</small></div><a href="/domains/{{.ID}}">Öffnen →</a></article>
|
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>Letzter Synchronisationsversuch <time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time></small></div><a href="/domains/{{.ID}}">Öffnen →</a></article>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<div class="panel-heading"><div><span class="section-label">Historie</span><h2>Problematische Update-Läufe</h2></div><span class="status-pill status-bad">{{len .ErrorRuns}}</span></div>
|
<div class="panel-heading"><div><span class="section-label">Historie</span><h2>Problematische Update-Läufe</h2></div><span class="status-pill status-bad">{{len .ErrorRuns}}</span></div>
|
||||||
<div class="error-list run-error-list">
|
<div class="error-list run-error-list">
|
||||||
{{range .ErrorRuns}}
|
{{range .ErrorRuns}}
|
||||||
<article class="error-item"><span class="state-dot {{statusClass .Status}}"></span><div><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}} · {{.Status}}{{if .IPv4}} · IPv4 {{.IPv4}}{{end}}{{if .IPv6}} · IPv6 {{.IPv6}}{{end}}</small></div></article>
|
<article class="error-item"><span class="state-dot {{statusClass .Status}}"></span><div><strong>{{.Summary}}</strong><small><time data-local-time="{{unixSeconds .StartedAt}}">{{formatTime .StartedAt}}</time> · {{.Status}}{{if .IPv4}} · IPv4 {{.IPv4}}{{end}}{{if .IPv6}} · IPv6 {{.IPv6}}{{end}}</small></div></article>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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-24">
|
<link rel="stylesheet" href="/static/styles.css?v=features-28">
|
||||||
<script src="/static/app.js?v=features-24" defer></script>
|
<script src="/static/app.js?v=features-28" 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>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
|
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
|
||||||
</header>
|
</header>
|
||||||
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
|
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
|
||||||
<aside class="software-update-banner" aria-label="Software-Update verfügbar">
|
<aside class="software-update-banner" data-software-update-banner="{{.SoftwareUpdate.LatestVersion}}" aria-label="Software-Update verfügbar">
|
||||||
<span class="software-update-banner-icon" aria-hidden="true">↗</span>
|
<span class="software-update-banner-icon" aria-hidden="true">↗</span>
|
||||||
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
|
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
|
||||||
<div class="software-update-banner-actions">
|
<div class="software-update-banner-actions">
|
||||||
@@ -77,17 +77,31 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
|
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
|
||||||
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title-global">
|
{{template "software_update_overlay" .}}
|
||||||
<div class="software-update-dialog">
|
|
||||||
<div class="software-update-orbit" aria-hidden="true"><span>DC</span><i></i></div>
|
|
||||||
<span class="section-label">Systemupdate</span><h2 id="software-update-title-global">Update wird vorbereitet</h2>
|
|
||||||
<p data-software-update-message>Das neue Container-Image wird geladen.</p>
|
|
||||||
<div class="software-update-progress"><i data-software-update-progress></i></div>
|
|
||||||
<ol class="software-update-phases" aria-label="Update-Fortschritt"><li data-update-phase="pulling"><span>01</span>Image laden</li><li data-update-phase="restarting"><span>02</span>Dienst wechseln</li><li data-update-phase="verifying"><span>03</span>Healthcheck</li></ol>
|
|
||||||
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "software_update_overlay"}}
|
||||||
|
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title{{if ne .Page "system"}}-global{{end}}">
|
||||||
|
<div class="software-update-dialog">
|
||||||
|
<div class="software-update-visual" aria-hidden="true">
|
||||||
|
<span class="software-update-scan"></span>
|
||||||
|
<div class="software-update-orbit"><span>DC</span><i></i></div>
|
||||||
|
<i class="update-signal signal-a"></i><i class="update-signal signal-b"></i><i class="update-signal signal-c"></i>
|
||||||
|
</div>
|
||||||
|
<span class="section-label">Systemupdate</span>
|
||||||
|
<h2 id="software-update-title{{if ne .Page "system"}}-global{{end}}" data-software-update-title>Update wird vorbereitet</h2>
|
||||||
|
<p data-software-update-message aria-live="polite">Das neue Container-Image wird sicher vorbereitet.</p>
|
||||||
|
<div class="software-update-progress-meta"><span data-software-update-progress-label>Vorbereitung</span><strong data-software-update-percent>04%</strong></div>
|
||||||
|
<div class="software-update-progress" data-software-update-progress-track role="progressbar" aria-label="Update-Fortschritt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="4"><i data-software-update-progress></i><span></span></div>
|
||||||
|
<ol class="software-update-phases" aria-label="Update-Fortschritt">
|
||||||
|
<li data-update-phase="pulling"><span><b>01</b></span><strong>Image laden</strong><small>Registry</small></li>
|
||||||
|
<li data-update-phase="restarting"><span><b>02</b></span><strong>Dienst wechseln</strong><small>Container</small></li>
|
||||||
|
<li data-update-phase="verifying"><span><b>03</b></span><strong>Healthcheck</strong><small>Verifizieren</small></li>
|
||||||
|
</ol>
|
||||||
|
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
{{$integration := $view.Integration}}
|
{{$integration := $view.Integration}}
|
||||||
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
|
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
|
||||||
<div><span>Provider</span><strong>Pi-hole API v6</strong></div>
|
<div><span>Provider</span><strong>Pi-hole API v6</strong></div>
|
||||||
<div><span>Letzter Import</span><strong>{{formatTime $integration.LastSyncAt}}</strong></div>
|
<div><span>Letzter Import</span><strong><time data-local-time="{{unixSeconds $integration.LastSyncAt}}">{{formatTime $integration.LastSyncAt}}</time></strong></div>
|
||||||
<div><span>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div>
|
<div><span>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div>
|
||||||
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
|
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<p class="panel-description">Diese lokalen DNS-Einträge bestehen weiterhin bei Pi-hole, sind aber nicht mehr mit der aktiven WebUI-Liste verknüpft.</p>
|
<p class="panel-description">Diese lokalen DNS-Einträge bestehen weiterhin bei Pi-hole, sind aber nicht mehr mit der aktiven WebUI-Liste verknüpft.</p>
|
||||||
<div class="archive-list">
|
<div class="archive-list">
|
||||||
{{range $view.ArchivedRecords}}
|
{{range $view.ArchivedRecords}}
|
||||||
<div class="archive-row"><span><strong>{{.Hostnames}}</strong><small>{{if contains .IP ":"}}AAAA{{else}}A{{end}} · {{.IP}} · {{if .Active}}aktiv{{else}}inaktiv{{end}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/pihole/{{$integration.ID}}/records/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
<div class="archive-row"><span><strong>{{.Hostnames}}</strong><small>{{if contains .IP ":"}}AAAA{{else}}A{{end}} · {{.IP}} · {{if .Active}}aktiv{{else}}inaktiv{{end}} · getrennt <time data-local-time="{{unixSeconds .ArchivedAt}}">{{formatTime .ArchivedAt}}</time></small></span><form action="/pihole/{{$integration.ID}}/records/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<input type="number" name="interval" min="60" max="86400" value="{{.Settings.UpdateIntervalSeconds}}" required>
|
<input type="number" name="interval" min="60" max="86400" value="{{.Settings.UpdateIntervalSeconds}}" required>
|
||||||
<small>Zulässig: 60 bis 86.400 Sekunden. Jeder manuelle oder automatisch ausgelöste Lauf startet dieses Intervall neu.</small>
|
<small>Zulässig: 60 bis 86.400 Sekunden. Jeder manuelle oder automatisch ausgelöste Lauf startet dieses Intervall neu.</small>
|
||||||
</label>
|
</label>
|
||||||
<div class="technical-summary"><span>Letzter Lauf</span><strong>{{formatTime .Settings.LastRunAt}}</strong><span>Status</span><strong class="{{statusClass .Settings.LastRunStatus}}">{{if .Settings.LastRunStatus}}{{.Settings.LastRunStatus}}{{else}}Nicht ausgeführt{{end}}</strong></div>
|
<div class="technical-summary"><span>Letzter Lauf</span><strong><time data-local-time="{{unixSeconds .Settings.LastRunAt}}">{{formatTime .Settings.LastRunAt}}</time></strong><span>Status</span><strong class="{{statusClass .Settings.LastRunStatus}}">{{if .Settings.LastRunStatus}}{{.Settings.LastRunStatus}}{{else}}Nicht ausgeführt{{end}}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid endpoint-grid">
|
<div class="form-grid endpoint-grid">
|
||||||
<label>IPv4-Endpunkte
|
<label>IPv4-Endpunkte
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
<div class="software-version-copy">
|
<div class="software-version-copy">
|
||||||
<div class="software-version-values">
|
<div class="software-version-values">
|
||||||
<div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div>
|
<div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div>
|
||||||
<div class="software-version-cell is-latest"><span>Verfügbar</span><strong>{{if .SoftwareUpdate.LatestVersion}}{{.SoftwareUpdate.LatestVersion}}{{else}}–{{end}}</strong></div>
|
<div class="software-version-cell is-latest"><span>{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}</span><strong>{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}–{{end}}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if .SoftwareUpdate.LastCheckedAt}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
|
<p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if .SoftwareUpdate.LastCheckedAt}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
|
||||||
<div class="software-version-meta"><span>Letzte Prüfung <b>{{formatTime .SoftwareUpdate.LastCheckedAt}}</b></span><span>Update-Agent <b>{{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}</b></span></div>
|
<div class="software-version-meta"><span>Letzte Prüfung <b><time data-local-time="{{unixSeconds .SoftwareUpdate.LastCheckedAt}}">{{formatTime .SoftwareUpdate.LastCheckedAt}}</time></b></span><span>Update-Agent <b>{{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}</b></span></div>
|
||||||
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}}
|
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}}
|
||||||
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die sichere Update-Basis noch nicht.</small></p></div>{{end}}
|
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die sichere Update-Basis noch nicht.</small></p></div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -33,29 +33,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel update-credential-panel" id="update-access">
|
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
|
||||||
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>{{if .SoftwareUpdate.CredentialConfigured}}Update-Zugang ersetzen{{else}}Update-Zugang einrichten{{end}}</h2></div>{{if .SoftwareUpdate.CredentialConfigured}}<span class="status-pill status-good">verschlüsselt</span>{{end}}</div>
|
{{if .SoftwareUpdate.CredentialConfigured}}
|
||||||
|
<div class="update-access-state">
|
||||||
|
<span class="update-access-state-icon" aria-hidden="true">✓</span>
|
||||||
|
<div><span class="section-label">Update-Zugang</span><h2>Mit Gitea verbunden</h2><p>Angemeldet als <strong>@{{.SoftwareUpdate.CredentialUsername}}</strong></p></div>
|
||||||
|
<span class="status-pill status-good"><i aria-hidden="true"></i> Verbunden</span>
|
||||||
|
</div>
|
||||||
|
<details class="update-credential-editor">
|
||||||
|
<summary><span><strong>Zugang ändern</strong><small>Benutzername oder Passwort/Token ersetzen</small></span><i aria-hidden="true">⌄</i></summary>
|
||||||
|
{{else}}
|
||||||
|
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>Update-Zugang einrichten</h2></div></div>
|
||||||
|
{{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}}">
|
||||||
<div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>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">Zugang sicher speichern</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>
|
||||||
</form>
|
</form>
|
||||||
|
{{if .SoftwareUpdate.CredentialConfigured}}</details>{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title">
|
{{template "software_update_overlay" .}}
|
||||||
<div class="software-update-dialog">
|
|
||||||
<div class="software-update-orbit" aria-hidden="true"><span>DC</span><i></i></div>
|
|
||||||
<span class="section-label">Systemupdate</span>
|
|
||||||
<h2 id="software-update-title">Update wird vorbereitet</h2>
|
|
||||||
<p data-software-update-message>Das neue Container-Image wird geladen. DNS-Daten und Secrets bleiben erhalten.</p>
|
|
||||||
<div class="software-update-progress"><i data-software-update-progress></i></div>
|
|
||||||
<ol class="software-update-phases" aria-label="Update-Fortschritt">
|
|
||||||
<li data-update-phase="pulling"><span>01</span>Image laden</li>
|
|
||||||
<li data-update-phase="restarting"><span>02</span>Dienst wechseln</li>
|
|
||||||
<li data-update-phase="verifying"><span>03</span>Healthcheck</li>
|
|
||||||
</ol>
|
|
||||||
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -5,5 +5,5 @@
|
|||||||
<article class="access-policy-card is-admin"><span class="access-policy-icon" aria-hidden="true">A</span><div><strong>Administratoren</strong><small>Vollzugriff ohne bearbeitbare Einzelrechte</small></div><span class="status-pill status-good">Vollzugriff</span></article>
|
<article class="access-policy-card is-admin"><span class="access-policy-icon" aria-hidden="true">A</span><div><strong>Administratoren</strong><small>Vollzugriff ohne bearbeitbare Einzelrechte</small></div><span class="status-pill status-good">Vollzugriff</span></article>
|
||||||
<article class="access-policy-card"><span class="access-policy-icon" aria-hidden="true">M</span><div><strong>Mitglieder</strong><small>Nur ausdrücklich zugewiesene Domains und Hosts</small></div><span class="status-pill">Freigaben</span></article>
|
<article class="access-policy-card"><span class="access-policy-icon" aria-hidden="true">M</span><div><strong>Mitglieder</strong><small>Nur ausdrücklich zugewiesene Domains und Hosts</small></div><span class="status-pill">Freigaben</span></article>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel user-table-panel"><div class="panel-heading"><div><span class="section-label">Konten</span><h2>{{len .Users}} Benutzer</h2></div></div><div class="user-table" role="table" aria-label="Benutzer"><div class="user-table-head" role="row"><span>Benutzer</span><span>Rolle</span><span>Status</span><span>Letzte Anmeldung</span><span></span></div>{{range .Users}}<a href="/users/{{.ID}}" class="user-table-row" role="row"><span><strong>{{.DisplayName}}</strong><small>@{{.Username}}</small></span><span class="role-chip {{.Role}}">{{if eq .Role "admin"}}Admin{{else}}Mitglied{{end}}</span><span class="status-pill {{if .Active}}status-good{{else}}status-bad{{end}}">{{if .Active}}aktiv{{else}}deaktiviert{{end}}</span><span class="muted-cell">{{formatTime .LastLoginAt}}</span><span class="row-arrow">→</span></a>{{end}}</div></section>
|
<section class="panel user-table-panel"><div class="panel-heading"><div><span class="section-label">Konten</span><h2>{{len .Users}} Benutzer</h2></div></div><div class="user-table" role="table" aria-label="Benutzer"><div class="user-table-head" role="row"><span>Benutzer</span><span>Rolle</span><span>Status</span><span>Letzte Anmeldung</span><span></span></div>{{range .Users}}<a href="/users/{{.ID}}" class="user-table-row" role="row"><span><strong>{{.DisplayName}}</strong><small>@{{.Username}}</small></span><span class="role-chip {{.Role}}">{{if eq .Role "admin"}}Admin{{else}}Mitglied{{end}}</span><span class="status-pill {{if .Active}}status-good{{else}}status-bad{{end}}">{{if .Active}}aktiv{{else}}deaktiviert{{end}}</span><span class="muted-cell"><time data-local-time="{{unixSeconds .LastLoginAt}}">{{formatTime .LastLoginAt}}</time></span><span class="row-arrow">→</span></a>{{end}}</div></section>
|
||||||
{{end}}
|
{{end}}
|
||||||
Reference in new issue
Block a user