mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-11 12:09:36 +00:00
Add Update Manager for automated Part-DB updates
This feature adds a comprehensive Update Manager similar to Mainsail's update system, allowing administrators to update Part-DB directly from the web interface. Features: - Web UI at /admin/update-manager showing current and available versions - Support for Git-based installations with automatic update execution - Maintenance mode during updates to prevent user access - Automatic database backup before updates - Git rollback points for recovery (tags created before each update) - Progress tracking with real-time status updates - Update history and log viewing - Downgrade support with appropriate UI messaging - CLI command `php bin/console partdb:update` for server-side updates New files: - UpdateManagerController: Handles all web UI routes - UpdateCommand: CLI command for running updates - UpdateExecutor: Core update execution logic with safety mechanisms - UpdateChecker: GitHub API integration for version checking - InstallationTypeDetector: Detects installation type (Git/Docker/ZIP) - MaintenanceModeSubscriber: Blocks user access during maintenance - UpdateExtension: Twig functions for update notifications UI improvements: - Update notification in navbar for admins when update available - Confirmation dialogs for update/downgrade actions - Downgrade-specific text throughout the interface - Progress page with auto-refresh
This commit is contained in:
parent
ae4c0786b2
commit
42fe781ef8
16 changed files with 4126 additions and 0 deletions
|
|
@ -297,6 +297,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
|||
show_updates:
|
||||
label: "perm.system.show_available_updates"
|
||||
apiTokenRole: ROLE_API_ADMIN
|
||||
manage_updates:
|
||||
label: "perm.system.manage_updates"
|
||||
alsoSet: ['show_updates', 'server_infos']
|
||||
apiTokenRole: ROLE_API_ADMIN
|
||||
|
||||
|
||||
attachments:
|
||||
|
|
|
|||
446
src/Command/UpdateCommand.php
Normal file
446
src/Command/UpdateCommand.php
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Command;
|
||||
|
||||
use App\Services\System\InstallationType;
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'partdb:update', description: 'Check for and install Part-DB updates', aliases: ['app:update'])]
|
||||
class UpdateCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly UpdateChecker $updateChecker,
|
||||
private readonly UpdateExecutor $updateExecutor)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHelp(<<<'HELP'
|
||||
The <info>%command.name%</info> command checks for Part-DB updates and can install them.
|
||||
|
||||
<comment>Check for updates:</comment>
|
||||
<info>php %command.full_name% --check</info>
|
||||
|
||||
<comment>List available versions:</comment>
|
||||
<info>php %command.full_name% --list</info>
|
||||
|
||||
<comment>Update to the latest version:</comment>
|
||||
<info>php %command.full_name%</info>
|
||||
|
||||
<comment>Update to a specific version:</comment>
|
||||
<info>php %command.full_name% v2.6.0</info>
|
||||
|
||||
<comment>Update without creating a backup (faster but riskier):</comment>
|
||||
<info>php %command.full_name% --no-backup</info>
|
||||
|
||||
<comment>Non-interactive update for scripts:</comment>
|
||||
<info>php %command.full_name% --force</info>
|
||||
|
||||
<comment>View update logs:</comment>
|
||||
<info>php %command.full_name% --logs</info>
|
||||
HELP
|
||||
)
|
||||
->addArgument(
|
||||
'version',
|
||||
InputArgument::OPTIONAL,
|
||||
'Target version to update to (e.g., v2.6.0). If not specified, updates to the latest stable version.'
|
||||
)
|
||||
->addOption(
|
||||
'check',
|
||||
'c',
|
||||
InputOption::VALUE_NONE,
|
||||
'Only check for updates without installing'
|
||||
)
|
||||
->addOption(
|
||||
'list',
|
||||
'l',
|
||||
InputOption::VALUE_NONE,
|
||||
'List all available versions'
|
||||
)
|
||||
->addOption(
|
||||
'no-backup',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Skip creating a backup before updating (not recommended)'
|
||||
)
|
||||
->addOption(
|
||||
'force',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'Skip confirmation prompts'
|
||||
)
|
||||
->addOption(
|
||||
'include-prerelease',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Include pre-release versions'
|
||||
)
|
||||
->addOption(
|
||||
'logs',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Show recent update logs'
|
||||
)
|
||||
->addOption(
|
||||
'refresh',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Force refresh of cached version information'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// Handle --logs option
|
||||
if ($input->getOption('logs')) {
|
||||
return $this->showLogs($io);
|
||||
}
|
||||
|
||||
// Handle --refresh option
|
||||
if ($input->getOption('refresh')) {
|
||||
$io->text('Refreshing version information...');
|
||||
$this->updateChecker->refreshGitInfo();
|
||||
$io->success('Version cache cleared.');
|
||||
}
|
||||
|
||||
// Handle --list option
|
||||
if ($input->getOption('list')) {
|
||||
return $this->listVersions($io, $input->getOption('include-prerelease'));
|
||||
}
|
||||
|
||||
// Get update status
|
||||
$status = $this->updateChecker->getUpdateStatus();
|
||||
|
||||
// Display current status
|
||||
$io->title('Part-DB Update Manager');
|
||||
|
||||
$this->displayStatus($io, $status);
|
||||
|
||||
// Handle --check option
|
||||
if ($input->getOption('check')) {
|
||||
return $this->checkOnly($io, $status);
|
||||
}
|
||||
|
||||
// Validate we can update
|
||||
$validationResult = $this->validateUpdate($io, $status);
|
||||
if ($validationResult !== null) {
|
||||
return $validationResult;
|
||||
}
|
||||
|
||||
// Determine target version
|
||||
$targetVersion = $input->getArgument('version');
|
||||
$includePrerelease = $input->getOption('include-prerelease');
|
||||
|
||||
if (!$targetVersion) {
|
||||
$latest = $this->updateChecker->getLatestRelease($includePrerelease);
|
||||
if (!$latest) {
|
||||
$io->error('Could not determine the latest version. Please specify a version manually.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$targetVersion = $latest['tag'];
|
||||
}
|
||||
|
||||
// Validate target version
|
||||
if (!$this->updateChecker->isNewerVersion($targetVersion)) {
|
||||
$io->warning(sprintf(
|
||||
'Version %s is not newer than the current version %s.',
|
||||
$targetVersion,
|
||||
$status['current_version']
|
||||
));
|
||||
|
||||
if (!$input->getOption('force')) {
|
||||
if (!$io->confirm('Do you want to proceed anyway?', false)) {
|
||||
$io->info('Update cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm update
|
||||
if (!$input->getOption('force')) {
|
||||
$io->section('Update Plan');
|
||||
|
||||
$io->listing([
|
||||
sprintf('Target version: <info>%s</info>', $targetVersion),
|
||||
$input->getOption('no-backup')
|
||||
? '<fg=yellow>Backup will be SKIPPED</>'
|
||||
: 'A full backup will be created before updating',
|
||||
'Maintenance mode will be enabled during update',
|
||||
'Database migrations will be run automatically',
|
||||
'Cache will be cleared and rebuilt',
|
||||
]);
|
||||
|
||||
$io->warning('The update process may take several minutes. Do not interrupt it.');
|
||||
|
||||
if (!$io->confirm('Do you want to proceed with the update?', false)) {
|
||||
$io->info('Update cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute update
|
||||
return $this->executeUpdate($io, $targetVersion, !$input->getOption('no-backup'));
|
||||
}
|
||||
|
||||
private function displayStatus(SymfonyStyle $io, array $status): void
|
||||
{
|
||||
$io->definitionList(
|
||||
['Current Version' => sprintf('<info>%s</info>', $status['current_version'])],
|
||||
['Latest Version' => $status['latest_version']
|
||||
? sprintf('<info>%s</info>', $status['latest_version'])
|
||||
: '<fg=yellow>Unknown</>'],
|
||||
['Installation Type' => $status['installation']['type_name']],
|
||||
['Git Branch' => $status['git']['branch'] ?? '<fg=gray>N/A</>'],
|
||||
['Git Commit' => $status['git']['commit'] ?? '<fg=gray>N/A</>'],
|
||||
['Local Changes' => $status['git']['has_local_changes']
|
||||
? '<fg=yellow>Yes (update blocked)</>'
|
||||
: '<fg=green>No</>'],
|
||||
['Commits Behind' => $status['git']['commits_behind'] > 0
|
||||
? sprintf('<fg=yellow>%d</>', $status['git']['commits_behind'])
|
||||
: '<fg=green>0</>'],
|
||||
['Update Available' => $status['update_available']
|
||||
? '<fg=green>Yes</>'
|
||||
: 'No'],
|
||||
['Can Auto-Update' => $status['can_auto_update']
|
||||
? '<fg=green>Yes</>'
|
||||
: '<fg=yellow>No</>'],
|
||||
);
|
||||
|
||||
if (!empty($status['update_blockers'])) {
|
||||
$io->warning('Update blockers: ' . implode(', ', $status['update_blockers']));
|
||||
}
|
||||
}
|
||||
|
||||
private function checkOnly(SymfonyStyle $io, array $status): int
|
||||
{
|
||||
if (!$status['check_enabled']) {
|
||||
$io->warning('Update checking is disabled in privacy settings.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($status['update_available']) {
|
||||
$io->success(sprintf(
|
||||
'A new version is available: %s (current: %s)',
|
||||
$status['latest_version'],
|
||||
$status['current_version']
|
||||
));
|
||||
|
||||
if ($status['release_url']) {
|
||||
$io->text(sprintf('Release notes: <href=%s>%s</>', $status['release_url'], $status['release_url']));
|
||||
}
|
||||
|
||||
if ($status['can_auto_update']) {
|
||||
$io->text('');
|
||||
$io->text('Run <info>php bin/console partdb:update</info> to update.');
|
||||
} else {
|
||||
$io->text('');
|
||||
$io->text($status['installation']['update_instructions']);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->success('You are running the latest version.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function validateUpdate(SymfonyStyle $io, array $status): ?int
|
||||
{
|
||||
// Check if update checking is enabled
|
||||
if (!$status['check_enabled']) {
|
||||
$io->error('Update checking is disabled in privacy settings. Enable it to use automatic updates.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Check installation type
|
||||
if (!$status['can_auto_update']) {
|
||||
$io->error('Automatic updates are not supported for this installation type.');
|
||||
$io->text($status['installation']['update_instructions']);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Validate preconditions
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$io->error('Cannot proceed with update:');
|
||||
$io->listing($validation['errors']);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executeUpdate(SymfonyStyle $io, string $targetVersion, bool $createBackup): int
|
||||
{
|
||||
$io->section('Executing Update');
|
||||
$io->text(sprintf('Updating to version: <info>%s</info>', $targetVersion));
|
||||
$io->text('');
|
||||
|
||||
$progressCallback = function (array $step) use ($io): void {
|
||||
$icon = $step['success'] ? '<fg=green>✓</>' : '<fg=red>✗</>';
|
||||
$duration = $step['duration'] ? sprintf(' <fg=gray>(%.1fs)</>', $step['duration']) : '';
|
||||
$io->text(sprintf(' %s <info>%s</info>: %s%s', $icon, $step['step'], $step['message'], $duration));
|
||||
};
|
||||
|
||||
// Use executeUpdateWithProgress to update the progress file for web UI
|
||||
$result = $this->updateExecutor->executeUpdateWithProgress($targetVersion, $createBackup, $progressCallback);
|
||||
|
||||
$io->text('');
|
||||
|
||||
if ($result['success']) {
|
||||
$io->success(sprintf(
|
||||
'Successfully updated to %s in %.1f seconds!',
|
||||
$targetVersion,
|
||||
$result['duration']
|
||||
));
|
||||
|
||||
$io->text([
|
||||
sprintf('Rollback tag: <info>%s</info>', $result['rollback_tag']),
|
||||
sprintf('Log file: <info>%s</info>', $result['log_file']),
|
||||
]);
|
||||
|
||||
$io->note('If you encounter any issues, you can rollback using: git checkout ' . $result['rollback_tag']);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->error('Update failed: ' . $result['error']);
|
||||
|
||||
if ($result['rollback_tag']) {
|
||||
$io->warning(sprintf('System was rolled back to: %s', $result['rollback_tag']));
|
||||
}
|
||||
|
||||
if ($result['log_file']) {
|
||||
$io->text(sprintf('See log file for details: %s', $result['log_file']));
|
||||
}
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
private function listVersions(SymfonyStyle $io, bool $includePrerelease): int
|
||||
{
|
||||
$releases = $this->updateChecker->getAvailableReleases(15);
|
||||
$currentVersion = $this->updateChecker->getCurrentVersionString();
|
||||
|
||||
if (empty($releases)) {
|
||||
$io->warning('Could not fetch available versions. Check your internet connection.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->title('Available Part-DB Versions');
|
||||
|
||||
$table = new Table($io);
|
||||
$table->setHeaders(['Tag', 'Version', 'Released', 'Status']);
|
||||
|
||||
foreach ($releases as $release) {
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $release['version'];
|
||||
$status = [];
|
||||
|
||||
if (version_compare($version, $currentVersion, '=')) {
|
||||
$status[] = '<fg=cyan>current</>';
|
||||
} elseif (version_compare($version, $currentVersion, '>')) {
|
||||
$status[] = '<fg=green>newer</>';
|
||||
}
|
||||
|
||||
if ($release['prerelease']) {
|
||||
$status[] = '<fg=yellow>pre-release</>';
|
||||
}
|
||||
|
||||
$table->addRow([
|
||||
$release['tag'],
|
||||
$version,
|
||||
(new \DateTime($release['published_at']))->format('Y-m-d'),
|
||||
implode(' ', $status) ?: '-',
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
|
||||
$io->text('');
|
||||
$io->text('Use <info>php bin/console partdb:update [tag]</info> to update to a specific version.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function showLogs(SymfonyStyle $io): int
|
||||
{
|
||||
$logs = $this->updateExecutor->getUpdateLogs();
|
||||
|
||||
if (empty($logs)) {
|
||||
$io->info('No update logs found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->title('Recent Update Logs');
|
||||
|
||||
$table = new Table($io);
|
||||
$table->setHeaders(['Date', 'File', 'Size']);
|
||||
|
||||
foreach (array_slice($logs, 0, 10) as $log) {
|
||||
$table->addRow([
|
||||
date('Y-m-d H:i:s', $log['date']),
|
||||
$log['file'],
|
||||
$this->formatBytes($log['size']),
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
|
||||
$io->text('');
|
||||
$io->text('Log files are stored in: <info>var/log/updates/</info>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$unitIndex = 0;
|
||||
|
||||
while ($bytes >= 1024 && $unitIndex < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
$unitIndex++;
|
||||
}
|
||||
|
||||
return sprintf('%.1f %s', $bytes, $units[$unitIndex]);
|
||||
}
|
||||
}
|
||||
268
src/Controller/UpdateManagerController.php
Normal file
268
src/Controller/UpdateManagerController.php
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Controller;
|
||||
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Controller for the Update Manager web interface.
|
||||
*
|
||||
* This provides a read-only view of update status and instructions.
|
||||
* Actual updates should be performed via the CLI command for safety.
|
||||
*/
|
||||
#[Route('/admin/update-manager')]
|
||||
class UpdateManagerController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly UpdateChecker $updateChecker,
|
||||
private readonly UpdateExecutor $updateExecutor,
|
||||
private readonly VersionManagerInterface $versionManager)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Main update manager page.
|
||||
*/
|
||||
#[Route('', name: 'admin_update_manager', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$status = $this->updateChecker->getUpdateStatus();
|
||||
$availableUpdates = $this->updateChecker->getAvailableUpdates();
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
|
||||
return $this->render('admin/update_manager/index.html.twig', [
|
||||
'status' => $status,
|
||||
'available_updates' => $availableUpdates,
|
||||
'all_releases' => $this->updateChecker->getAvailableReleases(10),
|
||||
'validation' => $validation,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'lock_info' => $this->updateExecutor->getLockInfo(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(),
|
||||
'update_logs' => $this->updateExecutor->getUpdateLogs(),
|
||||
'backups' => $this->updateExecutor->getBackups(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to check update status.
|
||||
*/
|
||||
#[Route('/status', name: 'admin_update_manager_status', methods: ['GET'])]
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
return $this->json([
|
||||
'status' => $this->updateChecker->getUpdateStatus(),
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'lock_info' => $this->updateExecutor->getLockInfo(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to refresh version information.
|
||||
*/
|
||||
#[Route('/refresh', name: 'admin_update_manager_refresh', methods: ['POST'])]
|
||||
public function refresh(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_refresh', $request->request->get('_token'))) {
|
||||
return $this->json(['error' => 'Invalid CSRF token'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$this->updateChecker->refreshGitInfo();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'status' => $this->updateChecker->getUpdateStatus(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View release notes for a specific version.
|
||||
*/
|
||||
#[Route('/release/{tag}', name: 'admin_update_manager_release', methods: ['GET'])]
|
||||
public function releaseNotes(string $tag): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$releases = $this->updateChecker->getAvailableReleases(20);
|
||||
$release = null;
|
||||
|
||||
foreach ($releases as $r) {
|
||||
if ($r['tag'] === $tag) {
|
||||
$release = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$release) {
|
||||
throw $this->createNotFoundException('Release not found');
|
||||
}
|
||||
|
||||
return $this->render('admin/update_manager/release_notes.html.twig', [
|
||||
'release' => $release,
|
||||
'current_version' => $this->updateChecker->getCurrentVersionString(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View an update log file.
|
||||
*/
|
||||
#[Route('/log/{filename}', name: 'admin_update_manager_log', methods: ['GET'])]
|
||||
public function viewLog(string $filename): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
// Security: Only allow viewing files from the update logs directory
|
||||
$logs = $this->updateExecutor->getUpdateLogs();
|
||||
$logPath = null;
|
||||
|
||||
foreach ($logs as $log) {
|
||||
if ($log['file'] === $filename) {
|
||||
$logPath = $log['path'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$logPath || !file_exists($logPath)) {
|
||||
throw $this->createNotFoundException('Log file not found');
|
||||
}
|
||||
|
||||
$content = file_get_contents($logPath);
|
||||
|
||||
return $this->render('admin/update_manager/log_viewer.html.twig', [
|
||||
'filename' => $filename,
|
||||
'content' => $content,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an update process.
|
||||
*/
|
||||
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
|
||||
public function startUpdate(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_start', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Check if update is already running
|
||||
if ($this->updateExecutor->isLocked() || $this->updateExecutor->isUpdateRunning()) {
|
||||
$this->addFlash('error', 'An update is already in progress.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$targetVersion = $request->request->get('version');
|
||||
$createBackup = $request->request->getBoolean('backup', true);
|
||||
|
||||
if (!$targetVersion) {
|
||||
// Get latest version if not specified
|
||||
$latest = $this->updateChecker->getLatestRelease();
|
||||
if (!$latest) {
|
||||
$this->addFlash('error', 'Could not determine target version.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
$targetVersion = $latest['tag'];
|
||||
}
|
||||
|
||||
// Validate preconditions
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$this->addFlash('error', implode(' ', $validation['errors']));
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Start the background update
|
||||
$pid = $this->updateExecutor->startBackgroundUpdate($targetVersion, $createBackup);
|
||||
|
||||
if (!$pid) {
|
||||
$this->addFlash('error', 'Failed to start update process.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Redirect to progress page
|
||||
return $this->redirectToRoute('admin_update_manager_progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress page.
|
||||
*/
|
||||
#[Route('/progress', name: 'admin_update_manager_progress', methods: ['GET'])]
|
||||
public function progress(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$progress = $this->updateExecutor->getProgress();
|
||||
$currentVersion = $this->versionManager->getVersion()->toString();
|
||||
|
||||
// Determine if this is a downgrade
|
||||
$isDowngrade = false;
|
||||
if ($progress && isset($progress['target_version'])) {
|
||||
$targetVersion = ltrim($progress['target_version'], 'v');
|
||||
$isDowngrade = version_compare($targetVersion, $currentVersion, '<');
|
||||
}
|
||||
|
||||
return $this->render('admin/update_manager/progress.html.twig', [
|
||||
'progress' => $progress,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'is_downgrade' => $isDowngrade,
|
||||
'current_version' => $currentVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to get update progress.
|
||||
*/
|
||||
#[Route('/progress/status', name: 'admin_update_manager_progress_status', methods: ['GET'])]
|
||||
public function progressStatus(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$progress = $this->updateExecutor->getProgress();
|
||||
|
||||
return $this->json([
|
||||
'progress' => $progress,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
231
src/EventSubscriber/MaintenanceModeSubscriber.php
Normal file
231
src/EventSubscriber/MaintenanceModeSubscriber.php
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\EventSubscriber;
|
||||
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Blocks all web requests when maintenance mode is enabled during updates.
|
||||
*/
|
||||
class MaintenanceModeSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private readonly UpdateExecutor $updateExecutor,
|
||||
private readonly Environment $twig)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
// High priority to run before other listeners
|
||||
KernelEvents::REQUEST => ['onKernelRequest', 512],
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
// Only handle main requests
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not in maintenance mode
|
||||
if (!$this->updateExecutor->isMaintenanceMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow CLI requests
|
||||
if (php_sapi_name() === 'cli') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get maintenance info
|
||||
$maintenanceInfo = $this->updateExecutor->getMaintenanceInfo();
|
||||
$lockInfo = $this->updateExecutor->getLockInfo();
|
||||
|
||||
// Calculate how long the update has been running
|
||||
$duration = null;
|
||||
if ($lockInfo && isset($lockInfo['started_at'])) {
|
||||
try {
|
||||
$startedAt = new \DateTime($lockInfo['started_at']);
|
||||
$now = new \DateTime();
|
||||
$duration = $now->getTimestamp() - $startedAt->getTimestamp();
|
||||
} catch (\Exception) {
|
||||
// Ignore date parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
// Try to render the Twig template, fall back to simple HTML
|
||||
try {
|
||||
$content = $this->twig->render('maintenance/maintenance.html.twig', [
|
||||
'reason' => $maintenanceInfo['reason'] ?? 'Maintenance in progress',
|
||||
'started_at' => $maintenanceInfo['enabled_at'] ?? null,
|
||||
'duration' => $duration,
|
||||
]);
|
||||
} catch (\Exception) {
|
||||
// Fallback to simple HTML if Twig fails
|
||||
$content = $this->getSimpleMaintenanceHtml($maintenanceInfo, $duration);
|
||||
}
|
||||
|
||||
$response = new Response($content, Response::HTTP_SERVICE_UNAVAILABLE);
|
||||
$response->headers->set('Retry-After', '30');
|
||||
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
|
||||
$event->setResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple maintenance page HTML without Twig.
|
||||
*/
|
||||
private function getSimpleMaintenanceHtml(?array $maintenanceInfo, ?int $duration): string
|
||||
{
|
||||
$reason = htmlspecialchars($maintenanceInfo['reason'] ?? 'Update in progress');
|
||||
$durationText = $duration !== null ? sprintf('%d seconds', $duration) : 'a moment';
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="15">
|
||||
<title>Part-DB - Maintenance</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
max-width: 600px;
|
||||
}
|
||||
.icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 30px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #00d4ff;
|
||||
}
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
color: #b8c5d6;
|
||||
}
|
||||
.reason {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 15px 25px;
|
||||
border-radius: 10px;
|
||||
margin: 20px 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.progress-bar-inner {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00d4ff, #00ff88);
|
||||
border-radius: 3px;
|
||||
animation: progress 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes progress {
|
||||
0% { width: 0%; margin-left: 0%; }
|
||||
50% { width: 50%; margin-left: 25%; }
|
||||
100% { width: 0%; margin-left: 100%; }
|
||||
}
|
||||
.info {
|
||||
font-size: 0.9rem;
|
||||
color: #8899aa;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.duration {
|
||||
font-family: monospace;
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">
|
||||
<span class="spinner">⚙️</span>
|
||||
</div>
|
||||
<h1>Part-DB is Updating</h1>
|
||||
<p>We're making things better. This should only take a moment.</p>
|
||||
|
||||
<div class="reason">
|
||||
<strong>{$reason}</strong>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-inner"></div>
|
||||
</div>
|
||||
|
||||
<p class="info">
|
||||
Update running for <span class="duration">{$durationText}</span><br>
|
||||
<small>This page will automatically refresh every 15 seconds.</small>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
224
src/Services/System/InstallationTypeDetector.php
Normal file
224
src/Services/System/InstallationTypeDetector.php
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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 Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Detects the installation type of Part-DB to determine the appropriate update strategy.
|
||||
*/
|
||||
enum InstallationType: string
|
||||
{
|
||||
case GIT = 'git';
|
||||
case DOCKER = 'docker';
|
||||
case ZIP_RELEASE = 'zip_release';
|
||||
case UNKNOWN = 'unknown';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::GIT => 'Git Clone',
|
||||
self::DOCKER => 'Docker',
|
||||
self::ZIP_RELEASE => 'Release Archive',
|
||||
self::UNKNOWN => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
public function supportsAutoUpdate(): bool
|
||||
{
|
||||
return match($this) {
|
||||
self::GIT => true,
|
||||
self::DOCKER => false,
|
||||
self::ZIP_RELEASE => true,
|
||||
self::UNKNOWN => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function getUpdateInstructions(): 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::ZIP_RELEASE => 'Download the new release, extract it, and run migrations.',
|
||||
self::UNKNOWN => 'Unable to determine installation type. Please update manually.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class InstallationTypeDetector
|
||||
{
|
||||
public function __construct(#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the installation type based on filesystem markers.
|
||||
*/
|
||||
public function detect(): InstallationType
|
||||
{
|
||||
// Check for Docker environment first
|
||||
if ($this->isDocker()) {
|
||||
return InstallationType::DOCKER;
|
||||
}
|
||||
|
||||
// Check for Git installation
|
||||
if ($this->isGitInstall()) {
|
||||
return InstallationType::GIT;
|
||||
}
|
||||
|
||||
// Check for ZIP release (has VERSION file but no .git)
|
||||
if ($this->isZipRelease()) {
|
||||
return InstallationType::ZIP_RELEASE;
|
||||
}
|
||||
|
||||
return InstallationType::UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running inside a Docker container.
|
||||
*/
|
||||
public function isDocker(): bool
|
||||
{
|
||||
// Check for /.dockerenv file
|
||||
if (file_exists('/.dockerenv')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for DOCKER environment variable
|
||||
if (getenv('DOCKER') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for container runtime in cgroup
|
||||
if (file_exists('/proc/1/cgroup')) {
|
||||
$cgroup = @file_get_contents('/proc/1/cgroup');
|
||||
if ($cgroup !== false && (str_contains($cgroup, 'docker') || str_contains($cgroup, 'containerd'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a Git-based installation.
|
||||
*/
|
||||
public function isGitInstall(): bool
|
||||
{
|
||||
return is_dir($this->project_dir . '/.git');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this appears to be a ZIP release installation.
|
||||
*/
|
||||
public function isZipRelease(): bool
|
||||
{
|
||||
// Has VERSION file but no .git directory
|
||||
return file_exists($this->project_dir . '/VERSION') && !$this->isGitInstall();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about the installation.
|
||||
*/
|
||||
public function getInstallationInfo(): array
|
||||
{
|
||||
$type = $this->detect();
|
||||
|
||||
$info = [
|
||||
'type' => $type,
|
||||
'type_name' => $type->getLabel(),
|
||||
'supports_auto_update' => $type->supportsAutoUpdate(),
|
||||
'update_instructions' => $type->getUpdateInstructions(),
|
||||
'project_dir' => $this->project_dir,
|
||||
];
|
||||
|
||||
if ($type === InstallationType::GIT) {
|
||||
$info['git'] = $this->getGitInfo();
|
||||
}
|
||||
|
||||
if ($type === InstallationType::DOCKER) {
|
||||
$info['docker'] = $this->getDockerInfo();
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Git-specific information.
|
||||
*/
|
||||
private function getGitInfo(): array
|
||||
{
|
||||
$info = [
|
||||
'branch' => null,
|
||||
'commit' => null,
|
||||
'remote_url' => null,
|
||||
'has_local_changes' => false,
|
||||
];
|
||||
|
||||
// Get branch
|
||||
$headFile = $this->project_dir . '/.git/HEAD';
|
||||
if (file_exists($headFile)) {
|
||||
$head = file_get_contents($headFile);
|
||||
if (preg_match('#ref: refs/heads/(.+)#', $head, $matches)) {
|
||||
$info['branch'] = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Get remote URL
|
||||
$configFile = $this->project_dir . '/.git/config';
|
||||
if (file_exists($configFile)) {
|
||||
$config = file_get_contents($configFile);
|
||||
if (preg_match('#url = (.+)#', $config, $matches)) {
|
||||
$info['remote_url'] = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Get commit hash
|
||||
$process = new Process(['git', 'rev-parse', '--short', 'HEAD'], $this->project_dir);
|
||||
$process->run();
|
||||
if ($process->isSuccessful()) {
|
||||
$info['commit'] = trim($process->getOutput());
|
||||
}
|
||||
|
||||
// Check for local changes
|
||||
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
|
||||
$process->run();
|
||||
$info['has_local_changes'] = !empty(trim($process->getOutput()));
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Docker-specific information.
|
||||
*/
|
||||
private function getDockerInfo(): array
|
||||
{
|
||||
return [
|
||||
'container_id' => @file_get_contents('/proc/1/cpuset') ?: null,
|
||||
'image' => getenv('DOCKER_IMAGE') ?: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
349
src/Services/System/UpdateChecker.php
Normal file
349
src/Services/System/UpdateChecker.php
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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 App\Settings\SystemSettings\PrivacySettings;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Version\Version;
|
||||
|
||||
/**
|
||||
* Enhanced update checker that fetches release information including changelogs.
|
||||
*/
|
||||
class UpdateChecker
|
||||
{
|
||||
private const GITHUB_API_BASE = 'https://api.github.com/repos/Part-DB/Part-DB-server';
|
||||
private const CACHE_KEY_RELEASES = 'update_checker_releases';
|
||||
private const CACHE_KEY_COMMITS = 'update_checker_commits_behind';
|
||||
private const CACHE_TTL = 60 * 60 * 6; // 6 hours
|
||||
private const CACHE_TTL_ERROR = 60 * 60; // 1 hour on error
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $httpClient,
|
||||
private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager,
|
||||
private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger,
|
||||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode,
|
||||
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current installed version.
|
||||
*/
|
||||
public function getCurrentVersion(): Version
|
||||
{
|
||||
return $this->versionManager->getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version as string.
|
||||
*/
|
||||
public function getCurrentVersionString(): string
|
||||
{
|
||||
return $this->getCurrentVersion()->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Git repository information.
|
||||
*/
|
||||
public function getGitInfo(): array
|
||||
{
|
||||
$info = [
|
||||
'branch' => null,
|
||||
'commit' => null,
|
||||
'has_local_changes' => false,
|
||||
'commits_behind' => 0,
|
||||
'is_git_install' => false,
|
||||
];
|
||||
|
||||
$gitDir = $this->project_dir . '/.git';
|
||||
|
||||
if (!is_dir($gitDir)) {
|
||||
return $info;
|
||||
}
|
||||
|
||||
$info['is_git_install'] = true;
|
||||
|
||||
// Get branch from HEAD file
|
||||
$headFile = $gitDir . '/HEAD';
|
||||
if (file_exists($headFile)) {
|
||||
$head = file_get_contents($headFile);
|
||||
if (preg_match('#ref: refs/heads/(.+)#', $head, $matches)) {
|
||||
$info['branch'] = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current commit
|
||||
$process = new Process(['git', 'rev-parse', '--short', 'HEAD'], $this->project_dir);
|
||||
$process->run();
|
||||
if ($process->isSuccessful()) {
|
||||
$info['commit'] = trim($process->getOutput());
|
||||
}
|
||||
|
||||
// Check for local changes
|
||||
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
|
||||
$process->run();
|
||||
$info['has_local_changes'] = !empty(trim($process->getOutput()));
|
||||
|
||||
// Get commits behind (fetch first)
|
||||
if ($info['branch']) {
|
||||
// Try to get cached commits behind count
|
||||
$info['commits_behind'] = $this->getCommitsBehind($info['branch']);
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of commits behind the remote branch (cached).
|
||||
*/
|
||||
private function getCommitsBehind(string $branch): int
|
||||
{
|
||||
if (!$this->privacySettings->checkForUpdates) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cacheKey = self::CACHE_KEY_COMMITS . '_' . md5($branch);
|
||||
|
||||
return $this->updateCache->get($cacheKey, function (ItemInterface $item) use ($branch) {
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
|
||||
// Fetch from remote first
|
||||
$process = new Process(['git', 'fetch', '--tags', 'origin'], $this->project_dir);
|
||||
$process->run();
|
||||
|
||||
// Count commits behind
|
||||
$process = new Process(['git', 'rev-list', 'HEAD..origin/' . $branch, '--count'], $this->project_dir);
|
||||
$process->run();
|
||||
|
||||
return $process->isSuccessful() ? (int) trim($process->getOutput()) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh git information by invalidating cache.
|
||||
*/
|
||||
public function refreshGitInfo(): void
|
||||
{
|
||||
$gitInfo = $this->getGitInfo();
|
||||
if ($gitInfo['branch']) {
|
||||
$this->updateCache->delete(self::CACHE_KEY_COMMITS . '_' . md5($gitInfo['branch']));
|
||||
}
|
||||
$this->updateCache->delete(self::CACHE_KEY_RELEASES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available releases from GitHub (cached).
|
||||
*
|
||||
* @return array<array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, assets: array}>
|
||||
*/
|
||||
public function getAvailableReleases(int $limit = 10): array
|
||||
{
|
||||
if (!$this->privacySettings->checkForUpdates) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->updateCache->get(self::CACHE_KEY_RELEASES, function (ItemInterface $item) use ($limit) {
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', self::GITHUB_API_BASE . '/releases', [
|
||||
'query' => ['per_page' => $limit],
|
||||
'headers' => [
|
||||
'Accept' => 'application/vnd.github.v3+json',
|
||||
'User-Agent' => 'Part-DB-Update-Checker',
|
||||
],
|
||||
]);
|
||||
|
||||
$releases = [];
|
||||
foreach ($response->toArray() as $release) {
|
||||
// Extract assets (for ZIP download)
|
||||
$assets = [];
|
||||
foreach ($release['assets'] ?? [] as $asset) {
|
||||
if (str_ends_with($asset['name'], '.zip') || str_ends_with($asset['name'], '.tar.gz')) {
|
||||
$assets[] = [
|
||||
'name' => $asset['name'],
|
||||
'url' => $asset['browser_download_url'],
|
||||
'size' => $asset['size'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$releases[] = [
|
||||
'version' => ltrim($release['tag_name'], 'v'),
|
||||
'tag' => $release['tag_name'],
|
||||
'name' => $release['name'] ?? $release['tag_name'],
|
||||
'url' => $release['html_url'],
|
||||
'published_at' => $release['published_at'],
|
||||
'body' => $release['body'] ?? '',
|
||||
'prerelease' => $release['prerelease'] ?? false,
|
||||
'draft' => $release['draft'] ?? false,
|
||||
'assets' => $assets,
|
||||
'tarball_url' => $release['tarball_url'] ?? null,
|
||||
'zipball_url' => $release['zipball_url'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $releases;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to fetch releases from GitHub: ' . $e->getMessage());
|
||||
$item->expiresAfter(self::CACHE_TTL_ERROR);
|
||||
|
||||
if ($this->is_dev_mode) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest stable release.
|
||||
*/
|
||||
public function getLatestRelease(bool $includePrerelease = false): ?array
|
||||
{
|
||||
$releases = $this->getAvailableReleases();
|
||||
|
||||
foreach ($releases as $release) {
|
||||
// Skip drafts always
|
||||
if ($release['draft']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip prereleases unless explicitly included
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $release;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific version is newer than current.
|
||||
*/
|
||||
public function isNewerVersion(string $version): bool
|
||||
{
|
||||
try {
|
||||
$targetVersion = Version::fromString(ltrim($version, 'v'));
|
||||
return $targetVersion->isGreaterThan($this->getCurrentVersion());
|
||||
} catch (\Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive update status.
|
||||
*/
|
||||
public function getUpdateStatus(): array
|
||||
{
|
||||
$current = $this->getCurrentVersion();
|
||||
$latest = $this->getLatestRelease();
|
||||
$gitInfo = $this->getGitInfo();
|
||||
$installInfo = $this->installationTypeDetector->getInstallationInfo();
|
||||
|
||||
$updateAvailable = false;
|
||||
$latestVersion = null;
|
||||
$latestTag = null;
|
||||
|
||||
if ($latest) {
|
||||
try {
|
||||
$latestVersionObj = Version::fromString($latest['version']);
|
||||
$updateAvailable = $latestVersionObj->isGreaterThan($current);
|
||||
$latestVersion = $latest['version'];
|
||||
$latestTag = $latest['tag'];
|
||||
} catch (\Exception) {
|
||||
// Invalid version string
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we can auto-update
|
||||
$canAutoUpdate = $installInfo['supports_auto_update'];
|
||||
$updateBlockers = [];
|
||||
|
||||
if ($gitInfo['has_local_changes']) {
|
||||
$canAutoUpdate = false;
|
||||
$updateBlockers[] = 'local_changes';
|
||||
}
|
||||
|
||||
if ($installInfo['type'] === InstallationType::DOCKER) {
|
||||
$updateBlockers[] = 'docker_installation';
|
||||
}
|
||||
|
||||
return [
|
||||
'current_version' => $current->toString(),
|
||||
'latest_version' => $latestVersion,
|
||||
'latest_tag' => $latestTag,
|
||||
'update_available' => $updateAvailable,
|
||||
'release_notes' => $latest['body'] ?? null,
|
||||
'release_url' => $latest['url'] ?? null,
|
||||
'published_at' => $latest['published_at'] ?? null,
|
||||
'git' => $gitInfo,
|
||||
'installation' => $installInfo,
|
||||
'can_auto_update' => $canAutoUpdate,
|
||||
'update_blockers' => $updateBlockers,
|
||||
'check_enabled' => $this->privacySettings->checkForUpdates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get releases newer than the current version.
|
||||
*/
|
||||
public function getAvailableUpdates(bool $includePrerelease = false): array
|
||||
{
|
||||
$releases = $this->getAvailableReleases();
|
||||
$current = $this->getCurrentVersion();
|
||||
$updates = [];
|
||||
|
||||
foreach ($releases as $release) {
|
||||
if ($release['draft']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$releaseVersion = Version::fromString($release['version']);
|
||||
if ($releaseVersion->isGreaterThan($current)) {
|
||||
$updates[] = $release;
|
||||
}
|
||||
} catch (\Exception) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $updates;
|
||||
}
|
||||
}
|
||||
832
src/Services/System/UpdateExecutor.php
Normal file
832
src/Services/System/UpdateExecutor.php
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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 Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Handles the execution of Part-DB updates with safety mechanisms.
|
||||
*
|
||||
* This service should primarily be used from CLI commands, not web requests,
|
||||
* due to the long-running nature of updates and permission requirements.
|
||||
*/
|
||||
class UpdateExecutor
|
||||
{
|
||||
private const LOCK_FILE = 'var/update.lock';
|
||||
private const MAINTENANCE_FILE = 'var/maintenance.flag';
|
||||
private const UPDATE_LOG_DIR = 'var/log/updates';
|
||||
private const BACKUP_DIR = 'var/backups';
|
||||
private const PROGRESS_FILE = 'var/update_progress.json';
|
||||
|
||||
/** @var array<array{step: string, message: string, success: bool, timestamp: string, duration: ?float}> */
|
||||
private array $steps = [];
|
||||
|
||||
private ?string $currentLogFile = null;
|
||||
|
||||
public function __construct(#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir,
|
||||
private readonly LoggerInterface $logger, private readonly Filesystem $filesystem,
|
||||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
private readonly VersionManagerInterface $versionManager)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version string for use in filenames.
|
||||
*/
|
||||
private function getCurrentVersionString(): string
|
||||
{
|
||||
return $this->versionManager->getVersion()->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is currently in progress.
|
||||
*/
|
||||
public function isLocked(): bool
|
||||
{
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
|
||||
if (!file_exists($lockFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if lock is stale (older than 1 hour)
|
||||
$lockData = json_decode(file_get_contents($lockFile), true);
|
||||
if ($lockData && isset($lockData['started_at'])) {
|
||||
$startedAt = new \DateTime($lockData['started_at']);
|
||||
$now = new \DateTime();
|
||||
$diff = $now->getTimestamp() - $startedAt->getTimestamp();
|
||||
|
||||
// If lock is older than 1 hour, consider it stale
|
||||
if ($diff > 3600) {
|
||||
$this->logger->warning('Found stale update lock, removing it');
|
||||
$this->releaseLock();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lock information.
|
||||
*/
|
||||
public function getLockInfo(): ?array
|
||||
{
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
|
||||
if (!file_exists($lockFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode(file_get_contents($lockFile), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if maintenance mode is enabled.
|
||||
*/
|
||||
public function isMaintenanceMode(): bool
|
||||
{
|
||||
return file_exists($this->project_dir . '/' . self::MAINTENANCE_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maintenance mode information.
|
||||
*/
|
||||
public function getMaintenanceInfo(): ?array
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
|
||||
if (!file_exists($maintenanceFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode(file_get_contents($maintenanceFile), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an exclusive lock for the update process.
|
||||
*/
|
||||
public function acquireLock(): bool
|
||||
{
|
||||
if ($this->isLocked()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
$lockDir = dirname($lockFile);
|
||||
|
||||
if (!is_dir($lockDir)) {
|
||||
$this->filesystem->mkdir($lockDir);
|
||||
}
|
||||
|
||||
$lockData = [
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'pid' => getmypid(),
|
||||
'user' => get_current_user(),
|
||||
];
|
||||
|
||||
$this->filesystem->dumpFile($lockFile, json_encode($lockData, JSON_PRETTY_PRINT));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the update lock.
|
||||
*/
|
||||
public function releaseLock(): void
|
||||
{
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
|
||||
if (file_exists($lockFile)) {
|
||||
$this->filesystem->remove($lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable maintenance mode to block user access during update.
|
||||
*/
|
||||
public function enableMaintenanceMode(string $reason = 'Update in progress'): void
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
$maintenanceDir = dirname($maintenanceFile);
|
||||
|
||||
if (!is_dir($maintenanceDir)) {
|
||||
$this->filesystem->mkdir($maintenanceDir);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'enabled_at' => (new \DateTime())->format('c'),
|
||||
'reason' => $reason,
|
||||
];
|
||||
|
||||
$this->filesystem->dumpFile($maintenanceFile, json_encode($data, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable maintenance mode.
|
||||
*/
|
||||
public function disableMaintenanceMode(): void
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
|
||||
if (file_exists($maintenanceFile)) {
|
||||
$this->filesystem->remove($maintenanceFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that we can perform an update.
|
||||
*
|
||||
* @return array{valid: bool, errors: array<string>}
|
||||
*/
|
||||
public function validateUpdatePreconditions(): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Check installation type
|
||||
$installType = $this->installationTypeDetector->detect();
|
||||
if (!$installType->supportsAutoUpdate()) {
|
||||
$errors[] = sprintf(
|
||||
'Installation type "%s" does not support automatic updates. %s',
|
||||
$installType->getLabel(),
|
||||
$installType->getUpdateInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Git installation
|
||||
if ($installType === InstallationType::GIT) {
|
||||
// Check if git is available
|
||||
$process = new Process(['git', '--version']);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
$errors[] = 'Git command not found. Please ensure Git is installed and in PATH.';
|
||||
}
|
||||
|
||||
// Check for local changes
|
||||
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
|
||||
$process->run();
|
||||
if (!empty(trim($process->getOutput()))) {
|
||||
$errors[] = 'There are uncommitted local changes. Please commit or stash them before updating.';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if composer is available
|
||||
$process = new Process(['composer', '--version']);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
$errors[] = 'Composer command not found. Please ensure Composer is installed and in PATH.';
|
||||
}
|
||||
|
||||
// Check if PHP CLI is available
|
||||
$process = new Process(['php', '--version']);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
$errors[] = 'PHP CLI not found. Please ensure PHP is installed and in PATH.';
|
||||
}
|
||||
|
||||
// Check write permissions
|
||||
$testDirs = ['var', 'vendor', 'public'];
|
||||
foreach ($testDirs as $dir) {
|
||||
$fullPath = $this->project_dir . '/' . $dir;
|
||||
if (is_dir($fullPath) && !is_writable($fullPath)) {
|
||||
$errors[] = sprintf('Directory "%s" is not writable.', $dir);
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the update to a specific version.
|
||||
*
|
||||
* @param string $targetVersion The target version/tag to update to (e.g., "v2.6.0")
|
||||
* @param bool $createBackup Whether to create a backup before updating
|
||||
* @param callable|null $onProgress Callback for progress updates
|
||||
*
|
||||
* @return array{success: bool, steps: array, rollback_tag: ?string, error: ?string, log_file: ?string}
|
||||
*/
|
||||
public function executeUpdate(
|
||||
string $targetVersion,
|
||||
bool $createBackup = true,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$this->steps = [];
|
||||
$rollbackTag = null;
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Initialize log file
|
||||
$this->initializeLogFile($targetVersion);
|
||||
|
||||
$log = function (string $step, string $message, bool $success = true, ?float $duration = null) use ($onProgress): void {
|
||||
$entry = [
|
||||
'step' => $step,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
'duration' => $duration,
|
||||
];
|
||||
|
||||
$this->steps[] = $entry;
|
||||
$this->writeToLogFile($entry);
|
||||
$this->logger->info("Update [{$step}]: {$message}", ['success' => $success]);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Validate preconditions
|
||||
$validation = $this->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
throw new \RuntimeException('Precondition check failed: ' . implode('; ', $validation['errors']));
|
||||
}
|
||||
|
||||
// Step 1: Acquire lock
|
||||
$stepStart = microtime(true);
|
||||
if (!$this->acquireLock()) {
|
||||
throw new \RuntimeException('Could not acquire update lock. Another update may be in progress.');
|
||||
}
|
||||
$log('lock', 'Acquired exclusive update lock', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 2: Enable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->enableMaintenanceMode('Updating to ' . $targetVersion);
|
||||
$log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 3: Create rollback point with version info
|
||||
$stepStart = microtime(true);
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$rollbackTag = 'pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His');
|
||||
$this->runCommand(['git', 'tag', $rollbackTag], 'Create rollback tag');
|
||||
$log('rollback_tag', 'Created rollback tag: ' . $rollbackTag, true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 4: Create backup (optional)
|
||||
if ($createBackup) {
|
||||
$stepStart = microtime(true);
|
||||
$backupFile = $this->createBackup($targetVersion);
|
||||
$log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart);
|
||||
}
|
||||
|
||||
// Step 5: Fetch from remote
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['git', 'fetch', '--tags', '--force', 'origin'], 'Fetch from origin', 120);
|
||||
$log('fetch', 'Fetched latest changes and tags from origin', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 6: Checkout target version
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version');
|
||||
$log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 7: Install dependencies
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'composer', 'install',
|
||||
'--no-dev',
|
||||
'--optimize-autoloader',
|
||||
'--no-interaction',
|
||||
'--no-progress',
|
||||
], 'Install dependencies', 600);
|
||||
$log('composer', 'Installed/updated dependencies', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 8: Run database migrations
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'doctrine:migrations:migrate',
|
||||
'--no-interaction',
|
||||
'--allow-no-migration',
|
||||
], 'Run migrations', 300);
|
||||
$log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 9: Clear cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:clear',
|
||||
'--env=prod',
|
||||
'--no-interaction',
|
||||
], 'Clear cache', 120);
|
||||
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 10: Warm up cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:warmup',
|
||||
'--env=prod',
|
||||
], 'Warmup cache', 120);
|
||||
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 11: Disable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->disableMaintenanceMode();
|
||||
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 12: Release lock
|
||||
$stepStart = microtime(true);
|
||||
$this->releaseLock();
|
||||
|
||||
$totalDuration = microtime(true) - $startTime;
|
||||
$log('complete', sprintf('Update completed successfully in %.1f seconds', $totalDuration), true, microtime(true) - $stepStart);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'steps' => $this->steps,
|
||||
'rollback_tag' => $rollbackTag,
|
||||
'error' => null,
|
||||
'log_file' => $this->currentLogFile,
|
||||
'duration' => $totalDuration,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$log('error', 'Update failed: ' . $e->getMessage(), false);
|
||||
|
||||
// Attempt rollback
|
||||
if ($rollbackTag) {
|
||||
try {
|
||||
$this->runCommand(['git', 'checkout', $rollbackTag], 'Rollback');
|
||||
$log('rollback', 'Rolled back to: ' . $rollbackTag, true);
|
||||
|
||||
// Re-run composer install after rollback
|
||||
$this->runCommand([
|
||||
'composer', 'install',
|
||||
'--no-dev',
|
||||
'--optimize-autoloader',
|
||||
'--no-interaction',
|
||||
], 'Reinstall dependencies after rollback', 600);
|
||||
$log('rollback_composer', 'Reinstalled dependencies after rollback', true);
|
||||
|
||||
// Clear cache after rollback
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:clear',
|
||||
'--env=prod',
|
||||
], 'Clear cache after rollback', 120);
|
||||
$log('rollback_cache', 'Cleared cache after rollback', true);
|
||||
|
||||
} catch (\Exception $rollbackError) {
|
||||
$log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
$this->disableMaintenanceMode();
|
||||
$this->releaseLock();
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'steps' => $this->steps,
|
||||
'rollback_tag' => $rollbackTag,
|
||||
'error' => $e->getMessage(),
|
||||
'log_file' => $this->currentLogFile,
|
||||
'duration' => microtime(true) - $startTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup before updating.
|
||||
*/
|
||||
private function createBackup(string $targetVersion): string
|
||||
{
|
||||
$backupDir = $this->project_dir . '/' . self::BACKUP_DIR;
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
$this->filesystem->mkdir($backupDir, 0755);
|
||||
}
|
||||
|
||||
// Include version numbers in backup filename: pre-update-v2.5.1-to-v2.6.0-2024-01-30-185400.zip
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$backupFile = $backupDir . '/pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.zip';
|
||||
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'partdb:backup',
|
||||
'--full',
|
||||
'--overwrite',
|
||||
$backupFile,
|
||||
], 'Create backup', 600);
|
||||
|
||||
return $backupFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a shell command with proper error handling.
|
||||
*/
|
||||
private function runCommand(array $command, string $description, int $timeout = 120): string
|
||||
{
|
||||
$process = new Process($command, $this->project_dir);
|
||||
$process->setTimeout($timeout);
|
||||
|
||||
// Set environment variables needed for Composer and other tools
|
||||
// This is especially important when running as www-data which may not have HOME set
|
||||
// We inherit from current environment and override/add specific variables
|
||||
$currentEnv = getenv();
|
||||
if (!is_array($currentEnv)) {
|
||||
$currentEnv = [];
|
||||
}
|
||||
$env = array_merge($currentEnv, [
|
||||
'HOME' => $this->project_dir,
|
||||
'COMPOSER_HOME' => $this->project_dir . '/var/composer',
|
||||
'PATH' => getenv('PATH') ?: '/usr/local/bin:/usr/bin:/bin',
|
||||
]);
|
||||
$process->setEnv($env);
|
||||
|
||||
$output = '';
|
||||
$process->run(function ($type, $buffer) use (&$output) {
|
||||
$output .= $buffer;
|
||||
});
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$errorOutput = $process->getErrorOutput() ?: $process->getOutput();
|
||||
throw new \RuntimeException(
|
||||
sprintf('%s failed: %s', $description, trim($errorOutput))
|
||||
);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the log file for this update.
|
||||
*/
|
||||
private function initializeLogFile(string $targetVersion): void
|
||||
{
|
||||
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
$this->filesystem->mkdir($logDir, 0755);
|
||||
}
|
||||
|
||||
// Include version numbers in log filename: update-v2.5.1-to-v2.6.0-2024-01-30-185400.log
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$this->currentLogFile = $logDir . '/update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.log';
|
||||
|
||||
$header = sprintf(
|
||||
"Part-DB Update Log\n" .
|
||||
"==================\n" .
|
||||
"Started: %s\n" .
|
||||
"From Version: %s\n" .
|
||||
"Target Version: %s\n" .
|
||||
"==================\n\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
$currentVersion,
|
||||
$targetVersion
|
||||
);
|
||||
|
||||
file_put_contents($this->currentLogFile, $header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an entry to the log file.
|
||||
*/
|
||||
private function writeToLogFile(array $entry): void
|
||||
{
|
||||
if (!$this->currentLogFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
$line = sprintf(
|
||||
"[%s] %s: %s%s\n",
|
||||
$entry['timestamp'],
|
||||
strtoupper($entry['step']),
|
||||
$entry['message'],
|
||||
$entry['duration'] ? sprintf(' (%.2fs)', $entry['duration']) : ''
|
||||
);
|
||||
|
||||
file_put_contents($this->currentLogFile, $line, FILE_APPEND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of update log files.
|
||||
*/
|
||||
public function getUpdateLogs(): array
|
||||
{
|
||||
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$logs = [];
|
||||
foreach (glob($logDir . '/update-*.log') as $logFile) {
|
||||
$logs[] = [
|
||||
'file' => basename($logFile),
|
||||
'path' => $logFile,
|
||||
'date' => filemtime($logFile),
|
||||
'size' => filesize($logFile),
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
usort($logs, fn($a, $b) => $b['date'] <=> $a['date']);
|
||||
|
||||
return $logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of backups.
|
||||
*/
|
||||
public function getBackups(): array
|
||||
{
|
||||
$backupDir = $this->project_dir . '/' . self::BACKUP_DIR;
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$backups = [];
|
||||
foreach (glob($backupDir . '/*.zip') as $backupFile) {
|
||||
$backups[] = [
|
||||
'file' => basename($backupFile),
|
||||
'path' => $backupFile,
|
||||
'date' => filemtime($backupFile),
|
||||
'size' => filesize($backupFile),
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
usort($backups, fn($a, $b) => $b['date'] <=> $a['date']);
|
||||
|
||||
return $backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the progress file.
|
||||
*/
|
||||
public function getProgressFilePath(): string
|
||||
{
|
||||
return $this->project_dir . '/' . self::PROGRESS_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save progress to file for web UI polling.
|
||||
*/
|
||||
public function saveProgress(array $progress): void
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
$progressDir = dirname($progressFile);
|
||||
|
||||
if (!is_dir($progressDir)) {
|
||||
$this->filesystem->mkdir($progressDir);
|
||||
}
|
||||
|
||||
$this->filesystem->dumpFile($progressFile, json_encode($progress, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current update progress from file.
|
||||
*/
|
||||
public function getProgress(): ?array
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
|
||||
if (!file_exists($progressFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($progressFile), true);
|
||||
|
||||
// If the progress file is stale (older than 30 minutes), consider it invalid
|
||||
if ($data && isset($data['started_at'])) {
|
||||
$startedAt = strtotime($data['started_at']);
|
||||
if (time() - $startedAt > 1800) {
|
||||
$this->clearProgress();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear progress file.
|
||||
*/
|
||||
public function clearProgress(): void
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
|
||||
if (file_exists($progressFile)) {
|
||||
$this->filesystem->remove($progressFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is currently running (based on progress file).
|
||||
*/
|
||||
public function isUpdateRunning(): bool
|
||||
{
|
||||
$progress = $this->getProgress();
|
||||
|
||||
if (!$progress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($progress['status']) && $progress['status'] === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the update process in the background.
|
||||
* Returns the process ID or null on failure.
|
||||
*/
|
||||
public function startBackgroundUpdate(string $targetVersion, bool $createBackup = true): ?int
|
||||
{
|
||||
// Validate first
|
||||
$validation = $this->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$this->logger->error('Update validation failed', ['errors' => $validation['errors']]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize progress file
|
||||
$this->saveProgress([
|
||||
'status' => 'starting',
|
||||
'target_version' => $targetVersion,
|
||||
'create_backup' => $createBackup,
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'current_step' => 0,
|
||||
'total_steps' => 12,
|
||||
'step_name' => 'initializing',
|
||||
'step_message' => 'Starting update process...',
|
||||
'steps' => [],
|
||||
'error' => null,
|
||||
]);
|
||||
|
||||
// Build the command to run in background
|
||||
// Use 'php' from PATH as PHP_BINARY might point to php-fpm
|
||||
$consolePath = $this->project_dir . '/bin/console';
|
||||
$logFile = $this->project_dir . '/var/log/update-background.log';
|
||||
|
||||
// Ensure log directory exists
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
$this->filesystem->mkdir($logDir, 0755);
|
||||
}
|
||||
|
||||
// Use nohup to properly detach the process from the web request
|
||||
// The process will continue running even after the PHP request ends
|
||||
$command = sprintf(
|
||||
'nohup php %s partdb:update %s %s --force --no-interaction >> %s 2>&1 &',
|
||||
escapeshellarg($consolePath),
|
||||
escapeshellarg($targetVersion),
|
||||
$createBackup ? '' : '--no-backup',
|
||||
escapeshellarg($logFile)
|
||||
);
|
||||
|
||||
$this->logger->info('Starting background update', [
|
||||
'command' => $command,
|
||||
'target_version' => $targetVersion,
|
||||
]);
|
||||
|
||||
// Execute in background using shell_exec for proper detachment
|
||||
// shell_exec with & runs the command in background
|
||||
$output = shell_exec($command);
|
||||
|
||||
// Give it a moment to start
|
||||
usleep(500000); // 500ms
|
||||
|
||||
// Check if progress file was updated (indicates process started)
|
||||
$progress = $this->getProgress();
|
||||
if ($progress && isset($progress['status'])) {
|
||||
$this->logger->info('Background update started successfully');
|
||||
return 1; // Return a non-null value to indicate success
|
||||
}
|
||||
|
||||
$this->logger->error('Background update may not have started', ['output' => $output]);
|
||||
return 1; // Still return success as the process might just be slow to start
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute update with progress file updates for web UI.
|
||||
* This is called by the CLI command and updates the progress file.
|
||||
*/
|
||||
public function executeUpdateWithProgress(
|
||||
string $targetVersion,
|
||||
bool $createBackup = true,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$totalSteps = 12;
|
||||
$currentStep = 0;
|
||||
|
||||
$updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void {
|
||||
$currentStep++;
|
||||
$progress = $this->getProgress() ?? [
|
||||
'status' => 'running',
|
||||
'target_version' => $targetVersion,
|
||||
'create_backup' => $createBackup,
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'steps' => [],
|
||||
];
|
||||
|
||||
$progress['current_step'] = $currentStep;
|
||||
$progress['total_steps'] = $totalSteps;
|
||||
$progress['step_name'] = $stepName;
|
||||
$progress['step_message'] = $message;
|
||||
$progress['status'] = 'running';
|
||||
$progress['steps'][] = [
|
||||
'step' => $stepName,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
];
|
||||
|
||||
$this->saveProgress($progress);
|
||||
};
|
||||
|
||||
// Wrap the existing executeUpdate with progress tracking
|
||||
$result = $this->executeUpdate($targetVersion, $createBackup, function ($entry) use ($updateProgress, $onProgress) {
|
||||
$updateProgress($entry['step'], $entry['message'], $entry['success']);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
});
|
||||
|
||||
// Update final status
|
||||
$finalProgress = $this->getProgress() ?? [];
|
||||
$finalProgress['status'] = $result['success'] ? 'completed' : 'failed';
|
||||
$finalProgress['completed_at'] = (new \DateTime())->format('c');
|
||||
$finalProgress['result'] = $result;
|
||||
$finalProgress['error'] = $result['error'];
|
||||
$this->saveProgress($finalProgress);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
|
@ -315,6 +315,13 @@ class ToolsTreeBuilder
|
|||
))->setIcon('fa fa-fw fa-gears fa-solid');
|
||||
}
|
||||
|
||||
if ($this->security->isGranted('@system.show_updates')) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.system.update_manager'),
|
||||
$this->urlGenerator->generate('admin_update_manager')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-cloud-download-alt');
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
79
src/Twig/UpdateExtension.php
Normal file
79
src/Twig/UpdateExtension.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Twig;
|
||||
|
||||
use App\Services\System\UpdateAvailableManager;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Twig extension for update-related functions.
|
||||
*/
|
||||
final class UpdateExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(private readonly UpdateAvailableManager $updateAvailableManager,
|
||||
private readonly Security $security)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('is_update_available', $this->isUpdateAvailable(...)),
|
||||
new TwigFunction('get_latest_version', $this->getLatestVersion(...)),
|
||||
new TwigFunction('get_latest_version_url', $this->getLatestVersionUrl(...)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is available and the user has permission to see it.
|
||||
*/
|
||||
public function isUpdateAvailable(): bool
|
||||
{
|
||||
// Only show to users with the show_updates permission
|
||||
if (!$this->security->isGranted('@system.show_updates')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->updateAvailableManager->isUpdateAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest available version string.
|
||||
*/
|
||||
public function getLatestVersion(): string
|
||||
{
|
||||
return $this->updateAvailableManager->getLatestVersionString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL to the latest version release page.
|
||||
*/
|
||||
public function getLatestVersionUrl(): string
|
||||
{
|
||||
return $this->updateAvailableManager->getLatestVersionUrl();
|
||||
}
|
||||
}
|
||||
|
|
@ -74,6 +74,19 @@
|
|||
|
||||
|
||||
<ul class="navbar-nav ms-3" id="login-content">
|
||||
{# Update notification badge #}
|
||||
{% if is_update_available() %}
|
||||
<li class="nav-item me-2">
|
||||
<a href="{{ path('admin_update_manager') }}" class="nav-link position-relative"
|
||||
title="{% trans %}update_manager.new_version_available.title{% endtrans %}: {{ get_latest_version() }}">
|
||||
<i class="fas fa-arrow-circle-up text-success"></i>
|
||||
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-success" style="font-size: 0.6rem;">
|
||||
{% trans %}update_manager.new{% endtrans %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<a href="#" class="dropdown-toggle link-anchor nav-link" data-bs-toggle="dropdown" role="button"
|
||||
aria-haspopup="true" aria-expanded="false" id="navbar-user-dropdown-btn" data-bs-reference="window">
|
||||
|
|
|
|||
374
templates/admin/update_manager/index.html.twig
Normal file
374
templates/admin/update_manager/index.html.twig
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-cloud-download-alt"></i> Part-DB {% trans %}update_manager.title{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<div>
|
||||
|
||||
{# Maintenance Mode Warning #}
|
||||
{% if is_maintenance %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-tools me-2"></i>
|
||||
<strong>{% trans %}update_manager.maintenance_mode_active{% endtrans %}</strong>
|
||||
{% if maintenance_info.reason is defined %}
|
||||
- {{ maintenance_info.reason }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Lock Warning #}
|
||||
{% if is_locked %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fas fa-lock me-2"></i>
|
||||
<strong>{% trans %}update_manager.update_in_progress{% endtrans %}</strong>
|
||||
{% if lock_info.started_at is defined %}
|
||||
({% trans %}update_manager.started_at{% endtrans %}: {{ lock_info.started_at }})
|
||||
{% endif %}
|
||||
<a href="{{ path('admin_update_manager_progress') }}" class="alert-link ms-2">
|
||||
{% trans %}update_manager.view_progress{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
{# Current Version Card #}
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-info-circle me-2"></i>{% trans %}update_manager.current_installation{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row" style="width: 40%">{% trans %}update_manager.version{% endtrans %}</th>
|
||||
<td>
|
||||
<span class="badge bg-primary fs-6">{{ status.current_version }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.installation_type{% endtrans %}</th>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ status.installation.type_name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% if status.git.is_git_install %}
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.git_branch{% endtrans %}</th>
|
||||
<td><code>{{ status.git.branch ?? 'N/A' }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.git_commit{% endtrans %}</th>
|
||||
<td><code>{{ status.git.commit ?? 'N/A' }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.local_changes{% endtrans %}</th>
|
||||
<td>
|
||||
{% if status.git.has_local_changes %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>{% trans %}update_manager.yes{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check me-1"></i>{% trans %}update_manager.no{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
|
||||
<td>
|
||||
{% if status.can_auto_update %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check me-1"></i>{% trans %}update_manager.yes{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="fas fa-times me-1"></i>{% trans %}update_manager.no{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<form action="{{ path('admin_update_manager_refresh') }}" method="post" class="d-inline">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_refresh') }}">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-sync-alt me-1"></i> {% trans %}update_manager.refresh{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Latest Version / Update Card #}
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100 {{ status.update_available ? 'border-success' : '' }}">
|
||||
<div class="card-header {{ status.update_available ? 'bg-success text-white' : '' }}">
|
||||
{% if status.update_available %}
|
||||
<i class="fas fa-gift me-2"></i>{% trans %}update_manager.new_version_available.title{% endtrans %}
|
||||
{% else %}
|
||||
<i class="fas fa-check-circle me-2"></i>{% trans %}update_manager.latest_release{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if status.latest_version %}
|
||||
<div class="text-center mb-3">
|
||||
<span class="badge bg-{{ status.update_available ? 'success' : 'primary' }} fs-4 px-4 py-2">
|
||||
{{ status.latest_tag }}
|
||||
</span>
|
||||
{% if not status.update_available %}
|
||||
<p class="text-success mt-2 mb-0">
|
||||
<i class="fas fa-check-circle me-1"></i>
|
||||
{% trans %}update_manager.already_up_to_date{% endtrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if status.update_available and status.can_auto_update and validation.valid %}
|
||||
<form action="{{ path('admin_update_manager_start') }}" method="post" onsubmit="return confirm('{% trans %}update_manager.confirm_update{% endtrans %}');">
|
||||
<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 %}
|
||||
|
||||
{% if status.published_at %}
|
||||
<p class="text-muted small mt-3 mb-0">
|
||||
<i class="fas fa-calendar me-1"></i>
|
||||
{% trans %}update_manager.released{% endtrans %}: {{ status.published_at|date('Y-m-d') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fas fa-question-circle fa-3x mb-3"></i>
|
||||
<p>{% trans %}update_manager.could_not_fetch_releases{% endtrans %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if status.latest_tag %}
|
||||
<div class="card-footer">
|
||||
<a href="{{ path('admin_update_manager_release', {tag: status.latest_tag}) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-file-alt me-1"></i> {% trans %}update_manager.view_release_notes{% endtrans %}
|
||||
</a>
|
||||
{% if status.release_url %}
|
||||
<a href="{{ status.release_url }}" class="btn btn-outline-secondary btn-sm" target="_blank">
|
||||
<i class="fab fa-github me-1"></i> GitHub
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Validation Issues #}
|
||||
{% if not validation.valid %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h6 class="alert-heading">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>{% trans %}update_manager.validation_issues{% endtrans %}
|
||||
</h6>
|
||||
<ul class="mb-0">
|
||||
{% for error in validation.errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
{# Available Versions #}
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-tags me-2"></i>{% trans %}update_manager.available_versions{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.version{% endtrans %}</th>
|
||||
<th>{% trans %}update_manager.released{% endtrans %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for release in all_releases %}
|
||||
<tr{% if release.version == status.current_version %} class="table-active"{% endif %}>
|
||||
<td>
|
||||
<code>{{ release.tag }}</code>
|
||||
{% if release.prerelease %}
|
||||
<span class="badge bg-warning text-dark ms-1">pre</span>
|
||||
{% endif %}
|
||||
{% if release.version == status.current_version %}
|
||||
<span class="badge bg-primary ms-1">{% trans %}update_manager.current{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
{{ release.published_at|date('Y-m-d') }}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ path('admin_update_manager_release', {tag: release.tag}) }}"
|
||||
class="btn btn-outline-secondary"
|
||||
title="{% trans %}update_manager.view_release_notes{% endtrans %}">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</a>
|
||||
{% if release.version != status.current_version and status.can_auto_update and validation.valid %}
|
||||
<form action="{{ path('admin_update_manager_start') }}" method="post" class="d-inline"
|
||||
onsubmit="return confirm('{% if release.version > status.current_version %}{% trans %}update_manager.confirm_update{% endtrans %}{% else %}{% trans %}update_manager.confirm_downgrade{% endtrans %}{% endif %}');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
|
||||
<input type="hidden" name="version" value="{{ release.tag }}">
|
||||
<input type="hidden" name="backup" value="1">
|
||||
<button type="submit"
|
||||
class="btn btn-{{ release.version > status.current_version ? 'outline-success' : 'outline-warning' }}"
|
||||
title="{% trans %}update_manager.switch_to{% endtrans %} {{ release.tag }}">
|
||||
{% if release.version > status.current_version %}
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted py-3">
|
||||
{% trans %}update_manager.no_releases_found{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Update History & Backups #}
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#logs-tab" type="button">
|
||||
<i class="fas fa-history me-1"></i>{% trans %}update_manager.update_logs{% endtrans %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#backups-tab" type="button">
|
||||
<i class="fas fa-archive me-1"></i>{% trans %}update_manager.backups{% endtrans %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="logs-tab">
|
||||
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.date{% endtrans %}</th>
|
||||
<th>{% trans %}update_manager.log_file{% endtrans %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in update_logs %}
|
||||
<tr>
|
||||
<td class="text-muted small">
|
||||
{{ log.date|date('Y-m-d H:i') }}
|
||||
</td>
|
||||
<td><code class="small">{{ log.file }}</code></td>
|
||||
<td>
|
||||
<a href="{{ path('admin_update_manager_log', {filename: log.file}) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted py-3">
|
||||
{% trans %}update_manager.no_logs_found{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="backups-tab">
|
||||
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.date{% endtrans %}</th>
|
||||
<th>{% trans %}update_manager.file{% endtrans %}</th>
|
||||
<th>{% trans %}update_manager.size{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td class="text-muted small">
|
||||
{{ backup.date|date('Y-m-d H:i') }}
|
||||
</td>
|
||||
<td><code class="small">{{ backup.file }}</code></td>
|
||||
<td class="text-muted small">
|
||||
{{ (backup.size / 1024 / 1024)|number_format(1) }} MB
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted py-3">
|
||||
{% trans %}update_manager.no_backups_found{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# 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>{{ status.installation.type_name }}
|
||||
</h6>
|
||||
<p class="mb-0">{{ status.installation.update_instructions }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
40
templates/admin/update_manager/log_viewer.html.twig
Normal file
40
templates/admin/update_manager/log_viewer.html.twig
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}{{ filename }} - {% trans %}update_manager.log_viewer{% endtrans %}{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-file-code"></i> {{ filename }}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<div class="mb-4">
|
||||
<a href="{{ path('admin_update_manager') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans %}update_manager.back_to_update_manager{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="fas fa-terminal me-2"></i>{% trans %}update_manager.update_log{% endtrans %}
|
||||
</span>
|
||||
<span class="badge bg-secondary">{{ content|length }} {% trans %}update_manager.bytes{% endtrans %}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<pre class="bg-dark text-light p-3 mb-0" style="max-height: 600px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;"><code>{{ content }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
pre code {
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Highlight different log levels */
|
||||
pre code {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
196
templates/admin/update_manager/progress.html.twig
Normal file
196
templates/admin/update_manager/progress.html.twig
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_title{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.title{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
{% if progress and progress.status == 'running' %}
|
||||
<i class="fas fa-sync-alt fa-spin"></i>
|
||||
{% elseif progress and progress.status == 'completed' %}
|
||||
<i class="fas fa-check-circle text-success"></i>
|
||||
{% elseif progress and progress.status == 'failed' %}
|
||||
<i class="fas fa-times-circle text-danger"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-hourglass-start"></i>
|
||||
{% endif %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_title{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.title{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ parent() }}
|
||||
{# Auto-refresh while update is running - also refresh when 'starting' status #}
|
||||
{% if not progress or progress.status == 'running' or progress.status == 'starting' %}
|
||||
<meta http-equiv="refresh" content="2">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<div id="update-progress">
|
||||
|
||||
{# Progress Header #}
|
||||
<div class="text-center mb-4">
|
||||
<div class="mb-3">
|
||||
{% if progress and progress.status == 'completed' %}
|
||||
<i class="fas fa-check-circle fa-3x text-success"></i>
|
||||
{% elseif progress and progress.status == 'failed' %}
|
||||
<i class="fas fa-times-circle fa-3x text-danger"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-cog fa-spin fa-3x text-primary"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h4>
|
||||
{% if progress and progress.status == 'running' %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrading{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.updating{% endtrans %}
|
||||
{% endif %}
|
||||
{% elseif progress and progress.status == 'completed' %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_completed{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.completed{% endtrans %}
|
||||
{% endif %}
|
||||
{% elseif progress and progress.status == 'failed' %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_failed{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.failed{% endtrans %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.initializing{% endtrans %}
|
||||
{% endif %}
|
||||
</h4>
|
||||
<p class="text-muted">
|
||||
{% if progress %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans with {'%version%': progress.target_version|default('unknown')} %}update_manager.progress.downgrading_to{% endtrans %}
|
||||
{% else %}
|
||||
{% trans with {'%version%': progress.target_version|default('unknown')} %}update_manager.progress.updating_to{% endtrans %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Progress Bar #}
|
||||
{% set percent = progress ? ((progress.current_step|default(0) / progress.total_steps|default(12)) * 100)|round : 0 %}
|
||||
{% if progress and progress.status == 'completed' %}
|
||||
{% set percent = 100 %}
|
||||
{% endif %}
|
||||
<div class="progress mb-4" style="height: 25px;">
|
||||
<div class="progress-bar {% if progress and progress.status == 'completed' %}bg-success{% elseif progress and progress.status == 'failed' %}bg-danger{% else %}progress-bar-striped progress-bar-animated{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ percent }}%"
|
||||
aria-valuenow="{{ progress.current_step|default(0) }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{ progress.total_steps|default(12) }}">
|
||||
{{ percent }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Current Step - shows what's currently being worked on #}
|
||||
{% if progress and (progress.status == 'running' or progress.status == 'starting') %}
|
||||
<div class="alert alert-info mb-4">
|
||||
<strong>{{ progress.step_name|default('initializing')|replace({'_': ' '})|capitalize }}</strong>:
|
||||
{{ progress.step_message|default('Processing...') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Error Message #}
|
||||
{% if progress and progress.status == 'failed' %}
|
||||
<div class="alert alert-danger mb-4">
|
||||
<strong>{% trans %}update_manager.progress.error{% endtrans %}:</strong>
|
||||
{{ progress.error|default('An unknown error occurred') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Success Message #}
|
||||
{% if progress and progress.status == 'completed' %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_success_message{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.success_message{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Steps Timeline #}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-list-ol me-2"></i>
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_steps{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.steps{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
{% if progress and progress.steps %}
|
||||
{% for step in progress.steps %}
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
{% if step.success %}
|
||||
<i class="fas fa-check-circle text-success me-3"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-times-circle text-danger me-3"></i>
|
||||
{% endif %}
|
||||
<div class="flex-grow-1">
|
||||
<strong>{{ step.step|replace({'_': ' '})|capitalize }}</strong>
|
||||
<br><small class="text-muted">{{ step.message }}</small>
|
||||
</div>
|
||||
<small class="text-muted">{{ step.timestamp|date('H:i:s') }}</small>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="list-group-item text-center text-muted py-3">
|
||||
<i class="fas fa-clock me-2"></i>{% trans %}update_manager.progress.waiting{% endtrans %}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="text-center">
|
||||
{% if progress and (progress.status == 'completed' or progress.status == 'failed') %}
|
||||
<a href="{{ path('admin_update_manager') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans %}update_manager.progress.back{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ path('admin_update_manager_progress') }}" class="btn btn-primary">
|
||||
<i class="fas fa-sync-alt me-1"></i> {% trans %}update_manager.progress.refresh_page{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Warning Notice #}
|
||||
{% if not progress or progress.status == 'running' or progress.status == 'starting' %}
|
||||
<div class="alert alert-warning mt-4">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>{% trans %}update_manager.progress.warning{% endtrans %}:</strong>
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_do_not_close{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.do_not_close{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# JavaScript refresh - more reliable than meta refresh #}
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
110
templates/admin/update_manager/release_notes.html.twig
Normal file
110
templates/admin/update_manager/release_notes.html.twig
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}{{ release.name }} - {% trans %}update_manager.release_notes{% endtrans %}{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-file-alt"></i> {{ release.name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<div class="mb-4">
|
||||
<a href="{{ path('admin_update_manager') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans %}update_manager.back_to_update_manager{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<th style="width: 30%">{% trans %}update_manager.version{% endtrans %}</th>
|
||||
<td>
|
||||
<span class="badge bg-primary fs-6">{{ release.version }}</span>
|
||||
{% if release.prerelease %}
|
||||
<span class="badge bg-warning text-dark ms-1">{% trans %}update_manager.prerelease{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.tag{% endtrans %}</th>
|
||||
<td><code>{{ release.tag }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.released{% endtrans %}</th>
|
||||
<td>{{ release.published_at|date('Y-m-d H:i') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.status{% endtrans %}</th>
|
||||
<td>
|
||||
{% if release.version == current_version %}
|
||||
<span class="badge bg-primary">{% trans %}update_manager.current{% endtrans %}</span>
|
||||
{% elseif release.version > current_version %}
|
||||
<span class="badge bg-success">{% trans %}update_manager.newer{% endtrans %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{% trans %}update_manager.older{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<a href="{{ release.url }}" class="btn btn-primary" target="_blank">
|
||||
<i class="fab fa-github me-1"></i> {% trans %}update_manager.view_on_github{% endtrans %}
|
||||
</a>
|
||||
{% if release.zipball_url %}
|
||||
<a href="{{ release.zipball_url }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-download me-1"></i> ZIP
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if release.assets is not empty %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-paperclip me-2"></i>{% trans %}update_manager.download_assets{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for asset in release.assets %}
|
||||
<li class="mb-2">
|
||||
<a href="{{ asset.url }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-download me-1"></i> {{ asset.name }}
|
||||
</a>
|
||||
<span class="text-muted ms-2">({{ (asset.size / 1024 / 1024)|number_format(1) }} MB)</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-list-ul me-2"></i>{% trans %}update_manager.changelog{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if release.body %}
|
||||
<div class="markdown-body">
|
||||
{{ release.body|markdown_to_html }}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">{% trans %}update_manager.no_release_notes{% endtrans %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if release.version > current_version %}
|
||||
<div class="card mt-4 border-success">
|
||||
<div class="card-header bg-success text-white">
|
||||
<i class="fas fa-arrow-up me-2"></i>{% trans %}update_manager.update_to_this_version{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>{% trans %}update_manager.run_command_to_update{% endtrans %}</p>
|
||||
<div class="bg-dark text-light p-3 rounded">
|
||||
<code class="text-info">php bin/console partdb:update {{ release.tag }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
251
templates/maintenance/maintenance.html.twig
Normal file
251
templates/maintenance/maintenance.html.twig
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="15">
|
||||
<title>Part-DB - {% trans %}update_manager.maintenance.title{% endtrans %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.maintenance-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 50px;
|
||||
max-width: 550px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 30px;
|
||||
background: linear-gradient(135deg, #00d4ff 0%, #00ff88 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.icon-container i {
|
||||
font-size: 50px;
|
||||
color: #1a1a2e;
|
||||
animation: spin 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.4); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 0 30px 10px rgba(0, 212, 255, 0.2); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #00d4ff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.reason-badge {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
padding: 12px 24px;
|
||||
border-radius: 30px;
|
||||
display: inline-block;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: linear-gradient(90deg, #00d4ff, #00ff88);
|
||||
animation: progressAnim 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progressAnim {
|
||||
0% { width: 0%; margin-left: 0%; }
|
||||
50% { width: 40%; margin-left: 30%; }
|
||||
100% { width: 0%; margin-left: 100%; }
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 2rem;
|
||||
color: #00ff88;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.status-steps {
|
||||
text-align: left;
|
||||
margin: 30px 0;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.status-step {
|
||||
padding: 8px 0;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-step i {
|
||||
width: 24px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.status-step.active {
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.status-step.completed {
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.refresh-info {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.refresh-info i {
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
.logo {
|
||||
opacity: 0.3;
|
||||
margin-top: 30px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="maintenance-card">
|
||||
<div class="icon-container">
|
||||
<i class="fas fa-cog"></i>
|
||||
</div>
|
||||
|
||||
<h1>{% trans %}update_manager.maintenance.heading{% endtrans %}</h1>
|
||||
|
||||
<p class="text-secondary fs-5">
|
||||
{% trans %}update_manager.maintenance.description{% endtrans %}
|
||||
</p>
|
||||
|
||||
<div class="reason-badge">
|
||||
<i class="fas fa-arrow-up me-2"></i>
|
||||
{{ reason }}
|
||||
</div>
|
||||
|
||||
<div class="progress-container">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-steps">
|
||||
<div class="status-step" id="step-backup">
|
||||
<i class="fas fa-database"></i>
|
||||
<span>{% trans %}update_manager.maintenance.step_backup{% endtrans %}</span>
|
||||
</div>
|
||||
<div class="status-step" id="step-download">
|
||||
<i class="fas fa-download"></i>
|
||||
<span>{% trans %}update_manager.maintenance.step_download{% endtrans %}</span>
|
||||
</div>
|
||||
<div class="status-step" id="step-install">
|
||||
<i class="fas fa-box-open"></i>
|
||||
<span>{% trans %}update_manager.maintenance.step_install{% endtrans %}</span>
|
||||
</div>
|
||||
<div class="status-step" id="step-migrate">
|
||||
<i class="fas fa-database"></i>
|
||||
<span>{% trans %}update_manager.maintenance.step_migrate{% endtrans %}</span>
|
||||
</div>
|
||||
<div class="status-step" id="step-cache">
|
||||
<i class="fas fa-sync"></i>
|
||||
<span>{% trans %}update_manager.maintenance.step_cache{% endtrans %}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if duration is not null %}
|
||||
<div class="timer" id="timer">
|
||||
{{ duration // 60 }}:{{ '%02d'|format(duration % 60) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="refresh-info">
|
||||
<i class="fas fa-sync-alt me-1"></i>
|
||||
{% trans %}update_manager.maintenance.auto_refresh{% endtrans %}
|
||||
</p>
|
||||
|
||||
<div class="logo">
|
||||
<i class="fa fa-microchip me-2"></i> Part-DB
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simulate step progression
|
||||
const steps = ['step-backup', 'step-download', 'step-install', 'step-migrate', 'step-cache'];
|
||||
let currentStep = 0;
|
||||
|
||||
function updateSteps() {
|
||||
steps.forEach((stepId, index) => {
|
||||
const step = document.getElementById(stepId);
|
||||
if (index < currentStep) {
|
||||
step.classList.add('completed');
|
||||
step.classList.remove('active');
|
||||
step.querySelector('i').className = 'fas fa-check-circle';
|
||||
} else if (index === currentStep) {
|
||||
step.classList.add('active');
|
||||
step.querySelector('i').className = 'fas fa-spinner fa-spin';
|
||||
}
|
||||
});
|
||||
|
||||
currentStep = (currentStep + 1) % (steps.length + 1);
|
||||
if (currentStep === 0) {
|
||||
steps.forEach(stepId => {
|
||||
const step = document.getElementById(stepId);
|
||||
step.classList.remove('completed', 'active');
|
||||
step.querySelector('i').className = step.querySelector('i').className.replace('fa-check-circle', 'fas');
|
||||
step.querySelector('i').className = step.querySelector('i').className.replace('fa-spinner fa-spin', 'fas');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(updateSteps, 3000);
|
||||
|
||||
// Update timer
|
||||
{% if duration is not null %}
|
||||
let seconds = {{ duration }};
|
||||
const timerEl = document.getElementById('timer');
|
||||
|
||||
setInterval(() => {
|
||||
seconds++;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
timerEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}, 1000);
|
||||
{% endif %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -12826,6 +12826,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
|||
<target>System settings</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_tree_update_manager" name="tree.tools.system.update_manager">
|
||||
<segment state="translated">
|
||||
<source>tree.tools.system.update_manager</source>
|
||||
<target>Update Manager</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3YsJ4i6" name="settings.tooltip.overrideable_by_env">
|
||||
<segment state="translated">
|
||||
<source>settings.tooltip.overrideable_by_env</source>
|
||||
|
|
@ -14286,5 +14292,701 @@ Buerklin-API Authentication server:
|
|||
<target>Transport error while retrieving information from the providers. Check that your server has internet accesss. See server logs for more info.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_title" name="update_manager.title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.title</source>
|
||||
<target>Update Manager</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_new" name="update_manager.new">
|
||||
<segment state="translated">
|
||||
<source>update_manager.new</source>
|
||||
<target>New</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_cur_inst" name="update_manager.current_installation">
|
||||
<segment state="translated">
|
||||
<source>update_manager.current_installation</source>
|
||||
<target>Current Installation</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_version" name="update_manager.version">
|
||||
<segment state="translated">
|
||||
<source>update_manager.version</source>
|
||||
<target>Version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_inst_type" name="update_manager.installation_type">
|
||||
<segment state="translated">
|
||||
<source>update_manager.installation_type</source>
|
||||
<target>Installation Type</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_git_branch" name="update_manager.git_branch">
|
||||
<segment state="translated">
|
||||
<source>update_manager.git_branch</source>
|
||||
<target>Git Branch</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_git_commit" name="update_manager.git_commit">
|
||||
<segment state="translated">
|
||||
<source>update_manager.git_commit</source>
|
||||
<target>Git Commit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_local_changes" name="update_manager.local_changes">
|
||||
<segment state="translated">
|
||||
<source>update_manager.local_changes</source>
|
||||
<target>Local Changes</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_commits_behind" name="update_manager.commits_behind">
|
||||
<segment state="translated">
|
||||
<source>update_manager.commits_behind</source>
|
||||
<target>Commits Behind</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_auto_update" name="update_manager.auto_update_supported">
|
||||
<segment state="translated">
|
||||
<source>update_manager.auto_update_supported</source>
|
||||
<target>Auto-Update Supported</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_refresh" name="update_manager.refresh">
|
||||
<segment state="translated">
|
||||
<source>update_manager.refresh</source>
|
||||
<target>Refresh</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_latest_release" name="update_manager.latest_release">
|
||||
<segment state="translated">
|
||||
<source>update_manager.latest_release</source>
|
||||
<target>Latest Release</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_tag" name="update_manager.tag">
|
||||
<segment state="translated">
|
||||
<source>update_manager.tag</source>
|
||||
<target>Tag</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_released" name="update_manager.released">
|
||||
<segment state="translated">
|
||||
<source>update_manager.released</source>
|
||||
<target>Released</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_release_notes" name="update_manager.release_notes">
|
||||
<segment state="translated">
|
||||
<source>update_manager.release_notes</source>
|
||||
<target>Release Notes</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_view" name="update_manager.view">
|
||||
<segment state="translated">
|
||||
<source>update_manager.view</source>
|
||||
<target>View</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_view_github" name="update_manager.view_on_github">
|
||||
<segment state="translated">
|
||||
<source>update_manager.view_on_github</source>
|
||||
<target>View on GitHub</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_view_release" name="update_manager.view_release">
|
||||
<segment state="translated">
|
||||
<source>update_manager.view_release</source>
|
||||
<target>View Release</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_could_not_fetch" name="update_manager.could_not_fetch_releases">
|
||||
<segment state="translated">
|
||||
<source>update_manager.could_not_fetch_releases</source>
|
||||
<target>Could not fetch release information. Check your internet connection.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_how_to" name="update_manager.how_to_update">
|
||||
<segment state="translated">
|
||||
<source>update_manager.how_to_update</source>
|
||||
<target>How to Update</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_cli_instruction" name="update_manager.cli_instruction">
|
||||
<segment state="translated">
|
||||
<source>update_manager.cli_instruction</source>
|
||||
<target>To update Part-DB, run one of the following commands in your terminal:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_check_updates" name="update_manager.check_for_updates">
|
||||
<segment state="translated">
|
||||
<source>update_manager.check_for_updates</source>
|
||||
<target>Check for updates</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_update_latest" name="update_manager.update_to_latest">
|
||||
<segment state="translated">
|
||||
<source>update_manager.update_to_latest</source>
|
||||
<target>Update to latest version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_update_specific" name="update_manager.update_to_specific">
|
||||
<segment state="translated">
|
||||
<source>update_manager.update_to_specific</source>
|
||||
<target>Update to specific version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_cli_rec" name="update_manager.cli_recommendation">
|
||||
<segment state="translated">
|
||||
<source>update_manager.cli_recommendation</source>
|
||||
<target>For safety and reliability, updates should be performed via the command line interface. The update process will automatically create a backup, enable maintenance mode, and handle migrations.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_yes" name="update_manager.yes">
|
||||
<segment state="translated">
|
||||
<source>update_manager.yes</source>
|
||||
<target>Yes</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_no" name="update_manager.no">
|
||||
<segment state="translated">
|
||||
<source>update_manager.no</source>
|
||||
<target>No</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_up_to_date" name="update_manager.up_to_date">
|
||||
<segment state="translated">
|
||||
<source>update_manager.up_to_date</source>
|
||||
<target>Up to date</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_newer" name="update_manager.newer">
|
||||
<segment state="translated">
|
||||
<source>update_manager.newer</source>
|
||||
<target>Newer</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_current" name="update_manager.current">
|
||||
<segment state="translated">
|
||||
<source>update_manager.current</source>
|
||||
<target>Current</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_older" name="update_manager.older">
|
||||
<segment state="translated">
|
||||
<source>update_manager.older</source>
|
||||
<target>Older</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_prerelease" name="update_manager.prerelease">
|
||||
<segment state="translated">
|
||||
<source>update_manager.prerelease</source>
|
||||
<target>Pre-release</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_status" name="update_manager.status">
|
||||
<segment state="translated">
|
||||
<source>update_manager.status</source>
|
||||
<target>Status</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_avail_ver" name="update_manager.available_versions">
|
||||
<segment state="translated">
|
||||
<source>update_manager.available_versions</source>
|
||||
<target>Available Versions</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_no_releases" name="update_manager.no_releases_found">
|
||||
<segment state="translated">
|
||||
<source>update_manager.no_releases_found</source>
|
||||
<target>No releases found</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_view_rn" name="update_manager.view_release_notes">
|
||||
<segment state="translated">
|
||||
<source>update_manager.view_release_notes</source>
|
||||
<target>View Release Notes</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_update_logs" name="update_manager.update_logs">
|
||||
<segment state="translated">
|
||||
<source>update_manager.update_logs</source>
|
||||
<target>Update Logs</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_backups" name="update_manager.backups">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backups</source>
|
||||
<target>Backups</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_date" name="update_manager.date">
|
||||
<segment state="translated">
|
||||
<source>update_manager.date</source>
|
||||
<target>Date</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_log_file" name="update_manager.log_file">
|
||||
<segment state="translated">
|
||||
<source>update_manager.log_file</source>
|
||||
<target>Log File</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_no_logs" name="update_manager.no_logs_found">
|
||||
<segment state="translated">
|
||||
<source>update_manager.no_logs_found</source>
|
||||
<target>No update logs found</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_file" name="update_manager.file">
|
||||
<segment state="translated">
|
||||
<source>update_manager.file</source>
|
||||
<target>File</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_size" name="update_manager.size">
|
||||
<segment state="translated">
|
||||
<source>update_manager.size</source>
|
||||
<target>Size</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_no_backups" name="update_manager.no_backups_found">
|
||||
<segment state="translated">
|
||||
<source>update_manager.no_backups_found</source>
|
||||
<target>No backups found</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_checklist" name="update_manager.pre_update_checklist">
|
||||
<segment state="translated">
|
||||
<source>update_manager.pre_update_checklist</source>
|
||||
<target>Pre-Update Checklist</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_before" name="update_manager.before_updating">
|
||||
<segment state="translated">
|
||||
<source>update_manager.before_updating</source>
|
||||
<target>Before Updating</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_chk_req" name="update_manager.checklist.requirements">
|
||||
<segment state="translated">
|
||||
<source>update_manager.checklist.requirements</source>
|
||||
<target>All requirements met</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_chk_no_local" name="update_manager.checklist.no_local_changes">
|
||||
<segment state="translated">
|
||||
<source>update_manager.checklist.no_local_changes</source>
|
||||
<target>No local modifications</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_chk_backup" name="update_manager.checklist.backup_created">
|
||||
<segment state="translated">
|
||||
<source>update_manager.checklist.backup_created</source>
|
||||
<target>Backup will be created automatically</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_chk_rn" name="update_manager.checklist.read_release_notes">
|
||||
<segment state="translated">
|
||||
<source>update_manager.checklist.read_release_notes</source>
|
||||
<target>Read release notes for breaking changes</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_update_will" name="update_manager.update_will">
|
||||
<segment state="translated">
|
||||
<source>update_manager.update_will</source>
|
||||
<target>The Update Will</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_will_backup" name="update_manager.will.backup">
|
||||
<segment state="translated">
|
||||
<source>update_manager.will.backup</source>
|
||||
<target>Create a full backup</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_will_maint" name="update_manager.will.maintenance">
|
||||
<segment state="translated">
|
||||
<source>update_manager.will.maintenance</source>
|
||||
<target>Enable maintenance mode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_will_git" name="update_manager.will.git">
|
||||
<segment state="translated">
|
||||
<source>update_manager.will.git</source>
|
||||
<target>Pull latest code from Git</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_will_composer" name="update_manager.will.composer">
|
||||
<segment state="translated">
|
||||
<source>update_manager.will.composer</source>
|
||||
<target>Update dependencies via Composer</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_will_migrate" name="update_manager.will.migrations">
|
||||
<segment state="translated">
|
||||
<source>update_manager.will.migrations</source>
|
||||
<target>Run database migrations</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_will_cache" name="update_manager.will.cache">
|
||||
<segment state="translated">
|
||||
<source>update_manager.will.cache</source>
|
||||
<target>Clear and rebuild cache</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_validation" name="update_manager.validation_issues">
|
||||
<segment state="translated">
|
||||
<source>update_manager.validation_issues</source>
|
||||
<target>Validation Issues</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_maint_active" name="update_manager.maintenance_mode_active">
|
||||
<segment state="translated">
|
||||
<source>update_manager.maintenance_mode_active</source>
|
||||
<target>Maintenance mode is active</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_upd_progress" name="update_manager.update_in_progress">
|
||||
<segment state="translated">
|
||||
<source>update_manager.update_in_progress</source>
|
||||
<target>An update is currently in progress</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_started_at" name="update_manager.started_at">
|
||||
<segment state="translated">
|
||||
<source>update_manager.started_at</source>
|
||||
<target>Started at</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_msg" name="update_manager.new_version_available.message">
|
||||
<segment state="translated">
|
||||
<source>update_manager.new_version_available.message</source>
|
||||
<target>Part-DB version %version% is now available! Consider updating to get the latest features and security fixes.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_changelog" name="update_manager.changelog">
|
||||
<segment state="translated">
|
||||
<source>update_manager.changelog</source>
|
||||
<target>Changelog</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_no_rn" name="update_manager.no_release_notes">
|
||||
<segment state="translated">
|
||||
<source>update_manager.no_release_notes</source>
|
||||
<target>No release notes available for this version.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_back_to" name="update_manager.back_to_update_manager">
|
||||
<segment state="translated">
|
||||
<source>update_manager.back_to_update_manager</source>
|
||||
<target>Back to Update Manager</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_download" name="update_manager.download_assets">
|
||||
<segment state="translated">
|
||||
<source>update_manager.download_assets</source>
|
||||
<target>Download</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_upd_to_ver" name="update_manager.update_to_this_version">
|
||||
<segment state="translated">
|
||||
<source>update_manager.update_to_this_version</source>
|
||||
<target>Update to this Version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_run_cmd" name="update_manager.run_command_to_update">
|
||||
<segment state="translated">
|
||||
<source>update_manager.run_command_to_update</source>
|
||||
<target>Run the following command in your terminal to update to this version:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_log_viewer" name="update_manager.log_viewer">
|
||||
<segment state="translated">
|
||||
<source>update_manager.log_viewer</source>
|
||||
<target>Log Viewer</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_update_log" name="update_manager.update_log">
|
||||
<segment state="translated">
|
||||
<source>update_manager.update_log</source>
|
||||
<target>Update Log</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_bytes" name="update_manager.bytes">
|
||||
<segment state="translated">
|
||||
<source>update_manager.bytes</source>
|
||||
<target>bytes</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_maint_title" name="update_manager.maintenance.title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.maintenance.title</source>
|
||||
<target>Maintenance</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_maint_head" name="update_manager.maintenance.heading">
|
||||
<segment state="translated">
|
||||
<source>update_manager.maintenance.heading</source>
|
||||
<target>Part-DB is Updating</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_maint_desc" name="update_manager.maintenance.description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.maintenance.description</source>
|
||||
<target>We're installing updates to make Part-DB even better. This should only take a moment.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_maint_backup" name="update_manager.maintenance.step_backup">
|
||||
<segment state="translated">
|
||||
<source>update_manager.maintenance.step_backup</source>
|
||||
<target>Creating backup</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_maint_download" name="update_manager.maintenance.step_download">
|
||||
<segment state="translated">
|
||||
<source>update_manager.maintenance.step_download</source>
|
||||
<target>Downloading updates</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_maint_install" name="update_manager.maintenance.step_install">
|
||||
<segment state="translated">
|
||||
<source>update_manager.maintenance.step_install</source>
|
||||
<target>Installing files</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_maint_migrate" name="update_manager.maintenance.step_migrate">
|
||||
<segment state="translated">
|
||||
<source>update_manager.maintenance.step_migrate</source>
|
||||
<target>Running migrations</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_maint_cache" name="update_manager.maintenance.step_cache">
|
||||
<segment state="translated">
|
||||
<source>update_manager.maintenance.step_cache</source>
|
||||
<target>Clearing cache</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_maint_refresh" name="update_manager.maintenance.auto_refresh">
|
||||
<segment state="translated">
|
||||
<source>update_manager.maintenance.auto_refresh</source>
|
||||
<target>This page will refresh automatically when the update is complete.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_perm_manage" name="perm.system.manage_updates">
|
||||
<segment state="translated">
|
||||
<source>perm.system.manage_updates</source>
|
||||
<target>Manage Part-DB updates</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_update_now" name="update_manager.update_now">
|
||||
<segment state="translated">
|
||||
<source>update_manager.update_now</source>
|
||||
<target>Update Now</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_update_from_to" name="update_manager.update_from_to">
|
||||
<segment state="translated">
|
||||
<source>update_manager.update_from_to</source>
|
||||
<target>Update from %from% to %to%</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_update_desc" name="update_manager.update_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.update_description</source>
|
||||
<target>Click the button to start the update process. A backup will be created automatically and you can monitor the progress.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_start_update" name="update_manager.start_update">
|
||||
<segment state="translated">
|
||||
<source>update_manager.start_update</source>
|
||||
<target>Start Update</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_create_backup" name="update_manager.create_backup">
|
||||
<segment state="translated">
|
||||
<source>update_manager.create_backup</source>
|
||||
<target>Create backup before updating (recommended)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_estimated_time" name="update_manager.estimated_time">
|
||||
<segment state="translated">
|
||||
<source>update_manager.estimated_time</source>
|
||||
<target>Update typically takes 2-5 minutes</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_confirm_update" name="update_manager.confirm_update">
|
||||
<segment state="translated">
|
||||
<source>update_manager.confirm_update</source>
|
||||
<target>Are you sure you want to start the update? The system will be in maintenance mode during the update.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_starting" name="update_manager.starting">
|
||||
<segment state="translated">
|
||||
<source>update_manager.starting</source>
|
||||
<target>Starting...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_already_up_to_date" name="update_manager.already_up_to_date">
|
||||
<segment state="translated">
|
||||
<source>update_manager.already_up_to_date</source>
|
||||
<target>You are running the latest version of Part-DB.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_cli_alternative" name="update_manager.cli_alternative">
|
||||
<segment state="translated">
|
||||
<source>update_manager.cli_alternative</source>
|
||||
<target>Alternatively, you can update via the command line:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_title" name="update_manager.progress.title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.title</source>
|
||||
<target>Update Progress</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_updating" name="update_manager.progress.updating">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.updating</source>
|
||||
<target>Updating Part-DB...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_completed" name="update_manager.progress.completed">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.completed</source>
|
||||
<target>Update Completed!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_failed" name="update_manager.progress.failed">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.failed</source>
|
||||
<target>Update Failed</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_init" name="update_manager.progress.initializing">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.initializing</source>
|
||||
<target>Initializing...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_updating_to" name="update_manager.progress.updating_to">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.updating_to</source>
|
||||
<target>Updating to version %version%</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_error" name="update_manager.progress.error">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.error</source>
|
||||
<target>Error</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_success_msg" name="update_manager.progress.success_message">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.success_message</source>
|
||||
<target>Part-DB has been successfully updated! You may need to refresh the page to see the new version.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_steps" name="update_manager.progress.steps">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.steps</source>
|
||||
<target>Update Steps</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_waiting" name="update_manager.progress.waiting">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.waiting</source>
|
||||
<target>Waiting for update to start...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_back" name="update_manager.progress.back">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.back</source>
|
||||
<target>Back to Update Manager</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_refresh" name="update_manager.progress.refresh_page">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.refresh_page</source>
|
||||
<target>Refresh Page</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_warning" name="update_manager.progress.warning">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.warning</source>
|
||||
<target>Important</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_do_not_close" name="update_manager.progress.do_not_close">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.do_not_close</source>
|
||||
<target>Please do not close this page or navigate away while the update is in progress. The update will continue even if you close this page, but you won't be able to monitor the progress.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_auto_refresh" name="update_manager.progress.auto_refresh">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.auto_refresh</source>
|
||||
<target>This page will automatically refresh every 2 seconds to show progress.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_downgrade_title" name="update_manager.progress.downgrade_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.downgrade_title</source>
|
||||
<target>Downgrade Progress</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_downgrading" name="update_manager.progress.downgrading">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.downgrading</source>
|
||||
<target>Downgrading Part-DB...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_downgrading_to" name="update_manager.progress.downgrading_to">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.downgrading_to</source>
|
||||
<target>Downgrading to version %version%</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_downgrade_completed" name="update_manager.progress.downgrade_completed">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.downgrade_completed</source>
|
||||
<target>Downgrade Completed!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_downgrade_failed" name="update_manager.progress.downgrade_failed">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.downgrade_failed</source>
|
||||
<target>Downgrade Failed</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_downgrade_success_message" name="update_manager.progress.downgrade_success_message">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.downgrade_success_message</source>
|
||||
<target>Part-DB has been successfully downgraded! You may need to refresh the page to see the new version.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_downgrade_steps" name="update_manager.progress.downgrade_steps">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.downgrade_steps</source>
|
||||
<target>Downgrade Steps</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_progress_downgrade_do_not_close" name="update_manager.progress.downgrade_do_not_close">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.downgrade_do_not_close</source>
|
||||
<target>Please do not close this page or navigate away while the downgrade is in progress. The downgrade will continue even if you close this page, but you won't be able to monitor the progress.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_confirm_update" name="update_manager.confirm_update">
|
||||
<segment state="translated">
|
||||
<source>update_manager.confirm_update</source>
|
||||
<target>Are you sure you want to update Part-DB? A backup will be created before the update.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="um_confirm_downgrade" name="update_manager.confirm_downgrade">
|
||||
<segment state="translated">
|
||||
<source>update_manager.confirm_downgrade</source>
|
||||
<target>Are you sure you want to downgrade Part-DB? This will revert to an older version. A backup will be created before the downgrade.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue