Compare commits

...

2 commits

Author SHA1 Message Date
Wieland Schopohl
673d5b5e83
Fix sort order after column reorder on page reload (#1346)
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
When columns are reordered via colReorder and the page is reloaded,
the saved sort state uses visual column indices. These were sent
directly as initial_order to the server, which interprets them as
original indices — causing the wrong column to be sorted.

Use the saved colReorder mapping to translate visual indices back to
original indices before sending initial_order in the _init request.
2026-05-03 23:16:02 +02:00
Sebastian Almberg
d346708150
Add Docker update support via Watchtower integration (#1330)
* 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

* Fixed translation message IDs

* Switch Watchtower docs to maintained nicholas-fedor fork

The original containrrr/watchtower is no longer maintained (last release
Nov 2023). Point users to the drop-in compatible active fork and add an
info note explaining why. No code changes — the HTTP API is identical,
so WatchtowerClient works against either image.

* Fixed exception when github is not reachable

* Only show version string in health endpoint, when user has permissions

* Do not expose watchtower API port in example docker-compose file

* Show if updates, backup restore and backup download are allowed in update manager page

* Report 'not authorized' for version in health endpoint if user lacks permission

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-05-03 23:00:31 +02:00
15 changed files with 1586 additions and 56 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

@ -113,8 +113,16 @@ export default class extends Controller {
return null;
}
//The saved order index is visual (post-reorder). If colReorder state
//exists, map it back to the original column index so the server sorts
//the correct column. colReorder[visualIndex] == originalIndex.
let columnIndex = order[0];
if (saved_state.colReorder) {
columnIndex = saved_state.colReorder[columnIndex];
}
return {
column: order[0],
column: columnIndex,
dir: order[1]
}
});

View file

@ -224,6 +224,52 @@ 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://github.com/nicholas-fedor/watchtower).
When configured, administrators can check for and apply updates from the **System > Update Manager** page.
{: .info }
> The original `containrrr/watchtower` project is no longer maintained (last release November 2023). These docs use the actively maintained community fork at [`nicholas-fedor/watchtower`](https://github.com/nicholas-fedor/watchtower), which is drop-in compatible with the original HTTP API.
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: ghcr.io/nicholas-fedor/watchtower:latest
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
```
{: .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,100 @@ 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
{
//Only show version if user is logged in and has permission
$response = [
'status' => 'ok',
];
if ($this->isGranted('@system.show_updates')) {
$response['version'] = $this->versionManager->getVersion()->toString();
} else {
$response['version'] = "not authorized";
}
return $this->json($response);
}
}

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

@ -75,6 +75,20 @@
<span class="badge bg-secondary">{{ status.installation.type_name }}</span>
</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 status.git.is_git_install %}
<tr>
<th scope="row">{% trans %}update_manager.git_branch{% endtrans %}</th>
@ -99,25 +113,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 +182,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 +286,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 +377,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 +400,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

@ -13055,6 +13055,312 @@ Buerklin-API Authentication server:
<target>Backup download allowed</target>
</segment>
</unit>
<unit id="Ld698rE" 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="A_UDLkn" 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="p092aHv" 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="VVejRLi" 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="3tlczB." 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="5ZI3hh9" 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=".0wFRZ." 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="PXadA3E" 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="zVnBU_y" 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="rygIXG_" 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="6YSYc7u" 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="4zjA6Z2" 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="N4ayoEp" name="update_manager.docker.elapsed">
<segment state="translated">
<source>update_manager.docker.elapsed</source>
<target>Elapsed</target>
</segment>
</unit>
<unit id="B2piu_D" name="update_manager.docker.waiting_title">
<segment state="translated">
<source>update_manager.docker.waiting_title</source>
<target>Update Triggered</target>
</segment>
</unit>
<unit id="K7uq04F" 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="nYC4Dw9" 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="IPd5mwk" 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="Q_XPcbn" name="update_manager.docker.restarting_title">
<segment state="translated">
<source>update_manager.docker.restarting_title</source>
<target>Container Restarting</target>
</segment>
</unit>
<unit id="fxJe7Uc" 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="fgtq0OK" 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="ZskYT6q" name="update_manager.docker.success_title">
<segment state="translated">
<source>update_manager.docker.success_title</source>
<target>Update Complete!</target>
</segment>
</unit>
<unit id="lIi.I9w" 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="RqtjuI7" name="update_manager.docker.previous_version">
<segment state="translated">
<source>update_manager.docker.previous_version</source>
<target>Previous version</target>
</segment>
</unit>
<unit id="KLNv9FX" name="update_manager.docker.new_version">
<segment state="translated">
<source>update_manager.docker.new_version</source>
<target>New version</target>
</segment>
</unit>
<unit id="EyK.Bvs" 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="qXEyzbC" 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="zLbfHFP" 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="fbpbM4e" 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="eTDf1ym" name="update_manager.docker.retry">
<segment state="translated">
<source>update_manager.docker.retry</source>
<target>Retry</target>
</segment>
</unit>
<unit id="BTQH5SL" name="update_manager.docker.warning">
<segment state="translated">
<source>update_manager.docker.warning</source>
<target>Warning</target>
</segment>
</unit>
<unit id="5A6K7gA" 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="JOGLw48" 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="L1aNTz2" name="update_manager.docker.step_waiting">
<segment state="translated">
<source>update_manager.docker.step_waiting</source>
<target>Pulling Image</target>
</segment>
</unit>
<unit id="K8G9Omp" name="update_manager.docker.steps">
<segment state="translated">
<source>update_manager.docker.steps</source>
<target>Update Steps</target>
</segment>
</unit>
<unit id="rwPMohr" name="update_manager.docker.step_trigger">
<segment state="translated">
<source>update_manager.docker.step_trigger</source>
<target>Trigger Update</target>
</segment>
</unit>
<unit id="C6T6dki" 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="sHx0nZN" 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="VC7sifr" 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="H5.XUFa" name="update_manager.docker.step_restart">
<segment state="translated">
<source>update_manager.docker.step_restart</source>
<target>Restart Container</target>
</segment>
</unit>
<unit id="KRNQZKS" 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="y5ibGqB" name="update_manager.docker.step_verify">
<segment state="translated">
<source>update_manager.docker.step_verify</source>
<target>Verify</target>
</segment>
</unit>
<unit id=".D286hQ" 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="s72BGA8" name="update_manager.docker.watchtower_status">
<segment state="translated">
<source>update_manager.docker.watchtower_status</source>
<target>Watchtower</target>
</segment>
</unit>
<unit id="HRw317s" name="update_manager.docker.watchtower_connected">
<segment state="translated">
<source>update_manager.docker.watchtower_connected</source>
<target>Connected</target>
</segment>
</unit>
<unit id="HcEk0Mh" 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="lMwRNf9" 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="IceG9y3" name="update_manager.docker.step_stop">
<segment state="translated">
<source>update_manager.docker.step_stop</source>
<target>Stop Container</target>
</segment>
</unit>
<unit id="0sq_TgW" 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="et8F1eO" name="update_manager.docker.step_health">
<segment state="translated">
<source>update_manager.docker.step_health</source>
<target>Health Check</target>
</segment>
</unit>
<unit id="v5tHPJK" 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="ZLRHnm_" 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>