feat: add resilient container restart controls
This commit is contained in:
1 parent
1bbb14470c
commit
7bb33e528c
13 files changed
+747
-28
No files matched your search
+84
-13
@@ -33,16 +33,20 @@ import (
|
||||
var embeddedAssets embed.FS
|
||||
|
||||
type Server struct {
|
||||
config config.Config
|
||||
store *store.Store
|
||||
cipher *secure.Cipher
|
||||
scheduler *scheduler.Scheduler
|
||||
updates *appupdate.Manager
|
||||
logger *slog.Logger
|
||||
templates map[string]*template.Template
|
||||
limiter *loginLimiter
|
||||
dummyHash string
|
||||
authSlots chan struct{}
|
||||
config config.Config
|
||||
store *store.Store
|
||||
cipher *secure.Cipher
|
||||
scheduler *scheduler.Scheduler
|
||||
updates *appupdate.Manager
|
||||
logger *slog.Logger
|
||||
templates map[string]*template.Template
|
||||
limiter *loginLimiter
|
||||
dummyHash string
|
||||
authSlots chan struct{}
|
||||
instanceID string
|
||||
restartContainer func()
|
||||
restartMu sync.Mutex
|
||||
restartPending bool
|
||||
}
|
||||
|
||||
type viewData struct {
|
||||
@@ -79,6 +83,7 @@ type viewData struct {
|
||||
ErrorCount int
|
||||
PiHoleView *piHoleView
|
||||
SoftwareUpdate appupdate.State
|
||||
InstanceID string
|
||||
}
|
||||
|
||||
type flashMessage struct {
|
||||
@@ -113,12 +118,16 @@ type schedulerResponse struct {
|
||||
RecentRuns *[]schedulerRunResponse `json:"recent_runs,omitempty"`
|
||||
}
|
||||
|
||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger) (*Server, error) {
|
||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger, restartContainer func()) (*Server, error) {
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2)}, nil
|
||||
instanceID, err := secure.RandomToken(18)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create process instance ID: %w", err)
|
||||
}
|
||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2), instanceID: instanceID, restartContainer: restartContainer}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
@@ -170,6 +179,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
|
||||
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
|
||||
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
|
||||
mux.Handle("POST /system/restart", s.adminOnly(s.containerRestart))
|
||||
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
|
||||
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
||||
mux.Handle("GET /account", s.authenticated(s.accountPage))
|
||||
@@ -192,6 +202,10 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if s.instanceID != "" {
|
||||
w.Header().Set("X-Domain-Control-Instance", s.instanceID)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
@@ -1304,7 +1318,7 @@ func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, sess
|
||||
statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
|
||||
_, _ = s.updates.UpdateStatus(statusCtx)
|
||||
cancel()
|
||||
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State()})
|
||||
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State(), InstanceID: s.instanceID})
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1418,6 +1432,63 @@ func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) containerRestart(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if s.restartContainer == nil {
|
||||
http.Error(w, "Container-Neustart ist nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if s.updates != nil {
|
||||
statusCtx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||
updateStatus, statusErr := s.updates.UpdateStatus(statusCtx)
|
||||
cancel()
|
||||
if statusErr == nil && updateStatus.Busy() {
|
||||
http.Error(w, "Während eines Software-Updates kann der Container nicht zusätzlich neu gestartet werden.", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.restartMu.Lock()
|
||||
if s.restartPending {
|
||||
s.restartMu.Unlock()
|
||||
s.writeRestartAccepted(w, r, true)
|
||||
return
|
||||
}
|
||||
if err := s.store.AddAudit(r.Context(), "system.container_restart", "Container-Neustart angefordert durch "+session.Username); err != nil {
|
||||
s.restartMu.Unlock()
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
s.restartPending = true
|
||||
restart := s.restartContainer
|
||||
s.restartMu.Unlock()
|
||||
|
||||
s.writeRestartAccepted(w, r, false)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
restart()
|
||||
}
|
||||
|
||||
func (s *Server) writeRestartAccepted(w http.ResponseWriter, r *http.Request, alreadyPending bool) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": true, "already_pending": alreadyPending, "instance_id": s.instanceID})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("Container-Neustart angefordert. Diese Seite ist kurz nicht erreichbar; lade sie anschließend neu.\n"))
|
||||
}
|
||||
|
||||
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
targets, err := s.store.ListTargetsWithErrors(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -292,7 +293,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
|
||||
`data-dashboard-ipv6`,
|
||||
`data-recent-runs aria-live="polite" aria-busy="true"`,
|
||||
`Aktualisierung läuft`,
|
||||
`features-28`,
|
||||
`features-29`,
|
||||
`data-local-time="1788001920"`,
|
||||
`dashboard-zone-item`,
|
||||
`DNS-Zone · example.de`,
|
||||
@@ -413,6 +414,7 @@ func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) {
|
||||
{method: http.MethodGet, path: "/users"},
|
||||
{method: http.MethodGet, path: "/users/new"},
|
||||
{method: http.MethodGet, path: "/system"},
|
||||
{method: http.MethodPost, path: "/system/restart", body: "csrf=member-csrf"},
|
||||
{method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"},
|
||||
}
|
||||
for _, test := range requests {
|
||||
@@ -523,13 +525,23 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
||||
t.Errorf("global update notification is missing %q", expected)
|
||||
}
|
||||
}
|
||||
var staleNotification bytes.Buffer
|
||||
staleData := accountData
|
||||
staleData.SoftwareUpdate.AgentAvailable = false
|
||||
staleData.SoftwareUpdate.AgentCompatible = false
|
||||
if err := templates["security"].ExecuteTemplate(&staleNotification, "security", staleData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(staleNotification.String(), `data-software-update-form`) {
|
||||
t.Fatal("stale boot-time agent state hides the live-checked update button")
|
||||
}
|
||||
|
||||
var page bytes.Buffer
|
||||
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
|
||||
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState, InstanceID: "instance-before-restart"}
|
||||
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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"`} {
|
||||
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"`, `action="/system/restart"`, `data-container-restart-form`, `data-instance-id="instance-before-restart"`, `data-container-restart-overlay`, "Container neu starten"} {
|
||||
if !strings.Contains(page.String(), expected) {
|
||||
t.Errorf("software update page is missing %q", expected)
|
||||
}
|
||||
@@ -565,6 +577,77 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerRestartRequiresCSRFAndAuditsOneRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
var restartCalls atomic.Int32
|
||||
server := &Server{store: database, instanceID: "instance-before-restart", restartContainer: func() { restartCalls.Add(1) }}
|
||||
session := &model.Session{CSRFToken: "valid-csrf", Username: "admin", Role: "admin"}
|
||||
|
||||
invalidResponse := httptest.NewRecorder()
|
||||
invalidRequest := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=wrong"))
|
||||
invalidRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
server.containerRestart(invalidResponse, invalidRequest, session)
|
||||
if invalidResponse.Code != http.StatusForbidden || restartCalls.Load() != 0 {
|
||||
t.Fatalf("invalid CSRF triggered restart: status=%d calls=%d", invalidResponse.Code, restartCalls.Load())
|
||||
}
|
||||
|
||||
requestRestart := func() *httptest.ResponseRecorder {
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=valid-csrf"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
server.containerRestart(response, request, session)
|
||||
return response
|
||||
}
|
||||
first := requestRestart()
|
||||
if first.Code != http.StatusAccepted || restartCalls.Load() != 1 {
|
||||
t.Fatalf("valid restart was not accepted once: status=%d calls=%d body=%s", first.Code, restartCalls.Load(), first.Body.String())
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(first.Body).Decode(&result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["accepted"] != true || result["instance_id"] != "instance-before-restart" {
|
||||
t.Fatalf("unexpected restart response: %#v", result)
|
||||
}
|
||||
second := requestRestart()
|
||||
if second.Code != http.StatusAccepted || restartCalls.Load() != 1 {
|
||||
t.Fatalf("duplicate restart was not idempotent: status=%d calls=%d", second.Code, restartCalls.Load())
|
||||
}
|
||||
events, err := database.RecentAudit(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].Kind != "system.container_restart" || !strings.Contains(events[0].Message, "admin") {
|
||||
t.Fatalf("restart audit is missing or duplicated: %#v", events)
|
||||
}
|
||||
|
||||
unconfigured := &Server{store: database}
|
||||
unconfiguredResponse := httptest.NewRecorder()
|
||||
unconfiguredRequest := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=valid-csrf"))
|
||||
unconfiguredRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
unconfigured.containerRestart(unconfiguredResponse, unconfiguredRequest, session)
|
||||
if unconfiguredResponse.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("restart without process callback returned %d", unconfiguredResponse.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthExposesProcessInstanceWithoutCaching(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := &Server{instanceID: "current-process-instance"}
|
||||
response := httptest.NewRecorder()
|
||||
server.health(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if response.Code != http.StatusOK || response.Header().Get("X-Domain-Control-Instance") != "current-process-instance" || response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("health response does not identify the current process: status=%d headers=%v", response.Code, response.Header())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
|
||||
@@ -478,6 +478,115 @@
|
||||
});
|
||||
}
|
||||
|
||||
const containerRestartForm = document.querySelector('[data-container-restart-form]');
|
||||
if (containerRestartForm) {
|
||||
const overlay = document.querySelector('[data-container-restart-overlay]');
|
||||
const title = overlay?.querySelector('[data-container-restart-title]');
|
||||
const message = overlay?.querySelector('[data-container-restart-message]');
|
||||
const close = overlay?.querySelector('[data-container-restart-close]');
|
||||
const submit = containerRestartForm.querySelector('button[type="submit"]');
|
||||
let polling = false;
|
||||
let startedAt = 0;
|
||||
let expectedInstance = containerRestartForm.dataset.instanceId || '';
|
||||
let consecutiveReady = 0;
|
||||
let sawUnavailable = false;
|
||||
|
||||
const setRestartState = (state, heading, text) => {
|
||||
if (!overlay) return;
|
||||
overlay.dataset.updateState = state;
|
||||
overlay.setAttribute('aria-busy', String(state !== 'complete' && state !== 'failed'));
|
||||
if (title) title.textContent = heading;
|
||||
if (message) message.textContent = text;
|
||||
};
|
||||
|
||||
const pollContainerRestart = async () => {
|
||||
if (!polling) return;
|
||||
if (Date.now() - startedAt > 90 * 1000) {
|
||||
polling = false;
|
||||
setRestartState('failed', 'Neustart nicht bestätigt', 'Die neue Container-Instanz wurde nicht rechtzeitig erreichbar. Prüfe den Containerstatus am Docker-Host.');
|
||||
if (close) close.hidden = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/healthz?restart-check=${Date.now()}`, {cache: 'no-store', credentials: 'same-origin'});
|
||||
if (!response.ok) throw new Error('healthcheck unavailable');
|
||||
const currentInstance = response.headers.get('X-Domain-Control-Instance') || '';
|
||||
const isNewInstance = expectedInstance
|
||||
? (currentInstance !== '' && currentInstance !== expectedInstance) || (currentInstance === '' && sawUnavailable)
|
||||
: sawUnavailable;
|
||||
if (isNewInstance) {
|
||||
consecutiveReady += 1;
|
||||
setRestartState('restarting', 'Neue Instanz wird geprüft', 'Der Container ist wieder erreichbar. Die Verbindung wird noch einmal bestätigt.');
|
||||
if (consecutiveReady >= 2) {
|
||||
polling = false;
|
||||
setRestartState('complete', 'Container neu gestartet', 'Die neue Container-Instanz ist bereit. Die Seite wird neu geladen.');
|
||||
window.setTimeout(() => window.location.assign('/system'), 900);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
consecutiveReady = 0;
|
||||
setRestartState('restarting', 'Container wird neu gestartet', 'Die bisherige Instanz wird geordnet beendet.');
|
||||
}
|
||||
} catch {
|
||||
sawUnavailable = true;
|
||||
consecutiveReady = 0;
|
||||
setRestartState('restarting', 'Verbindung wird aufgebaut', 'Der Container ist kurz nicht erreichbar und startet automatisch neu.');
|
||||
}
|
||||
window.setTimeout(pollContainerRestart, 700);
|
||||
};
|
||||
|
||||
containerRestartForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (polling || !overlay) return;
|
||||
if (!window.confirm('Den vollständigen DynDNS-Container jetzt neu starten? Der Dienst ist kurz nicht erreichbar.')) return;
|
||||
|
||||
const formData = new FormData(containerRestartForm);
|
||||
const requestBody = new URLSearchParams();
|
||||
for (const [name, value] of formData) {
|
||||
if (typeof value === 'string') requestBody.append(name, value);
|
||||
}
|
||||
polling = true;
|
||||
startedAt = Date.now();
|
||||
consecutiveReady = 0;
|
||||
sawUnavailable = false;
|
||||
overlay.hidden = false;
|
||||
if (close) close.hidden = true;
|
||||
if (submit) submit.disabled = true;
|
||||
document.body.classList.add('software-update-open');
|
||||
setRestartState('restarting', 'Neustart wird vorbereitet', 'Die Anwendung wird geordnet beendet und automatisch wieder gestartet.');
|
||||
|
||||
let responseReceived = false;
|
||||
try {
|
||||
const response = await fetch(containerRestartForm.action, {method: 'POST', body: requestBody, headers: {'Accept': 'application/json'}, credentials: 'same-origin'});
|
||||
responseReceived = true;
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const result = contentType.includes('application/json')
|
||||
? await response.json().catch(() => ({}))
|
||||
: {message: (await response.text()).trim().slice(0, 240)};
|
||||
if (!response.ok || !result.accepted) throw new Error(result.message || 'Container-Neustart konnte nicht gestartet werden.');
|
||||
expectedInstance = result.instance_id || expectedInstance;
|
||||
} catch (error) {
|
||||
if (responseReceived) {
|
||||
polling = false;
|
||||
setRestartState('failed', 'Neustart nicht gestartet', error instanceof Error ? error.message : 'Container-Neustart konnte nicht gestartet werden.');
|
||||
if (close) close.hidden = false;
|
||||
if (submit) submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
sawUnavailable = true;
|
||||
}
|
||||
window.setTimeout(pollContainerRestart, 350);
|
||||
});
|
||||
|
||||
close?.addEventListener('click', () => {
|
||||
polling = false;
|
||||
overlay.hidden = true;
|
||||
close.hidden = true;
|
||||
document.body.classList.remove('software-update-open');
|
||||
if (submit) submit.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
||||
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
|
||||
const dashboardIPv4 = document.querySelector('[data-dashboard-ipv4]');
|
||||
|
||||
@@ -513,6 +513,17 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.software-update-button { min-height: 44px; border-radius: 9px; box-shadow: 0 10px 22px rgba(37,99,235,.16); }
|
||||
.software-update-button i { font-style: normal; transition: transform .2s ease; }
|
||||
.software-update-button:hover i { transform: translate(2px,-2px); }
|
||||
.container-restart-panel { display: grid; width: min(930px,100%); grid-template-columns: 44px minmax(0,1fr) auto; gap: 14px; align-items: center; margin-bottom: 12px; }
|
||||
.container-restart-icon { display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid #c8d3df; border-radius: 12px; background: linear-gradient(145deg,#f7f9fb,#edf1f5); color: #526174; font-family: var(--mono); font-size: 18px; font-weight: 900; }
|
||||
.container-restart-copy h2 { margin-bottom: 3px; }
|
||||
.container-restart-copy p { max-width: 650px; margin: 0; color: var(--muted); font-size: 9px; line-height: 1.55; }
|
||||
.container-restart-panel form { position: relative; z-index: 1; }
|
||||
.container-restart-overlay .software-update-dialog { width: min(500px,100%); }
|
||||
.container-restart-progress { height: 7px; overflow: hidden; margin-top: 18px; border: 1px solid rgba(147,197,253,.08); border-radius: 99px; background: #202f44; box-shadow: inset 0 1px 2px rgba(0,0,0,.2); }
|
||||
.container-restart-progress i { display: block; width: 38%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#2563eb,#60a5fa,#2dd4bf); box-shadow: 0 0 17px rgba(96,165,250,.52); animation: container-restart-progress 1.35s ease-in-out infinite; }
|
||||
.container-restart-overlay[data-update-state="complete"] .container-restart-progress i { width: 100%; background: #34d399; animation: none; }
|
||||
.container-restart-overlay[data-update-state="failed"] .container-restart-progress i { width: 100%; background: #e45c70; animation: none; }
|
||||
.container-restart-overlay [data-container-restart-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; }
|
||||
.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; }
|
||||
@@ -613,6 +624,7 @@ body.software-update-open { overflow: hidden; }
|
||||
@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); } }
|
||||
@keyframes container-restart-progress { from { transform: translateX(-115%); } to { transform: translateX(305%); } }
|
||||
|
||||
.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; }
|
||||
@@ -709,6 +721,9 @@ body.software-update-open { overflow: hidden; }
|
||||
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
|
||||
.software-version-action { grid-column: 1 / -1; }
|
||||
.software-version-action form, .software-version-action .button { width: 100%; }
|
||||
.container-restart-panel { grid-template-columns: 42px minmax(0,1fr); }
|
||||
.container-restart-panel form { grid-column: 1 / -1; }
|
||||
.container-restart-panel form, .container-restart-panel .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); }
|
||||
@@ -755,6 +770,7 @@ body.software-update-open { overflow: hidden; }
|
||||
.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; }
|
||||
.container-restart-overlay [data-container-restart-close] { margin-top: 14px; }
|
||||
.onboarding-intro { padding-block: 30px; }
|
||||
.onboarding-copy { margin-top: 28px; }
|
||||
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>{{.Title}} · DNS Control</title>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=features-28">
|
||||
<script src="/static/app.js?v=features-28" defer></script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=features-29">
|
||||
<script src="/static/app.js?v=features-29" defer></script>
|
||||
</head>
|
||||
<body class="page-{{.Page}}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
@@ -49,7 +49,7 @@
|
||||
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
|
||||
<div class="software-update-banner-actions">
|
||||
<a class="button button-quiet" href="/system">Details</a>
|
||||
{{if .SoftwareUpdate.AgentCompatible}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
|
||||
{{if not .SoftwareUpdate.ComposeSyncRequired}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
|
||||
</div>
|
||||
</aside>
|
||||
{{end}}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{{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 class="software-version-action">
|
||||
{{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentCompatible}}
|
||||
{{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}}
|
||||
<form action="/system/update" method="post" data-software-update-form>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}">
|
||||
<button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
|
||||
@@ -33,6 +33,15 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel container-restart-panel">
|
||||
<span class="container-restart-icon" aria-hidden="true">↻</span>
|
||||
<div class="container-restart-copy"><span class="section-label">Containersteuerung</span><h2>Anwendungscontainer neu starten</h2><p>Beendet den vollständigen DynDNS-Container geordnet und startet ihn mit unveränderten Daten, Secrets und Einstellungen neu. Der Dienst ist dabei kurz nicht erreichbar.</p></div>
|
||||
<form action="/system/restart" method="post" data-container-restart-form data-instance-id="{{.InstanceID}}">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button button-quiet" type="submit">Container neu starten</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
|
||||
{{if .SoftwareUpdate.CredentialConfigured}}
|
||||
<div class="update-access-state">
|
||||
@@ -54,5 +63,19 @@
|
||||
{{if .SoftwareUpdate.CredentialConfigured}}</details>{{end}}
|
||||
</section>
|
||||
|
||||
<div class="software-update-overlay container-restart-overlay" data-container-restart-overlay data-update-state="restarting" hidden role="dialog" aria-modal="true" aria-labelledby="container-restart-title">
|
||||
<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>
|
||||
</div>
|
||||
<span class="section-label">Container-Neustart</span>
|
||||
<h2 id="container-restart-title" data-container-restart-title>Neustart wird vorbereitet</h2>
|
||||
<p data-container-restart-message aria-live="polite">Die Anwendung wird geordnet beendet und automatisch wieder gestartet.</p>
|
||||
<div class="container-restart-progress" aria-hidden="true"><i></i></div>
|
||||
<button class="button button-quiet" type="button" data-container-restart-close hidden>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "software_update_overlay" .}}
|
||||
{{end}}
|
||||
Reference in new issue
Block a user