Unifi-Voucher-Tool/updater/templates/update.php
Friederich Loheide 6da46f040a Alle Frontend-Assets lokal ausliefern statt über CDNs
Inter, Font Awesome, Chart.js, qrcodejs und TinyMCE liegen jetzt unter
assets/vendor/ und werden vom eigenen Server ausgeliefert.

Warum:
- Datenschutz: bisher ging bei jedem Seitenaufruf die IP der Nutzer an
  Google Fonts, cdnjs, jsDelivr und Tiny Cloud
- Funktion: UniFi-Installationen stehen oft in abgeschotteten Netzen –
  dort fehlten bisher Schrift, Icons, Diagramme und Editor

Neu: includes/Ui.php
- Ui::head()/Ui::script() binden die Assets ein und hängen einen
  Versionsstempel an (?v=filemtime), damit Browser nach einem Update
  nicht das alte CSS aus dem Cache nehmen
- Ui::themeScript() setzt das Theme aus der gespeicherten Auswahl oder
  – wenn keine vorliegt – aus prefers-color-scheme; global.js folgt
  Systemwechseln live, solange nichts manuell gewählt wurde
- <meta name="color-scheme"> ergänzt, damit Formularelemente passen

Der TinyMCE-API-Key entfällt: der Editor läuft immer lokal (GPL-Variante),
inklusive deutscher Oberfläche, wenn die App auf Deutsch steht.

Neu: tools/demo/build.py – baut aus dem Projekt eine Demo-Instanz ohne
Datenbank (Stubs für Database/Auth, feste Beispieldaten). Damit lassen
sich Screenshots reproduzierbar erzeugen und alle Seiten einmal rendern
(Smoke-Test), ohne eine MySQL-Instanz aufzusetzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:23:44 +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"></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"></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"></i> Auf Updates prüfen</button>
<button class="btn btn-success" id="btnInstall" style="display:none;margin-left:8px;"><i class="fas fa-download"></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"></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"></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>