Unifi-Voucher-Tool/updater/templates/update.php
Friederich Loheide 7f1d93debd Barrierefreiheit und mobile Darstellung
- Kontrast: gedämpfter Text war mit 3,1:1 unter WCAG AA, jetzt 4,9:1
  (hell) bzw. 6,4:1 (dunkel)
- Sprungmarke „Zum Inhalt springen" in Admin-Shell und Voucher-Seite
- aria-label für alle reinen Icon-Schaltflächen (Theme, Menü, Abmelden,
  Zeilenaktionen), aria-current auf dem aktiven Navigationspunkt,
  role="group" für den Sprachumschalter
- 194 dekorative Icons mit aria-hidden versehen, damit Screenreader sie
  nicht vorlesen
- Toast-Container als aria-live-Bereich ausgezeichnet
- prefers-reduced-motion schaltet Animationen und Übergänge ab

Tabellen (Benutzer, Vouchers, Profile, Audit-Log, Dashboard, API-Keys)
werden unter 720 px zu Karten: die Spaltenüberschrift steht per
data-label vor dem Wert, statt horizontal zu scrollen.

Datums- und Zeitformat in der Voucher-Liste folgen jetzt der gewählten
Sprache statt fest de-DE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:38:08 +00:00

266 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Admin-UI fuer den Updater. Nutzt das gemeinsame Design-System
* (assets/global.css); nur Updater-spezifische Regeln stehen inline.
*
* Verfuegbare Variablen: $manager, $auth, $currentSha, $channel, $csrfToken
*
* @var \Updater\UpdateManager $manager
* @var \Auth $auth
* @var string $currentSha
* @var string $channel
* @var string $csrfToken
*/
$shortSha = $currentSha !== '' ? substr($currentSha, 0, 7) : 'unbekannt';
$channels = \Updater\UpdateManager::CHANNELS;
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System-Update</title>
<?php require_once dirname(__DIR__, 2) . '/includes/Ui.php'; ?>
<?= Ui::head(null, '../') ?>
<style>
/* Updater-spezifisch der Rest kommt aus dem gemeinsamen Design-System. */
.wrap { max-width: 780px; margin: 0 auto; padding: 36px 0 60px; }
.update-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; }
.update-head .title { display: flex; align-items: center; gap: 13px; }
.kv-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 11px 0; border-bottom: 1px solid var(--border-color); }
.kv-row:last-of-type { border-bottom: none; }
.kv-row .k { color: var(--text-secondary); font-size: 13.5px; }
.kv-row .v { color: var(--text-primary); font-size: 13.5px; font-weight: 600; font-family: var(--font-mono); }
.kv-row .v select { font-family: var(--font-sans); font-weight: 500; width: auto; min-width: 150px; }
.progress-wrap { display: none; margin-top: 18px; }
.progress-bar { height: 8px; border-radius: var(--r-pill); background: var(--bg-hover); overflow: hidden; }
.progress-fill { height: 100%; width: 0; border-radius: var(--r-pill); background: var(--accent); transition: width .3s; }
.progress-msg { margin-top: 8px; font-size: 12.5px; color: var(--text-muted); }
.alert { display: none; margin: 16px 0 0; }
.alert.show { display: flex; }
.changelog {
display: none;
margin-top: 16px; padding: 14px;
max-height: 260px; overflow: auto;
background: var(--bg-subtle); border: 1px solid var(--border-color); border-radius: var(--r-md);
font-size: 13px; color: var(--text-secondary); white-space: pre-wrap;
}
.mig-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--border-color); font-size: 13.5px; }
.mig-item:last-child { border-bottom: none; }
.mig-item span:first-child { font-family: var(--font-mono); font-size: 12.5px; }
.badge-on { background: var(--success-soft); color: var(--success); border: 1px solid var(--success-border); }
.badge-off { background: var(--warning-soft); color: var(--warning); border: 1px solid var(--warning-border); }
.tabs { display: inline-flex; gap: 3px; padding: 3px; margin-bottom: 18px; background: var(--bg-hover); border: 1px solid var(--border-color); border-radius: var(--r-md); }
.tab { padding: 7px 16px; border-radius: var(--r-sm); font-size: 13px; font-weight: 550; color: var(--text-secondary); cursor: pointer; }
.tab.active { background: var(--bg-card); color: var(--text-primary); box-shadow: var(--shadow-xs); font-weight: 600; }
.pane { display: none; }
.pane.active { display: block; }
</style>
</head>
<body class="app-body">
<div class="wrap">
<div class="update-head">
<div class="title">
<span class="focus-icon"><i class="fas fa-rotate" aria-hidden="true"></i></span>
<div>
<h1 style="font-size:20px;">System-Update</h1>
<p class="sub">Aktualisierung aus dem gewählten Release-Channel</p>
</div>
</div>
<a href="/admin/" class="btn btn-secondary"><i class="fas fa-arrow-left" aria-hidden="true"></i> Administration</a>
</div>
<div class="tabs">
<div class="tab active" data-pane="update">Update</div>
<div class="tab" data-pane="migrations">Migrationen</div>
</div>
<div class="pane active" id="pane-update">
<div class="card">
<h2>Aktuelle Version</h2>
<div class="kv-row"><span class="k">Installierte Version (SHA)</span><span class="v" id="curSha"><?= htmlspecialchars($shortSha) ?></span></div>
<div class="kv-row"><span class="k">Update-Channel</span>
<span class="v">
<select id="channel">
<?php foreach ($channels as $key => $url): ?>
<option value="<?= htmlspecialchars($key) ?>" <?= $channel === $key ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst($key)) ?></option>
<?php endforeach; ?>
</select>
</span>
</div>
<p class="muted">Channel wird in <code>updater/storage/updater-settings.json</code> gespeichert.</p>
</div>
<div class="card">
<h2>Updates</h2>
<button class="btn btn-primary" id="btnCheck"><i class="fas fa-magnifying-glass" aria-hidden="true"></i> Auf Updates prüfen</button>
<button class="btn btn-success" id="btnInstall" style="display:none;margin-left:8px;"><i class="fas fa-download" aria-hidden="true"></i> Update installieren</button>
<div class="changelog" id="changelog"></div>
<div class="progress-wrap" id="progressWrap">
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
<div class="progress-msg" id="progressMsg"></div>
</div>
<div class="alert alert-error" id="alertError"></div>
<div class="alert alert-ok" id="alertOk"></div>
</div>
</div>
<div class="pane" id="pane-migrations">
<div class="card">
<h2>Migrations-Status</h2>
<div id="migList"><p class="muted">Wird geladen …</p></div>
<button class="btn btn-primary" id="btnRunMig" style="margin-top:16px; display:none;">Ausstehende Migrationen ausführen</button>
<div class="alert alert-ok" id="migAlert"></div>
</div>
</div>
</div>
<script>
const CSRF = <?= json_encode($csrfToken) ?>;
const $ = (id) => document.getElementById(id);
function showAlert(type, msg) {
const el = type === 'error' ? $('alertError') : $('alertOk');
el.textContent = msg; el.classList.add('show');
const other = type === 'error' ? $('alertOk') : $('alertError');
other.classList.remove('show');
}
function clearAlerts() { $('alertError').classList.remove('show'); $('alertOk').classList.remove('show'); }
// Tabs
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.pane').forEach(x => x.classList.remove('active'));
t.classList.add('active');
$('pane-' + t.dataset.pane).classList.add('active');
if (t.dataset.pane === 'migrations') loadMigrations();
}));
// Channel speichern
$('channel').addEventListener('change', async (e) => {
const body = new URLSearchParams({ action: 'set_channel', channel: e.target.value, csrf_token: CSRF });
const r = await fetch('update.php', { method: 'POST', body });
if (r.ok) showAlert('ok', 'Channel auf "' + e.target.value + '" gesetzt.');
else showAlert('error', 'Channel konnte nicht gespeichert werden.');
});
// Auf Updates pruefen
$('btnCheck').addEventListener('click', async () => {
clearAlerts();
$('btnCheck').disabled = true; $('btnCheck').innerHTML = '<i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> Prüfe …';
try {
const r = await fetch('update.php?action=check');
const d = await r.json();
if (d.error) { showAlert('error', d.error); return; }
if (d.has_update) {
showAlert('ok', 'Update verfügbar: ' + (d.latest_sha ? d.latest_sha.substring(0,7) : '') +
(d.versions_behind ? ' (' + d.versions_behind + ' Commits zurück)' : ''));
$('btnInstall').style.display = 'inline-block';
if (d.changelog || d.title) {
const cl = $('changelog');
cl.textContent = (d.title ? d.title + '\n\n' : '') + (d.changelog || '');
cl.style.display = 'block';
}
} else {
showAlert('ok', 'System ist aktuell.');
$('btnInstall').style.display = 'none';
$('changelog').style.display = 'none';
}
} catch (e) {
showAlert('error', 'Prüfung fehlgeschlagen: ' + e.message);
} finally {
$('btnCheck').disabled = false; $('btnCheck').innerHTML = '<i class="fas fa-magnifying-glass" aria-hidden="true"></i> Auf Updates prüfen';
}
});
// Update installieren
$('btnInstall').addEventListener('click', async () => {
if (!confirm('Update jetzt installieren? Die Seite ist während des Updates kurz im Wartungsmodus.')) return;
clearAlerts();
$('btnInstall').disabled = true; $('btnCheck').disabled = true;
$('progressWrap').style.display = 'block';
let polling = setInterval(pollProgress, 1500);
try {
const body = new URLSearchParams({ action: 'install', csrf_token: CSRF });
const r = await fetch('update.php', { method: 'POST', body });
const d = await r.json();
clearInterval(polling); pollProgress();
if (d.success) {
setProgress(100, 'Update abgeschlossen.');
showAlert('ok', 'Update erfolgreich installiert (' + (d.sha ? d.sha.substring(0,7) : '') + '). Seite wird neu geladen …');
setTimeout(() => location.reload(), 2500);
} else {
showAlert('error', 'Update fehlgeschlagen: ' + (d.error || 'Unbekannter Fehler'));
$('btnInstall').disabled = false; $('btnCheck').disabled = false;
}
} catch (e) {
clearInterval(polling);
showAlert('error', 'Update-Request fehlgeschlagen: ' + e.message);
$('btnInstall').disabled = false; $('btnCheck').disabled = false;
}
});
function setProgress(pct, msg) {
$('progressFill').style.width = pct + '%';
$('progressMsg').textContent = msg || '';
}
async function pollProgress() {
try {
const r = await fetch('update.php?action=progress');
const d = await r.json();
setProgress(d.percent || 0, d.message || '');
} catch (e) { /* ignore */ }
}
async function loadMigrations() {
const el = $('migList');
el.innerHTML = '<p class="muted">Wird geladen …</p>';
try {
const r = await fetch('update.php?action=migrations');
const d = await r.json();
if (d.error) { el.innerHTML = '<p class="muted">' + d.error + '</p>'; return; }
if (!d.migrations || !d.migrations.length) {
el.innerHTML = '<p class="muted">Keine Updater-Migrationen vorhanden.</p>';
return;
}
el.innerHTML = d.migrations.map(m =>
'<div class="mig-item"><span>' + m.filename + '</span>' +
'<span class="badge ' + (m.applied ? 'badge-on">angewandt' : 'badge-off">offen') + '</span></div>'
).join('');
const pending = d.migrations.some(m => !m.applied);
$('btnRunMig').style.display = pending ? 'inline-block' : 'none';
} catch (e) {
el.innerHTML = '<p class="muted">Fehler: ' + e.message + '</p>';
}
}
$('btnRunMig').addEventListener('click', async () => {
$('btnRunMig').disabled = true; $('btnRunMig').textContent = 'Führe aus …';
$('migAlert').classList.remove('show');
try {
const body = new URLSearchParams({ action: 'run_migrations', csrf_token: CSRF });
const r = await fetch('update.php', { method: 'POST', body });
const d = await r.json();
if (d.error) {
$('migAlert').textContent = 'Fehler: ' + d.error;
$('migAlert').className = 'alert alert-error show';
} else {
const n = (d.applied || []).length;
$('migAlert').textContent = n > 0 ? (n + ' Migration(en) ausgeführt.') : 'Keine ausstehenden Migrationen.';
$('migAlert').className = 'alert alert-ok show';
loadMigrations();
}
} catch (e) {
$('migAlert').textContent = 'Fehler: ' + e.message;
$('migAlert').className = 'alert alert-error show';
} finally {
$('btnRunMig').disabled = false; $('btnRunMig').textContent = 'Ausstehende Migrationen ausführen';
}
});
</script>
</body>
</html>