mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-10 15:12:12 +00:00
Add Docker update support via Watchtower integration
Add web-based Docker container updates using Watchtower HTTP API. When configured with WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN environment variables, administrators can trigger container updates from the Update Manager page. Features: - WatchtowerClient service for Watchtower HTTP API communication - Docker update progress page with animated Docker whale logo - Real-time step tracking: Trigger, Pull, Stop, Restart, Health Check, Verify - CSP-compatible progress bar using CSS classes - Translated UI strings via Stimulus values - Health endpoint polling to detect container restart - Watchtower setup documentation for Docker installations - WatchtowerClient made nullable for non-Docker installations - Unit tests for WatchtowerClient
This commit is contained in:
parent
4206b702ff
commit
3cdd085d3b
14 changed files with 1553 additions and 55 deletions
6
.env
6
.env
|
|
@ -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
|
||||
###################################################################################
|
||||
|
|
|
|||
377
assets/controllers/docker_update_progress_controller.js
Normal file
377
assets/controllers/docker_update_progress_controller.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
|
|
@ -219,6 +219,51 @@ docker-compose up -d
|
|||
docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate
|
||||
```
|
||||
|
||||
### Automatic updates via Watchtower (Web UI)
|
||||
|
||||
Part-DB supports triggering Docker container updates directly from the web interface using [Watchtower](https://containrrr.dev/watchtower/).
|
||||
When configured, administrators can check for and apply updates from the **System > Update Manager** page.
|
||||
|
||||
To enable this feature, add a Watchtower service to your `docker-compose.yaml` and configure the connection:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
partdb:
|
||||
container_name: partdb
|
||||
image: jbtronics/part-db1:latest
|
||||
labels:
|
||||
- com.centurylinklabs.watchtower.enable=true
|
||||
environment:
|
||||
# ... your existing environment variables ...
|
||||
|
||||
# Watchtower integration for web-based updates
|
||||
- WATCHTOWER_API_URL=http://watchtower:8080
|
||||
- WATCHTOWER_API_TOKEN=your-secret-token
|
||||
# ... your existing ports/volumes ...
|
||||
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
container_name: watchtower
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- WATCHTOWER_HTTP_API_UPDATE=true
|
||||
- WATCHTOWER_HTTP_API_TOKEN=your-secret-token
|
||||
- WATCHTOWER_LABEL_ENABLE=true
|
||||
- WATCHTOWER_CLEANUP=true
|
||||
ports:
|
||||
- '8081:8080'
|
||||
```
|
||||
|
||||
{: .important }
|
||||
> Replace `your-secret-token` with a strong, unique token. The same token must be set in both the Part-DB (`WATCHTOWER_API_TOKEN`) and Watchtower (`WATCHTOWER_HTTP_API_TOKEN`) environment variables.
|
||||
|
||||
{: .info }
|
||||
> `WATCHTOWER_LABEL_ENABLE=true` ensures Watchtower only manages containers with the `com.centurylinklabs.watchtower.enable=true` label, preventing it from updating other containers on the same host.
|
||||
|
||||
Once configured, the Update Manager page will show the Watchtower connection status and provide an **Update via Watchtower** button when a new version is available. Clicking it triggers Watchtower to pull the latest image and recreate the Part-DB container automatically.
|
||||
|
||||
## Direct use of docker image
|
||||
|
||||
You can use the `jbtronics/part-db1:master` image directly. You have to expose port 80 to a host port and configure
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use App\Services\System\BackupManager;
|
|||
use App\Services\System\InstallationTypeDetector;
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use App\Services\System\WatchtowerClient;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
|
@ -56,6 +57,7 @@ class UpdateManagerController extends AbstractController
|
|||
private readonly BackupManager $backupManager,
|
||||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly WatchtowerClient $watchtowerClient,
|
||||
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
|
||||
private readonly bool $webUpdatesDisabled = false,
|
||||
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
|
||||
|
|
@ -504,4 +506,91 @@ class UpdateManagerController extends AbstractController
|
|||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Docker update via Watchtower.
|
||||
*/
|
||||
#[Route('/start-docker', name: 'admin_update_manager_start_docker', methods: ['POST'])]
|
||||
public function startDockerUpdate(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfWebUpdatesDisabled();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_start_docker', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Check if Watchtower is configured and available
|
||||
if (!$this->watchtowerClient->isConfigured()) {
|
||||
$this->addFlash('error', 'Watchtower is not configured. Please set WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
if (!$this->watchtowerClient->isAvailable()) {
|
||||
$this->addFlash('error', 'Watchtower is not reachable. Please check that the Watchtower container is running and accessible.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Create backup if requested
|
||||
$createBackup = $request->request->getBoolean('backup', true);
|
||||
if ($createBackup) {
|
||||
try {
|
||||
$this->backupManager->createBackup();
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('error', 'Failed to create backup before update: ' . $e->getMessage());
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger Watchtower update
|
||||
$success = $this->watchtowerClient->triggerUpdate();
|
||||
|
||||
if (!$success) {
|
||||
$this->addFlash('error', 'Failed to trigger Watchtower update. Check the logs for details.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$currentVersion = $this->versionManager->getVersion()->toString();
|
||||
|
||||
// Redirect to Docker progress page
|
||||
return $this->redirectToRoute('admin_update_manager_docker_progress', [
|
||||
'previous_version' => $currentVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker update progress page.
|
||||
* This page contains client-side JavaScript that polls until the container restarts.
|
||||
*/
|
||||
#[Route('/progress/docker', name: 'admin_update_manager_docker_progress', methods: ['GET'])]
|
||||
public function dockerProgress(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
$previousVersion = $request->query->get('previous_version', 'unknown');
|
||||
|
||||
return $this->render('admin/update_manager/docker_progress.html.twig', [
|
||||
'previous_version' => $previousVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight health check endpoint used by Docker update progress page.
|
||||
* Returns current version so the client-side JS can detect when the container restarts with a new version.
|
||||
*
|
||||
* Intentionally unauthenticated: after a Docker container restart, the user's session may not survive
|
||||
* (depends on session storage backend). The version string is non-sensitive public information.
|
||||
* This endpoint is also whitelisted in MaintenanceModeSubscriber.
|
||||
*/
|
||||
#[Route('/health', name: 'admin_update_manager_health', methods: ['GET'])]
|
||||
public function healthCheck(): JsonResponse
|
||||
{
|
||||
return $this->json([
|
||||
'status' => 'ok',
|
||||
'version' => $this->versionManager->getVersion()->toString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
125
src/Services/System/WatchtowerClient.php
Normal file
125
src/Services/System/WatchtowerClient.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
235
templates/admin/update_manager/docker_progress.html.twig
Normal file
235
templates/admin/update_manager/docker_progress.html.twig
Normal 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>
|
||||
→
|
||||
<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 %}
|
||||
|
|
@ -99,25 +99,35 @@
|
|||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
|
||||
<td>
|
||||
{{ helper.boolean_badge(status.can_auto_update) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.web_updates_allowed{% endtrans %}</th>
|
||||
<td>{{ helper.boolean_badge(not web_updates_disabled) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.backup_restore_allowed{% endtrans %}</th>
|
||||
<td>{{ helper.boolean_badge(not backup_restore_disabled) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.backup_download_allowed{% endtrans %}</th>
|
||||
<td>{{ helper.boolean_badge(not backup_download_disabled) }}</td>
|
||||
</tr>
|
||||
{% if is_docker %}
|
||||
{# Docker: show Watchtower status #}
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.docker.watchtower_status{% endtrans %}</th>
|
||||
<td>
|
||||
{% if status.watchtower_configured|default(false) and status.watchtower_available|default(false) %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check me-1"></i>{% trans %}update_manager.docker.watchtower_connected{% endtrans %}
|
||||
</span>
|
||||
{% elseif status.watchtower_configured|default(false) %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>{% trans %}update_manager.docker.watchtower_unreachable_short{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="fas fa-times me-1"></i>{% trans %}update_manager.docker.watchtower_not_configured{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{# Git/other: show update readiness #}
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
|
||||
<td>
|
||||
{{ helper.boolean_badge(status.can_auto_update) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -158,30 +168,63 @@
|
|||
</div>
|
||||
|
||||
{% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %}
|
||||
<form action="{{ path('admin_update_manager_start') }}" method="post"
|
||||
data-controller="update-confirm"
|
||||
data-update-confirm-is-downgrade-value="false"
|
||||
data-update-confirm-target-version-value="{{ status.latest_tag }}"
|
||||
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
|
||||
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
|
||||
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
|
||||
<input type="hidden" name="version" value="{{ status.latest_tag }}">
|
||||
{% if is_docker %}
|
||||
{# Docker update via Watchtower #}
|
||||
<form action="{{ path('admin_update_manager_start_docker') }}" method="post"
|
||||
data-controller="update-confirm"
|
||||
data-update-confirm-is-downgrade-value="false"
|
||||
data-update-confirm-target-version-value="{{ status.latest_tag }}"
|
||||
data-update-confirm-confirm-update-value="{{ 'update_manager.docker.confirm_update'|trans }}"
|
||||
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
|
||||
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start_docker') }}">
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-success btn-lg">
|
||||
<i class="fas fa-download me-2"></i>
|
||||
{% trans %}update_manager.update_to{% endtrans %} {{ status.latest_tag }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-success btn-lg">
|
||||
<i class="fab fa-docker me-2"></i>
|
||||
{% trans %}update_manager.docker.update_via_watchtower{% endtrans %} {{ status.latest_tag }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup" checked>
|
||||
<label class="form-check-label" for="create-backup">
|
||||
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup-docker" checked>
|
||||
<label class="form-check-label" for="create-backup-docker">
|
||||
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3 mb-0 small">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
{% trans %}update_manager.docker.no_rollback_warning{% endtrans %}
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
{# Git update #}
|
||||
<form action="{{ path('admin_update_manager_start') }}" method="post"
|
||||
data-controller="update-confirm"
|
||||
data-update-confirm-is-downgrade-value="false"
|
||||
data-update-confirm-target-version-value="{{ status.latest_tag }}"
|
||||
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
|
||||
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
|
||||
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
|
||||
<input type="hidden" name="version" value="{{ status.latest_tag }}">
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-success btn-lg">
|
||||
<i class="fas fa-download me-2"></i>
|
||||
{% trans %}update_manager.update_to{% endtrans %} {{ status.latest_tag }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup" checked>
|
||||
<label class="form-check-label" for="create-backup">
|
||||
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if status.published_at %}
|
||||
|
|
@ -229,12 +272,55 @@
|
|||
|
||||
{# Non-auto-update installations info #}
|
||||
{% if not status.can_auto_update %}
|
||||
<div class="alert alert-secondary">
|
||||
<h6 class="alert-heading">
|
||||
<i class="fas fa-info-circle me-2"></i>{% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
|
||||
</h6>
|
||||
<p class="mb-0">{{ status.installation.update_instructions }}</p>
|
||||
</div>
|
||||
{% if is_docker and not status.watchtower_configured|default(false) %}
|
||||
{# Docker without Watchtower - show setup instructions #}
|
||||
<div class="card border-info mb-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<i class="fab fa-docker me-2"></i>{% trans %}update_manager.docker.setup_title{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>{% trans %}update_manager.docker.setup_description{% endtrans %}</p>
|
||||
|
||||
<h6>{% trans %}update_manager.docker.setup_step1{% endtrans %}</h6>
|
||||
<pre class="bg-dark text-light p-3 rounded"><code>services:
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- WATCHTOWER_HTTP_API_UPDATE=true
|
||||
- WATCHTOWER_HTTP_API_TOKEN=your-secret-token
|
||||
- WATCHTOWER_LABEL_ENABLE=true
|
||||
ports:
|
||||
- "8080:8080"</code></pre>
|
||||
|
||||
<h6>{% trans %}update_manager.docker.setup_step2{% endtrans %}</h6>
|
||||
<pre class="bg-dark text-light p-3 rounded"><code>WATCHTOWER_API_URL=http://watchtower:8080
|
||||
WATCHTOWER_API_TOKEN=your-secret-token</code></pre>
|
||||
|
||||
<div class="alert alert-warning mb-0 mt-3">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{% trans %}update_manager.docker.setup_network_hint{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elseif is_docker and status.watchtower_configured|default(false) and not status.watchtower_available|default(false) %}
|
||||
{# Docker with Watchtower configured but not reachable #}
|
||||
<div class="alert alert-warning">
|
||||
<h6 class="alert-heading">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>{% trans %}update_manager.docker.watchtower_unreachable_title{% endtrans %}
|
||||
</h6>
|
||||
<p class="mb-0">{% trans %}update_manager.docker.watchtower_unreachable_description{% endtrans %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Other non-auto-update installations (ZIP, unknown) #}
|
||||
<div class="alert alert-secondary">
|
||||
<h6 class="alert-heading">
|
||||
<i class="fas fa-info-circle me-2"></i>{% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
|
||||
</h6>
|
||||
<p class="mb-0">{{ status.installation.update_instructions }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
|
|
@ -277,6 +363,9 @@
|
|||
<i class="fas fa-file-alt"></i>
|
||||
</a>
|
||||
{% if release.version != status.current_version and status.can_auto_update and validation.valid and not web_updates_disabled %}
|
||||
{% if is_docker %}
|
||||
{# Docker: version switching not supported, only update to latest via Watchtower #}
|
||||
{% else %}
|
||||
<form action="{{ path('admin_update_manager_start') }}" method="post" class="d-inline"
|
||||
data-controller="update-confirm"
|
||||
data-update-confirm-is-downgrade-value="{{ release.version < status.current_version ? 'true' : 'false' }}"
|
||||
|
|
@ -297,6 +386,7 @@
|
|||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
197
tests/Services/System/WatchtowerClientTest.php
Normal file
197
tests/Services/System/WatchtowerClientTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -12941,6 +12941,312 @@ Buerklin-API Authentication server:
|
|||
<target>Backup download allowed</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_setup_title" name="update_manager.docker.setup_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_title</source>
|
||||
<target>Enable One-Click Docker Updates with Watchtower</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_setup_desc" name="update_manager.docker.setup_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_description</source>
|
||||
<target>Part-DB can update your Docker container automatically using Watchtower, an open-source container updater. Add Watchtower as a companion container and configure the connection below.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_setup_s1" name="update_manager.docker.setup_step1">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_step1</source>
|
||||
<target>1. Add Watchtower to your docker-compose.yml:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_setup_s2" name="update_manager.docker.setup_step2">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_step2</source>
|
||||
<target>2. Add these environment variables to your Part-DB container:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_setup_net" name="update_manager.docker.setup_network_hint">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_network_hint</source>
|
||||
<target>Make sure Part-DB and Watchtower are on the same Docker network. If you use label-based filtering in Watchtower (WATCHTOWER_LABEL_ENABLE=true), add the label "com.centurylinklabs.watchtower.enable=true" to your Part-DB container.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_unreach_t" name="update_manager.docker.watchtower_unreachable_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_unreachable_title</source>
|
||||
<target>Watchtower Not Reachable</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_unreach_d" name="update_manager.docker.watchtower_unreachable_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_unreachable_description</source>
|
||||
<target>Watchtower is configured but cannot be reached. Please verify that the Watchtower container is running and that the API URL and token are correct.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_confirm" name="update_manager.docker.confirm_update">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.confirm_update</source>
|
||||
<target>Are you sure you want to update Part-DB via Watchtower? The container will be restarted with the new image. Unlike Git updates, Docker updates cannot be automatically rolled back.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_update_btn" name="update_manager.docker.update_via_watchtower">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.update_via_watchtower</source>
|
||||
<target>Update via Watchtower to</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_no_rollback" name="update_manager.docker.no_rollback_warning">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.no_rollback_warning</source>
|
||||
<target>Docker updates cannot be automatically rolled back. A database backup will be created before updating so you can restore your data if needed.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_prog_title" name="update_manager.docker.progress_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.progress_title</source>
|
||||
<target>Docker Update in Progress</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_waiting" name="update_manager.docker.waiting_for_watchtower">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.waiting_for_watchtower</source>
|
||||
<target>Waiting for Watchtower to pull the new image...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_elapsed" name="update_manager.docker.elapsed">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.elapsed</source>
|
||||
<target>Elapsed</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_wait_t" name="update_manager.docker.waiting_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.waiting_title</source>
|
||||
<target>Update Triggered</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_wait_d" name="update_manager.docker.waiting_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.waiting_description</source>
|
||||
<target>Watchtower has been notified. It will pull the latest Docker image and restart the Part-DB container.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_working" name="update_manager.docker.watchtower_working">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_working</source>
|
||||
<target>Watchtower is processing the update...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_work_hint" name="update_manager.docker.watchtower_working_hint">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_working_hint</source>
|
||||
<target>This may take a few minutes depending on your internet speed and image size.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_restart_t" name="update_manager.docker.restarting_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.restarting_title</source>
|
||||
<target>Container Restarting</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_restart_d" name="update_manager.docker.restarting_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.restarting_description</source>
|
||||
<target>Watchtower has pulled the new image and is restarting the Part-DB container.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_restart_h" name="update_manager.docker.restarting_hint">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.restarting_hint</source>
|
||||
<target>The page will automatically detect when the server comes back online. This usually takes 10-30 seconds.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_success_t" name="update_manager.docker.success_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.success_title</source>
|
||||
<target>Update Complete!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_success_m" name="update_manager.docker.success_message">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.success_message</source>
|
||||
<target>Part-DB has been successfully updated via Watchtower.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_prev_ver" name="update_manager.docker.previous_version">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.previous_version</source>
|
||||
<target>Previous version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_new_ver" name="update_manager.docker.new_version">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.new_version</source>
|
||||
<target>New version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_back" name="update_manager.docker.back_to_update_manager">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.back_to_update_manager</source>
|
||||
<target>Back to Update Manager</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_home" name="update_manager.docker.go_to_homepage">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.go_to_homepage</source>
|
||||
<target>Go to Homepage</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_timeout_t" name="update_manager.docker.timeout_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.timeout_title</source>
|
||||
<target>Update Taking Longer Than Expected</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_timeout_m" name="update_manager.docker.timeout_message">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.timeout_message</source>
|
||||
<target>The update is taking longer than expected. Check the Watchtower container logs for details. The update may still be in progress.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_retry" name="update_manager.docker.retry">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.retry</source>
|
||||
<target>Retry</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_warning" name="update_manager.docker.warning">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.warning</source>
|
||||
<target>Warning</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_no_close" name="update_manager.docker.do_not_close">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.do_not_close</source>
|
||||
<target>Do not close this page. It will automatically detect when the update is complete.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_via" name="update_manager.docker.updating_via_watchtower">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.updating_via_watchtower</source>
|
||||
<target>Updating via Watchtower</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_wait" name="update_manager.docker.step_waiting">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_waiting</source>
|
||||
<target>Pulling Image</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_steps" name="update_manager.docker.steps">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.steps</source>
|
||||
<target>Update Steps</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_trig" name="update_manager.docker.step_trigger">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_trigger</source>
|
||||
<target>Trigger Update</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_trig_d" name="update_manager.docker.step_trigger_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_trigger_desc</source>
|
||||
<target>Watchtower has been notified to check for updates</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_pull" name="update_manager.docker.step_pull">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_pull</source>
|
||||
<target>Pull New Image</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_pull_d" name="update_manager.docker.step_pull_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_pull_desc</source>
|
||||
<target>Downloading the latest Docker image from the registry</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_rest" name="update_manager.docker.step_restart">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_restart</source>
|
||||
<target>Restart Container</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_rest_d" name="update_manager.docker.step_restart_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_restart_desc</source>
|
||||
<target>Stopping old container and starting new one</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_ver" name="update_manager.docker.step_verify">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_verify</source>
|
||||
<target>Verify</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_ver_d" name="update_manager.docker.step_verify_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_verify_desc</source>
|
||||
<target>Confirming Part-DB is running on the new version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_status" name="update_manager.docker.watchtower_status">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_status</source>
|
||||
<target>Watchtower</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_connected" name="update_manager.docker.watchtower_connected">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_connected</source>
|
||||
<target>Connected</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_unreach_s" name="update_manager.docker.watchtower_unreachable_short">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_unreachable_short</source>
|
||||
<target>Unreachable</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_not_conf" name="update_manager.docker.watchtower_not_configured">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_not_configured</source>
|
||||
<target>Not configured</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_stop" name="update_manager.docker.step_stop">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_stop</source>
|
||||
<target>Stop Container</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_stop_d" name="update_manager.docker.step_stop_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_stop_desc</source>
|
||||
<target>Gracefully stopping the current container before recreation</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_health" name="update_manager.docker.step_health">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_health</source>
|
||||
<target>Health Check</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_step_health_d" name="update_manager.docker.step_health_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_health_desc</source>
|
||||
<target>Waiting for the new container to pass health checks</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wt_updating" name="update_manager.docker.updating">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.updating</source>
|
||||
<target>Updating Part-DB via Docker...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="b8JxfcX" name="part.create_from_info_provider.lot_filled_from_barcode">
|
||||
<segment state="translated">
|
||||
<source>part.create_from_info_provider.lot_filled_from_barcode</source>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue