Add search, filters and pages to the central registry
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-14 11:04:46 +02:00
1 parent 821c799225
commit 7cdb431fd4
7 files changed
+158 -18

No files matched your search

+10
View File
@@ -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.
+6
View File
@@ -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.
+12
View File
@@ -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
{
+50 -8
View File
@@ -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,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))),
]);
+2
View File
@@ -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;
+59 -10
View File
@@ -8,35 +8,65 @@
</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) { ?>
<?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" aria-describedby="treffer">
<button type="submit" class="knopf">Suchen</button>
<?php if ('' !== $suche || null !== $filter) { ?><a class="knopf knopf-leise" href="/">Zurücksetzen</a><?php } ?>
</div>
<p class="klein" id="treffer" role="status">
<?php if (0 === $treffer) { ?>Keine Treffer.<?php } else { ?><?= $von ?>–<?= $bis ?> von <?= $treffer ?> <?= 1 === $treffer ? 'Installation' : 'Installationen' ?><?= null !== $filter ? ' · Filter: '.$v->e($kacheln[$filter][1]) : '' ?><?= '' !== $suche ? ' · Suche: „'.$v->e($suche).'“' : '' ?><?php } ?>
</p>
</form>
<?php } ?>
<?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">Alle gemeldeten Installationen</caption>
<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>
@@ -66,4 +96,23 @@
</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 } ?>
+19
View File
@@ -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; }