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:
Sebastian Almberg 2026-01-30 21:36:33 +01:00
parent ae4c0786b2
commit 42fe781ef8
16 changed files with 4126 additions and 0 deletions

View file

@ -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">

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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>