Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3ee8e3d1e | ||
|
|
6b99781819 | ||
|
|
6cfbc6cf7e | ||
|
|
bb736e8258 | ||
|
|
7cdb431fd4 | ||
|
|
821c799225 |
No files matched your search
Generated
+216
-217
File diff suppressed because it is too large.
Load diff
+27
-1
@@ -1,5 +1,29 @@
|
||||
# Projektstatus
|
||||
|
||||
## Symfony-Patchupdate, Release 1.36.1 (14.10.2026)
|
||||
|
||||
**Anlass:** Monatlicher Sicherheitscheck (`reports/sicherheit-2026-10.md`, nicht in Git): keine Sicherheitslücken, aber Patch-Rückstand bei Symfony. Umut: „dann mach neueste symfony version dann kann ich den updater mal testen“.
|
||||
|
||||
**Erledigt:** `composer update "symfony/*" --with-all-dependencies`: Symfony 7.4.15–7.4.18 → 7.4.19–7.4.21 (framework-bundle, http-kernel, security-*, twig-bundle, console, yaml u. a.), Polyfills 1.43.0, twig/twig 3.28 → 3.30, doctrine/lexer 3.0.3. Bewusst bei der LTS-Linie 7.4 geblieben (Sicherheitssupport bis 11/2029); 8.x wäre ein Major-Sprung mit PHP-8.4-Pflicht. `composer.json` unverändert, Plattform weiter PHP 8.2.12. Kein eigener Code geändert, keine Migration.
|
||||
|
||||
**Geprüft:** `composer audit` ohne Befund. PHPUnit 685 Tests grün unter PHP 8.4 und 8.2; unter 8.2 eine Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings` (`file_get_contents` auf eine von `git checkout` entfernte `.env`, `Updater.php:468`), die mit der alten `composer.lock` genauso auftritt – vorbestehend. php-cs-fixer ohne Befund, `npm test` 41/41, `test:editor` grün (erster Lauf hing einmal beim Warten auf „Veröffentlicht“, zweiter Lauf vollständig grün), `test:browser` gegen 8080 grün (zwei Kontrastbefunde aus im Editor gesetzten Farben auf `/grundlagen`, Inhalt, nicht Code).
|
||||
|
||||
**Release:** Tag `v1.36.1`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (fe286f49…), `releases/latest` meldet v1.36.1.
|
||||
|
||||
**Nächster Schritt:** Umut testet den Updater: eine Installation auf 1.36.0 per „System“ oder über die Zentrale auf 1.36.1 heben. Weitere Punkte aus dem Sicherheitscheck: HSTS auf den Kundenseiten einschalten (System → HSTS), PHP-Mindestfassung vor dem 31.12.2026 (Ende PHP 8.2) anheben.
|
||||
|
||||
## Zentrale: Suche, Filter und Seiten (14.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Kannst du Suche und Pagination dort einbauen bei Zentrale“.
|
||||
|
||||
**Erledigt:** Übersicht in `zentrale/` mit Suche (Domain, frühere Domain, Kunde/Notiz, Fassung, IP), Zählerkacheln als Filter (mit Markierung und `aria-current`), Seiten zu je 25 Einträgen mit Zurück/Weiter und Auslassungen, Trefferzeile als Statusmeldung, eigener Leerzustand „Nichts gefunden“. Domains natürlich sortiert, „www.“ zählt nicht. „Alle aktualisieren“ nennt höchstens acht Domains und „und N weitere“. Kein Eingriff ins CMS, keine Schemaänderung.
|
||||
|
||||
**Geprüft:** Lokal mit 63 Demo-Installationen im Browser: Seite 2 von 3, Suche + Filter kombiniert (8 Treffer), kein Treffer, Seitenzahl außerhalb des Bereichs wird begrenzt, 375 px ohne Querscrollen. `php scripts/zentrale-check.php` grün.
|
||||
|
||||
**Nachtrag, gleicher Tag – Live-Suche:** Umut: „Suche sollte Ajax-Livesearch sein, nicht Enter-Suche“. `zentrale/public/app.js` lädt beim Tippen (250 ms Pause) nur die Liste nach (`/?…&teil=liste`, Vorlage `_liste.php`), ebenso bei Klick auf Kacheln und Seitenzahlen; Adresse per `history`, Zurück-Taste funktioniert, Trefferzeile bleibt Statusmeldung für Screenreader, abgelaufene Sitzung fällt auf normales Laden zurück. Der Suchen-Knopf verschwindet mit JavaScript. CSP erlaubt jetzt `script-src 'self'` und `connect-src 'self'`. Nebenbei behoben: `.knopf` überschrieb das `hidden`-Attribut. Geprüft im Browser mit 57 Demo-Installationen: Tippen ohne Neuladen, Filter + Suche, Zurücksetzen, Seite 2, Zurück-Taste, 375 px, keine Konsolenfehler. Die lokale Demo-Datenbank ist wieder gelöscht.
|
||||
|
||||
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (Paket `var/zentrale-upload.zip`); `config.php` und `var/` auf dem Server bleiben unangetastet.
|
||||
|
||||
## Zentrale sites.kant-it.de: Meldung und Update aus der Ferne (14.10.2026)
|
||||
|
||||
**Anlass:** Umut: Alle KantSites sollen sich bei einer Hauptplattform melden; dort Domains und Fassungen sehen und Updates anstoßen. Entschieden: `sites.kant-it.de`, ohne Lizenzschlüssel, Updates einzeln und für alle; Umut lädt die Plattform selbst hoch.
|
||||
@@ -10,7 +34,9 @@
|
||||
|
||||
**Bekannte Lücken:** Installationen vor 1.36.0 melden sich erst nach einem Update von Hand. Ohne Lizenzschlüssel bleibt unsichtbar, wer die Meldung ausbaut. Die IP-Adresse der letzten Meldung wird gespeichert – Hinweis in Datenschutz/AV-Vertrag nötig. Lokal fehlt PHP 8.4 ein Zertifikatsspeicher; die Launch-Konfiguration `zentrale` setzt dafür `curl.cainfo` auf das Bündel von Git.
|
||||
|
||||
**Nächster Schritt:** Release 1.36.0 bauen und veröffentlichen, `zentrale/` nach sites.kant-it.de hochladen (Docroot `public/`, HTTPS, `config.php`), erstes Konto anlegen, kant-it.de auf 1.36.0 heben und die erste Meldung sowie ein Fern-Update mit 1.36.1 prüfen.
|
||||
**Release:** sites.kant-it.de ist online (Umut; `/api/melden` antwortet, `config.php` nicht abrufbar). Fassung 1.36.0 am 14.10.2026 veröffentlicht: Tag `v1.36.0`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (688a0689…), `releases/latest` meldet v1.36.0.
|
||||
|
||||
**Nächster Schritt:** kant-it.de und die übrigen Live-Installationen unter „System“ auf 1.36.0 heben, erste Meldung in der Zentrale prüfen, das erste Fern-Update mit der nächsten Fassung ausprobieren.
|
||||
|
||||
## Redesign-Entwurf EBI Immobilien, Karlsruhe (13.10.2026)
|
||||
|
||||
|
||||
@@ -37,6 +37,14 @@ Nicht gemeldet wird von Installationen, deren `SITE_ORIGIN` auf `localhost`, `12
|
||||
Archiv, Wartungszustand, Stand des letzten Updates. Keine Inhalte, keine Besucher, keine
|
||||
Zugangsdaten. Die Zentrale speichert zusätzlich die IP-Adresse der letzten Meldung.
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die Zählerkacheln oben sind zugleich Filter (aktuell, veraltet, verstummt, ohne Kunde). Die Suche
|
||||
findet Domain, frühere Domain, Kunde/Notiz, Fassung und IP-Adresse; Suche und Filter lassen sich
|
||||
kombinieren. Die Suche filtert schon beim Tippen; Kacheln und Seitenzahlen wechseln nur die
|
||||
Liste (`public/app.js`, ohne JavaScript funktioniert alles als normales Formular). 25 Installationen
|
||||
je Seite, Domains natürlich sortiert und ohne „www.“.
|
||||
|
||||
## Update aus der Ferne
|
||||
|
||||
1. In der Übersicht „Auf x.y.z“ (oder „Alle aktualisieren“ mit Bestätigung) drücken.
|
||||
|
||||
@@ -34,6 +34,18 @@ final class Ansicht
|
||||
return '<input type="hidden" name="_csrf" value="'.$this->e($this->csrf).'">';
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Adresse der Übersicht; leere Werte und Seite 1 fallen weg.
|
||||
*
|
||||
* @param array<string, mixed> $werte
|
||||
*/
|
||||
public function adresse(array $werte): string
|
||||
{
|
||||
$werte = array_filter($werte, static fn (mixed $wert): bool => null !== $wert && '' !== $wert && 1 !== $wert);
|
||||
|
||||
return '/'.([] === $werte ? '' : '?'.http_build_query($werte));
|
||||
}
|
||||
|
||||
/** Ein Zeitpunkt in deutscher Ortszeit. */
|
||||
public function zeit(?string $iso): string
|
||||
{
|
||||
|
||||
+67
-12
@@ -10,6 +10,9 @@ namespace Zentrale;
|
||||
*/
|
||||
final class Anwendung
|
||||
{
|
||||
/** Zeilen je Seite in der Übersicht. */
|
||||
private const PRO_SEITE = 25;
|
||||
|
||||
private \PDO $db;
|
||||
|
||||
/** @param array<string, mixed> $config */
|
||||
@@ -113,20 +116,67 @@ final class Anwendung
|
||||
{
|
||||
$neueste = $fassungen->neueste();
|
||||
$alle = $bestand->alle($neueste['version'] ?? null);
|
||||
$this->zeigen('uebersicht', [
|
||||
|
||||
// Die Zähler oben sind zugleich die Filter; sie zählen immer den ganzen Bestand.
|
||||
$filter = [
|
||||
'aktuell' => static fn (array $i): bool => null !== $neueste && !$i['veraltet'] && 'unbekannt' !== $i['fassung'],
|
||||
'veraltet' => static fn (array $i): bool => $i['veraltet'],
|
||||
'still' => static fn (array $i): bool => $i['still'],
|
||||
'unbekannt' => static fn (array $i): bool => $i['unbekannt'],
|
||||
];
|
||||
$zahlen = ['alle' => count($alle)];
|
||||
foreach ($filter as $name => $passt) {
|
||||
$zahlen[$name] = count(array_filter($alle, $passt));
|
||||
}
|
||||
if (null === $neueste) {
|
||||
$zahlen['aktuell'] = null;
|
||||
}
|
||||
|
||||
$suche = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
|
||||
$gewaehlt = is_string($_GET['filter'] ?? null) && isset($filter[$_GET['filter']]) ? $_GET['filter'] : null;
|
||||
$treffer = array_values(array_filter($alle, static function (array $i) use ($suche, $gewaehlt, $filter): bool {
|
||||
if (null !== $gewaehlt && !$filter[$gewaehlt]($i)) {
|
||||
return false;
|
||||
}
|
||||
if ('' === $suche) {
|
||||
return true;
|
||||
}
|
||||
foreach (['domain', 'domain_vorher', 'adresse', 'notiz', 'fassung', 'ip'] as $feld) {
|
||||
if (null !== $i[$feld] && false !== mb_stripos((string) $i[$feld], $suche)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}));
|
||||
|
||||
$proSeite = self::PRO_SEITE;
|
||||
$seiten = max(1, (int) ceil(count($treffer) / $proSeite));
|
||||
$seite = min($seiten, max(1, (int) ($_GET['seite'] ?? 1)));
|
||||
|
||||
$namen = ['aktuell' => 'aktuell', 'veraltet' => 'veraltet', 'still' => 'ohne Meldung seit 48 h', 'unbekannt' => 'ohne Kundenzuordnung'];
|
||||
$von = [] === $treffer ? 0 : ($seite - 1) * $proSeite + 1;
|
||||
$bis = min(count($treffer), $seite * $proSeite);
|
||||
$trefferText = [] === $treffer ? 'Keine Treffer.' : sprintf('%d–%d von %d %s', $von, $bis, count($treffer), 1 === count($treffer) ? 'Installation' : 'Installationen')
|
||||
.(null !== $gewaehlt ? ' · Filter: '.$namen[$gewaehlt] : '')
|
||||
.('' !== $suche ? ' · Suche: „'.$suche.'“' : '');
|
||||
|
||||
// Die Live-Suche holt nur die Liste; ohne JavaScript kommt die ganze Seite.
|
||||
$teil = 'liste' === ($_GET['teil'] ?? null);
|
||||
$this->zeigen($teil ? '_liste' : 'uebersicht', [
|
||||
'titel' => 'Installationen',
|
||||
'trefferText' => $trefferText,
|
||||
'neueste' => $neueste,
|
||||
'installationen' => $alle,
|
||||
'zahlen' => [
|
||||
'alle' => count($alle),
|
||||
'aktuell' => null === $neueste ? null : count(array_filter($alle, static fn (array $i): bool => !$i['veraltet'] && 'unbekannt' !== $i['fassung'])),
|
||||
'veraltet' => count(array_filter($alle, static fn (array $i): bool => $i['veraltet'])),
|
||||
'still' => count(array_filter($alle, static fn (array $i): bool => $i['still'])),
|
||||
'unbekannt' => count(array_filter($alle, static fn (array $i): bool => $i['unbekannt'])),
|
||||
],
|
||||
'installationen' => array_slice($treffer, ($seite - 1) * $proSeite, $proSeite),
|
||||
'treffer' => count($treffer),
|
||||
'suche' => $suche,
|
||||
'filter' => $gewaehlt,
|
||||
'seite' => $seite,
|
||||
'seiten' => $seiten,
|
||||
'zahlen' => $zahlen,
|
||||
// Verstummte bleiben außen vor: Sie holen den Auftrag womöglich nie ab. Wartende haben schon einen.
|
||||
'aktualisierbar' => array_values(array_filter($alle, static fn (array $i): bool => $i['kann_update'] && !$i['still'] && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null))),
|
||||
]);
|
||||
], true, $teil);
|
||||
}
|
||||
|
||||
private function detail(Bestand $bestand, Fassungen $fassungen): void
|
||||
@@ -311,7 +361,7 @@ final class Anwendung
|
||||
|
||||
private function kopfzeilen(): void
|
||||
{
|
||||
header("Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'");
|
||||
header("Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'");
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('X-Robots-Tag: noindex, nofollow');
|
||||
@@ -319,9 +369,14 @@ final class Anwendung
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $daten */
|
||||
private function zeigen(string $vorlage, array $daten, bool $angemeldet = true): void
|
||||
private function zeigen(string $vorlage, array $daten, bool $angemeldet = true, bool $ohneRahmen = false): void
|
||||
{
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
if ($ohneRahmen) {
|
||||
echo (new Ansicht((string) ($_SESSION['csrf'] ?? '')))->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten);
|
||||
|
||||
return;
|
||||
}
|
||||
$hinweis = $_SESSION['hinweis'] ?? null;
|
||||
unset($_SESSION['hinweis']);
|
||||
$v = new Ansicht((string) ($_SESSION['csrf'] ?? ''));
|
||||
|
||||
@@ -23,6 +23,8 @@ final class Bestand
|
||||
public function alle(?string $neueste): array
|
||||
{
|
||||
$zeilen = $this->db->query('SELECT * FROM installation ORDER BY domain, erste_meldung')->fetchAll();
|
||||
// Natürlich sortiert und ohne www: kunde-2 vor kunde-10, www.ebsolar.de bei e.
|
||||
usort($zeilen, static fn (array $a, array $b): int => strnatcasecmp(preg_replace('/^www\./', '', (string) $a['domain']), preg_replace('/^www\./', '', (string) $b['domain'])));
|
||||
$auftraege = [];
|
||||
foreach ($this->db->query('SELECT a.* FROM auftrag a JOIN (SELECT installation, MAX(id) AS id FROM auftrag GROUP BY installation) j ON j.id = a.id')->fetchAll() as $auftrag) {
|
||||
$auftraege[$auftrag['installation']] = $auftrag;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php /** @var Zentrale\Ansicht $v */ ?>
|
||||
<div id="liste" tabindex="-1" data-treffer="<?= $v->e($trefferText) ?>">
|
||||
<?php if (0 === $zahlen['alle']) { ?>
|
||||
<div class="karte leer">
|
||||
<h2>Noch keine Meldung</h2>
|
||||
<p>Sobald eine KantSites-Installation ab Fassung 1.36.0 läuft, meldet sie sich hier – spätestens 15 Minuten nach dem ersten Besuch oder Cron-Aufruf.</p>
|
||||
</div>
|
||||
<?php } elseif (0 === $treffer) { ?>
|
||||
<div class="karte leer">
|
||||
<h2>Nichts gefunden</h2>
|
||||
<p>Keine Installation passt zu Suche und Filter. <a href="/">Alle anzeigen</a></p>
|
||||
</div>
|
||||
<?php } else { ?>
|
||||
<div class="tabelle-rahmen">
|
||||
<table class="tabelle">
|
||||
<caption class="unsichtbar">Gemeldete Installationen, Seite <?= $seite ?> von <?= $seiten ?></caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Domain</th><th scope="col">Kunde</th><th scope="col">Fassung</th><th scope="col">Letzte Meldung</th><th scope="col">Zustand</th><th scope="col"><span class="unsichtbar">Aktion</span></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($installationen as $i) { ?>
|
||||
<tr>
|
||||
<th scope="row" data-label="Domain"><a href="/installation?id=<?= $v->e(rawurlencode($i['id'])) ?>"><?= $v->e($i['domain']) ?></a>
|
||||
<?php if (null !== $i['domain_vorher']) { ?><span class="klein">vorher <?= $v->e($i['domain_vorher']) ?></span><?php } ?></th>
|
||||
<td data-label="Kunde"><?= $i['unbekannt'] ? '<span class="marke-fremd">Unbekannt</span>' : $v->e($i['notiz']) ?></td>
|
||||
<td data-label="Fassung"><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?php if ('git' === $i['art']) { ?> <span class="klein">Git</span><?php } ?></td>
|
||||
<td data-label="Letzte Meldung"><span class="<?= $i['still'] ? 'still' : '' ?>"><?= $v->e($v->vor($i['alter'])) ?></span></td>
|
||||
<td data-label="Zustand">
|
||||
<?php if ($i['wartung']) { ?><span class="pille p-warn">Wartung</span> <?php } ?>
|
||||
<?php if (null !== $i['auftrag_zustand']) { ?><span class="pille p-<?= $v->e($i['auftrag_zustand']['art']) ?>"><?= $v->e($i['auftrag_zustand']['text']) ?></span><?php } elseif (!$i['wartung']) { ?><span class="klein">–</span><?php } ?>
|
||||
</td>
|
||||
<td class="aktion">
|
||||
<?php if ($i['kann_update'] && null !== $neueste && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null)) { ?>
|
||||
<form method="post" action="/auftrag">
|
||||
<?= $v->csrf() ?>
|
||||
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
|
||||
<button type="submit" class="knopf knopf-klein">Auf <?= $v->e($neueste['version']) ?><span class="unsichtbar"> aktualisieren: <?= $v->e($i['domain']) ?></span></button>
|
||||
</form>
|
||||
<?php } ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php if ($seiten > 1) { ?>
|
||||
<nav class="seiten" aria-label="Seiten">
|
||||
<?php $basis = ['q' => $suche, 'filter' => $filter]; ?>
|
||||
<?php if ($seite > 1) { ?><a class="knopf knopf-leise" href="<?= $v->e($v->adresse([...$basis, 'seite' => $seite - 1])) ?>" rel="prev">← Zurück</a><?php } ?>
|
||||
<ul>
|
||||
<?php
|
||||
// Erste, letzte und zwei um die aktuelle; dazwischen Auslassungen.
|
||||
$zeigen = array_unique(array_filter([1, $seite - 2, $seite - 1, $seite, $seite + 1, $seite + 2, $seiten], static fn (int $n): bool => $n >= 1 && $n <= $seiten));
|
||||
sort($zeigen);
|
||||
$vorher = 0;
|
||||
foreach ($zeigen as $n) {
|
||||
if ($n > $vorher + 1) { ?><li aria-hidden="true">…</li><?php }
|
||||
$vorher = $n; ?>
|
||||
<li><?php if ($n === $seite) { ?><span aria-current="page"><span class="unsichtbar">Seite </span><?= $n ?></span><?php } else { ?><a href="<?= $v->e($v->adresse([...$basis, 'seite' => $n])) ?>"><span class="unsichtbar">Seite </span><?= $n ?></a><?php } ?></li>
|
||||
<?php } ?>
|
||||
</ul>
|
||||
<?php if ($seite < $seiten) { ?><a class="knopf knopf-leise" href="<?= $v->e($v->adresse([...$basis, 'seite' => $seite + 1])) ?>" rel="next">Weiter →</a><?php } ?>
|
||||
</nav>
|
||||
<?php } ?>
|
||||
<?php } ?>
|
||||
</div>
|
||||
@@ -6,7 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?= $v->e($titel) ?> · KantSites Zentrale</title>
|
||||
<link rel="stylesheet" href="/app.css?v=1">
|
||||
<link rel="stylesheet" href="/app.css?v=3">
|
||||
<script src="/app.js?v=1" defer></script>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23173f6b'/%3E%3Cpath d='M9 8v16M9 16l9-8M12.5 13l7 11' stroke='%23fff' stroke-width='3.2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -8,62 +8,47 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ul class="zahlen" aria-label="Zusammenfassung">
|
||||
<li><strong><?= $zahlen['alle'] ?></strong> gesamt</li>
|
||||
<li class="z-ok"><strong><?= $zahlen['aktuell'] ?? '–' ?></strong> aktuell</li>
|
||||
<li class="z-warn"><strong><?= $zahlen['veraltet'] ?></strong> veraltet</li>
|
||||
<li class="z-still"><strong><?= $zahlen['still'] ?></strong> ohne Meldung seit 48 h</li>
|
||||
<li class="z-fremd"><strong><?= $zahlen['unbekannt'] ?></strong> ohne Kundenzuordnung</li>
|
||||
</ul>
|
||||
<?php
|
||||
$kacheln = [
|
||||
null => ['', 'gesamt', $zahlen['alle']],
|
||||
'aktuell' => ['z-ok', 'aktuell', $zahlen['aktuell'] ?? '–'],
|
||||
'veraltet' => ['z-warn', 'veraltet', $zahlen['veraltet']],
|
||||
'still' => ['z-still', 'ohne Meldung seit 48 h', $zahlen['still']],
|
||||
'unbekannt' => ['z-fremd', 'ohne Kundenzuordnung', $zahlen['unbekannt']],
|
||||
];
|
||||
?>
|
||||
<nav aria-label="Nach Zustand filtern">
|
||||
<ul class="zahlen">
|
||||
<?php foreach ($kacheln as $name => [$klasse, $text, $zahl]) { $aktiv = ($filter ?? '') === $name; ?>
|
||||
<li class="<?= $klasse ?><?= $aktiv ? ' gewaehlt' : '' ?>"><a href="<?= $v->e($v->adresse(['q' => $suche, 'filter' => '' === $name ? null : $name])) ?>"<?= $aktiv ? ' aria-current="true"' : '' ?>><strong><?= $v->e($zahl) ?></strong> <?= $v->e($text) ?></a></li>
|
||||
<?php } ?>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<?php if ([] !== $aktualisierbar && null !== $neueste) { ?>
|
||||
<form method="post" action="/auftrag/alle" class="karte alle">
|
||||
<?= $v->csrf() ?>
|
||||
<div>
|
||||
<h2>Alle auf <?= $v->e($neueste['version']) ?> aktualisieren</h2>
|
||||
<p><?= count($aktualisierbar) ?> Installation(en): <?= $v->e(implode(', ', array_column($aktualisierbar, 'domain'))) ?>. Jede holt den Auftrag mit ihrer nächsten Meldung ab, sichert sich vorher und schaltet bei einem Fehler zurück.</p>
|
||||
<?php $namen = array_column($aktualisierbar, 'domain'); ?>
|
||||
<p><?= count($aktualisierbar) ?> Installation(en): <?= $v->e(implode(', ', array_slice($namen, 0, 8))) ?><?= count($namen) > 8 ? ' und '.(count($namen) - 8).' weitere' : '' ?>. Jede holt den Auftrag mit ihrer nächsten Meldung ab, sichert sich vorher und schaltet bei einem Fehler zurück.</p>
|
||||
<label class="bestaetigen"><input type="checkbox" name="bestaetigt" value="ja" required> Ja, alle <?= count($aktualisierbar) ?> aktualisieren</label>
|
||||
</div>
|
||||
<button type="submit" class="knopf">Alle aktualisieren</button>
|
||||
</form>
|
||||
<?php } ?>
|
||||
|
||||
<?php if ([] === $installationen) { ?>
|
||||
<div class="karte leer">
|
||||
<h2>Noch keine Meldung</h2>
|
||||
<p>Sobald eine KantSites-Installation ab Fassung 1.36.0 läuft, meldet sie sich hier – spätestens 15 Minuten nach dem ersten Besuch oder Cron-Aufruf.</p>
|
||||
</div>
|
||||
<?php } else { ?>
|
||||
<div class="tabelle-rahmen">
|
||||
<table class="tabelle">
|
||||
<caption class="unsichtbar">Alle gemeldeten Installationen</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Domain</th><th scope="col">Kunde</th><th scope="col">Fassung</th><th scope="col">Letzte Meldung</th><th scope="col">Zustand</th><th scope="col"><span class="unsichtbar">Aktion</span></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($installationen as $i) { ?>
|
||||
<tr>
|
||||
<th scope="row" data-label="Domain"><a href="/installation?id=<?= $v->e(rawurlencode($i['id'])) ?>"><?= $v->e($i['domain']) ?></a>
|
||||
<?php if (null !== $i['domain_vorher']) { ?><span class="klein">vorher <?= $v->e($i['domain_vorher']) ?></span><?php } ?></th>
|
||||
<td data-label="Kunde"><?= $i['unbekannt'] ? '<span class="marke-fremd">Unbekannt</span>' : $v->e($i['notiz']) ?></td>
|
||||
<td data-label="Fassung"><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?php if ('git' === $i['art']) { ?> <span class="klein">Git</span><?php } ?></td>
|
||||
<td data-label="Letzte Meldung"><span class="<?= $i['still'] ? 'still' : '' ?>"><?= $v->e($v->vor($i['alter'])) ?></span></td>
|
||||
<td data-label="Zustand">
|
||||
<?php if ($i['wartung']) { ?><span class="pille p-warn">Wartung</span> <?php } ?>
|
||||
<?php if (null !== $i['auftrag_zustand']) { ?><span class="pille p-<?= $v->e($i['auftrag_zustand']['art']) ?>"><?= $v->e($i['auftrag_zustand']['text']) ?></span><?php } elseif (!$i['wartung']) { ?><span class="klein">–</span><?php } ?>
|
||||
</td>
|
||||
<td class="aktion">
|
||||
<?php if ($i['kann_update'] && null !== $neueste && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null)) { ?>
|
||||
<form method="post" action="/auftrag">
|
||||
<?= $v->csrf() ?>
|
||||
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
|
||||
<button type="submit" class="knopf knopf-klein">Auf <?= $v->e($neueste['version']) ?><span class="unsichtbar"> aktualisieren: <?= $v->e($i['domain']) ?></span></button>
|
||||
</form>
|
||||
<?php } ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php if ($zahlen['alle'] > 0) { ?>
|
||||
<form method="get" action="/" class="suche" role="search">
|
||||
<?php if (null !== $filter) { ?><input type="hidden" name="filter" value="<?= $v->e($filter) ?>"><?php } ?>
|
||||
<label for="q">Suchen</label>
|
||||
<div class="suche-zeile">
|
||||
<input id="q" name="q" type="search" value="<?= $v->e($suche) ?>" placeholder="Domain, Kunde, Fassung oder IP" autocomplete="off" aria-describedby="treffer" aria-controls="liste">
|
||||
<button type="submit" class="knopf">Suchen</button>
|
||||
<a class="knopf knopf-leise zuruecksetzen" href="/"<?= '' === $suche && null === $filter ? ' hidden' : '' ?>>Zurücksetzen</a>
|
||||
</div>
|
||||
<p class="klein" id="treffer" role="status"><?= $v->e($trefferText) ?></p>
|
||||
</form>
|
||||
<?php } ?>
|
||||
|
||||
<?php require __DIR__.'/_liste.php'; ?>
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
@@ -72,6 +74,9 @@ code { background: var(--marke-hell); padding: 1px 5px; border-radius: 4px; font
|
||||
.zahlen { list-style: none; padding: 0; margin: 0 0 18px; display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.zahlen li { flex: 1 1 110px; background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); padding: 10px 14px; color: var(--leise); font-size: .9rem; }
|
||||
.zahlen strong { display: block; font-size: 1.35rem; color: var(--text); line-height: 1.2; }
|
||||
.zahlen a { display: block; margin: -10px -14px; padding: 10px 14px; color: inherit; text-decoration: none; border-radius: inherit; }
|
||||
.zahlen a:hover { background: var(--marke-hell); }
|
||||
.zahlen li.gewaehlt { border-color: var(--marke); box-shadow: inset 0 0 0 1px var(--marke); }
|
||||
.z-ok strong { color: var(--ok); }
|
||||
.z-warn strong { color: var(--warn); }
|
||||
.z-still strong { color: var(--still); }
|
||||
@@ -143,6 +148,22 @@ input[type="text"]:focus-visible, input[type="password"]:focus-visible { border-
|
||||
|
||||
.leer p { color: var(--leise); margin: 0; }
|
||||
|
||||
.suche { margin-bottom: 14px; }
|
||||
.suche label { margin-top: 0; }
|
||||
.suche-zeile { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.suche-zeile input { flex: 1 1 260px; width: auto; font: inherit; font-size: 16px; padding: 9px 11px; border: 1px solid #b9c3cf; border-radius: 8px; background: #fff; color: var(--text); }
|
||||
.suche-zeile .knopf-leise { min-height: 40px; text-decoration: none; }
|
||||
.suche .klein { margin: 8px 0 0; }
|
||||
|
||||
.seiten { display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; margin-top: 16px; }
|
||||
.seiten .knopf-leise { text-decoration: none; }
|
||||
.seiten ul { list-style: none; display: flex; gap: 4px; margin: 0; padding: 0; flex-wrap: wrap; }
|
||||
.seiten li a, .seiten li span[aria-current] { display: inline-grid; place-items: center; min-width: 36px; min-height: 36px; padding: 0 8px; border-radius: 8px; text-decoration: none; }
|
||||
.seiten li a { color: var(--marke); border: 1px solid var(--linie); background: var(--flaeche); }
|
||||
.seiten li a:hover { background: var(--marke-hell); }
|
||||
.seiten li span[aria-current] { background: var(--marke); color: #fff; font-weight: 700; }
|
||||
.seiten li[aria-hidden] { align-self: center; color: var(--leise); padding: 0 2px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
h1 { font-size: 1.35rem; }
|
||||
.karte { padding: 16px; }
|
||||
@@ -157,3 +178,6 @@ input[type="text"]:focus-visible, input[type="password"]:focus-visible { border-
|
||||
.fakten { grid-template-columns: 1fr; gap: 0; }
|
||||
.fakten dd { margin-bottom: 8px; }
|
||||
}
|
||||
|
||||
#liste:focus { outline: none; }
|
||||
#liste[aria-busy="true"] { opacity: .6; transition: opacity .15s; }
|
||||
@@ -0,0 +1,118 @@
|
||||
// Live-Suche der Übersicht: Tippen, Filter und Seitenwechsel laden nur die Liste nach.
|
||||
// Ohne JavaScript bleibt alles ein gewöhnliches Formular mit Links.
|
||||
(() => {
|
||||
const form = document.querySelector('form.suche');
|
||||
const liste = document.getElementById('liste');
|
||||
if (!form || !liste || !window.fetch || !window.URL) {
|
||||
return;
|
||||
}
|
||||
const feld = form.querySelector('#q');
|
||||
const status = document.getElementById('treffer');
|
||||
const zuruecksetzen = form.querySelector('.zuruecksetzen');
|
||||
const knopf = form.querySelector('button[type="submit"]');
|
||||
const filterFeld = () => form.querySelector('input[name="filter"]');
|
||||
knopf.hidden = true;
|
||||
|
||||
let warten = 0;
|
||||
let laufend = null;
|
||||
|
||||
/** Die Adresse aus Suchfeld und Filter, ohne leere Werte. */
|
||||
const adresseAusFormular = () => {
|
||||
const url = new URL(form.action, location.href);
|
||||
const q = feld.value.trim();
|
||||
if (q !== '') url.searchParams.set('q', q);
|
||||
const filter = filterFeld();
|
||||
if (filter && filter.value !== '') url.searchParams.set('filter', filter.value);
|
||||
return url;
|
||||
};
|
||||
|
||||
/** Kacheln und Filterfeld an Suche und Filter der neuen Adresse angleichen. */
|
||||
const umgebungAnpassen = (url) => {
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const filter = url.searchParams.get('filter') || '';
|
||||
document.querySelectorAll('.zahlen a').forEach((a) => {
|
||||
const ziel = new URL(a.href, location.href);
|
||||
if (q === '') ziel.searchParams.delete('q'); else ziel.searchParams.set('q', q);
|
||||
ziel.searchParams.delete('seite');
|
||||
a.href = ziel.pathname + ziel.search;
|
||||
const gewaehlt = (ziel.searchParams.get('filter') || '') === filter;
|
||||
a.parentElement.classList.toggle('gewaehlt', gewaehlt);
|
||||
if (gewaehlt) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current');
|
||||
});
|
||||
let feldFilter = filterFeld();
|
||||
if (filter !== '' && !feldFilter) {
|
||||
feldFilter = document.createElement('input');
|
||||
feldFilter.type = 'hidden';
|
||||
feldFilter.name = 'filter';
|
||||
form.prepend(feldFilter);
|
||||
}
|
||||
if (feldFilter) {
|
||||
if (filter === '') feldFilter.remove(); else feldFilter.value = filter;
|
||||
}
|
||||
if (feld.value.trim() !== q) feld.value = q;
|
||||
zuruecksetzen.hidden = q === '' && filter === '';
|
||||
};
|
||||
|
||||
const laden = (url, verlauf) => {
|
||||
if (laufend) laufend.abort();
|
||||
laufend = new AbortController();
|
||||
const abruf = new URL(url, location.href);
|
||||
abruf.searchParams.set('teil', 'liste');
|
||||
liste.setAttribute('aria-busy', 'true');
|
||||
fetch(abruf, { signal: laufend.signal, credentials: 'same-origin', headers: { Accept: 'text/html' } })
|
||||
.then((antwort) => {
|
||||
// Abgelaufene Sitzung: Die Antwort ist die Anmeldeseite, nicht die Liste.
|
||||
if (!antwort.ok || antwort.redirected) throw new Error('voll');
|
||||
return antwort.text();
|
||||
})
|
||||
.then((html) => {
|
||||
const neu = new DOMParser().parseFromString(html, 'text/html').getElementById('liste');
|
||||
if (!neu) throw new Error('voll');
|
||||
liste.replaceChildren(...neu.childNodes);
|
||||
liste.dataset.treffer = neu.dataset.treffer || '';
|
||||
status.textContent = liste.dataset.treffer;
|
||||
const sichtbar = new URL(url, location.href);
|
||||
if (verlauf === 'push') history.pushState(null, '', sichtbar.pathname + sichtbar.search);
|
||||
else if (verlauf === 'replace') history.replaceState(null, '', sichtbar.pathname + sichtbar.search);
|
||||
umgebungAnpassen(sichtbar);
|
||||
})
|
||||
.catch((fehler) => {
|
||||
if (fehler.name !== 'AbortError') location.href = url.toString();
|
||||
})
|
||||
.finally(() => liste.removeAttribute('aria-busy'));
|
||||
};
|
||||
|
||||
feld.addEventListener('input', () => {
|
||||
clearTimeout(warten);
|
||||
warten = setTimeout(() => laden(adresseAusFormular(), 'replace'), 250);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
clearTimeout(warten);
|
||||
laden(adresseAusFormular(), 'replace');
|
||||
});
|
||||
|
||||
zuruecksetzen.addEventListener('click', (ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
feld.value = '';
|
||||
const filter = filterFeld();
|
||||
if (filter) filter.remove();
|
||||
laden(new URL('/', location.href), 'push');
|
||||
feld.focus();
|
||||
});
|
||||
|
||||
// Filterkacheln und Seitenzahlen: nur die Liste wechseln.
|
||||
document.addEventListener('click', (ereignis) => {
|
||||
const link = ereignis.target.closest('.zahlen a, #liste .seiten a');
|
||||
if (!link || ereignis.ctrlKey || ereignis.metaKey || ereignis.shiftKey || ereignis.button !== 0) return;
|
||||
ereignis.preventDefault();
|
||||
laden(new URL(link.href, location.href), 'push');
|
||||
if (link.closest('.seiten')) {
|
||||
form.scrollIntoView({ block: 'start' });
|
||||
liste.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', () => laden(new URL(location.href), null));
|
||||
})();
|
||||
Reference in new issue
Block a user