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
This commit is contained in:
Sebastian Almberg 2026-03-31 10:08:11 +02:00
parent 4206b702ff
commit 3cdd085d3b
14 changed files with 1553 additions and 55 deletions

6
.env
View file

@ -76,6 +76,12 @@ DISABLE_BACKUP_RESTORE=1
# When enabled, users must confirm their password before downloading.
DISABLE_BACKUP_DOWNLOAD=1
# Watchtower integration for Docker-based updates.
# Set these to enable one-click updates via the Update Manager UI.
# See https://containrrr.dev/watchtower/ for Watchtower setup.
WATCHTOWER_API_URL=
WATCHTOWER_API_TOKEN=
###################################################################################
# SAML Single sign on-settings
###################################################################################

View file

@ -0,0 +1,377 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Controller } from '@hotwired/stimulus';
/**
* Stimulus controller for Docker update progress tracking.
*
* Polls the health check endpoint to detect when the container restarts
* after a Watchtower-triggered update. Drives the step timeline UI
* with timestamps, matching the git update progress style.
*/
export default class extends Controller {
static values = {
healthUrl: String,
previousVersion: { type: String, default: 'unknown' },
pollInterval: { type: Number, default: 5000 },
maxWaitTime: { type: Number, default: 600000 }, // 10 minutes
// Translated UI strings (passed from Twig template)
textPulling: { type: String, default: 'Waiting for Watchtower to pull the new image...' },
textPullingDetail: { type: String, default: 'Watchtower is checking for and downloading the latest Docker image...' },
textRestarting: { type: String, default: 'Container is restarting with the new image...' },
textRestartingDetail: { type: String, default: 'The container is being recreated with the updated image. This may take a moment...' },
textSuccess: { type: String, default: 'Update Complete!' },
textSuccessDetail: { type: String, default: 'Part-DB has been updated successfully via Docker.' },
textTimeout: { type: String, default: 'Update Taking Longer Than Expected' },
textTimeoutDetail: { type: String, default: 'The update may still be in progress. Check your Docker logs for details.' },
textStepPull: { type: String, default: 'Pull Image' },
textStepRestart: { type: String, default: 'Restart Container' },
};
static targets = [
// Header
'headerWhale', 'titleIcon',
'statusText', 'statusSubtext',
'progressBar', 'elapsedTime',
// Alerts
'stepAlert', 'stepName', 'stepMessage',
'successAlert', 'timeoutAlert', 'errorAlert', 'errorMessage', 'warningAlert',
// Step timeline (multi-target arrays)
'stepRow', 'stepIcon', 'stepDetail', 'stepTime',
// Version display
'newVersion', 'previousVersion',
// Actions
'actions',
];
// Step definitions: name -> { index, progress% }
static STEPS = {
trigger: { index: 0, progress: 15 },
pull: { index: 1, progress: 30 },
stop: { index: 2, progress: 50 },
restart: { index: 3, progress: 65 },
health: { index: 4, progress: 80 },
verify: { index: 5, progress: 100 },
};
connect() {
this.serverWentDown = false;
this.serverCameBack = false;
this.startTime = Date.now();
this.timer = null;
this.currentStep = 'pull'; // trigger is already done
this.stepTimestamps = { trigger: this.formatTime(new Date()) };
this.consecutiveSuccessCount = 0;
// Set the trigger step timestamp
this.setStepTimestamp(0, this.stepTimestamps.trigger);
this.poll();
}
disconnect() {
if (this.timer) {
clearTimeout(this.timer);
}
}
createTimeoutSignal(ms) {
if (typeof AbortSignal.timeout === 'function') {
return AbortSignal.timeout(ms);
}
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
async poll() {
const elapsed = Date.now() - this.startTime;
this.updateElapsedTime(elapsed);
if (elapsed > this.maxWaitTimeValue) {
this.showTimeout();
return;
}
try {
const response = await fetch(this.healthUrlValue, {
cache: 'no-store',
signal: this.createTimeoutSignal(4000),
});
if (response.ok) {
let data;
try {
data = await response.json();
} catch (parseError) {
this.schedulePoll();
return;
}
if (this.serverWentDown) {
// Server came back! Move through health check -> verify
if (!this.serverCameBack) {
this.serverCameBack = true;
this.advanceToStep('health');
}
this.consecutiveSuccessCount++;
// Wait for 2 consecutive successes to confirm stability
if (this.consecutiveSuccessCount >= 2) {
this.showSuccess(data.version);
return;
}
} else {
// Server still up - Watchtower pulling image
this.showPulling();
}
} else if (response.status === 503) {
// Maintenance mode or shutting down
this.serverWentDown = true;
this.consecutiveSuccessCount = 0;
this.advanceToStep('stop');
} else {
if (this.serverWentDown) {
this.showRestarting();
} else {
this.showPulling();
}
}
} catch (e) {
// Connection refused = container is down
if (!this.serverWentDown) {
this.serverWentDown = true;
this.advanceToStep('stop');
}
this.consecutiveSuccessCount = 0;
this.showRestarting();
}
this.schedulePoll();
}
schedulePoll() {
this.timer = setTimeout(() => this.poll(), this.pollIntervalValue);
}
/**
* Advance the step timeline to a specific step.
* Marks all previous steps as complete with timestamps.
*/
advanceToStep(stepName) {
const steps = this.constructor.STEPS;
const targetIndex = steps[stepName]?.index;
if (targetIndex === undefined) return;
const stepNames = Object.keys(steps);
const now = this.formatTime(new Date());
for (let i = 0; i < stepNames.length; i++) {
const name = stepNames[i];
if (i < targetIndex) {
// Completed step
this.markStepComplete(i, this.stepTimestamps[name] || now);
if (!this.stepTimestamps[name]) {
this.stepTimestamps[name] = now;
}
} else if (i === targetIndex) {
// Current active step
this.markStepActive(i);
this.stepTimestamps[name] = now;
this.setStepTimestamp(i, now);
this.currentStep = name;
}
// Steps after targetIndex remain pending (no change needed)
}
// Update progress bar
this.updateProgressBar(steps[stepName].progress);
}
showPulling() {
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textPullingValue;
}
if (this.hasStepNameTarget) {
this.stepNameTarget.textContent = this.textStepPullValue;
}
if (this.hasStepMessageTarget) {
this.stepMessageTarget.textContent = this.textPullingDetailValue;
}
this.updateProgressBar(30);
}
showRestarting() {
// Advance to restart step if we haven't already
if (this.currentStep !== 'restart' && this.currentStep !== 'health' && this.currentStep !== 'verify') {
this.advanceToStep('restart');
}
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textRestartingValue;
}
if (this.hasStepNameTarget) {
this.stepNameTarget.textContent = this.textStepRestartValue;
}
if (this.hasStepMessageTarget) {
this.stepMessageTarget.textContent = this.textRestartingDetailValue;
}
}
showSuccess(newVersion) {
// Advance all steps to complete
const steps = this.constructor.STEPS;
const stepNames = Object.keys(steps);
const now = this.formatTime(new Date());
for (let i = 0; i < stepNames.length; i++) {
const name = stepNames[i];
this.markStepComplete(i, this.stepTimestamps[name] || now);
}
this.updateProgressBar(100);
// Update whale animation
if (this.hasHeaderWhaleTarget) {
this.headerWhaleTarget.classList.add('success');
}
if (this.hasTitleIconTarget) {
this.titleIconTarget.className = 'fas fa-check-circle text-success';
}
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textSuccessValue;
}
if (this.hasStatusSubtextTarget) {
this.statusSubtextTarget.textContent = this.textSuccessDetailValue;
}
// Hide step alert, show success alert
this.toggleTarget('stepAlert', false);
this.toggleTarget('successAlert', true);
this.toggleTarget('warningAlert', false);
this.toggleTarget('actions', true);
if (this.hasNewVersionTarget) {
this.newVersionTarget.textContent = newVersion || 'latest';
}
if (this.hasPreviousVersionTarget) {
this.previousVersionTarget.textContent = this.previousVersionValue;
}
}
showTimeout() {
this.updateProgressBar(0);
if (this.hasHeaderWhaleTarget) {
this.headerWhaleTarget.classList.add('timeout');
}
if (this.hasTitleIconTarget) {
this.titleIconTarget.className = 'fas fa-exclamation-triangle text-warning';
}
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textTimeoutValue;
}
if (this.hasStatusSubtextTarget) {
this.statusSubtextTarget.textContent = this.textTimeoutDetailValue;
}
this.toggleTarget('stepAlert', false);
this.toggleTarget('timeoutAlert', true);
this.toggleTarget('warningAlert', false);
this.toggleTarget('actions', true);
}
// --- Step timeline helpers ---
markStepComplete(index, timestamp) {
if (this.stepIconTargets[index]) {
this.stepIconTargets[index].className = 'fas fa-check-circle text-success me-3';
}
if (this.stepRowTargets[index]) {
this.stepRowTargets[index].classList.remove('text-muted');
}
if (timestamp) {
this.setStepTimestamp(index, timestamp);
}
}
markStepActive(index) {
if (this.stepIconTargets[index]) {
this.stepIconTargets[index].className = 'fas fa-spinner fa-spin text-primary me-3';
}
if (this.stepRowTargets[index]) {
this.stepRowTargets[index].classList.remove('text-muted');
}
}
setStepTimestamp(index, time) {
if (this.stepTimeTargets[index]) {
this.stepTimeTargets[index].textContent = time;
}
}
// --- UI helpers ---
toggleTarget(name, show) {
const hasMethod = 'has' + name.charAt(0).toUpperCase() + name.slice(1) + 'Target';
if (this[hasMethod]) {
this[name + 'Target'].classList.toggle('d-none', !show);
}
}
updateProgressBar(percent) {
if (this.hasProgressBarTarget) {
const bar = this.progressBarTarget;
// Remove all width classes
bar.classList.remove('progress-w-0', 'progress-w-15', 'progress-w-30', 'progress-w-50', 'progress-w-65', 'progress-w-80', 'progress-w-100');
bar.classList.add('progress-w-' + percent);
bar.textContent = percent + '%';
bar.setAttribute('aria-valuenow', percent);
bar.classList.remove('bg-success', 'bg-danger', 'progress-bar-striped', 'progress-bar-animated');
if (percent === 100) {
bar.classList.add('bg-success');
} else if (percent === 0) {
bar.classList.add('bg-danger');
} else {
bar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
updateElapsedTime(elapsed) {
if (this.hasElapsedTimeTarget) {
const seconds = Math.floor(elapsed / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
this.elapsedTimeTarget.textContent = minutes > 0
? `${minutes}m ${remainingSeconds}s`
: `${remainingSeconds}s`;
}
}
formatTime(date) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
}

View file

@ -219,6 +219,51 @@ docker-compose up -d
docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate
```
### Automatic updates via Watchtower (Web UI)
Part-DB supports triggering Docker container updates directly from the web interface using [Watchtower](https://containrrr.dev/watchtower/).
When configured, administrators can check for and apply updates from the **System > Update Manager** page.
To enable this feature, add a Watchtower service to your `docker-compose.yaml` and configure the connection:
```yaml
services:
partdb:
container_name: partdb
image: jbtronics/part-db1:latest
labels:
- com.centurylinklabs.watchtower.enable=true
environment:
# ... your existing environment variables ...
# Watchtower integration for web-based updates
- WATCHTOWER_API_URL=http://watchtower:8080
- WATCHTOWER_API_TOKEN=your-secret-token
# ... your existing ports/volumes ...
watchtower:
image: containrrr/watchtower
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_HTTP_API_UPDATE=true
- WATCHTOWER_HTTP_API_TOKEN=your-secret-token
- WATCHTOWER_LABEL_ENABLE=true
- WATCHTOWER_CLEANUP=true
ports:
- '8081:8080'
```
{: .important }
> Replace `your-secret-token` with a strong, unique token. The same token must be set in both the Part-DB (`WATCHTOWER_API_TOKEN`) and Watchtower (`WATCHTOWER_HTTP_API_TOKEN`) environment variables.
{: .info }
> `WATCHTOWER_LABEL_ENABLE=true` ensures Watchtower only manages containers with the `com.centurylinklabs.watchtower.enable=true` label, preventing it from updating other containers on the same host.
Once configured, the Update Manager page will show the Watchtower connection status and provide an **Update via Watchtower** button when a new version is available. Clicking it triggers Watchtower to pull the latest image and recreate the Part-DB container automatically.
## Direct use of docker image
You can use the `jbtronics/part-db1:master` image directly. You have to expose port 80 to a host port and configure

View file

@ -28,6 +28,7 @@ use App\Services\System\BackupManager;
use App\Services\System\InstallationTypeDetector;
use App\Services\System\UpdateChecker;
use App\Services\System\UpdateExecutor;
use App\Services\System\WatchtowerClient;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@ -56,6 +57,7 @@ class UpdateManagerController extends AbstractController
private readonly BackupManager $backupManager,
private readonly InstallationTypeDetector $installationTypeDetector,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly WatchtowerClient $watchtowerClient,
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
private readonly bool $webUpdatesDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
@ -504,4 +506,91 @@ class UpdateManagerController extends AbstractController
return $this->redirectToRoute('admin_update_manager');
}
/**
* Start a Docker update via Watchtower.
*/
#[Route('/start-docker', name: 'admin_update_manager_start_docker', methods: ['POST'])]
public function startDockerUpdate(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfWebUpdatesDisabled();
// Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_start_docker', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token');
return $this->redirectToRoute('admin_update_manager');
}
// Check if Watchtower is configured and available
if (!$this->watchtowerClient->isConfigured()) {
$this->addFlash('error', 'Watchtower is not configured. Please set WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN.');
return $this->redirectToRoute('admin_update_manager');
}
if (!$this->watchtowerClient->isAvailable()) {
$this->addFlash('error', 'Watchtower is not reachable. Please check that the Watchtower container is running and accessible.');
return $this->redirectToRoute('admin_update_manager');
}
// Create backup if requested
$createBackup = $request->request->getBoolean('backup', true);
if ($createBackup) {
try {
$this->backupManager->createBackup();
} catch (\Throwable $e) {
$this->addFlash('error', 'Failed to create backup before update: ' . $e->getMessage());
return $this->redirectToRoute('admin_update_manager');
}
}
// Trigger Watchtower update
$success = $this->watchtowerClient->triggerUpdate();
if (!$success) {
$this->addFlash('error', 'Failed to trigger Watchtower update. Check the logs for details.');
return $this->redirectToRoute('admin_update_manager');
}
$currentVersion = $this->versionManager->getVersion()->toString();
// Redirect to Docker progress page
return $this->redirectToRoute('admin_update_manager_docker_progress', [
'previous_version' => $currentVersion,
]);
}
/**
* Docker update progress page.
* This page contains client-side JavaScript that polls until the container restarts.
*/
#[Route('/progress/docker', name: 'admin_update_manager_docker_progress', methods: ['GET'])]
public function dockerProgress(Request $request): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$previousVersion = $request->query->get('previous_version', 'unknown');
return $this->render('admin/update_manager/docker_progress.html.twig', [
'previous_version' => $previousVersion,
]);
}
/**
* Lightweight health check endpoint used by Docker update progress page.
* Returns current version so the client-side JS can detect when the container restarts with a new version.
*
* Intentionally unauthenticated: after a Docker container restart, the user's session may not survive
* (depends on session storage backend). The version string is non-sensitive public information.
* This endpoint is also whitelisted in MaintenanceModeSubscriber.
*/
#[Route('/health', name: 'admin_update_manager_health', methods: ['GET'])]
public function healthCheck(): JsonResponse
{
return $this->json([
'status' => 'ok',
'version' => $this->versionManager->getVersion()->toString(),
]);
}
}

View file

@ -62,8 +62,8 @@ readonly class MaintenanceModeSubscriber implements EventSubscriberInterface
return;
}
//Allow to view the progress page
if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) {
//Allow to view the progress page and health check endpoint
if (preg_match('#^/[a-z]{2}(?:_[A-Z]{2})?/system/update-manager/(progress|health)#', $event->getRequest()->getPathInfo())) {
return;
}

View file

@ -46,7 +46,7 @@ enum InstallationType: string
{
return match ($this) {
self::GIT => true,
self::DOCKER => false,
self::DOCKER => true,
// ZIP_RELEASE auto-update not yet implemented
self::ZIP_RELEASE => false,
self::UNKNOWN => false,
@ -57,7 +57,7 @@ enum InstallationType: string
{
return match ($this) {
self::GIT => 'Run: php bin/console partdb:update',
self::DOCKER => 'Pull the new Docker image and recreate the container: docker-compose pull && docker-compose up -d',
self::DOCKER => 'Configure Watchtower for one-click updates, or manually: docker-compose pull && docker-compose up -d',
self::ZIP_RELEASE => 'Download the new release ZIP from GitHub, extract it over your installation, and run: php bin/console doctrine:migrations:migrate && php bin/console cache:clear',
self::UNKNOWN => 'Unable to determine installation type. Please update manually.',
};

View file

@ -50,7 +50,8 @@ class UpdateChecker
private readonly InstallationTypeDetector $installationTypeDetector,
private readonly GitVersionInfoProvider $gitVersionInfoProvider,
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode,
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir)
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir,
private readonly ?WatchtowerClient $watchtowerClient = null)
{
}
@ -284,8 +285,16 @@ class UpdateChecker
$updateBlockers[] = 'local_changes';
}
if ($installInfo['type'] === InstallationType::DOCKER) {
$updateBlockers[] = 'docker_installation';
// Docker installations require Watchtower for auto-update
$watchtowerConfigured = $this->watchtowerClient !== null && $this->watchtowerClient->isConfigured();
$watchtowerAvailable = $watchtowerConfigured && $this->watchtowerClient->isAvailable();
if ($installInfo['type'] === InstallationType::DOCKER && !$watchtowerConfigured) {
$canAutoUpdate = false;
$updateBlockers[] = 'docker_no_watchtower';
} elseif ($installInfo['type'] === InstallationType::DOCKER && !$watchtowerAvailable) {
$canAutoUpdate = false;
$updateBlockers[] = 'docker_watchtower_unreachable';
}
return [
@ -301,6 +310,8 @@ class UpdateChecker
'can_auto_update' => $canAutoUpdate,
'update_blockers' => $updateBlockers,
'check_enabled' => $this->privacySettings->checkForUpdates,
'watchtower_configured' => $watchtowerConfigured,
'watchtower_available' => $watchtowerAvailable,
];
}

View file

@ -299,6 +299,23 @@ class UpdateExecutor
);
}
// Docker installations are updated via Watchtower - skip Git/Composer/Yarn checks
if ($installType === InstallationType::DOCKER) {
// Only check if already locked
if ($this->isLocked()) {
$lockInfo = $this->getLockInfo();
$errors[] = sprintf(
'An update is already in progress (started at %s).',
$lockInfo['started_at'] ?? 'unknown time'
);
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
// Check for Git installation
if ($installType === InstallationType::GIT) {
// Check if git is available

View file

@ -0,0 +1,125 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\System;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* HTTP client for communicating with the Watchtower container updater API.
* Used to trigger Docker container updates from the Part-DB UI.
*
* @see https://containrrr.dev/watchtower/
*/
readonly class WatchtowerClient
{
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
#[Autowire(env: 'WATCHTOWER_API_URL')] private string $apiUrl,
#[Autowire(env: 'WATCHTOWER_API_TOKEN')] private string $apiToken,
) {
}
/**
* Whether Watchtower integration is configured (URL and token are set).
*/
public function isConfigured(): bool
{
return $this->apiUrl !== '' && $this->apiToken !== '';
}
/**
* Check if the Watchtower API is reachable.
* Makes a lightweight HTTP request with a short timeout.
*/
public function isAvailable(): bool
{
if (!$this->isConfigured()) {
return false;
}
try {
$response = $this->httpClient->request('GET', $this->getUpdateEndpoint(), [
'headers' => $this->getAuthHeaders(),
'timeout' => 3,
]);
// Any response means Watchtower is reachable
$statusCode = $response->getStatusCode();
return $statusCode < 500;
} catch (\Throwable $e) {
$this->logger->debug('Watchtower availability check failed: ' . $e->getMessage());
return false;
}
}
/**
* Trigger a container update via the Watchtower HTTP API.
* This is fire-and-forget: Watchtower will pull the new image and restart the container.
*
* @return bool True if Watchtower accepted the update request
*/
public function triggerUpdate(): bool
{
if (!$this->isConfigured()) {
throw new \RuntimeException('Watchtower is not configured. Set WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN.');
}
try {
$response = $this->httpClient->request('POST', $this->getUpdateEndpoint(), [
'headers' => $this->getAuthHeaders(),
'timeout' => 10,
]);
$statusCode = $response->getStatusCode();
if ($statusCode >= 200 && $statusCode < 300) {
$this->logger->info('Watchtower update triggered successfully.');
return true;
}
$this->logger->error('Watchtower update request returned HTTP ' . $statusCode);
return false;
} catch (\Throwable $e) {
$this->logger->error('Failed to trigger Watchtower update: ' . $e->getMessage());
return false;
}
}
private function getUpdateEndpoint(): string
{
return rtrim($this->apiUrl, '/') . '/v1/update';
}
/**
* @return array<string, string>
*/
private function getAuthHeaders(): array
{
return [
'Authorization' => 'Bearer ' . $this->apiToken,
];
}
}

View file

@ -0,0 +1,235 @@
{% extends "main_card.html.twig" %}
{% block title %}{% trans %}update_manager.docker.progress_title{% endtrans %}{% endblock %}
{% block card_title %}
<i class="fab fa-docker" data-docker-update-progress-target="titleIcon"></i>
{% trans %}update_manager.docker.progress_title{% endtrans %}
{% endblock %}
{% block card_content %}
<style nonce="{{ csp_nonce('style') }}">
.docker-whale {
display: inline-block;
animation: whale-bob 2s ease-in-out infinite;
}
.docker-whale svg {
fill: #2496ED;
}
.docker-whale.success {
animation: whale-arrive 0.6s ease-out forwards;
}
.docker-whale.success svg { fill: #198754; }
.docker-whale.timeout { animation: none; }
.docker-whale.timeout svg { fill: #ffc107; }
.docker-whale.failed { animation: none; }
.docker-whale.failed svg { fill: #dc3545; }
@keyframes whale-bob {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-8px) rotate(-2deg); }
75% { transform: translateY(4px) rotate(1deg); }
}
@keyframes whale-arrive {
0% { transform: scale(1); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
.whale-waves {
text-align: center;
font-size: 0.5rem;
color: #2496ED66;
animation: wave-flow 3s linear infinite;
letter-spacing: -1px;
margin-top: -4px;
}
.docker-whale.success + .whale-waves,
.docker-whale.timeout + .whale-waves,
.docker-whale.failed + .whale-waves { animation: none; }
@keyframes wave-flow {
0% { opacity: 0.3; }
50% { opacity: 0.7; }
100% { opacity: 0.3; }
}
.step-timestamp { min-width: 60px; text-align: right; }
/* Progress bar widths - CSS classes to avoid CSP inline style violations */
.docker-progress { height: 25px; }
.progress-w-0 { width: 0%; }
.progress-w-15 { width: 15%; }
.progress-w-30 { width: 30%; }
.progress-w-50 { width: 50%; }
.progress-w-65 { width: 65%; }
.progress-w-80 { width: 80%; }
.progress-w-100 { width: 100%; }
</style>
<div data-controller="docker-update-progress"
data-docker-update-progress-health-url-value="{{ path('admin_update_manager_health') }}"
data-docker-update-progress-previous-version-value="{{ previous_version }}"
data-docker-update-progress-text-pulling-value="{% trans %}update_manager.docker.waiting_for_watchtower{% endtrans %}"
data-docker-update-progress-text-pulling-detail-value="{% trans %}update_manager.docker.step_pull_desc{% endtrans %}"
data-docker-update-progress-text-restarting-value="{% trans %}update_manager.docker.restarting_title{% endtrans %}"
data-docker-update-progress-text-restarting-detail-value="{% trans %}update_manager.docker.step_restart_desc{% endtrans %}"
data-docker-update-progress-text-success-value="{% trans %}update_manager.docker.success_title{% endtrans %}"
data-docker-update-progress-text-success-detail-value="{% trans %}update_manager.docker.success_message{% endtrans %}"
data-docker-update-progress-text-timeout-value="{% trans %}update_manager.docker.timeout_title{% endtrans %}"
data-docker-update-progress-text-timeout-detail-value="{% trans %}update_manager.docker.timeout_message{% endtrans %}"
data-docker-update-progress-text-step-pull-value="{% trans %}update_manager.docker.step_pull{% endtrans %}"
data-docker-update-progress-text-step-restart-value="{% trans %}update_manager.docker.step_restart{% endtrans %}">
{# Progress Header #}
<div class="text-center mb-4">
<div class="mb-3">
<div class="docker-whale-container">
<div class="docker-whale" data-docker-update-progress-target="headerWhale">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="80" height="64">
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>
</svg>
</div>
<div class="whale-waves">~ ~ ~ ~ ~</div>
</div>
</div>
<h4 data-docker-update-progress-target="statusText">
{% trans %}update_manager.docker.updating{% endtrans %}
</h4>
<p class="text-muted" data-docker-update-progress-target="statusSubtext">
{% trans %}update_manager.docker.updating_via_watchtower{% endtrans %}
</p>
</div>
{# Progress Bar #}
<div class="progress mb-4 docker-progress">
<div class="progress-bar progress-bar-striped progress-bar-animated progress-w-15"
role="progressbar"
aria-valuenow="15"
aria-valuemin="0"
aria-valuemax="100"
data-docker-update-progress-target="progressBar">
15%
</div>
</div>
{# Current Step Info #}
<div class="alert alert-info mb-4" data-docker-update-progress-target="stepAlert">
<strong data-docker-update-progress-target="stepName">{% trans %}update_manager.docker.step_trigger{% endtrans %}</strong>:
<span data-docker-update-progress-target="stepMessage">{% trans %}update_manager.docker.step_trigger_desc{% endtrans %}</span>
</div>
{# Success Message #}
<div class="alert alert-success mb-4 d-none" data-docker-update-progress-target="successAlert">
<i class="fas fa-check-circle me-2"></i>
{% trans %}update_manager.docker.success_message{% endtrans %}
<br>
<strong>{% trans %}update_manager.docker.previous_version{% endtrans %}:</strong>
<span data-docker-update-progress-target="previousVersion">{{ previous_version }}</span>
&rarr;
<strong>{% trans %}update_manager.docker.new_version{% endtrans %}:</strong>
<span class="badge bg-success" data-docker-update-progress-target="newVersion">...</span>
</div>
{# Timeout Message #}
<div class="alert alert-warning mb-4 d-none" data-docker-update-progress-target="timeoutAlert">
<i class="fas fa-exclamation-triangle me-2"></i>
{% trans %}update_manager.docker.timeout_message{% endtrans %}
</div>
{# Error Message #}
<div class="alert alert-danger mb-4 d-none" data-docker-update-progress-target="errorAlert">
<i class="fas fa-times-circle me-2"></i>
<strong>{% trans %}update_manager.progress.error{% endtrans %}:</strong>
<span data-docker-update-progress-target="errorMessage"></span>
</div>
{# Steps Timeline - matches git progress style #}
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-list-ol me-2"></i>{% trans %}update_manager.docker.steps{% endtrans %}
</div>
<div class="card-body p-0">
<ul class="list-group list-group-flush">
{# Step 1: Trigger Watchtower #}
<li class="list-group-item d-flex align-items-center" data-docker-update-progress-target="stepRow" data-step="trigger">
<i class="fas fa-check-circle text-success me-3" data-docker-update-progress-target="stepIcon"></i>
<div class="flex-grow-1">
<strong>{% trans %}update_manager.docker.step_trigger{% endtrans %}</strong>
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_trigger_desc{% endtrans %}</small>
</div>
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
</li>
{# Step 2: Pull Image #}
<li class="list-group-item d-flex align-items-center" data-docker-update-progress-target="stepRow" data-step="pull">
<i class="fas fa-spinner fa-spin text-primary me-3" data-docker-update-progress-target="stepIcon"></i>
<div class="flex-grow-1">
<strong>{% trans %}update_manager.docker.step_pull{% endtrans %}</strong>
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_pull_desc{% endtrans %}</small>
</div>
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
</li>
{# Step 3: Stop Container #}
<li class="list-group-item d-flex align-items-center text-muted" data-docker-update-progress-target="stepRow" data-step="stop">
<i class="fas fa-circle me-3" data-docker-update-progress-target="stepIcon"></i>
<div class="flex-grow-1">
<strong>{% trans %}update_manager.docker.step_stop{% endtrans %}</strong>
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_stop_desc{% endtrans %}</small>
</div>
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
</li>
{# Step 4: Restart Container #}
<li class="list-group-item d-flex align-items-center text-muted" data-docker-update-progress-target="stepRow" data-step="restart">
<i class="fas fa-circle me-3" data-docker-update-progress-target="stepIcon"></i>
<div class="flex-grow-1">
<strong>{% trans %}update_manager.docker.step_restart{% endtrans %}</strong>
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_restart_desc{% endtrans %}</small>
</div>
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
</li>
{# Step 5: Health Check #}
<li class="list-group-item d-flex align-items-center text-muted" data-docker-update-progress-target="stepRow" data-step="health">
<i class="fas fa-circle me-3" data-docker-update-progress-target="stepIcon"></i>
<div class="flex-grow-1">
<strong>{% trans %}update_manager.docker.step_health{% endtrans %}</strong>
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_health_desc{% endtrans %}</small>
</div>
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
</li>
{# Step 6: Verify Version #}
<li class="list-group-item d-flex align-items-center text-muted" data-docker-update-progress-target="stepRow" data-step="verify">
<i class="fas fa-circle me-3" data-docker-update-progress-target="stepIcon"></i>
<div class="flex-grow-1">
<strong>{% trans %}update_manager.docker.step_verify{% endtrans %}</strong>
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_verify_desc{% endtrans %}</small>
</div>
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
</li>
</ul>
</div>
</div>
{# Elapsed Time #}
<div class="text-center text-muted small mb-3">
<i class="fas fa-clock me-1"></i>
{% trans %}update_manager.docker.elapsed{% endtrans %}:
<span data-docker-update-progress-target="elapsedTime">0s</span>
</div>
{# Actions - shown after completion or timeout #}
<div class="text-center d-none" data-docker-update-progress-target="actions">
<a href="{{ path('admin_update_manager') }}" class="btn btn-secondary me-2">
<i class="fas fa-arrow-left me-1"></i> {% trans %}update_manager.progress.back{% endtrans %}
</a>
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-1"></i> {% trans %}update_manager.docker.go_to_homepage{% endtrans %}
</a>
</div>
{# Warning Notice #}
<div class="alert alert-warning mt-4" data-docker-update-progress-target="warningAlert">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>{% trans %}update_manager.docker.warning{% endtrans %}:</strong>
{% trans %}update_manager.docker.do_not_close{% endtrans %}
</div>
</div>
{% endblock %}

View file

@ -99,25 +99,35 @@
</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
<td>
{{ helper.boolean_badge(status.can_auto_update) }}
</td>
</tr>
<tr>
<th scope="row">{% trans %}update_manager.web_updates_allowed{% endtrans %}</th>
<td>{{ helper.boolean_badge(not web_updates_disabled) }}</td>
</tr>
<tr>
<th scope="row">{% trans %}update_manager.backup_restore_allowed{% endtrans %}</th>
<td>{{ helper.boolean_badge(not backup_restore_disabled) }}</td>
</tr>
<tr>
<th scope="row">{% trans %}update_manager.backup_download_allowed{% endtrans %}</th>
<td>{{ helper.boolean_badge(not backup_download_disabled) }}</td>
</tr>
{% if is_docker %}
{# Docker: show Watchtower status #}
<tr>
<th scope="row">{% trans %}update_manager.docker.watchtower_status{% endtrans %}</th>
<td>
{% if status.watchtower_configured|default(false) and status.watchtower_available|default(false) %}
<span class="badge bg-success">
<i class="fas fa-check me-1"></i>{% trans %}update_manager.docker.watchtower_connected{% endtrans %}
</span>
{% elseif status.watchtower_configured|default(false) %}
<span class="badge bg-warning text-dark">
<i class="fas fa-exclamation-triangle me-1"></i>{% trans %}update_manager.docker.watchtower_unreachable_short{% endtrans %}
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-times me-1"></i>{% trans %}update_manager.docker.watchtower_not_configured{% endtrans %}
</span>
{% endif %}
</td>
</tr>
{% else %}
{# Git/other: show update readiness #}
<tr>
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
<td>
{{ helper.boolean_badge(status.can_auto_update) }}
</td>
</tr>
{% endif %}
</tbody>
</table>
@ -158,30 +168,63 @@
</div>
{% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %}
<form action="{{ path('admin_update_manager_start') }}" method="post"
data-controller="update-confirm"
data-update-confirm-is-downgrade-value="false"
data-update-confirm-target-version-value="{{ status.latest_tag }}"
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
<input type="hidden" name="version" value="{{ status.latest_tag }}">
{% if is_docker %}
{# Docker update via Watchtower #}
<form action="{{ path('admin_update_manager_start_docker') }}" method="post"
data-controller="update-confirm"
data-update-confirm-is-downgrade-value="false"
data-update-confirm-target-version-value="{{ status.latest_tag }}"
data-update-confirm-confirm-update-value="{{ 'update_manager.docker.confirm_update'|trans }}"
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start_docker') }}">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-success btn-lg">
<i class="fas fa-download me-2"></i>
{% trans %}update_manager.update_to{% endtrans %} {{ status.latest_tag }}
</button>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-success btn-lg">
<i class="fab fa-docker me-2"></i>
{% trans %}update_manager.docker.update_via_watchtower{% endtrans %} {{ status.latest_tag }}
</button>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup" checked>
<label class="form-check-label" for="create-backup">
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
</label>
</div>
</form>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup-docker" checked>
<label class="form-check-label" for="create-backup-docker">
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
</label>
</div>
<div class="alert alert-info mt-3 mb-0 small">
<i class="fas fa-info-circle me-1"></i>
{% trans %}update_manager.docker.no_rollback_warning{% endtrans %}
</div>
</form>
{% else %}
{# Git update #}
<form action="{{ path('admin_update_manager_start') }}" method="post"
data-controller="update-confirm"
data-update-confirm-is-downgrade-value="false"
data-update-confirm-target-version-value="{{ status.latest_tag }}"
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
<input type="hidden" name="version" value="{{ status.latest_tag }}">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-success btn-lg">
<i class="fas fa-download me-2"></i>
{% trans %}update_manager.update_to{% endtrans %} {{ status.latest_tag }}
</button>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup" checked>
<label class="form-check-label" for="create-backup">
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
</label>
</div>
</form>
{% endif %}
{% endif %}
{% if status.published_at %}
@ -229,12 +272,55 @@
{# Non-auto-update installations info #}
{% if not status.can_auto_update %}
<div class="alert alert-secondary">
<h6 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>{% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
</h6>
<p class="mb-0">{{ status.installation.update_instructions }}</p>
</div>
{% if is_docker and not status.watchtower_configured|default(false) %}
{# Docker without Watchtower - show setup instructions #}
<div class="card border-info mb-4">
<div class="card-header bg-info text-white">
<i class="fab fa-docker me-2"></i>{% trans %}update_manager.docker.setup_title{% endtrans %}
</div>
<div class="card-body">
<p>{% trans %}update_manager.docker.setup_description{% endtrans %}</p>
<h6>{% trans %}update_manager.docker.setup_step1{% endtrans %}</h6>
<pre class="bg-dark text-light p-3 rounded"><code>services:
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_HTTP_API_UPDATE=true
- WATCHTOWER_HTTP_API_TOKEN=your-secret-token
- WATCHTOWER_LABEL_ENABLE=true
ports:
- "8080:8080"</code></pre>
<h6>{% trans %}update_manager.docker.setup_step2{% endtrans %}</h6>
<pre class="bg-dark text-light p-3 rounded"><code>WATCHTOWER_API_URL=http://watchtower:8080
WATCHTOWER_API_TOKEN=your-secret-token</code></pre>
<div class="alert alert-warning mb-0 mt-3">
<i class="fas fa-exclamation-triangle me-2"></i>
{% trans %}update_manager.docker.setup_network_hint{% endtrans %}
</div>
</div>
</div>
{% elseif is_docker and status.watchtower_configured|default(false) and not status.watchtower_available|default(false) %}
{# Docker with Watchtower configured but not reachable #}
<div class="alert alert-warning">
<h6 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>{% trans %}update_manager.docker.watchtower_unreachable_title{% endtrans %}
</h6>
<p class="mb-0">{% trans %}update_manager.docker.watchtower_unreachable_description{% endtrans %}</p>
</div>
{% else %}
{# Other non-auto-update installations (ZIP, unknown) #}
<div class="alert alert-secondary">
<h6 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>{% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
</h6>
<p class="mb-0">{{ status.installation.update_instructions }}</p>
</div>
{% endif %}
{% endif %}
<div class="row">
@ -277,6 +363,9 @@
<i class="fas fa-file-alt"></i>
</a>
{% if release.version != status.current_version and status.can_auto_update and validation.valid and not web_updates_disabled %}
{% if is_docker %}
{# Docker: version switching not supported, only update to latest via Watchtower #}
{% else %}
<form action="{{ path('admin_update_manager_start') }}" method="post" class="d-inline"
data-controller="update-confirm"
data-update-confirm-is-downgrade-value="{{ release.version < status.current_version ? 'true' : 'false' }}"
@ -297,6 +386,7 @@
{% endif %}
</button>
</form>
{% endif %}
{% endif %}
</div>
</td>

View file

@ -17,7 +17,7 @@
Consider yourself lucky. You found some rare error code. <br> You should maybe inform your administrator about it...
{% endblock %}
</h3>
{% block further_actions %}<p class="help_text">You can try to <a href="javascript:history.back()">Go Back</a> or <a href="{{ path('homepage') }}">Visit the homepage</a>.</p>{% endblock %}
{% block further_actions %}<p class="help_text">You can try to <a href="#" onclick="history.back();return false;">Go Back</a> or <a href="{{ path('homepage') }}">Visit the homepage</a>.</p>{% endblock %}
{% block admin_contact %}<p class="help_text">If this error persists, please contact your
{% if error_page_admin_email is not empty %}
<a href="mailto:{{ error_page_admin_email }}">administrator.</a>

View file

@ -0,0 +1,197 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Services\System;
use App\Services\System\WatchtowerClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
final class WatchtowerClientTest extends TestCase
{
private function createClient(string $url = 'http://watchtower:8080', string $token = 'test-token', ?HttpClientInterface $httpClient = null): WatchtowerClient
{
return new WatchtowerClient(
$httpClient ?? $this->createMock(HttpClientInterface::class),
new NullLogger(),
$url,
$token,
);
}
public function testIsConfiguredReturnsTrueWhenBothSet(): void
{
$client = $this->createClient('http://watchtower:8080', 'my-token');
$this->assertTrue($client->isConfigured());
}
public function testIsConfiguredReturnsFalseWhenUrlEmpty(): void
{
$client = $this->createClient('', 'my-token');
$this->assertFalse($client->isConfigured());
}
public function testIsConfiguredReturnsFalseWhenTokenEmpty(): void
{
$client = $this->createClient('http://watchtower:8080', '');
$this->assertFalse($client->isConfigured());
}
public function testIsConfiguredReturnsFalseWhenBothEmpty(): void
{
$client = $this->createClient('', '');
$this->assertFalse($client->isConfigured());
}
public function testIsAvailableReturnsFalseWhenNotConfigured(): void
{
$client = $this->createClient('', '');
$this->assertFalse($client->isAvailable());
}
public function testIsAvailableReturnsTrueOnSuccessResponse(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(200);
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->expects($this->once())
->method('request')
->with('GET', 'http://watchtower:8080/v1/update', $this->callback(function (array $options) {
return $options['headers']['Authorization'] === 'Bearer test-token'
&& $options['timeout'] === 3;
}))
->willReturn($response);
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
$this->assertTrue($client->isAvailable());
}
public function testIsAvailableReturnsTrueOn401(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(401);
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->method('request')->willReturn($response);
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
$this->assertTrue($client->isAvailable());
}
public function testIsAvailableReturnsFalseOn500(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(500);
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->method('request')->willReturn($response);
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
$this->assertFalse($client->isAvailable());
}
public function testIsAvailableReturnsFalseOnException(): void
{
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->method('request')->willThrowException(new \RuntimeException('Connection refused'));
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
$this->assertFalse($client->isAvailable());
}
public function testTriggerUpdateThrowsWhenNotConfigured(): void
{
$client = $this->createClient('', '');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Watchtower is not configured');
$client->triggerUpdate();
}
public function testTriggerUpdateReturnsTrueOnSuccess(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(200);
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->expects($this->once())
->method('request')
->with('POST', 'http://watchtower:8080/v1/update', $this->callback(function (array $options) {
return $options['headers']['Authorization'] === 'Bearer test-token'
&& $options['timeout'] === 10;
}))
->willReturn($response);
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
$this->assertTrue($client->triggerUpdate());
}
public function testTriggerUpdateReturnsTrueOn202(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(202);
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->method('request')->willReturn($response);
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
$this->assertTrue($client->triggerUpdate());
}
public function testTriggerUpdateReturnsFalseOnServerError(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(500);
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->method('request')->willReturn($response);
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
$this->assertFalse($client->triggerUpdate());
}
public function testTriggerUpdateReturnsFalseOnException(): void
{
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->method('request')->willThrowException(new \RuntimeException('Network error'));
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
$this->assertFalse($client->triggerUpdate());
}
public function testUrlTrailingSlashIsNormalized(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(200);
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->expects($this->once())
->method('request')
->with('GET', 'http://watchtower:8080/v1/update', $this->anything())
->willReturn($response);
$client = $this->createClient('http://watchtower:8080/', 'test-token', $httpClient);
$client->isAvailable();
}
}

View file

@ -12941,6 +12941,312 @@ Buerklin-API Authentication server:
<target>Backup download allowed</target>
</segment>
</unit>
<unit id="wt_setup_title" name="update_manager.docker.setup_title">
<segment state="translated">
<source>update_manager.docker.setup_title</source>
<target>Enable One-Click Docker Updates with Watchtower</target>
</segment>
</unit>
<unit id="wt_setup_desc" name="update_manager.docker.setup_description">
<segment state="translated">
<source>update_manager.docker.setup_description</source>
<target>Part-DB can update your Docker container automatically using Watchtower, an open-source container updater. Add Watchtower as a companion container and configure the connection below.</target>
</segment>
</unit>
<unit id="wt_setup_s1" name="update_manager.docker.setup_step1">
<segment state="translated">
<source>update_manager.docker.setup_step1</source>
<target>1. Add Watchtower to your docker-compose.yml:</target>
</segment>
</unit>
<unit id="wt_setup_s2" name="update_manager.docker.setup_step2">
<segment state="translated">
<source>update_manager.docker.setup_step2</source>
<target>2. Add these environment variables to your Part-DB container:</target>
</segment>
</unit>
<unit id="wt_setup_net" name="update_manager.docker.setup_network_hint">
<segment state="translated">
<source>update_manager.docker.setup_network_hint</source>
<target>Make sure Part-DB and Watchtower are on the same Docker network. If you use label-based filtering in Watchtower (WATCHTOWER_LABEL_ENABLE=true), add the label "com.centurylinklabs.watchtower.enable=true" to your Part-DB container.</target>
</segment>
</unit>
<unit id="wt_unreach_t" name="update_manager.docker.watchtower_unreachable_title">
<segment state="translated">
<source>update_manager.docker.watchtower_unreachable_title</source>
<target>Watchtower Not Reachable</target>
</segment>
</unit>
<unit id="wt_unreach_d" name="update_manager.docker.watchtower_unreachable_description">
<segment state="translated">
<source>update_manager.docker.watchtower_unreachable_description</source>
<target>Watchtower is configured but cannot be reached. Please verify that the Watchtower container is running and that the API URL and token are correct.</target>
</segment>
</unit>
<unit id="wt_confirm" name="update_manager.docker.confirm_update">
<segment state="translated">
<source>update_manager.docker.confirm_update</source>
<target>Are you sure you want to update Part-DB via Watchtower? The container will be restarted with the new image. Unlike Git updates, Docker updates cannot be automatically rolled back.</target>
</segment>
</unit>
<unit id="wt_update_btn" name="update_manager.docker.update_via_watchtower">
<segment state="translated">
<source>update_manager.docker.update_via_watchtower</source>
<target>Update via Watchtower to</target>
</segment>
</unit>
<unit id="wt_no_rollback" name="update_manager.docker.no_rollback_warning">
<segment state="translated">
<source>update_manager.docker.no_rollback_warning</source>
<target>Docker updates cannot be automatically rolled back. A database backup will be created before updating so you can restore your data if needed.</target>
</segment>
</unit>
<unit id="wt_prog_title" name="update_manager.docker.progress_title">
<segment state="translated">
<source>update_manager.docker.progress_title</source>
<target>Docker Update in Progress</target>
</segment>
</unit>
<unit id="wt_waiting" name="update_manager.docker.waiting_for_watchtower">
<segment state="translated">
<source>update_manager.docker.waiting_for_watchtower</source>
<target>Waiting for Watchtower to pull the new image...</target>
</segment>
</unit>
<unit id="wt_elapsed" name="update_manager.docker.elapsed">
<segment state="translated">
<source>update_manager.docker.elapsed</source>
<target>Elapsed</target>
</segment>
</unit>
<unit id="wt_wait_t" name="update_manager.docker.waiting_title">
<segment state="translated">
<source>update_manager.docker.waiting_title</source>
<target>Update Triggered</target>
</segment>
</unit>
<unit id="wt_wait_d" name="update_manager.docker.waiting_description">
<segment state="translated">
<source>update_manager.docker.waiting_description</source>
<target>Watchtower has been notified. It will pull the latest Docker image and restart the Part-DB container.</target>
</segment>
</unit>
<unit id="wt_working" name="update_manager.docker.watchtower_working">
<segment state="translated">
<source>update_manager.docker.watchtower_working</source>
<target>Watchtower is processing the update...</target>
</segment>
</unit>
<unit id="wt_work_hint" name="update_manager.docker.watchtower_working_hint">
<segment state="translated">
<source>update_manager.docker.watchtower_working_hint</source>
<target>This may take a few minutes depending on your internet speed and image size.</target>
</segment>
</unit>
<unit id="wt_restart_t" name="update_manager.docker.restarting_title">
<segment state="translated">
<source>update_manager.docker.restarting_title</source>
<target>Container Restarting</target>
</segment>
</unit>
<unit id="wt_restart_d" name="update_manager.docker.restarting_description">
<segment state="translated">
<source>update_manager.docker.restarting_description</source>
<target>Watchtower has pulled the new image and is restarting the Part-DB container.</target>
</segment>
</unit>
<unit id="wt_restart_h" name="update_manager.docker.restarting_hint">
<segment state="translated">
<source>update_manager.docker.restarting_hint</source>
<target>The page will automatically detect when the server comes back online. This usually takes 10-30 seconds.</target>
</segment>
</unit>
<unit id="wt_success_t" name="update_manager.docker.success_title">
<segment state="translated">
<source>update_manager.docker.success_title</source>
<target>Update Complete!</target>
</segment>
</unit>
<unit id="wt_success_m" name="update_manager.docker.success_message">
<segment state="translated">
<source>update_manager.docker.success_message</source>
<target>Part-DB has been successfully updated via Watchtower.</target>
</segment>
</unit>
<unit id="wt_prev_ver" name="update_manager.docker.previous_version">
<segment state="translated">
<source>update_manager.docker.previous_version</source>
<target>Previous version</target>
</segment>
</unit>
<unit id="wt_new_ver" name="update_manager.docker.new_version">
<segment state="translated">
<source>update_manager.docker.new_version</source>
<target>New version</target>
</segment>
</unit>
<unit id="wt_back" name="update_manager.docker.back_to_update_manager">
<segment state="translated">
<source>update_manager.docker.back_to_update_manager</source>
<target>Back to Update Manager</target>
</segment>
</unit>
<unit id="wt_home" name="update_manager.docker.go_to_homepage">
<segment state="translated">
<source>update_manager.docker.go_to_homepage</source>
<target>Go to Homepage</target>
</segment>
</unit>
<unit id="wt_timeout_t" name="update_manager.docker.timeout_title">
<segment state="translated">
<source>update_manager.docker.timeout_title</source>
<target>Update Taking Longer Than Expected</target>
</segment>
</unit>
<unit id="wt_timeout_m" name="update_manager.docker.timeout_message">
<segment state="translated">
<source>update_manager.docker.timeout_message</source>
<target>The update is taking longer than expected. Check the Watchtower container logs for details. The update may still be in progress.</target>
</segment>
</unit>
<unit id="wt_retry" name="update_manager.docker.retry">
<segment state="translated">
<source>update_manager.docker.retry</source>
<target>Retry</target>
</segment>
</unit>
<unit id="wt_warning" name="update_manager.docker.warning">
<segment state="translated">
<source>update_manager.docker.warning</source>
<target>Warning</target>
</segment>
</unit>
<unit id="wt_no_close" name="update_manager.docker.do_not_close">
<segment state="translated">
<source>update_manager.docker.do_not_close</source>
<target>Do not close this page. It will automatically detect when the update is complete.</target>
</segment>
</unit>
<unit id="wt_via" name="update_manager.docker.updating_via_watchtower">
<segment state="translated">
<source>update_manager.docker.updating_via_watchtower</source>
<target>Updating via Watchtower</target>
</segment>
</unit>
<unit id="wt_step_wait" name="update_manager.docker.step_waiting">
<segment state="translated">
<source>update_manager.docker.step_waiting</source>
<target>Pulling Image</target>
</segment>
</unit>
<unit id="wt_steps" name="update_manager.docker.steps">
<segment state="translated">
<source>update_manager.docker.steps</source>
<target>Update Steps</target>
</segment>
</unit>
<unit id="wt_step_trig" name="update_manager.docker.step_trigger">
<segment state="translated">
<source>update_manager.docker.step_trigger</source>
<target>Trigger Update</target>
</segment>
</unit>
<unit id="wt_step_trig_d" name="update_manager.docker.step_trigger_desc">
<segment state="translated">
<source>update_manager.docker.step_trigger_desc</source>
<target>Watchtower has been notified to check for updates</target>
</segment>
</unit>
<unit id="wt_step_pull" name="update_manager.docker.step_pull">
<segment state="translated">
<source>update_manager.docker.step_pull</source>
<target>Pull New Image</target>
</segment>
</unit>
<unit id="wt_step_pull_d" name="update_manager.docker.step_pull_desc">
<segment state="translated">
<source>update_manager.docker.step_pull_desc</source>
<target>Downloading the latest Docker image from the registry</target>
</segment>
</unit>
<unit id="wt_step_rest" name="update_manager.docker.step_restart">
<segment state="translated">
<source>update_manager.docker.step_restart</source>
<target>Restart Container</target>
</segment>
</unit>
<unit id="wt_step_rest_d" name="update_manager.docker.step_restart_desc">
<segment state="translated">
<source>update_manager.docker.step_restart_desc</source>
<target>Stopping old container and starting new one</target>
</segment>
</unit>
<unit id="wt_step_ver" name="update_manager.docker.step_verify">
<segment state="translated">
<source>update_manager.docker.step_verify</source>
<target>Verify</target>
</segment>
</unit>
<unit id="wt_step_ver_d" name="update_manager.docker.step_verify_desc">
<segment state="translated">
<source>update_manager.docker.step_verify_desc</source>
<target>Confirming Part-DB is running on the new version</target>
</segment>
</unit>
<unit id="wt_status" name="update_manager.docker.watchtower_status">
<segment state="translated">
<source>update_manager.docker.watchtower_status</source>
<target>Watchtower</target>
</segment>
</unit>
<unit id="wt_connected" name="update_manager.docker.watchtower_connected">
<segment state="translated">
<source>update_manager.docker.watchtower_connected</source>
<target>Connected</target>
</segment>
</unit>
<unit id="wt_unreach_s" name="update_manager.docker.watchtower_unreachable_short">
<segment state="translated">
<source>update_manager.docker.watchtower_unreachable_short</source>
<target>Unreachable</target>
</segment>
</unit>
<unit id="wt_not_conf" name="update_manager.docker.watchtower_not_configured">
<segment state="translated">
<source>update_manager.docker.watchtower_not_configured</source>
<target>Not configured</target>
</segment>
</unit>
<unit id="wt_step_stop" name="update_manager.docker.step_stop">
<segment state="translated">
<source>update_manager.docker.step_stop</source>
<target>Stop Container</target>
</segment>
</unit>
<unit id="wt_step_stop_d" name="update_manager.docker.step_stop_desc">
<segment state="translated">
<source>update_manager.docker.step_stop_desc</source>
<target>Gracefully stopping the current container before recreation</target>
</segment>
</unit>
<unit id="wt_step_health" name="update_manager.docker.step_health">
<segment state="translated">
<source>update_manager.docker.step_health</source>
<target>Health Check</target>
</segment>
</unit>
<unit id="wt_step_health_d" name="update_manager.docker.step_health_desc">
<segment state="translated">
<source>update_manager.docker.step_health_desc</source>
<target>Waiting for the new container to pass health checks</target>
</segment>
</unit>
<unit id="wt_updating" name="update_manager.docker.updating">
<segment state="translated">
<source>update_manager.docker.updating</source>
<target>Updating Part-DB via Docker...</target>
</segment>
</unit>
<unit id="b8JxfcX" name="part.create_from_info_provider.lot_filled_from_barcode">
<segment state="translated">
<source>part.create_from_info_provider.lot_filled_from_barcode</source>