diff --git a/docs/STATUS.md b/docs/STATUS.md index 3d7bcbf..2c429b1 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,15 @@ # Projektstatus +## 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. + +**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/app.css` neu hoch (Paket `var/zentrale-upload.zip`); `config.php` und `var/` bleiben. + ## 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. diff --git a/zentrale/README.md b/zentrale/README.md index 8eeefd6..a51411d 100644 --- a/zentrale/README.md +++ b/zentrale/README.md @@ -37,6 +37,12 @@ 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. 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. diff --git a/zentrale/app/Ansicht.php b/zentrale/app/Ansicht.php index 25a710c..513e03c 100644 --- a/zentrale/app/Ansicht.php +++ b/zentrale/app/Ansicht.php @@ -34,6 +34,18 @@ final class Ansicht return ''; } + /** + * Eine Adresse der Übersicht; leere Werte und Seite 1 fallen weg. + * + * @param array $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 { diff --git a/zentrale/app/Anwendung.php b/zentrale/app/Anwendung.php index f261f4b..66a01f5 100644 --- a/zentrale/app/Anwendung.php +++ b/zentrale/app/Anwendung.php @@ -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 $config */ @@ -113,17 +116,56 @@ final class Anwendung { $neueste = $fassungen->neueste(); $alle = $bestand->alle($neueste['version'] ?? null); + + // 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))); + $this->zeigen('uebersicht', [ 'titel' => 'Installationen', '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, + 'von' => [] === $treffer ? 0 : ($seite - 1) * $proSeite + 1, + 'bis' => min(count($treffer), $seite * $proSeite), + '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))), ]); diff --git a/zentrale/app/Bestand.php b/zentrale/app/Bestand.php index 68e05ad..6eb4259 100644 --- a/zentrale/app/Bestand.php +++ b/zentrale/app/Bestand.php @@ -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; diff --git a/zentrale/app/vorlagen/uebersicht.php b/zentrale/app/vorlagen/uebersicht.php index 1923e5e..c8b17ed 100644 --- a/zentrale/app/vorlagen/uebersicht.php +++ b/zentrale/app/vorlagen/uebersicht.php @@ -8,35 +8,65 @@ - + ['', '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']], +]; +?> +
csrf() ?>

Alle auf e($neueste['version']) ?> aktualisieren

-

Installation(en): 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.

+ +

Installation(en): e(implode(', ', array_slice($namen, 0, 8))) ?> 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.

- + 0) { ?> + + + +

Noch keine Meldung

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.

+ +
+

Nichts gefunden

+

Keine Installation passt zu Suche und Filter. Alle anzeigen

+
- + @@ -66,4 +96,23 @@
Alle gemeldeten InstallationenGemeldete Installationen, Seite von
DomainKundeFassungLetzte MeldungZustandAktion
+ 1) { ?> + + diff --git a/zentrale/public/app.css b/zentrale/public/app.css index 1669912..60048fe 100644 --- a/zentrale/public/app.css +++ b/zentrale/public/app.css @@ -72,6 +72,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 +146,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; }