Skip to content

Commit 3cdd085

Browse files
committed
Add Docker update support via Watchtower integration
Add web-based Docker container updates using Watchtower HTTP API. When configured with WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN environment variables, administrators can trigger container updates from the Update Manager page. Features: - WatchtowerClient service for Watchtower HTTP API communication - Docker update progress page with animated Docker whale logo - Real-time step tracking: Trigger, Pull, Stop, Restart, Health Check, Verify - CSP-compatible progress bar using CSS classes - Translated UI strings via Stimulus values - Health endpoint polling to detect container restart - Watchtower setup documentation for Docker installations - WatchtowerClient made nullable for non-Docker installations - Unit tests for WatchtowerClient
1 parent 4206b70 commit 3cdd085

File tree

14 files changed

+1553
-55
lines changed

14 files changed

+1553
-55
lines changed

.env

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ DISABLE_BACKUP_RESTORE=1
7676
# When enabled, users must confirm their password before downloading.
7777
DISABLE_BACKUP_DOWNLOAD=1
7878

79+
# Watchtower integration for Docker-based updates.
80+
# Set these to enable one-click updates via the Update Manager UI.
81+
# See https://containrrr.dev/watchtower/ for Watchtower setup.
82+
WATCHTOWER_API_URL=
83+
WATCHTOWER_API_TOKEN=
84+
7985
###################################################################################
8086
# SAML Single sign on-settings
8187
###################################################################################
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
/*
2+
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
3+
*
4+
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published
8+
* by the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
*/
19+
20+
import { Controller } from '@hotwired/stimulus';
21+
22+
/**
23+
* Stimulus controller for Docker update progress tracking.
24+
*
25+
* Polls the health check endpoint to detect when the container restarts
26+
* after a Watchtower-triggered update. Drives the step timeline UI
27+
* with timestamps, matching the git update progress style.
28+
*/
29+
export default class extends Controller {
30+
static values = {
31+
healthUrl: String,
32+
previousVersion: { type: String, default: 'unknown' },
33+
pollInterval: { type: Number, default: 5000 },
34+
maxWaitTime: { type: Number, default: 600000 }, // 10 minutes
35+
// Translated UI strings (passed from Twig template)
36+
textPulling: { type: String, default: 'Waiting for Watchtower to pull the new image...' },
37+
textPullingDetail: { type: String, default: 'Watchtower is checking for and downloading the latest Docker image...' },
38+
textRestarting: { type: String, default: 'Container is restarting with the new image...' },
39+
textRestartingDetail: { type: String, default: 'The container is being recreated with the updated image. This may take a moment...' },
40+
textSuccess: { type: String, default: 'Update Complete!' },
41+
textSuccessDetail: { type: String, default: 'Part-DB has been updated successfully via Docker.' },
42+
textTimeout: { type: String, default: 'Update Taking Longer Than Expected' },
43+
textTimeoutDetail: { type: String, default: 'The update may still be in progress. Check your Docker logs for details.' },
44+
textStepPull: { type: String, default: 'Pull Image' },
45+
textStepRestart: { type: String, default: 'Restart Container' },
46+
};
47+
48+
static targets = [
49+
// Header
50+
'headerWhale', 'titleIcon',
51+
'statusText', 'statusSubtext',
52+
'progressBar', 'elapsedTime',
53+
// Alerts
54+
'stepAlert', 'stepName', 'stepMessage',
55+
'successAlert', 'timeoutAlert', 'errorAlert', 'errorMessage', 'warningAlert',
56+
// Step timeline (multi-target arrays)
57+
'stepRow', 'stepIcon', 'stepDetail', 'stepTime',
58+
// Version display
59+
'newVersion', 'previousVersion',
60+
// Actions
61+
'actions',
62+
];
63+
64+
// Step definitions: name -> { index, progress% }
65+
static STEPS = {
66+
trigger: { index: 0, progress: 15 },
67+
pull: { index: 1, progress: 30 },
68+
stop: { index: 2, progress: 50 },
69+
restart: { index: 3, progress: 65 },
70+
health: { index: 4, progress: 80 },
71+
verify: { index: 5, progress: 100 },
72+
};
73+
74+
connect() {
75+
this.serverWentDown = false;
76+
this.serverCameBack = false;
77+
this.startTime = Date.now();
78+
this.timer = null;
79+
this.currentStep = 'pull'; // trigger is already done
80+
this.stepTimestamps = { trigger: this.formatTime(new Date()) };
81+
this.consecutiveSuccessCount = 0;
82+
83+
// Set the trigger step timestamp
84+
this.setStepTimestamp(0, this.stepTimestamps.trigger);
85+
86+
this.poll();
87+
}
88+
89+
disconnect() {
90+
if (this.timer) {
91+
clearTimeout(this.timer);
92+
}
93+
}
94+
95+
createTimeoutSignal(ms) {
96+
if (typeof AbortSignal.timeout === 'function') {
97+
return AbortSignal.timeout(ms);
98+
}
99+
const controller = new AbortController();
100+
setTimeout(() => controller.abort(), ms);
101+
return controller.signal;
102+
}
103+
104+
async poll() {
105+
const elapsed = Date.now() - this.startTime;
106+
this.updateElapsedTime(elapsed);
107+
108+
if (elapsed > this.maxWaitTimeValue) {
109+
this.showTimeout();
110+
return;
111+
}
112+
113+
try {
114+
const response = await fetch(this.healthUrlValue, {
115+
cache: 'no-store',
116+
signal: this.createTimeoutSignal(4000),
117+
});
118+
119+
if (response.ok) {
120+
let data;
121+
try {
122+
data = await response.json();
123+
} catch (parseError) {
124+
this.schedulePoll();
125+
return;
126+
}
127+
128+
if (this.serverWentDown) {
129+
// Server came back! Move through health check -> verify
130+
if (!this.serverCameBack) {
131+
this.serverCameBack = true;
132+
this.advanceToStep('health');
133+
}
134+
135+
this.consecutiveSuccessCount++;
136+
137+
// Wait for 2 consecutive successes to confirm stability
138+
if (this.consecutiveSuccessCount >= 2) {
139+
this.showSuccess(data.version);
140+
return;
141+
}
142+
} else {
143+
// Server still up - Watchtower pulling image
144+
this.showPulling();
145+
}
146+
} else if (response.status === 503) {
147+
// Maintenance mode or shutting down
148+
this.serverWentDown = true;
149+
this.consecutiveSuccessCount = 0;
150+
this.advanceToStep('stop');
151+
} else {
152+
if (this.serverWentDown) {
153+
this.showRestarting();
154+
} else {
155+
this.showPulling();
156+
}
157+
}
158+
} catch (e) {
159+
// Connection refused = container is down
160+
if (!this.serverWentDown) {
161+
this.serverWentDown = true;
162+
this.advanceToStep('stop');
163+
}
164+
this.consecutiveSuccessCount = 0;
165+
this.showRestarting();
166+
}
167+
168+
this.schedulePoll();
169+
}
170+
171+
schedulePoll() {
172+
this.timer = setTimeout(() => this.poll(), this.pollIntervalValue);
173+
}
174+
175+
/**
176+
* Advance the step timeline to a specific step.
177+
* Marks all previous steps as complete with timestamps.
178+
*/
179+
advanceToStep(stepName) {
180+
const steps = this.constructor.STEPS;
181+
const targetIndex = steps[stepName]?.index;
182+
if (targetIndex === undefined) return;
183+
184+
const stepNames = Object.keys(steps);
185+
const now = this.formatTime(new Date());
186+
187+
for (let i = 0; i < stepNames.length; i++) {
188+
const name = stepNames[i];
189+
190+
if (i < targetIndex) {
191+
// Completed step
192+
this.markStepComplete(i, this.stepTimestamps[name] || now);
193+
if (!this.stepTimestamps[name]) {
194+
this.stepTimestamps[name] = now;
195+
}
196+
} else if (i === targetIndex) {
197+
// Current active step
198+
this.markStepActive(i);
199+
this.stepTimestamps[name] = now;
200+
this.setStepTimestamp(i, now);
201+
this.currentStep = name;
202+
}
203+
// Steps after targetIndex remain pending (no change needed)
204+
}
205+
206+
// Update progress bar
207+
this.updateProgressBar(steps[stepName].progress);
208+
}
209+
210+
showPulling() {
211+
if (this.hasStatusTextTarget) {
212+
this.statusTextTarget.textContent = this.textPullingValue;
213+
}
214+
if (this.hasStepNameTarget) {
215+
this.stepNameTarget.textContent = this.textStepPullValue;
216+
}
217+
if (this.hasStepMessageTarget) {
218+
this.stepMessageTarget.textContent = this.textPullingDetailValue;
219+
}
220+
this.updateProgressBar(30);
221+
}
222+
223+
showRestarting() {
224+
// Advance to restart step if we haven't already
225+
if (this.currentStep !== 'restart' && this.currentStep !== 'health' && this.currentStep !== 'verify') {
226+
this.advanceToStep('restart');
227+
}
228+
229+
if (this.hasStatusTextTarget) {
230+
this.statusTextTarget.textContent = this.textRestartingValue;
231+
}
232+
if (this.hasStepNameTarget) {
233+
this.stepNameTarget.textContent = this.textStepRestartValue;
234+
}
235+
if (this.hasStepMessageTarget) {
236+
this.stepMessageTarget.textContent = this.textRestartingDetailValue;
237+
}
238+
}
239+
240+
showSuccess(newVersion) {
241+
// Advance all steps to complete
242+
const steps = this.constructor.STEPS;
243+
const stepNames = Object.keys(steps);
244+
const now = this.formatTime(new Date());
245+
246+
for (let i = 0; i < stepNames.length; i++) {
247+
const name = stepNames[i];
248+
this.markStepComplete(i, this.stepTimestamps[name] || now);
249+
}
250+
251+
this.updateProgressBar(100);
252+
253+
// Update whale animation
254+
if (this.hasHeaderWhaleTarget) {
255+
this.headerWhaleTarget.classList.add('success');
256+
}
257+
if (this.hasTitleIconTarget) {
258+
this.titleIconTarget.className = 'fas fa-check-circle text-success';
259+
}
260+
261+
if (this.hasStatusTextTarget) {
262+
this.statusTextTarget.textContent = this.textSuccessValue;
263+
}
264+
if (this.hasStatusSubtextTarget) {
265+
this.statusSubtextTarget.textContent = this.textSuccessDetailValue;
266+
}
267+
268+
// Hide step alert, show success alert
269+
this.toggleTarget('stepAlert', false);
270+
this.toggleTarget('successAlert', true);
271+
this.toggleTarget('warningAlert', false);
272+
this.toggleTarget('actions', true);
273+
274+
if (this.hasNewVersionTarget) {
275+
this.newVersionTarget.textContent = newVersion || 'latest';
276+
}
277+
if (this.hasPreviousVersionTarget) {
278+
this.previousVersionTarget.textContent = this.previousVersionValue;
279+
}
280+
}
281+
282+
showTimeout() {
283+
this.updateProgressBar(0);
284+
285+
if (this.hasHeaderWhaleTarget) {
286+
this.headerWhaleTarget.classList.add('timeout');
287+
}
288+
if (this.hasTitleIconTarget) {
289+
this.titleIconTarget.className = 'fas fa-exclamation-triangle text-warning';
290+
}
291+
292+
if (this.hasStatusTextTarget) {
293+
this.statusTextTarget.textContent = this.textTimeoutValue;
294+
}
295+
if (this.hasStatusSubtextTarget) {
296+
this.statusSubtextTarget.textContent = this.textTimeoutDetailValue;
297+
}
298+
299+
this.toggleTarget('stepAlert', false);
300+
this.toggleTarget('timeoutAlert', true);
301+
this.toggleTarget('warningAlert', false);
302+
this.toggleTarget('actions', true);
303+
}
304+
305+
// --- Step timeline helpers ---
306+
307+
markStepComplete(index, timestamp) {
308+
if (this.stepIconTargets[index]) {
309+
this.stepIconTargets[index].className = 'fas fa-check-circle text-success me-3';
310+
}
311+
if (this.stepRowTargets[index]) {
312+
this.stepRowTargets[index].classList.remove('text-muted');
313+
}
314+
if (timestamp) {
315+
this.setStepTimestamp(index, timestamp);
316+
}
317+
}
318+
319+
markStepActive(index) {
320+
if (this.stepIconTargets[index]) {
321+
this.stepIconTargets[index].className = 'fas fa-spinner fa-spin text-primary me-3';
322+
}
323+
if (this.stepRowTargets[index]) {
324+
this.stepRowTargets[index].classList.remove('text-muted');
325+
}
326+
}
327+
328+
setStepTimestamp(index, time) {
329+
if (this.stepTimeTargets[index]) {
330+
this.stepTimeTargets[index].textContent = time;
331+
}
332+
}
333+
334+
// --- UI helpers ---
335+
336+
toggleTarget(name, show) {
337+
const hasMethod = 'has' + name.charAt(0).toUpperCase() + name.slice(1) + 'Target';
338+
if (this[hasMethod]) {
339+
this[name + 'Target'].classList.toggle('d-none', !show);
340+
}
341+
}
342+
343+
updateProgressBar(percent) {
344+
if (this.hasProgressBarTarget) {
345+
const bar = this.progressBarTarget;
346+
// Remove all width classes
347+
bar.classList.remove('progress-w-0', 'progress-w-15', 'progress-w-30', 'progress-w-50', 'progress-w-65', 'progress-w-80', 'progress-w-100');
348+
bar.classList.add('progress-w-' + percent);
349+
bar.textContent = percent + '%';
350+
bar.setAttribute('aria-valuenow', percent);
351+
352+
bar.classList.remove('bg-success', 'bg-danger', 'progress-bar-striped', 'progress-bar-animated');
353+
if (percent === 100) {
354+
bar.classList.add('bg-success');
355+
} else if (percent === 0) {
356+
bar.classList.add('bg-danger');
357+
} else {
358+
bar.classList.add('progress-bar-striped', 'progress-bar-animated');
359+
}
360+
}
361+
}
362+
363+
updateElapsedTime(elapsed) {
364+
if (this.hasElapsedTimeTarget) {
365+
const seconds = Math.floor(elapsed / 1000);
366+
const minutes = Math.floor(seconds / 60);
367+
const remainingSeconds = seconds % 60;
368+
this.elapsedTimeTarget.textContent = minutes > 0
369+
? `${minutes}m ${remainingSeconds}s`
370+
: `${remainingSeconds}s`;
371+
}
372+
}
373+
374+
formatTime(date) {
375+
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
376+
}
377+
}

0 commit comments

Comments
 (0)