+ */
+ 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
new file mode 100644
index 000000000..e43b9afa0
--- /dev/null
+++ b/templates/admin/update_manager/docker_progress.html.twig
@@ -0,0 +1,235 @@
+{% 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 #}
+
+
+ {# 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 #}
+
+
+
+
+ {# 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 2c6db63c8..0d3c88e59 100644
--- a/templates/admin/update_manager/index.html.twig
+++ b/templates/admin/update_manager/index.html.twig
@@ -99,25 +99,35 @@
{% 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) }}
-
+ {% 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 %}
@@ -158,30 +168,63 @@
{% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %}
-
+
+
+
+ {% trans %}update_manager.create_backup{% endtrans %}
+
+
+
+
+
+ {% trans %}update_manager.docker.no_rollback_warning{% endtrans %}
+
+
+ {% else %}
+ {# Git update #}
+
+ {% endif %}
{% endif %}
{% if status.published_at %}
@@ -229,12 +272,55 @@
{# Non-auto-update installations info #}
{% if not status.can_auto_update %}
-
-
- {% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
-
-
{{ status.installation.update_instructions }}
-
+ {% if is_docker and not status.watchtower_configured|default(false) %}
+ {# Docker without Watchtower - show setup instructions #}
+
+
+
+
{% 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 %}
{% endif %}
@@ -277,6 +363,9 @@
{% 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 %}
diff --git a/templates/bundles/TwigBundle/Exception/error.html.twig b/templates/bundles/TwigBundle/Exception/error.html.twig
index efdba462a..936f5ca3a 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
new file mode 100644
index 000000000..1de4bd2c4
--- /dev/null
+++ b/tests/Services/System/WatchtowerClientTest.php
@@ -0,0 +1,197 @@
+.
+ */
+
+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 ce92bda69..acd346a75 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -12941,6 +12941,312 @@ 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