diff --git a/.env b/.env index 8d5e5a54..2ea04325 100644 --- a/.env +++ b/.env @@ -76,12 +76,6 @@ 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 ################################################################################### diff --git a/assets/controllers/docker_update_progress_controller.js b/assets/controllers/docker_update_progress_controller.js deleted file mode 100644 index bc4c6ff3..00000000 --- a/assets/controllers/docker_update_progress_controller.js +++ /dev/null @@ -1,377 +0,0 @@ -/* - * 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 . - */ - -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' }); - } -} diff --git a/assets/controllers/elements/datatables/datatables_controller.js b/assets/controllers/elements/datatables/datatables_controller.js index 98b9cf29..9ac23483 100644 --- a/assets/controllers/elements/datatables/datatables_controller.js +++ b/assets/controllers/elements/datatables/datatables_controller.js @@ -113,16 +113,8 @@ 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: columnIndex, + column: order[0], dir: order[1] } }); diff --git a/docs/installation/installation_docker.md b/docs/installation/installation_docker.md index ab07e010..391e1e03 100644 --- a/docs/installation/installation_docker.md +++ b/docs/installation/installation_docker.md @@ -224,52 +224,6 @@ 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 diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php index 4901da48..70be714d 100644 --- a/src/Controller/UpdateManagerController.php +++ b/src/Controller/UpdateManagerController.php @@ -28,7 +28,6 @@ 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; @@ -57,7 +56,6 @@ 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')] @@ -506,100 +504,4 @@ 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); - } } diff --git a/src/EventSubscriber/MaintenanceModeSubscriber.php b/src/EventSubscriber/MaintenanceModeSubscriber.php index 0ba5aa99..654ba9f2 100644 --- a/src/EventSubscriber/MaintenanceModeSubscriber.php +++ b/src/EventSubscriber/MaintenanceModeSubscriber.php @@ -62,8 +62,8 @@ readonly class MaintenanceModeSubscriber implements EventSubscriberInterface return; } - //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())) { + //Allow to view the progress page + if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) { return; } diff --git a/src/Services/System/InstallationType.php b/src/Services/System/InstallationType.php index 2631e644..74479bb9 100644 --- a/src/Services/System/InstallationType.php +++ b/src/Services/System/InstallationType.php @@ -46,7 +46,7 @@ enum InstallationType: string { return match ($this) { self::GIT => true, - self::DOCKER => true, + self::DOCKER => false, // 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 => 'Configure Watchtower for one-click updates, or manually: docker-compose pull && docker-compose up -d', + self::DOCKER => 'Pull the new Docker image and recreate the container: 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.', }; diff --git a/src/Services/System/UpdateChecker.php b/src/Services/System/UpdateChecker.php index 366e8d67..fdb8d9dd 100644 --- a/src/Services/System/UpdateChecker.php +++ b/src/Services/System/UpdateChecker.php @@ -50,8 +50,7 @@ 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, - private readonly ?WatchtowerClient $watchtowerClient = null) + #[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir) { } @@ -285,16 +284,8 @@ class UpdateChecker $updateBlockers[] = 'local_changes'; } - // 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'; + if ($installInfo['type'] === InstallationType::DOCKER) { + $updateBlockers[] = 'docker_installation'; } return [ @@ -310,8 +301,6 @@ class UpdateChecker 'can_auto_update' => $canAutoUpdate, 'update_blockers' => $updateBlockers, 'check_enabled' => $this->privacySettings->checkForUpdates, - 'watchtower_configured' => $watchtowerConfigured, - 'watchtower_available' => $watchtowerAvailable, ]; } diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index ccc346d5..0992663e 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -299,23 +299,6 @@ 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 diff --git a/src/Services/System/WatchtowerClient.php b/src/Services/System/WatchtowerClient.php deleted file mode 100644 index 87cc06fd..00000000 --- a/src/Services/System/WatchtowerClient.php +++ /dev/null @@ -1,125 +0,0 @@ -. - */ - -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 - */ - private function getAuthHeaders(): array - { - return [ - 'Authorization' => 'Bearer ' . $this->apiToken, - ]; - } -} diff --git a/templates/admin/update_manager/docker_progress.html.twig b/templates/admin/update_manager/docker_progress.html.twig deleted file mode 100644 index e43b9afa..00000000 --- a/templates/admin/update_manager/docker_progress.html.twig +++ /dev/null @@ -1,235 +0,0 @@ -{% extends "main_card.html.twig" %} - -{% block title %}{% trans %}update_manager.docker.progress_title{% endtrans %}{% endblock %} - -{% block card_title %} - - {% trans %}update_manager.docker.progress_title{% endtrans %} -{% endblock %} - -{% block card_content %} - -
- - {# Progress Header #} -
-
-
-
- - - -
-
~ ~ ~ ~ ~
-
-
-

- {% trans %}update_manager.docker.updating{% endtrans %} -

-

- {% trans %}update_manager.docker.updating_via_watchtower{% endtrans %} -

-
- - {# Progress Bar #} -
-
- 15% -
-
- - {# Current Step Info #} -
- {% trans %}update_manager.docker.step_trigger{% endtrans %}: - {% trans %}update_manager.docker.step_trigger_desc{% endtrans %} -
- - {# Success Message #} -
- - {% trans %}update_manager.docker.success_message{% endtrans %} -
- {% trans %}update_manager.docker.previous_version{% endtrans %}: - {{ previous_version }} - → - {% trans %}update_manager.docker.new_version{% endtrans %}: - ... -
- - {# Timeout Message #} -
- - {% trans %}update_manager.docker.timeout_message{% endtrans %} -
- - {# Error Message #} -
- - {% trans %}update_manager.progress.error{% endtrans %}: - -
- - {# Steps Timeline - matches git progress style #} -
-
- {% trans %}update_manager.docker.steps{% endtrans %} -
-
-
    - {# Step 1: Trigger Watchtower #} -
  • - -
    - {% trans %}update_manager.docker.step_trigger{% endtrans %} -
    {% trans %}update_manager.docker.step_trigger_desc{% endtrans %} -
    - -
  • - - {# Step 2: Pull Image #} -
  • - -
    - {% trans %}update_manager.docker.step_pull{% endtrans %} -
    {% trans %}update_manager.docker.step_pull_desc{% endtrans %} -
    - -
  • - - {# Step 3: Stop Container #} -
  • - -
    - {% trans %}update_manager.docker.step_stop{% endtrans %} -
    {% trans %}update_manager.docker.step_stop_desc{% endtrans %} -
    - -
  • - - {# Step 4: Restart Container #} -
  • - -
    - {% trans %}update_manager.docker.step_restart{% endtrans %} -
    {% trans %}update_manager.docker.step_restart_desc{% endtrans %} -
    - -
  • - - {# Step 5: Health Check #} -
  • - -
    - {% trans %}update_manager.docker.step_health{% endtrans %} -
    {% trans %}update_manager.docker.step_health_desc{% endtrans %} -
    - -
  • - - {# Step 6: Verify Version #} -
  • - -
    - {% trans %}update_manager.docker.step_verify{% endtrans %} -
    {% trans %}update_manager.docker.step_verify_desc{% endtrans %} -
    - -
  • -
-
-
- - {# Elapsed Time #} -
- - {% trans %}update_manager.docker.elapsed{% endtrans %}: - 0s -
- - {# Actions - shown after completion or timeout #} - - - {# Warning Notice #} -
- - {% trans %}update_manager.docker.warning{% endtrans %}: - {% trans %}update_manager.docker.do_not_close{% endtrans %} -
-
-{% endblock %} diff --git a/templates/admin/update_manager/index.html.twig b/templates/admin/update_manager/index.html.twig index 0b4eeceb..2c6db63c 100644 --- a/templates/admin/update_manager/index.html.twig +++ b/templates/admin/update_manager/index.html.twig @@ -75,20 +75,6 @@ {{ status.installation.type_name }} - - - {% trans %}update_manager.web_updates_allowed{% endtrans %} - {{ helper.boolean_badge(not web_updates_disabled) }} - - - {% trans %}update_manager.backup_restore_allowed{% endtrans %} - {{ helper.boolean_badge(not backup_restore_disabled) }} - - - {% trans %}update_manager.backup_download_allowed{% endtrans %} - {{ helper.boolean_badge(not backup_download_disabled) }} - - {% if status.git.is_git_install %} {% trans %}update_manager.git_branch{% endtrans %} @@ -113,35 +99,25 @@ {% endif %} - {% if is_docker %} - {# Docker: show Watchtower status #} - - {% trans %}update_manager.docker.watchtower_status{% endtrans %} - - {% if status.watchtower_configured|default(false) and status.watchtower_available|default(false) %} - - {% trans %}update_manager.docker.watchtower_connected{% endtrans %} - - {% elseif status.watchtower_configured|default(false) %} - - {% trans %}update_manager.docker.watchtower_unreachable_short{% endtrans %} - - {% else %} - - {% trans %}update_manager.docker.watchtower_not_configured{% endtrans %} - - {% endif %} - - - {% else %} - {# Git/other: show update readiness #} - - {% trans %}update_manager.auto_update_supported{% endtrans %} - - {{ helper.boolean_badge(status.can_auto_update) }} - - - {% endif %} + + {% trans %}update_manager.auto_update_supported{% endtrans %} + + {{ helper.boolean_badge(status.can_auto_update) }} + + + + + {% trans %}update_manager.web_updates_allowed{% endtrans %} + {{ helper.boolean_badge(not web_updates_disabled) }} + + + {% trans %}update_manager.backup_restore_allowed{% endtrans %} + {{ helper.boolean_badge(not backup_restore_disabled) }} + + + {% trans %}update_manager.backup_download_allowed{% endtrans %} + {{ helper.boolean_badge(not backup_download_disabled) }} + @@ -182,63 +158,30 @@ {% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %} - {% if is_docker %} - {# Docker update via Watchtower #} -
- + + + -
- -
+
+ +
-
- - -
- -
- - {% trans %}update_manager.docker.no_rollback_warning{% endtrans %} -
-
- {% else %} - {# Git update #} -
- - - -
- -
- -
- - -
-
- {% endif %} +
+ + +
+ {% endif %} {% if status.published_at %} @@ -286,55 +229,12 @@ {# Non-auto-update installations info #} {% if not status.can_auto_update %} - {% if is_docker and not status.watchtower_configured|default(false) %} - {# Docker without Watchtower - show setup instructions #} -
-
- {% trans %}update_manager.docker.setup_title{% endtrans %} -
-
-

{% trans %}update_manager.docker.setup_description{% endtrans %}

- -
{% trans %}update_manager.docker.setup_step1{% endtrans %}
-
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"
- -
{% trans %}update_manager.docker.setup_step2{% endtrans %}
-
WATCHTOWER_API_URL=http://watchtower:8080
-WATCHTOWER_API_TOKEN=your-secret-token
- -
- - {% trans %}update_manager.docker.setup_network_hint{% endtrans %} -
-
-
- {% elseif is_docker and status.watchtower_configured|default(false) and not status.watchtower_available|default(false) %} - {# Docker with Watchtower configured but not reachable #} -
-
- {% trans %}update_manager.docker.watchtower_unreachable_title{% endtrans %} -
-

{% trans %}update_manager.docker.watchtower_unreachable_description{% endtrans %}

-
- {% else %} - {# Other non-auto-update installations (ZIP, unknown) #} -
-
- {% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }} -
-

{{ status.installation.update_instructions }}

-
- {% endif %} +
+
+ {% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }} +
+

{{ status.installation.update_instructions }}

+
{% endif %}
@@ -377,9 +277,6 @@ WATCHTOWER_API_TOKEN=your-secret-token {% 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 %}
{% endif %}
- {% endif %} {% endif %}
diff --git a/templates/bundles/TwigBundle/Exception/error.html.twig b/templates/bundles/TwigBundle/Exception/error.html.twig index 936f5ca3..efdba462 100644 --- a/templates/bundles/TwigBundle/Exception/error.html.twig +++ b/templates/bundles/TwigBundle/Exception/error.html.twig @@ -17,7 +17,7 @@ Consider yourself lucky. You found some rare error code.
You should maybe inform your administrator about it... {% endblock %} - {% block further_actions %}

You can try to Go Back or Visit the homepage.

{% endblock %} + {% block further_actions %}

You can try to Go Back or Visit the homepage.

{% endblock %} {% block admin_contact %}

If this error persists, please contact your {% if error_page_admin_email is not empty %} administrator. diff --git a/tests/Services/System/WatchtowerClientTest.php b/tests/Services/System/WatchtowerClientTest.php deleted file mode 100644 index 1de4bd2c..00000000 --- a/tests/Services/System/WatchtowerClientTest.php +++ /dev/null @@ -1,197 +0,0 @@ -. - */ - -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(); - } -} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index d5f5c183..1a37106f 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13055,312 +13055,6 @@ Buerklin-API Authentication server: Backup download allowed - - - update_manager.docker.setup_title - Enable One-Click Docker Updates with Watchtower - - - - - update_manager.docker.setup_description - 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. - - - - - update_manager.docker.setup_step1 - 1. Add Watchtower to your docker-compose.yml: - - - - - update_manager.docker.setup_step2 - 2. Add these environment variables to your Part-DB container: - - - - - update_manager.docker.setup_network_hint - 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. - - - - - update_manager.docker.watchtower_unreachable_title - Watchtower Not Reachable - - - - - update_manager.docker.watchtower_unreachable_description - Watchtower is configured but cannot be reached. Please verify that the Watchtower container is running and that the API URL and token are correct. - - - - - update_manager.docker.confirm_update - 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. - - - - - update_manager.docker.update_via_watchtower - Update via Watchtower to - - - - - update_manager.docker.no_rollback_warning - Docker updates cannot be automatically rolled back. A database backup will be created before updating so you can restore your data if needed. - - - - - update_manager.docker.progress_title - Docker Update in Progress - - - - - update_manager.docker.waiting_for_watchtower - Waiting for Watchtower to pull the new image... - - - - - update_manager.docker.elapsed - Elapsed - - - - - update_manager.docker.waiting_title - Update Triggered - - - - - update_manager.docker.waiting_description - Watchtower has been notified. It will pull the latest Docker image and restart the Part-DB container. - - - - - update_manager.docker.watchtower_working - Watchtower is processing the update... - - - - - update_manager.docker.watchtower_working_hint - This may take a few minutes depending on your internet speed and image size. - - - - - update_manager.docker.restarting_title - Container Restarting - - - - - update_manager.docker.restarting_description - Watchtower has pulled the new image and is restarting the Part-DB container. - - - - - update_manager.docker.restarting_hint - The page will automatically detect when the server comes back online. This usually takes 10-30 seconds. - - - - - update_manager.docker.success_title - Update Complete! - - - - - update_manager.docker.success_message - Part-DB has been successfully updated via Watchtower. - - - - - update_manager.docker.previous_version - Previous version - - - - - update_manager.docker.new_version - New version - - - - - update_manager.docker.back_to_update_manager - Back to Update Manager - - - - - update_manager.docker.go_to_homepage - Go to Homepage - - - - - update_manager.docker.timeout_title - Update Taking Longer Than Expected - - - - - update_manager.docker.timeout_message - The update is taking longer than expected. Check the Watchtower container logs for details. The update may still be in progress. - - - - - update_manager.docker.retry - Retry - - - - - update_manager.docker.warning - Warning - - - - - update_manager.docker.do_not_close - Do not close this page. It will automatically detect when the update is complete. - - - - - update_manager.docker.updating_via_watchtower - Updating via Watchtower - - - - - update_manager.docker.step_waiting - Pulling Image - - - - - update_manager.docker.steps - Update Steps - - - - - update_manager.docker.step_trigger - Trigger Update - - - - - update_manager.docker.step_trigger_desc - Watchtower has been notified to check for updates - - - - - update_manager.docker.step_pull - Pull New Image - - - - - update_manager.docker.step_pull_desc - Downloading the latest Docker image from the registry - - - - - update_manager.docker.step_restart - Restart Container - - - - - update_manager.docker.step_restart_desc - Stopping old container and starting new one - - - - - update_manager.docker.step_verify - Verify - - - - - update_manager.docker.step_verify_desc - Confirming Part-DB is running on the new version - - - - - update_manager.docker.watchtower_status - Watchtower - - - - - update_manager.docker.watchtower_connected - Connected - - - - - update_manager.docker.watchtower_unreachable_short - Unreachable - - - - - update_manager.docker.watchtower_not_configured - Not configured - - - - - update_manager.docker.step_stop - Stop Container - - - - - update_manager.docker.step_stop_desc - Gracefully stopping the current container before recreation - - - - - update_manager.docker.step_health - Health Check - - - - - update_manager.docker.step_health_desc - Waiting for the new container to pass health checks - - - - - update_manager.docker.updating - Updating Part-DB via Docker... - - part.create_from_info_provider.lot_filled_from_barcode