mailwolt/app/Livewire/Ui/System/UpdatePage.php

308 lines
10 KiB
PHP

<?php
namespace App\Livewire\Ui\System;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Updates · Mailwolt')]
class UpdatePage extends Component
{
/* ===== Version ===== */
public ?string $current = null;
public ?string $latest = null;
public ?string $displayCurrent = null;
public ?string $displayLatest = null;
public bool $hasUpdate = false;
/* ===== Run state ===== */
public string $state = 'idle'; // idle | running
public bool $running = false;
public ?int $rc = null;
public ?string $lowState = null; // running | done | null
public array $logLines = [];
public int $progressPct = 0;
public bool $postActionsDone = false;
protected string $cacheStartedAtKey = 'mw.update.started_at';
protected int $failsafeSeconds = 20 * 60;
private const VERSION_FILE = '/var/lib/mailwolt/version';
private const VERSION_FILE_RAW = '/var/lib/mailwolt/version_raw';
private const BUILD_INFO = '/etc/mailwolt/build.info';
private const UPDATE_LOG = '/var/log/mailwolt-update.log';
private const STATE_DIR = '/var/lib/mailwolt/update';
/* ========================================================= */
public function mount(): void
{
$this->reloadVersionsAndStatus();
$this->recompute();
$this->readLogLines();
if ($this->running) {
$this->state = 'running';
}
$this->recalcProgress();
}
public function render()
{
return view('livewire.ui.system.update-page');
}
/* ================== Aktionen ================== */
public function checkForUpdates(): void
{
@shell_exec('php ' . base_path('artisan') . ' mailwolt:check-updates 2>&1');
$this->reloadVersionsAndStatus();
$this->recompute();
if ($this->hasUpdate) {
$this->dispatch('toast', type: 'done', badge: 'Updates',
title: 'Update verfügbar',
text: "Version {$this->displayLatest} ist verfügbar.",
duration: 4000);
} else {
$this->dispatch('toast', type: 'done', badge: 'Updates',
title: 'Alles aktuell',
text: 'Es sind keine Updates verfügbar.',
duration: 3000);
}
}
public function runUpdate(): void
{
if ($this->running || $this->state === 'running') {
$this->dispatch('toast', type: 'warn', badge: 'Updates',
title: 'Läuft bereits',
text: 'Ein Update-Prozess ist bereits aktiv.',
duration: 3000);
return;
}
Cache::forget('mailwolt.update_available');
Cache::put($this->cacheStartedAtKey, time(), now()->addHour());
@shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-update >/dev/null 2>&1 &');
$this->latest = null;
$this->displayLatest = null;
$this->hasUpdate = false;
$this->state = 'running';
$this->running = true;
$this->rc = null;
$this->postActionsDone = false;
$this->logLines = ['Update gestartet …'];
$this->progressPct = 5;
}
public function pollStatus(): void
{
$this->refreshLowLevelState();
$this->readLogLines();
$this->recalcProgress();
if ($this->rc !== null) {
$this->running = false;
}
// Failsafe
$started = (int) Cache::get($this->cacheStartedAtKey, 0);
if ($this->running && $started > 0 && (time() - $started) > $this->failsafeSeconds) {
$this->running = false;
$this->lowState = 'done';
$this->rc ??= 0;
}
if ($this->lowState === 'done') {
Cache::forget($this->cacheStartedAtKey);
usleep(300_000);
$this->reloadVersionsAndStatus();
$this->recompute();
$this->readLogLines();
$this->progressPct = 100;
if ($this->rc === 0 && !$this->postActionsDone) {
@shell_exec('nohup php /var/www/mailwolt/artisan optimize:clear >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan settings:sync >/dev/null 2>&1 &');
$this->postActionsDone = true;
$ver = $this->displayCurrent ?? 'aktuelle Version';
$this->dispatch('toast', type: 'done', badge: 'Updates',
title: 'Update abgeschlossen',
text: "Mailwolt wurde auf {$ver} aktualisiert.",
duration: 6000);
} elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
$this->postActionsDone = true;
$this->dispatch('toast', type: 'error', badge: 'Updates',
title: 'Update fehlgeschlagen',
text: "Rückgabecode: {$this->rc}. Bitte Log prüfen.",
duration: 0);
}
$this->state = 'idle';
}
}
public function clearLog(): void
{
// Only admins / allow via gate if you have policy; basic protection:
@file_put_contents(self::UPDATE_LOG, '');
$this->logLines = [];
$this->dispatch('toast', type: 'done', badge: 'Updates',
title: 'Log geleert', text: '', duration: 2500);
}
/* ================== Helpers ================== */
protected function reloadVersionsAndStatus(): void
{
$this->current = $this->readCurrentVersion();
$latNorm = Cache::get('updates:latest');
$latRaw = Cache::get('updates:latest_raw');
if (!$latNorm && ($legacy = Cache::get('mailwolt.update_available'))) {
$latNorm = $this->normalizeVersion($legacy);
$latRaw = $legacy;
}
$this->latest = $latNorm ?: null;
$this->displayLatest = $latRaw ?: ($latNorm ? 'v' . $latNorm : null);
$this->refreshLowLevelState();
}
protected function recompute(): void
{
$curNorm = $this->normalizeVersion($this->current);
$latNorm = $this->normalizeVersion($this->latest);
$this->hasUpdate = ($curNorm && $latNorm)
? version_compare($latNorm, $curNorm, '>')
: false;
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
if (!$this->displayLatest && $latNorm) {
$this->displayLatest = 'v' . $latNorm;
}
}
protected function refreshLowLevelState(): void
{
$state = @trim(@file_get_contents(self::STATE_DIR . '/state') ?: '');
$rcRaw = @trim(@file_get_contents(self::STATE_DIR . '/rc') ?: '');
$this->lowState = $state !== '' ? $state : null;
$this->running = ($this->lowState !== 'done');
$this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int) $rcRaw : null;
}
protected function readLogLines(): void
{
$p = self::UPDATE_LOG;
if (!is_readable($p)) {
$this->logLines = [];
return;
}
$lines = @file($p, FILE_IGNORE_NEW_LINES) ?: [];
$this->logLines = array_slice($lines, -100);
}
protected function recalcProgress(): void
{
if ($this->state !== 'running' && $this->lowState !== 'running') {
if ($this->lowState === 'done') {
$this->progressPct = 100;
}
return;
}
$text = implode("\n", $this->logLines);
$pct = 5;
foreach ([
'Update gestartet' => 10,
'Composer' => 25,
'npm ci' => 40,
'npm run build' => 60,
'migrate' => 75,
'optimize' => 85,
'Version aktualisiert' => 95,
'Update beendet' => 100,
] as $needle => $val) {
if (stripos($text, $needle) !== false) {
$pct = max($pct, $val);
}
}
if ($this->lowState === 'done') {
$pct = 100;
}
$this->progressPct = $pct;
}
protected function readCurrentVersion(): ?string
{
// Lokal: direkt aus git describe lesen damit Entwicklungsumgebung immer aktuell ist
if (app()->isLocal()) {
$tag = @trim((string) shell_exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null'));
$v = $this->normalizeVersion($tag);
if ($v) return $v;
}
$v = @trim(@file_get_contents(self::VERSION_FILE) ?: '');
if ($v !== '') return $v;
$raw = @trim(@file_get_contents(self::VERSION_FILE_RAW) ?: '');
if ($raw !== '') return $this->normalizeVersion($raw);
$build = @file_get_contents(self::BUILD_INFO);
if ($build) {
foreach (preg_split('/\R+/', $build) as $line) {
if (str_starts_with($line, 'version=')) {
$v = $this->normalizeVersion(trim(substr($line, 8)));
if ($v) return $v;
}
}
}
$v = $this->normalizeVersion(config('app.version') ?: '');
return $v ?: null;
}
protected function normalizeVersion(?string $v): ?string
{
if ($v === null) return null;
$v = trim($v);
if ($v === '') return null;
$v = ltrim($v, "vV \t\n\r\0\x0B");
$v = preg_replace('/-.*$/', '', $v);
return $v !== '' ? $v : null;
}
protected function getBuildTimestamp(): ?string
{
$build = @file_get_contents(self::BUILD_INFO);
if (!$build) return null;
foreach (preg_split('/\R+/', $build) as $line) {
if (str_starts_with($line, 'built_at=') || str_starts_with($line, 'date=')) {
$parts = explode('=', $line, 2);
return trim($parts[1] ?? '');
}
}
return null;
}
}