Merge pull request 'Überwachte Hosts über ein Zahnrad am System-Monitor pflegen (Issue #70)' (#71) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/71
This commit is contained in:
fds2610 2026-09-01 14:11:49 +02:00
commit 0bad450059
19 changed files with 1633 additions and 30 deletions

57
API.md
View file

@ -19,6 +19,7 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
- [KPIs](#kpis)
- [Live-Updates (SSE)](#live-updates-sse)
- [Projekte (Codeberg-Sync konfigurieren)](#projekte-codeberg-sync-konfigurieren)
- [Hosts (Ping-Ueberwachung konfigurieren)](#hosts-ping-ueberwachung-konfigurieren)
- [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone)
- [Fehlerformat](#fehlerformat)
@ -283,6 +284,18 @@ ungueltigem `status`.
Response: `{ "systems": [ { "id", "name", "type", "status", "detail", "source", "last_check_at" }, ... ] }`
(maximal 9 Eintraege).
### `DELETE /api/v1/systems/{id}` — Kachel entfernen
Auth: `X-Admin-Key`. Issue #70: Entfernt ein System aus dem Monitor. Gedacht fuer Kacheln,
die niemand mehr meldet - ein einmal gemeldetes System blieb sonst fuer immer stehen, nur
eben ohne Aktualisierung. Response `200` mit `{ "deleted": true|false }` (`false` = es gab
kein System mit dieser Id). Meldet sich das System spaeter erneut, legt
`POST /api/v1/systems/{id}/status` es unveraendert neu an.
Einen Host der Ping-Ueberwachung entfernt man dagegen ueber
[`DELETE /api/v1/hosts/{id}`](#hosts-ping-ueberwachung-konfigurieren) - der nimmt seine Kachel
selbst mit.
## KPIs
F7: Konkrete Kennzahlen sind noch offen ("kommt spaeter"). Phase 1 stellt nur die
@ -396,6 +409,50 @@ Bearbeiten-/Loeschen-Symbolen (Tooltip = `message`). Beide Checks nutzen `CODEBE
Hooks erfordert Admin-Rechte auf das Projekt - fehlen die, liefert der Check `ok: false` mit
entsprechender Meldung statt eines Fehlers.
## Hosts (Ping-Ueberwachung konfigurieren)
Seit Issue #70 steht die Liste der per Ping ueberwachten Hosts in der Datenbank statt nur in
der `hosts.conf` neben `check_fds.sh` - pflegbar ueber das Zahnrad am Wandmonitor "Systeme"
in der Buero-Visualisierung. Jeder Host gehoert dem OPs-Agenten (Issue #66), der ihn anpingt:
API und Ping-Check laufen nicht zwangslaeufig auf demselben Rechner, die API kann die
`hosts.conf` also nicht selbst schreiben. Stattdessen holt `check_fds.sh` seine Liste bei
jedem Lauf mit dem eigenen Agenten-Key ab und schreibt daraus die lokale Datei.
| Endpunkt | Auth | Beschreibung |
|---|---|---|
| `GET /api/v1/hosts` | - | Alle Hosts ueber alle Ping-Rechner, je mit `agent_name` und `system_ids` (siehe unten) |
| `POST /api/v1/hosts` | `X-Admin-Key` | Legt einen Host an (`agent_id`, `hostname`, `address`) |
| `PATCH /api/v1/hosts/{id}` | `X-Admin-Key` | Aendert `hostname`/`address`; der zustaendige Agent bleibt, wie er ist |
| `DELETE /api/v1/hosts/{id}` | `X-Admin-Key` | Nimmt den Host aus der Ueberwachung und loescht dessen Kachel im System-Monitor |
| `GET /api/v1/hosts/conf` | `X-Api-Key` | Die `hosts.conf` des anfragenden Ping-Rechners als `text/plain` |
| `POST /api/v1/hosts/import` | `X-Api-Key` | Erstbefuellung aus einer hochgeladenen `hosts.conf` (`text/plain`) |
`hostname` und `address` duerfen keine Leerzeichen enthalten und nicht mit `#` beginnen -
`hosts.conf` ist zeilen- und leerzeichengetrennt, ein solcher Wert wuerde dort still
verstuemmelt. `hostname` muss je Ping-Rechner eindeutig sein (`422` bei einem Duplikat),
ueber verschiedene Rechner hinweg ist er erlaubt. `agent_id` muss zu einem registrierten
Agenten der Rolle `ops` gehoeren.
`system_ids` nennt die Ids, unter denen dieser Host im System-Monitor auftauchen kann - es
sind zwei, weil beide Meldewege ihn unterschiedlich benennen: der Legacy-Import aus
`status.txt` nimmt den Hostnamen unveraendert, die REST-Meldung aus `check_fds.sh` dessen
kleingeschriebenen Slug (`Nextcloud(lokal)` -> `nextcloud-lokal`). Das Frontend erkennt daran,
welche Kachel zu welchem Host gehoert; `DELETE /api/v1/hosts/{id}` raeumt beide ab.
### Ablauf im Ping-Check
1. `GET /api/v1/hosts/conf` mit dem Agenten-Key des OPs-Agenten.
- `200`: der Body ist die neue `hosts.conf` - auch wenn er keinen Host enthaelt (dann
wurden im Frontend alle entfernt und es wird nichts mehr geprueft).
- `204`: fuer diesen Rechner ist noch nie etwas konfiguriert worden. Dann laedt
`check_fds.sh` seine bestehende `hosts.conf` per `POST /api/v1/hosts/import` hoch, damit
die bereits ueberwachten Hosts nicht von Hand nachgetragen werden muessen.
- Alles andere (inkl. nicht erreichbarer API): die zuletzt geschriebene lokale Datei bleibt
in Kraft - eine Stoerung der API darf die Ueberwachung nicht anhalten.
2. `POST /api/v1/hosts/import` nimmt nur den allerersten Upload eines Rechners an und
antwortet danach mit `{ "imported": 0 }`. Sonst wuerde ein Ping-Rechner mit alter lokaler
Datei die im Frontend geloeschten Hosts wieder anlegen.
## Codeberg-Sync (Backlog/Done)
`api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus den konfigurierten

View file

@ -25,6 +25,11 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und
(Owner/Repo + Farbe) hinzufügen, bearbeiten und löschen lassen (Issue #27) — Änderungen
wirken sofort im laufenden `sync`-Dienst, ohne Neustart. Schreibende Aktionen fragen einmalig
den `MONITOR_ADMIN_API_KEY` ab und merken ihn sich im Browser.
- Klick auf das Zahnrad neben der Überschrift „Systeme" öffnet die Verwaltung der per Ping
überwachten Hosts (Issue #70): Server hinzufügen, umbenennen und entfernen, ohne sich auf
den Rechner mit `check_fds.sh` einzuloggen — der holt seine `hosts.conf` bei jedem Lauf von
der API. Ein entfernter Host nimmt seine Kachel im System-Monitor mit; Kacheln ohne
zugehörigen Host lassen sich im selben Dialog einzeln aufräumen.
- Codeberg-Sync als Dauerläufer (Compose-Service `sync`): füllt Backlog und Done automatisch
aus den Issues mehrerer konfigurierter Codeberg-Projekte (je Projekt mit eigener
Ticket-Farbe, siehe oben) — periodisch alle `MONITOR_SYNC_INTERVAL` Sekunden und, wenn ein
@ -95,7 +100,8 @@ kein System gemeldet wurde — dafür gibt es zwei unabhängige Wege:
**Weg 1: Klassische Ping-Überwachung (`hosts.conf`)**
1. `hosts-sample.conf` nach `hosts.conf` kopieren und um den neuen Server erweitern
(eine Zeile pro Server, Format `Hostname IP`):
(eine Zeile pro Server, Format `Hostname IP`) — ist Weg 3 eingerichtet, geht das
stattdessen über die Oberfläche (siehe „Hosts über die Oberfläche pflegen" weiter unten):
```
Webserver 192.168.178.10
```
@ -156,4 +162,34 @@ Die System-ID entsteht dabei aus dem Hostnamen (`Nextcloud(intern)` → `nextclo
der Originalname bleibt als Anzeigename erhalten. Ist die API nicht erreichbar, läuft die
Ping-Überwachung normal weiter und protokolliert nur eine Warnung.
## ⚙️ Hosts über die Oberfläche pflegen
Ist Weg 3 eingerichtet, muss für einen neuen oder entfernten Server niemand mehr die
`hosts.conf` auf dem Ping-Rechner editieren (Issue #70): Ein Klick auf das **Zahnrad neben
der Überschrift „Systeme"** öffnet die Verwaltung der überwachten Hosts — dasselbe Muster
wie das Zahnrad am PO-Platz für die Codeberg-Projekte. Änderungen verlangen den
`MONITOR_ADMIN_API_KEY`; der Browser fragt einmalig danach und merkt ihn sich lokal.
Wie die Liste auf den Ping-Rechner kommt:
- `check_fds.sh` holt sie bei **jedem** Lauf mit seinem Agenten-Key ab und schreibt daraus
seine lokale `hosts.conf`. Von Hand editiert man die Datei danach nicht mehr — der nächste
Cron-Lauf überschreibt sie.
- Beim ersten Lauf lädt der Check seine **bestehende** `hosts.conf` einmalig hoch, damit die
schon überwachten Server nicht von Hand nachgetragen werden müssen. Danach hat die
Oberfläche das letzte Wort: Ein dort entfernter Host kommt über diesen Weg nicht zurück.
- Ist die API nicht erreichbar, pingt der Check unverändert nach der zuletzt geschriebenen
Datei weiter und protokolliert nur eine Warnung.
Ein hier entfernter Host nimmt seine **Kachel im System-Monitor mit** — auch die aus dem
`status.txt`-Import, die unter dem unveränderten Hostnamen läuft. Kacheln, die zu keinem
gepflegten Host gehören (etwa Server, die sich nach Weg 2 selbst melden, oder Reste eines
längst entfernten Hosts), stehen im selben Dialog unter „Weitere Kacheln im System-Monitor"
und lassen sich dort einzeln entfernen.
> Voraussetzung ist ein registrierter OPs-Agent (`register-ops-agent.sh`, siehe Weg 3) — nur
> für Hosts, die von einem solchen Ping-Check geprüft werden, kann die Oberfläche die
> `hosts.conf` überhaupt pflegen. Server, die ihren Status nach Weg 2 selbst melden, bleiben
> unberührt.

View file

@ -10,6 +10,7 @@ use Monitor\Api\Auth\AgentAuthenticator;
use Monitor\Api\Auth\WebhookAuthenticator;
use Monitor\Api\Controller\AgentController;
use Monitor\Api\Controller\EventController;
use Monitor\Api\Controller\HostConfigController;
use Monitor\Api\Controller\KpiController;
use Monitor\Api\Controller\RepoConfigController;
use Monitor\Api\Controller\StreamController;
@ -26,6 +27,7 @@ use Monitor\Api\Metrics\HookCounter;
use Monitor\Api\Realtime\ChangeFeed;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository;
use Monitor\Api\Repository\HostConfigRepository;
use Monitor\Api\Repository\KpiRepository;
use Monitor\Api\Repository\LegacyStatusFileReader;
use Monitor\Api\Repository\RepoConfigRepository;
@ -57,6 +59,7 @@ final class Application
$systems = new SystemRepository($db);
$kpis = new KpiRepository($db);
$repoConfigs = new RepoConfigRepository($db);
$hostConfigs = new HostConfigRepository($db);
$hooks = new HookCounter($config->hooksCounterPath());
$this->agentAuth = new AgentAuthenticator($agents);
@ -73,6 +76,7 @@ final class Application
$eventController = new EventController($events);
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
$kpiController = new KpiController($kpis);
$hostConfigController = new HostConfigController($hostConfigs, $systems);
$repoConfigController = new RepoConfigController($repoConfigs, $tickets, new RepoHealthChecker($codebergClient));
$webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()), $hooks);
$streamController = new StreamController(
@ -96,6 +100,29 @@ final class Application
$this->router->add('GET', '/api/v1/systems', fn (Request $r, array $p) => $systemController->index($r));
$this->router->add('POST', '/api/v1/systems/{id}/status', fn (Request $r, array $p) => $systemController->reportStatus($r, $p), 'agent');
// Issue #70: Kachel eines Systems entfernen, das niemand mehr meldet - Admin-Key wie
// die uebrige Konfiguration aus dem Frontend.
$this->router->add('DELETE', '/api/v1/systems/{id}', fn (Request $r, array $p) => $systemController->delete($r, $p), 'admin');
// Issue #70: Hosts der Ping-Ueberwachung, gepflegt ueber das Zahnrad am Wandmonitor
// "Systeme". Die beiden Endpunkte fuer den Ping-Check selbst stehen zuerst, damit sie
// nicht von einem spaeteren "/{id}"-Muster verdeckt werden koennen.
$this->router->add(
'GET',
'/api/v1/hosts/conf',
fn (Request $r, array $p, ?array $agent) => $hostConfigController->conf($r, $p, $agent),
'agent'
);
$this->router->add(
'POST',
'/api/v1/hosts/import',
fn (Request $r, array $p, ?array $agent) => $hostConfigController->import($r, $p, $agent),
'agent'
);
$this->router->add('GET', '/api/v1/hosts', fn (Request $r, array $p) => $hostConfigController->index($r));
$this->router->add('POST', '/api/v1/hosts', fn (Request $r, array $p) => $hostConfigController->create($r), 'admin');
$this->router->add('PATCH', '/api/v1/hosts/{id}', fn (Request $r, array $p) => $hostConfigController->update($r, $p), 'admin');
$this->router->add('DELETE', '/api/v1/hosts/{id}', fn (Request $r, array $p) => $hostConfigController->delete($r, $p), 'admin');
$this->router->add('GET', '/api/v1/kpis', fn (Request $r, array $p) => $kpiController->index($r));
$this->router->add('POST', '/api/v1/kpis/{key}', fn (Request $r, array $p) => $kpiController->report($r, $p), 'agent');

View file

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Controller;
use InvalidArgumentException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Repository\HostConfigRepository;
use Monitor\Api\Repository\SystemRepository;
/**
* Issue #70: Die Hosts der Ping-Ueberwachung, bisher nur in der hosts.conf neben
* check_fds.sh. Zwei Zugriffsarten mit unterschiedlicher Berechtigung:
*
* - Pflege aus dem Frontend (Zahnrad am Wandmonitor "Systeme") - schreibend hinter dem
* Admin-Key wie die Projekte-Verwaltung (Issue #27), kein Bot-Vorgang.
* - Abruf durch den Ping-Check selbst - hinter dem Agenten-Key, weil erst der sagt,
* welcher Ping-Rechner fragt und damit welche Liste er bekommt.
*/
final class HostConfigController
{
public function __construct(
private readonly HostConfigRepository $hosts,
private readonly SystemRepository $systems,
) {
}
public function index(Request $request): Response
{
// system_ids kommen mit, damit das Frontend eine Kachel im System-Monitor sicher
// ihrem Host zuordnen kann, ohne die Id-Ableitung (Hostname vs. Slug) nachzubauen.
$hosts = array_map(
fn (array $host) => $host + ['system_ids' => SystemRepository::idsForHostname($host['hostname'])],
$this->hosts->all()
);
return Response::json(['hosts' => $hosts]);
}
public function create(Request $request): Response
{
try {
$host = $this->hosts->create($request->body);
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 422);
}
return Response::json(['host' => $host], 201);
}
/** @param array<string, string> $params */
public function update(Request $request, array $params): Response
{
$previous = $this->hosts->find((int) $params['id']);
try {
$host = $this->hosts->update((int) $params['id'], $request->body);
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 422);
}
// Ein umbenannter Host meldet sich unter neuer System-Id; die alte Kachel haette
// sonst niemanden mehr, der sie aktualisiert, und bliebe fuer immer stehen.
if ($previous !== null && $previous['hostname'] !== $host['hostname']) {
$this->systems->delete(SystemRepository::idsForHostname($previous['hostname']));
}
return Response::json(['host' => $host]);
}
/**
* Entfernt den Host aus der Ueberwachung und raeumt seine Kachel im System-Monitor
* gleich mit ab - analog zu Issue #43, wo ein entferntes Projekt auch seine Tickets
* mitnimmt. Ohne das bliebe die (womoeglich rote) Kachel stehen, obwohl niemand den
* Host mehr prueft.
*
* @param array<string, string> $params
*/
public function delete(Request $request, array $params): Response
{
$host = $this->hosts->delete((int) $params['id']);
$this->systems->delete(SystemRepository::idsForHostname($host['hostname']));
return Response::json(['deleted' => true]);
}
/**
* Die hosts.conf dieses Ping-Rechners als Klartext - check_fds.sh schreibt den Body
* direkt in seine lokale Datei.
*
* 204 statt 200 heisst "fuer dich ist hier noch nichts konfiguriert": dann soll der
* Ping-Rechner seine bestehende Datei abgeben (import()), statt sie zu ueberschreiben.
* Ohne diese Unterscheidung waere eine leere Antwort zweideutig - eine im Frontend
* geleerte Liste saehe aus wie eine unbenutzte, und der Check pingte munter nach seiner
* alten lokalen Datei weiter.
*
* @param array<string, string> $params
* @param array<string, mixed> $agent - der ueber X-Api-Key authentifizierte Agent.
*/
public function conf(Request $request, array $params, array $agent): Response
{
if (($agent['role'] ?? '') !== 'ops') {
return Response::error('Nur Agenten der Rolle "ops" haben eine hosts.conf.', 403);
}
$agentId = (int) $agent['id'];
if (!$this->hosts->hasImported($agentId) && $this->hosts->allForAgent($agentId) === []) {
return Response::text('', 204);
}
return Response::text($this->hosts->renderHostsConf($agentId));
}
/**
* Erstbefuellung: check_fds.sh schickt seine bestehende hosts.conf als Klartext, solange
* fuer diesen Agenten serverseitig noch nichts konfiguriert ist. Ist bereits etwas
* hinterlegt, passiert nichts (imported = 0) - die im Frontend gepflegte Liste hat
* Vorrang vor der lokalen Datei.
*
* @param array<string, string> $params
* @param array<string, mixed> $agent
*/
public function import(Request $request, array $params, array $agent): Response
{
if (($agent['role'] ?? '') !== 'ops') {
return Response::error('Nur Agenten der Rolle "ops" haben eine hosts.conf.', 403);
}
$rows = HostConfigRepository::parseHostsConf($request->rawBody);
$imported = $this->hosts->importForAgent((int) $agent['id'], $rows);
return Response::json(['imported' => $imported]);
}
}

View file

@ -26,6 +26,22 @@ final class SystemController
return Response::json(['systems' => $this->systems->all()]);
}
/**
* Issue #70: Entfernt eine Kachel aus dem System-Monitor. Gedacht fuer Systeme, die
* niemand mehr meldet - etwa weil der Host laengst aus der Ueberwachung raus ist, seine
* letzte Meldung aber in der Datenbank steht. Meldet sich das System danach wieder,
* legt reportStatus() es unveraendert neu an.
*
* Hinter dem Admin-Key wie die uebrige Konfiguration im Frontend: Loeschen ist kein
* Agenten-Vorgang, und ein fremder Agenten-Key soll keine Kacheln abraeumen koennen.
*
* @param array<string, string> $params
*/
public function delete(Request $request, array $params): Response
{
return Response::json(['deleted' => $this->systems->delete([$params['id']]) > 0]);
}
/** @param array<string, string> $params */
public function reportStatus(Request $request, array $params): Response
{

View file

@ -0,0 +1,39 @@
-- Issue #70: Welche Hosts der Ping-Check ueberwacht, stand bisher ausschliesslich in der
-- hosts.conf neben check_fds.sh - aenderbar nur per Login auf dem Rechner mit dem Check.
-- Die Liste wird jetzt hier gefuehrt und ueber ein Modal im Frontend (Zahnrad am Wandmonitor
-- "Systeme") gepflegt; check_fds.sh holt sie bei jedem Lauf ab und schreibt daraus seine
-- lokale hosts.conf.
--
-- agent_id bindet jede Zeile an den OPs-Agenten (Issue #66), der diesen Host anpingt: Der
-- Check laeuft nicht zwangslaeufig auf einem einzigen Rechner, und jeder Ping-Host darf nur
-- die Liste bekommen, die er auch erreichen kann. Die Zuordnung ergibt sich beim Abruf aus
-- dem Agenten-Key, es gibt also keine Liste "fuer alle".
--
-- Kein ON DELETE CASCADE: Agenten werden derzeit nirgends geloescht (AgentRepository kennt
-- kein delete()), ein Cascade waere also toter Code mit Ueberraschungspotenzial.
CREATE TABLE IF NOT EXISTS host_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL,
hostname TEXT NOT NULL,
address TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (agent_id) REFERENCES agents (id)
);
-- Derselbe Hostname zweimal in einer hosts.conf waere ein doppelter Ping und eine doppelte
-- Kachel (die System-Id leitet sich aus dem Namen ab). Ueber verschiedene Ping-Hosts hinweg
-- ist er dagegen erlaubt - zwei Standorte duerfen denselben Namen ueberwachen.
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_configs_agent_hostname ON host_configs (agent_id, hostname);
-- Merkt, dass ein Ping-Rechner seine bestehende hosts.conf einmal hochgeladen hat
-- (HostConfigRepository::importForAgent). Ohne diesen Marker waere "die API kennt keine
-- Hosts fuer dich" zweideutig: einmal "noch nie konfiguriert" (dann soll die lokale Datei
-- uebernommen werden) und einmal "im Frontend wurden alle Hosts entfernt" - im zweiten Fall
-- wuerde der naechste Lauf die geloeschten Hosts aus der lokalen Datei wieder anlegen.
CREATE TABLE IF NOT EXISTS host_config_imports (
agent_id INTEGER PRIMARY KEY,
imported_at TEXT NOT NULL,
FOREIGN KEY (agent_id) REFERENCES agents (id)
);

View file

@ -16,6 +16,9 @@ final class Response
public readonly int $status,
private readonly mixed $data,
private readonly ?\Closure $streamWriter = null,
// Gesetzt fuer Antworten, deren Body kein JSON ist (Issue #70: die hosts.conf, die
// check_fds.sh direkt in eine Datei schreibt) - dann ist $data der fertige Body.
private readonly ?string $contentType = null,
) {
}
@ -30,6 +33,17 @@ final class Response
return new self($status, ['error' => $message]);
}
/**
* Klartext-Antwort: $body geht unveraendert raus, ohne JSON-Kodierung. Issue #70: Der
* Ping-Check holt seine hosts.conf ueber die API und schreibt den Body direkt in die
* Datei - eine JSON-Huelle muesste er in Shell wieder aufdroeseln, was bei jeder
* unerwarteten Zeile die Ueberwachungsliste beschaedigen koennte.
*/
public static function text(string $body, int $status = 200): self
{
return new self($status, $body, null, 'text/plain; charset=utf-8');
}
/**
* Antwort auf einen CORS-Preflight (OPTIONS): 204 ohne Body, die eigentliche Auskunft
* stehen in den Access-Control-Headern aus send().
@ -101,6 +115,13 @@ final class Response
return;
}
if ($this->contentType !== null) {
header('Content-Type: ' . $this->contentType);
echo (string) $this->data;
return;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}

View file

@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
/**
* Verwaltet die Hosts der Ping-Ueberwachung (Issue #70: vorher ausschliesslich die hosts.conf
* neben check_fds.sh, siehe 005_host_configs.sql). Jede Zeile gehoert dem OPs-Agenten, der
* diesen Host anpingt; check_fds.sh holt seine Liste bei jedem Lauf ueber den eigenen
* Agenten-Key ab, damit eine Aenderung im Frontend ohne Login auf dem Ping-Rechner wirkt.
*/
final class HostConfigRepository
{
public function __construct(private readonly Database $db)
{
}
/**
* Alle Hosts ueber alle Ping-Hosts hinweg, inklusive Anzeigename des zustaendigen
* Agenten - so kann das Frontend-Modal ohne zweiten Request zeigen, wer den Host prueft.
*
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
$stmt = $this->db->pdo()->query(
'SELECT h.*, a.display_name AS agent_name
FROM host_configs h
JOIN agents a ON a.id = h.agent_id
ORDER BY h.agent_id ASC, h.sort_order ASC, h.id ASC'
);
return $stmt->fetchAll();
}
/** @return array<int, array<string, mixed>> */
public function allForAgent(int $agentId): array
{
$stmt = $this->db->pdo()->prepare(
'SELECT * FROM host_configs WHERE agent_id = :agent_id ORDER BY sort_order ASC, id ASC'
);
$stmt->execute(['agent_id' => $agentId]);
return $stmt->fetchAll();
}
/** @return array<string, mixed>|null */
public function find(int $id): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM host_configs WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/** @param array<string, mixed> $data @return array<string, mixed> */
public function create(array $data): array
{
$agentId = $this->validateAgent($data['agent_id'] ?? null);
[$hostname, $address] = $this->validate($data, $agentId, null);
$now = $this->db->now();
$stmt = $this->db->pdo()->prepare(
'INSERT INTO host_configs (agent_id, hostname, address, sort_order, created_at, updated_at)
VALUES (:agent_id, :hostname, :address, :sort_order, :created_at, :updated_at)'
);
$stmt->execute([
'agent_id' => $agentId,
'hostname' => $hostname,
'address' => $address,
'sort_order' => $this->nextSortOrder($agentId),
'created_at' => $now,
'updated_at' => $now,
]);
return $this->find((int) $this->db->pdo()->lastInsertId());
}
/** @param array<string, mixed> $data @return array<string, mixed> */
public function update(int $id, array $data): array
{
$existing = $this->find($id);
if ($existing === null) {
throw new NotFoundException('Host nicht gefunden: ' . $id);
}
// Den Ping-Host wechselt man nicht per Bearbeiten-Formular: dafuer den Eintrag beim
// alten Agenten loeschen und beim neuen anlegen (sonst waere die alte hosts.conf
// stillschweigend um einen Host aermer, ohne dass die Zeile im Modal verschwindet).
$agentId = (int) $existing['agent_id'];
[$hostname, $address] = $this->validate($data, $agentId, $id);
$stmt = $this->db->pdo()->prepare(
'UPDATE host_configs SET hostname = :hostname, address = :address, updated_at = :updated_at
WHERE id = :id'
);
$stmt->execute([
'id' => $id,
'hostname' => $hostname,
'address' => $address,
'updated_at' => $this->db->now(),
]);
return $this->find($id);
}
/**
* @return array<string, mixed> - der geloeschte Eintrag, damit der Aufrufer
* (HostConfigController::delete() raeumt anschliessend die Kachel im System-Monitor ab)
* dessen Hostnamen kennt, ohne ihn erneut nachzuschlagen.
*/
public function delete(int $id): array
{
$host = $this->find($id);
if ($host === null) {
throw new NotFoundException('Host nicht gefunden: ' . $id);
}
$stmt = $this->db->pdo()->prepare('DELETE FROM host_configs WHERE id = :id');
$stmt->execute(['id' => $id]);
return $host;
}
/**
* Erstbefuellung aus der bestehenden hosts.conf eines Ping-Rechners (check_fds.sh laedt
* sie hoch, solange serverseitig noch nichts fuer ihn steht) - sonst muesste jeder
* bereits ueberwachte Host von Hand im Modal nachgetragen werden.
*
* Bewusst nur, wenn die Liste dieses Agenten leer ist: ein spaeterer Aufruf darf eine im
* Frontend gepflegte Liste nicht durch die (dann veraltete) lokale Datei ersetzen.
*
* @param array<int, array{hostname: string, address: string}> $rows
* @return int Anzahl uebernommener Hosts (0 = es war bereits etwas konfiguriert).
*/
public function importForAgent(int $agentId, array $rows): int
{
if ($this->hasImported($agentId) || $this->allForAgent($agentId) !== []) {
return 0;
}
// Der Marker steht vor dem Import: Auch ein Rechner, dessen hosts.conf leer oder
// unbrauchbar war, hat seine Datei damit abgegeben - sonst wuerde er es bei jedem
// Lauf erneut versuchen und irgendwann eine im Frontend geleerte Liste neu befuellen.
$stmt = $this->db->pdo()->prepare(
'INSERT INTO host_config_imports (agent_id, imported_at) VALUES (:agent_id, :imported_at)'
);
$stmt->execute(['agent_id' => $agentId, 'imported_at' => $this->db->now()]);
$imported = 0;
foreach ($rows as $row) {
try {
$this->create([
'agent_id' => $agentId,
'hostname' => $row['hostname'] ?? '',
'address' => $row['address'] ?? '',
]);
$imported++;
} catch (InvalidArgumentException) {
// Eine unbrauchbare oder doppelte Zeile darf den Import der uebrigen nicht
// verhindern - die hosts.conf ist eine handgepflegte Datei.
continue;
}
}
return $imported;
}
/** Hat dieser Ping-Rechner seine lokale hosts.conf schon einmal abgegeben? */
public function hasImported(int $agentId): bool
{
$stmt = $this->db->pdo()->prepare('SELECT COUNT(*) FROM host_config_imports WHERE agent_id = :agent_id');
$stmt->execute(['agent_id' => $agentId]);
return ((int) $stmt->fetchColumn()) > 0;
}
/**
* Baut den Inhalt der hosts.conf fuer einen Ping-Rechner: eine Zeile "Hostname IP" je
* Host, im Format, das check_fds.sh liest.
*/
public function renderHostsConf(int $agentId): string
{
$lines = [
'# Von der monitor-API erzeugt (Issue #70) - Aenderungen bitte im Frontend',
'# vornehmen (Zahnrad am Wandmonitor "Systeme"), nicht hier: check_fds.sh',
'# ueberschreibt diese Datei bei jedem Lauf mit der Liste aus der API.',
];
foreach ($this->allForAgent($agentId) as $host) {
$lines[] = $host['hostname'] . ' ' . $host['address'];
}
return implode("\n", $lines) . "\n";
}
/**
* Liest eine hosts.conf im Format von check_fds.sh ("Hostname IP" je Zeile, "#" ist
* Kommentar) - Gegenstueck zu renderHostsConf(), fuer die Erstbefuellung aus der bereits
* gepflegten Datei eines Ping-Rechners.
*
* @return array<int, array{hostname: string, address: string}>
*/
public static function parseHostsConf(string $content): array
{
$rows = [];
foreach (preg_split('/\r\n|\r|\n/', $content) ?: [] as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
$parts = preg_split('/\s+/', $line);
if ($parts === false || count($parts) < 2) {
continue;
}
$rows[] = ['hostname' => $parts[0], 'address' => $parts[1]];
}
return $rows;
}
/**
* Prueft Pflichtfelder und Format vorab, statt eine Verletzung der UNIQUE-Constraint
* (agent_id, hostname) als PDOException auflaufen zu lassen - Konvention wie in
* RepoConfigRepository::validate().
*
* @param array<string, mixed> $data
* @param int|null $excludeId - beim Update die eigene Zeile von der Duplikatspruefung ausnehmen.
* @return array{0: string, 1: string}
*/
private function validate(array $data, int $agentId, ?int $excludeId): array
{
$hostname = trim((string) ($data['hostname'] ?? ''));
$address = trim((string) ($data['address'] ?? ''));
if ($hostname === '' || $address === '') {
throw new InvalidArgumentException('Felder "hostname" und "address" sind erforderlich.');
}
// hosts.conf ist eine zeilen- und leerzeichengetrennte Datei: ein Wert mit Leerzeichen,
// Zeilenumbruch oder fuehrendem "#" wuerde dort als zweites Feld bzw. als Kommentar
// gelesen und den Eintrag stillschweigend verstuemmeln.
foreach (['hostname' => $hostname, 'address' => $address] as $field => $value) {
if (preg_match('/\s/', $value) === 1 || str_starts_with($value, '#')) {
throw new InvalidArgumentException(sprintf(
'Feld "%s" darf keine Leerzeichen enthalten und nicht mit "#" beginnen.',
$field
));
}
}
if ($this->existsWithHostname($agentId, $hostname, $excludeId)) {
throw new InvalidArgumentException(sprintf('Host "%s" ist bereits konfiguriert.', $hostname));
}
return [$hostname, $address];
}
/** Nur registrierte Agenten der Rolle "ops" pingen - alles andere waere eine Liste ohne Leser. */
private function validateAgent(mixed $agentId): int
{
$id = is_numeric($agentId) ? (int) $agentId : 0;
if ($id <= 0) {
throw new InvalidArgumentException('Feld "agent_id" ist erforderlich (OPs-Agent des Ping-Rechners).');
}
$stmt = $this->db->pdo()->prepare('SELECT role FROM agents WHERE id = :id');
$stmt->execute(['id' => $id]);
$role = $stmt->fetchColumn();
if ($role === false) {
throw new InvalidArgumentException('Unbekannter Agent: ' . $id);
}
if ($role !== 'ops') {
throw new InvalidArgumentException('Agent ' . $id . ' hat nicht die Rolle "ops".');
}
return $id;
}
private function existsWithHostname(int $agentId, string $hostname, ?int $excludeId): bool
{
$sql = 'SELECT COUNT(*) FROM host_configs WHERE agent_id = :agent_id AND hostname = :hostname';
$params = ['agent_id' => $agentId, 'hostname' => $hostname];
if ($excludeId !== null) {
$sql .= ' AND id != :exclude_id';
$params['exclude_id'] = $excludeId;
}
$stmt = $this->db->pdo()->prepare($sql);
$stmt->execute($params);
return ((int) $stmt->fetchColumn()) > 0;
}
private function nextSortOrder(int $agentId): int
{
$stmt = $this->db->pdo()->prepare(
'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM host_configs WHERE agent_id = :agent_id'
);
$stmt->execute(['agent_id' => $agentId]);
return (int) $stmt->fetch()['next'];
}
}

View file

@ -103,6 +103,44 @@ final class SystemRepository
return $stmt->fetchAll();
}
/**
* Issue #70: Entfernt Systeme aus dem Monitor. Noetig, seit die Hosts der Ping-Ueberwachung
* im Frontend gepflegt werden - ein einmal gemeldetes System blieb sonst als Kachel stehen,
* auch wenn niemand es mehr prueft (die Kachel wurde nur nicht mehr aktualisiert).
*
* @param array<int, string> $ids
* @return int Anzahl tatsaechlich geloeschter Zeilen.
*/
public function delete(array $ids): int
{
$deleted = 0;
$stmt = $this->db->pdo()->prepare('DELETE FROM systems WHERE id = :id');
foreach (array_unique($ids) as $id) {
$stmt->execute(['id' => $id]);
$deleted += $stmt->rowCount();
}
return $deleted;
}
/**
* Die System-Ids, unter denen ein Host aus der hosts.conf im Monitor auftauchen kann.
* Es sind zwei, weil beide Meldewege denselben Host unterschiedlich benennen: Der
* Legacy-Import aus status.txt nimmt den Hostnamen unveraendert (LegacyStatusFileReader),
* die REST-Meldung aus check_fds.sh dessen URL-sicheren Slug (dortige system_id()). Wer
* einen Host entfernt, will beide Kacheln los.
*
* @return array<int, string>
*/
public static function idsForHostname(string $hostname): array
{
$slug = strtolower($hostname);
$slug = (string) preg_replace('/[^a-z0-9]+/', '-', $slug);
$slug = trim($slug, '-');
return array_values(array_unique([$hostname, $slug === '' ? 'unbekannt' : $slug]));
}
/** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
public function fingerprint(): string
{

View file

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\HostConfigController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\HostConfigRepository;
use Monitor\Api\Repository\SystemRepository;
use PHPUnit\Framework\TestCase;
final class HostConfigControllerTest extends TestCase
{
private HostConfigController $controller;
private HostConfigRepository $hosts;
private SystemRepository $systems;
/** @var array<string, mixed> */
private array $opsAgent;
protected function setUp(): void
{
$db = Database::inMemory();
$agents = new AgentRepository($db);
$this->opsAgent = $agents->register('OPs', 'ops')['agent'];
$this->hosts = new HostConfigRepository($db);
$this->systems = new SystemRepository($db);
$this->controller = new HostConfigController($this->hosts, $this->systems);
}
public function testIndexReturnsDerivedSystemIds(): void
{
$this->hosts->create([
'agent_id' => $this->opsAgent['id'],
'hostname' => 'Nextcloud(lokal)',
'address' => '192.168.2.7',
]);
$body = $this->controller->index(Request::create('GET', '/api/v1/hosts'))->body();
self::assertSame(
['Nextcloud(lokal)', 'nextcloud-lokal'],
$body['hosts'][0]['system_ids']
);
self::assertSame('OPs', $body['hosts'][0]['agent_name']);
}
public function testDeleteAlsoRemovesTilesOfBothReportingPaths(): void
{
$host = $this->hosts->create([
'agent_id' => $this->opsAgent['id'],
'hostname' => 'Nextcloud(lokal)',
'address' => '192.168.2.7',
]);
// So wuerde derselbe Host in den Monitor kommen: per REST unter dem Slug,
// per status.txt-Import unter dem Namen.
$this->systems->reportStatus('nextcloud-lokal', ['status' => 'error']);
$this->systems->importLegacyRows([[
'id' => 'Nextcloud(lokal)', 'name' => 'Nextcloud(lokal)', 'status' => 'error',
'detail' => '192.168.2.7', 'last_check_at' => '2026-01-01 00:00',
]]);
$this->controller->delete(Request::create('DELETE', '/api/v1/hosts/1'), ['id' => (string) $host['id']]);
self::assertNull($this->systems->find('nextcloud-lokal'));
self::assertNull($this->systems->find('Nextcloud(lokal)'));
}
public function testUpdateRemovesTileOfPreviousHostname(): void
{
$host = $this->hosts->create([
'agent_id' => $this->opsAgent['id'],
'hostname' => 'alt',
'address' => '10.0.0.1',
]);
$this->systems->reportStatus('alt', ['status' => 'ok']);
$this->controller->update(
Request::create('PATCH', '/api/v1/hosts/1', [], ['hostname' => 'neu', 'address' => '10.0.0.1']),
['id' => (string) $host['id']]
);
self::assertNull($this->systems->find('alt'));
}
public function testConfReturnsPlainHostsFileForAuthenticatedAgent(): void
{
$this->hosts->create(['agent_id' => $this->opsAgent['id'], 'hostname' => 'Web', 'address' => '10.0.0.1']);
$response = $this->controller->conf(Request::create('GET', '/api/v1/hosts/conf'), [], $this->opsAgent);
self::assertSame(200, $response->status);
self::assertStringContainsString("Web 10.0.0.1\n", (string) $response->body());
}
public function testConfAnswers204BeforeTheFirstImport(): void
{
$response = $this->controller->conf(Request::create('GET', '/api/v1/hosts/conf'), [], $this->opsAgent);
self::assertSame(204, $response->status);
}
public function testConfAnswers200WithAnEmptyListAfterTheImport(): void
{
// Alle Hosts im Frontend entfernt: der Ping-Rechner soll seine Datei leeren, nicht
// die alte Liste behalten - also 200 mit leerer Liste statt 204.
$this->controller->import(
Request::create('POST', '/api/v1/hosts/import', [], [], [], "Web 10.0.0.1\n"),
[],
$this->opsAgent
);
foreach ($this->hosts->allForAgent((int) $this->opsAgent['id']) as $host) {
$this->hosts->delete((int) $host['id']);
}
$response = $this->controller->conf(Request::create('GET', '/api/v1/hosts/conf'), [], $this->opsAgent);
self::assertSame(200, $response->status);
self::assertStringNotContainsString('Web', (string) $response->body());
}
public function testConfRejectsAgentWithoutOpsRole(): void
{
$response = $this->controller->conf(
Request::create('GET', '/api/v1/hosts/conf'),
[],
['id' => 99, 'role' => 'entwickler']
);
self::assertSame(403, $response->status);
}
public function testImportFillsListFromUploadedHostsConf(): void
{
$conf = "# Kommentar\nWeb 10.0.0.1\nMail 10.0.0.2\n";
$response = $this->controller->import(
Request::create('POST', '/api/v1/hosts/import', [], [], [], $conf),
[],
$this->opsAgent
);
self::assertSame(2, $response->body()['imported']);
self::assertCount(2, $this->hosts->allForAgent((int) $this->opsAgent['id']));
}
public function testImportDoesNotOverwriteExistingList(): void
{
$this->hosts->create(['agent_id' => $this->opsAgent['id'], 'hostname' => 'Web', 'address' => '10.0.0.1']);
$response = $this->controller->import(
Request::create('POST', '/api/v1/hosts/import', [], [], [], "Anders 10.0.0.9\n"),
[],
$this->opsAgent
);
self::assertSame(0, $response->body()['imported']);
self::assertCount(1, $this->hosts->allForAgent((int) $this->opsAgent['id']));
}
}

View file

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\HostConfigRepository;
use PHPUnit\Framework\TestCase;
final class HostConfigRepositoryTest extends TestCase
{
private HostConfigRepository $repository;
private int $opsAgentId;
private int $devAgentId;
protected function setUp(): void
{
$db = Database::inMemory();
$agents = new AgentRepository($db);
$this->opsAgentId = (int) $agents->register('OPs', 'ops')['agent']['id'];
$this->devAgentId = (int) $agents->register('Dev', 'entwickler')['agent']['id'];
$this->repository = new HostConfigRepository($db);
}
public function testCreateStoresHost(): void
{
$host = $this->repository->create([
'agent_id' => $this->opsAgentId,
'hostname' => 'Webserver',
'address' => '192.168.178.10',
]);
self::assertSame('Webserver', $host['hostname']);
self::assertSame('192.168.178.10', $host['address']);
self::assertSame($this->opsAgentId, (int) $host['agent_id']);
}
public function testCreateRejectsDuplicateHostnameForSameAgent(): void
{
$this->repository->create(['agent_id' => $this->opsAgentId, 'hostname' => 'Web', 'address' => '10.0.0.1']);
$this->expectException(InvalidArgumentException::class);
$this->repository->create(['agent_id' => $this->opsAgentId, 'hostname' => 'Web', 'address' => '10.0.0.2']);
}
public function testCreateRejectsValueWithWhitespace(): void
{
$this->expectException(InvalidArgumentException::class);
$this->repository->create([
'agent_id' => $this->opsAgentId,
'hostname' => 'Mein Server',
'address' => '10.0.0.1',
]);
}
public function testCreateRejectsAgentWithoutOpsRole(): void
{
$this->expectException(InvalidArgumentException::class);
$this->repository->create(['agent_id' => $this->devAgentId, 'hostname' => 'Web', 'address' => '10.0.0.1']);
}
public function testAllForAgentOnlyReturnsOwnHosts(): void
{
$db = Database::inMemory();
$agents = new AgentRepository($db);
$first = (int) $agents->register('OPs A', 'ops')['agent']['id'];
$second = (int) $agents->register('OPs B', 'ops')['agent']['id'];
$repository = new HostConfigRepository($db);
$repository->create(['agent_id' => $first, 'hostname' => 'A', 'address' => '10.0.0.1']);
$repository->create(['agent_id' => $second, 'hostname' => 'B', 'address' => '10.0.0.2']);
$hosts = $repository->allForAgent($first);
self::assertCount(1, $hosts);
self::assertSame('A', $hosts[0]['hostname']);
}
public function testRenderHostsConfMatchesCheckScriptFormat(): void
{
$this->repository->create(['agent_id' => $this->opsAgentId, 'hostname' => 'Web', 'address' => '10.0.0.1']);
$this->repository->create(['agent_id' => $this->opsAgentId, 'hostname' => 'Mail', 'address' => '10.0.0.2']);
$conf = $this->repository->renderHostsConf($this->opsAgentId);
$lines = array_values(array_filter(
explode("\n", $conf),
fn (string $line) => $line !== '' && !str_starts_with($line, '#')
));
self::assertSame(['Web 10.0.0.1', 'Mail 10.0.0.2'], $lines);
}
public function testParseHostsConfSkipsCommentsAndBlankLines(): void
{
$rows = HostConfigRepository::parseHostsConf("# Kommentar\n\nWeb 10.0.0.1\nMail\t10.0.0.2\nkaputt\n");
self::assertSame(
[['hostname' => 'Web', 'address' => '10.0.0.1'], ['hostname' => 'Mail', 'address' => '10.0.0.2']],
$rows
);
}
public function testImportForAgentOnlyFillsEmptyList(): void
{
$imported = $this->repository->importForAgent($this->opsAgentId, [
['hostname' => 'Web', 'address' => '10.0.0.1'],
['hostname' => 'Mail', 'address' => '10.0.0.2'],
]);
self::assertSame(2, $imported);
$again = $this->repository->importForAgent($this->opsAgentId, [
['hostname' => 'Alt', 'address' => '10.0.0.9'],
]);
self::assertSame(0, $again);
self::assertCount(2, $this->repository->allForAgent($this->opsAgentId));
}
public function testImportForAgentDoesNotRefillAnEmptiedList(): void
{
$this->repository->importForAgent($this->opsAgentId, [['hostname' => 'Web', 'address' => '10.0.0.1']]);
foreach ($this->repository->allForAgent($this->opsAgentId) as $host) {
$this->repository->delete((int) $host['id']);
}
// Genau der Fall, der ohne Marker die im Frontend geloeschten Hosts zurueckholen
// wuerde: der Ping-Rechner hat seine alte hosts.conf lokal noch.
$imported = $this->repository->importForAgent($this->opsAgentId, [['hostname' => 'Web', 'address' => '10.0.0.1']]);
self::assertSame(0, $imported);
self::assertSame([], $this->repository->allForAgent($this->opsAgentId));
}
public function testUpdateChangesHostnameAndAddress(): void
{
$host = $this->repository->create([
'agent_id' => $this->opsAgentId,
'hostname' => 'Web',
'address' => '10.0.0.1',
]);
$updated = $this->repository->update((int) $host['id'], ['hostname' => 'Web2', 'address' => '10.0.0.5']);
self::assertSame('Web2', $updated['hostname']);
self::assertSame('10.0.0.5', $updated['address']);
}
public function testDeleteRemovesHostAndReturnsIt(): void
{
$host = $this->repository->create([
'agent_id' => $this->opsAgentId,
'hostname' => 'Web',
'address' => '10.0.0.1',
]);
$deleted = $this->repository->delete((int) $host['id']);
self::assertSame('Web', $deleted['hostname']);
self::assertSame([], $this->repository->allForAgent($this->opsAgentId));
}
}

View file

@ -86,4 +86,23 @@ final class SystemRepositoryTest extends TestCase
self::assertNotSame($before, $this->repository->fingerprint());
}
public function testDeleteRemovesSystems(): void
{
$this->repository->reportStatus('srv1', ['status' => 'ok']);
$this->repository->reportStatus('srv2', ['status' => 'ok']);
$deleted = $this->repository->delete(['srv1', 'gibt-es-nicht']);
self::assertSame(1, $deleted);
self::assertNull($this->repository->find('srv1'));
self::assertNotNull($this->repository->find('srv2'));
}
public function testIdsForHostnameCoversBothReportingPaths(): void
{
self::assertSame(['Nextcloud(lokal)', 'nextcloud-lokal'], SystemRepository::idsForHostname('Nextcloud(lokal)'));
// Ein Name ohne Sonderzeichen ergibt nur eine Id - der Slug ist dann identisch.
self::assertSame(['monitor'], SystemRepository::idsForHostname('monitor'));
}
}

View file

@ -25,6 +25,72 @@ if [ -f "$API_CONF" ]; then
. "$API_CONF"
fi
# Issue #70: Die Liste der ueberwachten Hosts wird im Frontend gepflegt (Zahnrad am
# Wandmonitor "Systeme"). Dieser Aufruf holt sie vor jedem Durchlauf ab und schreibt daraus
# die lokale hosts.conf - deshalb muss auf diesem Rechner nichts mehr von Hand editiert
# werden. Die Ueberwachung darf dabei nie stehenbleiben: Ist die API nicht erreichbar oder
# antwortet sie mit einem Fehler, bleibt die zuletzt geschriebene hosts.conf in Kraft.
sync_hosts_from_api() {
if [ -z "$MONITOR_API_BASE" ] || [ -z "$MONITOR_API_KEY" ]; then
return 0
fi
local tmp code count
tmp=$(mktemp) || return 0
if ! code=$(curl -s -o "$tmp" -w '%{http_code}' --max-time 10 \
-H "X-Api-Key: ${MONITOR_API_KEY}" \
"${MONITOR_API_BASE%/}/api/v1/hosts/conf"); then
echo " Warnung: API unter $MONITOR_API_BASE nicht erreichbar, benutze die lokale $HOST_FILE."
rm -f "$tmp"
return 0
fi
# 204 heisst: fuer diesen Rechner ist serverseitig noch nichts konfiguriert. Dann geht
# die bestehende lokale Datei einmalig an die API, damit die bereits ueberwachten Hosts
# nicht von Hand im Frontend nachgetragen werden muessen. Ein spaeterer Aufruf wird mit
# 200 beantwortet - auch bei leerer Liste; eine im Frontend geleerte Liste fuellt dieser
# Zweig also nicht wieder auf.
if [ "$code" = "204" ]; then
if [ -s "$HOST_FILE" ]; then
local imported
imported=$(curl -s --max-time 10 -X POST \
"${MONITOR_API_BASE%/}/api/v1/hosts/import" \
-H "Content-Type: text/plain" \
-H "X-Api-Key: ${MONITOR_API_KEY}" \
--data-binary "@$HOST_FILE")
case "$imported" in
*'"imported":0'*) : ;;
*'"imported"'*) echo " Bestehende $HOST_FILE an die API uebergeben." ;;
*) echo " Warnung: Uebergabe der $HOST_FILE an die API fehlgeschlagen." ;;
esac
fi
rm -f "$tmp"
return 0
fi
if [ "$code" != "200" ]; then
echo " Warnung: API antwortet mit HTTP $code auf die Host-Liste, benutze die lokale $HOST_FILE."
rm -f "$tmp"
return 0
fi
# Zeilen, die check_fds.sh spaeter auch wirklich pingt (ohne Kommentare/Leerzeilen).
count=$(grep -cvE '^[[:space:]]*(#|$)' "$tmp")
if ! cmp -s "$tmp" "$HOST_FILE"; then
# cat statt mv: so behalten Besitzer und Rechte der bestehenden Datei ihren Wert,
# und ein abgebrochener Schreibvorgang trifft keine Datei aus /tmp mit 600/root.
if cat "$tmp" > "$HOST_FILE"; then
echo " Host-Liste aus der API uebernommen ($count Hosts)."
else
echo " Warnung: $HOST_FILE ist nicht schreibbar, benutze die bisherige Liste."
fi
fi
rm -f "$tmp"
}
# Erzeugt aus dem Hostnamen eine URL-sichere System-ID. Der Router der API matcht auf dem
# rohen, nicht dekodierten Pfad (api/src/Http/Router.php) - eine prozentkodierte ID landete
# also kodiert in der Datenbank. Der lesbare Originalname geht im Feld "name" mit.
@ -121,6 +187,7 @@ check_ip() {
# Hauptskript
echo "Pruefung: $(date)"
sync_hosts_from_api
if [ ! -f "$HOST_FILE" ]; then
echo "Fehler: Datei $HOST_FILE nicht gefunden."
exit 1

View file

@ -1,4 +1,9 @@
# Nach hosts.conf kopieren und um eigene Server erweitern.
#
# Sobald der Ping-Check bei der API angemeldet ist (register-ops-agent.sh, "Weg 3" im
# README), pflegt man die Liste stattdessen ueber das Zahnrad am Wandmonitor "Systeme":
# check_fds.sh holt sie dann bei jedem Lauf ab und ueberschreibt hosts.conf damit. Diese
# Datei ist dann nur noch der Startbestand, den der erste Lauf einmalig hochlaedt.
# Format: Hostname IP (ein Server pro Zeile, siehe README.md "Einen Server hinzufügen")
Server1 192.168.178.2
Server2 192.168.178.3

View file

@ -410,34 +410,44 @@ header.office-header h1 {
pointer-events: all;
}
/* Zahnrad am PO-Platz (Issue #27): zeigt ueberhaupt erst, dass sich hier die
Projektkonfiguration oeffnen laesst. Beim Hover ueber den Platz tritt es hervor. */
.po-config-hint-bg {
/* Konfig-Zahnrad (Issue #27 am PO-Platz, Issue #70 am Systeme-Monitor): zeigt ueberhaupt
erst, dass sich hier etwas einstellen laesst. Aussehen ist ueberall gleich, nur der
Ausloeser des Hovers unterscheidet sich - beim PO der ganze Schreibtisch, beim Monitor
das Symbol selbst (der Monitor traegt schon eine eigene Klick-Aktion). */
.config-hint-bg {
fill: #ffffff;
stroke: #94a3b8;
stroke-width: 1.5;
opacity: 0.85;
}
.po-config-hint-tooth,
.po-config-hint-hub {
.config-hint-tooth,
.config-hint-hub {
stroke: #475569;
stroke-width: 2;
stroke-linecap: round;
fill: none;
}
.po-desk:hover .po-config-hint-bg {
.po-desk:hover .config-hint-bg,
.system-config-hint:hover .config-hint-bg {
fill: #e0f2fe;
stroke: #0284c7;
opacity: 1;
}
.po-desk:hover .po-config-hint-tooth,
.po-desk:hover .po-config-hint-hub {
.po-desk:hover .config-hint-tooth,
.po-desk:hover .config-hint-hub,
.system-config-hint:hover .config-hint-tooth,
.system-config-hint:hover .config-hint-hub {
stroke: #0284c7;
}
.system-config-hint-hit {
fill: transparent;
pointer-events: all;
}
/* Reload-Symbol neben der Board-Ueberschrift (Issue #51): stoesst ein komplettes
Neueinlesen des Kanban-Boards an, analog zum Zahnrad am PO-Platz. */
.board-reload-hint-bg {
@ -885,3 +895,21 @@ header.office-header h1 {
.repo-config-status-icon.is-fail {
color: #dc2626;
}
/* Hosts-Modal (Issue #70) - teilt sich Zeilen-, Formular- und Symbolstile mit dem
Projekte-Modal; eigene Regeln braucht nur, was es dort nicht gibt. */
.host-config-subheading {
margin: 18px 0 4px;
font-size: 0.95rem;
color: #1f2937;
}
.host-config-agent {
flex: none;
padding: 2px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 0.8rem;
}

View file

@ -166,3 +166,39 @@ export async function resyncTickets(adminKey) {
const { tickets } = await adminJson("POST", "/tickets/resync", undefined, adminKey);
return tickets;
}
// ---------------------------------------------------------------------------------------
// Hosts der Ping-Ueberwachung (Issue #70: Zahnrad am Wandmonitor "Systeme" oeffnet ein Modal,
// um die hosts.conf des Ping-Rechners ueber die GUI zu pflegen). Schreibend hinter demselben
// Admin-Key wie die Projekte-Verwaltung; der Ping-Check selbst holt seine Liste dagegen mit
// seinem Agenten-Key ab (GET /hosts/conf, siehe check_fds.sh) - das macht der Browser nicht.
// ---------------------------------------------------------------------------------------
export async function fetchHostConfigs() {
const { hosts } = await getJson("/hosts");
return hosts;
}
export async function createHostConfig(data, adminKey) {
const { host } = await adminJson("POST", "/hosts", data, adminKey);
return host;
}
export async function updateHostConfig(id, data, adminKey) {
const { host } = await adminJson("PATCH", `/hosts/${encodeURIComponent(id)}`, data, adminKey);
return host;
}
export async function deleteHostConfig(id, adminKey) {
await adminJson("DELETE", `/hosts/${encodeURIComponent(id)}`, undefined, adminKey);
}
/**
* Entfernt eine Kachel aus dem System-Monitor. Fuer Systeme, die zu keinem hier gepflegten
* Host gehoeren - etwa weil sie sich frueher selbst per REST gemeldet haben und das nicht
* mehr tun. Ein Host aus der Ping-Ueberwachung nimmt seine Kachel dagegen beim Loeschen
* selbst mit (serverseitig, siehe HostConfigController::delete).
*/
export async function deleteSystem(id, adminKey) {
await adminJson("DELETE", `/systems/${encodeURIComponent(id)}`, undefined, adminKey);
}

View file

@ -51,6 +51,7 @@ import {
PO_DISPLAY_NAME,
resolveReloadIntervalSeconds,
nextCountdownSeconds,
systemsWithoutHostConfig,
} from "./office-scene.js";
import {
DEMO_TICKET_COUNTS,
@ -72,6 +73,11 @@ import {
deleteRepoConfig,
checkRepoConfig,
resyncTickets,
fetchHostConfigs,
createHostConfig,
updateHostConfig,
deleteHostConfig,
deleteSystem,
getStoredAdminKey,
setStoredAdminKey,
} from "./office-api.js";
@ -379,11 +385,12 @@ function renderEventMonitor(frame, events) {
return g;
}
function renderSystemMonitor(frame, systems) {
function renderSystemMonitor(frame, systems, onConfigClick) {
const tiles = buildSystemTiles(systems);
const g = el("g", { class: "monitor monitor-systems" });
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
g.appendChild(text("Systeme", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
g.appendChild(renderSystemConfigHint(frame, onConfigClick));
g.appendChild(
el("rect", {
x: frame.x + 10,
@ -527,7 +534,8 @@ function renderPlaceholderText(g, frame, placeholder) {
}
/**
* @param {{onEventsClick: () => void, onSystemsClick: () => void, onKpiClick: () => void}} handlers
* @param {{onEventsClick: () => void, onSystemsClick: () => void, onKpiClick: () => void,
* onSystemsConfigClick: () => void}} handlers
*/
function renderMonitorWall(systems, events, kpis, handlers) {
const g = el("g", { class: "monitor-wall" });
@ -544,7 +552,7 @@ function renderMonitorWall(systems, events, kpis, handlers) {
eventsGroup.classList.add("clickable");
eventsGroup.addEventListener("click", handlers.onEventsClick);
const systemsGroup = renderSystemMonitor(frames[1], systems);
const systemsGroup = renderSystemMonitor(frames[1], systems, handlers.onSystemsConfigClick);
systemsGroup.classList.add("clickable");
systemsGroup.addEventListener("click", handlers.onSystemsClick);
@ -740,23 +748,67 @@ function renderPoConfigHint() {
const title = document.createElementNS(SVG_NS, "title");
title.textContent = "Projekte konfigurieren";
g.appendChild(title);
gearHintShapes().forEach((node) => g.appendChild(node));
return g;
}
/**
* Zahnrad aus acht Zaehnen plus Nabe - in dieser Groesse von einem exakten Zahnradpfad nicht
* zu unterscheiden und ohne fremde Icon-Bibliothek zu haben. Gemeinsame Silhouette fuer alle
* Konfig-Hinweise (PO-Platz, Issue #27; Systeme-Monitor, Issue #70): Dass ueberall dasselbe
* Symbol fuer "hier laesst sich etwas einstellen" steht, ist der halbe Wiedererkennungswert.
* Die Farben kommen ueber die Klassen config-hint-*; das Hover-Verhalten haengt dagegen an
* der umgebenden Gruppe, weil es sich unterscheidet (der PO reagiert auf den ganzen
* Schreibtisch, das Monitor-Zahnrad nur auf sich selbst).
*
* @param {number} scale - 1 = die Groesse am PO-Platz (Radius 15).
*/
function gearHintShapes(scale = 1) {
const nodes = [el("circle", { cx: 0, cy: 0, r: 15 * scale, class: "config-hint-bg" })];
g.appendChild(el("circle", { cx: 0, cy: 0, r: 15, class: "po-config-hint-bg" }));
// Zahnrad aus acht Zaehnen plus Nabe - in dieser Groesse von einem exakten Zahnradpfad
// nicht zu unterscheiden und ohne fremde Icon-Bibliothek zu haben.
for (let i = 0; i < 8; i++) {
const angle = (i * Math.PI) / 4;
g.appendChild(
nodes.push(
el("line", {
x1: Math.cos(angle) * 5.5,
y1: Math.sin(angle) * 5.5,
x2: Math.cos(angle) * 9.5,
y2: Math.sin(angle) * 9.5,
class: "po-config-hint-tooth",
x1: Math.cos(angle) * 5.5 * scale,
y1: Math.sin(angle) * 5.5 * scale,
x2: Math.cos(angle) * 9.5 * scale,
y2: Math.sin(angle) * 9.5 * scale,
class: "config-hint-tooth",
})
);
}
g.appendChild(el("circle", { cx: 0, cy: 0, r: 5, class: "po-config-hint-hub" }));
nodes.push(el("circle", { cx: 0, cy: 0, r: 5 * scale, class: "config-hint-hub" }));
return nodes;
}
/**
* Issue #70: Zahnrad neben der Ueberschrift "Systeme" - oeffnet die Verwaltung der
* ueberwachten Hosts. Sitzt an derselben Stelle wie das Reload-Symbol am Board
* (renderBoardReloadHint), damit beide Bedienelemente ein System ergeben.
*/
function renderSystemConfigHint(frame, onClick) {
const g = el("g", {
class: "system-config-hint clickable",
transform: `translate(${frame.x + 128}, ${frame.y - 15})`,
});
const title = document.createElementNS(SVG_NS, "title");
title.textContent = "Überwachte Hosts konfigurieren";
g.appendChild(title);
// 13/15 = etwas kleiner als am PO-Platz, passend zum Reload-Symbol am Board.
gearHintShapes(13 / 15).forEach((node) => g.appendChild(node));
// Groessere unsichtbare Trefferflaeche, analog zu .board-reload-hint-hit.
g.appendChild(el("rect", { x: -16, y: -16, width: 32, height: 32, class: "system-config-hint-hit" }));
g.addEventListener("click", (event) => {
// Der ganze Monitor traegt einen Klick-Handler (Detailliste der Systeme) - ohne das
// Stoppen laege ueber der frisch geoeffneten Host-Verwaltung sofort das Detail-Modal.
event.stopPropagation();
onClick();
});
return g;
}
@ -1652,12 +1704,15 @@ async function handleRepoConfigDelete(repo) {
* der naechste Versuch erneut fragt statt denselben ungueltigen Wert wiederzuverwenden.
*
* @param {(adminKey: string) => Promise<unknown>} action
* @param {{setError: (message: string) => void, prompt: string}} [ui] - wohin die
* Fehlermeldung gehoert und wonach der Prompt fragt. Beides haengt am aufrufenden Modal
* (Projekte, Issue #27 - Hosts, Issue #70), die Key-Logik ist fuer beide dieselbe.
* @returns {Promise<boolean>}
*/
async function withAdminKey(action) {
async function withAdminKey(action, ui = REPO_MODAL_ADMIN_UI) {
let adminKey = getStoredAdminKey();
if (!adminKey) {
adminKey = window.prompt("Admin-Key für Änderungen an Projekten:") || "";
adminKey = window.prompt(ui.prompt) || "";
if (!adminKey) {
return false;
}
@ -1670,14 +1725,28 @@ async function withAdminKey(action) {
} catch (err) {
if (err && err.isAuthError) {
setStoredAdminKey("");
repoModalError = "Admin-Key ungültig.";
ui.setError("Admin-Key ungültig.");
} else {
repoModalError = err instanceof Error ? err.message : "Aktion fehlgeschlagen.";
ui.setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen.");
}
return false;
}
}
const REPO_MODAL_ADMIN_UI = {
setError: (message) => {
repoModalError = message;
},
prompt: "Admin-Key für Änderungen an Projekten:",
};
const HOST_MODAL_ADMIN_UI = {
setError: (message) => {
hostModalError = message;
},
prompt: "Admin-Key für Änderungen an den überwachten Hosts:",
};
/**
* Wie withAdminKey(), gibt bei Erfolg aber den Rueckgabewert von action() zurueck statt nur
* true - fuer Aufrufer, die (anders als Speichern/Loeschen, die den Zustand ohnehin per
@ -1687,10 +1756,10 @@ async function withAdminKey(action) {
* @param {(adminKey: string) => Promise<T>} action
* @returns {Promise<T|undefined>}
*/
async function withAdminKeyResult(action) {
async function withAdminKeyResult(action, ui = REPO_MODAL_ADMIN_UI) {
let adminKey = getStoredAdminKey();
if (!adminKey) {
adminKey = window.prompt("Admin-Key für Änderungen an Projekten:") || "";
adminKey = window.prompt(ui.prompt) || "";
if (!adminKey) {
return undefined;
}
@ -1703,14 +1772,336 @@ async function withAdminKeyResult(action) {
} catch (err) {
if (err && err.isAuthError) {
setStoredAdminKey("");
repoModalError = "Admin-Key ungültig.";
ui.setError("Admin-Key ungültig.");
} else {
repoModalError = err instanceof Error ? err.message : "Aktion fehlgeschlagen.";
ui.setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen.");
}
return undefined;
}
}
// ---------------------------------------------------------------------------------------
// Hosts-Modal (Issue #70: Zahnrad am Wandmonitor "Systeme" -> Ping-Ueberwachung verwalten)
//
// Aufbau wie das Projekte-Modal (Issue #27): interaktiv, mit Server-Rundtrip, zeichnet sich
// nach jeder Aenderung selbst neu. Zwei Abschnitte, weil es zwei Arten von Kacheln gibt:
// oben die Hosts, die ein OPs-Agent per Ping prueft (die hier wirklich gepflegt werden),
// unten die uebrigen Kacheln des System-Monitors - fuer die kann die GUI nur aufraeumen,
// denn ihre Meldung kommt von woanders.
// ---------------------------------------------------------------------------------------
let hostModalBodyEl = null;
let hostModalToken = null;
let hostModalHosts = [];
let hostModalSystems = [];
// Nur Agenten der Rolle "ops" - nur sie pingen, alle anderen haetten keine hosts.conf.
let hostModalOpsAgents = [];
let hostModalMode = null; // null | "new" | <id des bearbeiteten Hosts>
let hostModalError = "";
function openHostConfigModal(systems = []) {
hostModalHosts = [];
hostModalSystems = Array.isArray(systems) ? systems : [];
hostModalOpsAgents = [];
hostModalMode = null;
hostModalError = "";
hostModalToken = openModal({ title: "Überwachte Hosts" }, (body) => {
hostModalBodyEl = body;
renderHostConfigModal();
});
loadHostConfigModal(hostModalToken);
}
async function loadHostConfigModal(token) {
let loaded = null;
let error = "";
try {
const [hosts, agents, systems] = await Promise.all([fetchHostConfigs(), fetchAgents(), fetchSystems()]);
loaded = { hosts, agents, systems };
} catch {
error = "Hosts konnten nicht geladen werden.";
}
if (!isCurrentModal(token)) {
return;
}
if (loaded !== null) {
hostModalHosts = loaded.hosts;
hostModalOpsAgents = loaded.agents.filter((agent) => agent.role === "ops");
hostModalSystems = loaded.systems;
}
hostModalError = error;
renderHostConfigModal();
}
function renderHostConfigModal() {
const body = hostModalBodyEl;
if (!body) return;
body.replaceChildren();
if (hostModalError) {
const errorEl = document.createElement("p");
errorEl.className = "office-modal-error";
errorEl.textContent = hostModalError;
body.appendChild(errorEl);
}
// Ohne registrierten OPs-Agenten gibt es niemanden, der die Liste abholt und pingt - ein
// hier angelegter Host waere wirkungslos. Das sagt das Modal lieber, als ein Formular zu
// zeigen, das serverseitig ohnehin an der Rollenpruefung scheitert.
if (hostModalOpsAgents.length === 0) {
const hint = document.createElement("p");
hint.className = "office-modal-empty";
hint.textContent =
"Kein OPs-Agent registriert. Der Ping-Check meldet sich mit einem eigenen Agenten an – " +
"einzurichten mit register-ops-agent.sh auf dem Rechner, der die Hosts anpingt.";
body.appendChild(hint);
}
if (hostModalHosts.length === 0 && hostModalMode !== "new") {
body.appendChild(emptyMessage("Keine Hosts konfiguriert."));
} else {
const list = document.createElement("ul");
list.className = "office-modal-list repo-config-list host-config-list";
for (const host of hostModalHosts) {
list.appendChild(hostModalMode === host.id ? renderHostConfigForm(host) : renderHostConfigRow(host));
}
body.appendChild(list);
}
if (hostModalMode === "new") {
let list = body.querySelector(".host-config-list");
if (!list) {
list = document.createElement("ul");
list.className = "office-modal-list repo-config-list host-config-list";
body.appendChild(list);
}
list.appendChild(renderHostConfigForm(null));
} else if (hostModalOpsAgents.length > 0) {
const addButton = iconButton("add", "Host hinzufügen", () => {
hostModalMode = "new";
renderHostConfigModal();
});
addButton.classList.add("repo-config-add-button");
body.appendChild(addButton);
}
renderOrphanSystems(body);
}
function renderOrphanSystems(body) {
const orphans = systemsWithoutHostConfig(hostModalSystems, hostModalHosts);
if (orphans.length === 0) {
return;
}
const heading = document.createElement("h3");
heading.className = "host-config-subheading";
heading.textContent = "Weitere Kacheln im System-Monitor";
body.appendChild(heading);
const note = document.createElement("p");
note.className = "office-modal-empty";
note.textContent =
"Gehören zu keinem hier gepflegten Host: Sie melden sich selbst per REST – oder ihr " +
"letzter Stand steht noch in der Datenbank, obwohl sie niemand mehr prüft.";
body.appendChild(note);
const list = document.createElement("ul");
list.className = "office-modal-list repo-config-list host-config-list";
for (const system of orphans) {
const li = document.createElement("li");
li.className = `office-modal-item repo-config-item status-${system.status}`;
const label = document.createElement("span");
label.className = "repo-config-slug";
label.textContent = system.detail ? `${system.name} (${system.detail})` : system.name;
li.appendChild(label);
const actions = document.createElement("span");
actions.className = "repo-config-actions";
actions.appendChild(iconButton("delete", "Kachel entfernen", () => handleSystemDelete(system)));
li.appendChild(actions);
list.appendChild(li);
}
body.appendChild(list);
}
function renderHostConfigRow(host) {
const li = document.createElement("li");
li.className = "office-modal-item repo-config-item";
const label = document.createElement("span");
label.className = "repo-config-slug";
label.textContent = `${host.hostname} · ${host.address}`;
li.appendChild(label);
// Wer den Host anpingt, steht nur dann dabei, wenn es ueberhaupt mehrere Ping-Rechner
// gibt - sonst waere es bei jeder Zeile dieselbe Auskunft.
if (hostModalOpsAgents.length > 1) {
const agent = document.createElement("span");
agent.className = "host-config-agent";
agent.textContent = host.agent_name;
li.appendChild(agent);
}
const actions = document.createElement("span");
actions.className = "repo-config-actions";
actions.appendChild(
iconButton("edit", "Host bearbeiten", () => {
hostModalMode = host.id;
renderHostConfigModal();
})
);
actions.appendChild(iconButton("delete", "Host entfernen", () => handleHostConfigDelete(host)));
li.appendChild(actions);
return li;
}
function renderHostConfigForm(host) {
const li = document.createElement("li");
li.className = "office-modal-item repo-config-item repo-config-form";
const hostnameInput = document.createElement("input");
hostnameInput.type = "text";
hostnameInput.placeholder = "Name, z. B. Webserver";
hostnameInput.value = host?.hostname ?? "";
hostnameInput.setAttribute("aria-label", "Hostname");
const addressInput = document.createElement("input");
addressInput.type = "text";
addressInput.placeholder = "IP oder Adresse, z. B. 192.168.178.10";
addressInput.value = host?.address ?? "";
addressInput.setAttribute("aria-label", "IP-Adresse");
const fields = document.createElement("span");
fields.className = "repo-config-form-fields";
fields.append(hostnameInput, addressInput);
// Den Ping-Rechner waehlt man nur beim Anlegen und nur, wenn es mehrere gibt; ein Wechsel
// im Nachhinein ginge sonst still an der alten hosts.conf vorbei (siehe
// HostConfigRepository::update).
let agentSelect = null;
if (host === null && hostModalOpsAgents.length > 1) {
agentSelect = document.createElement("select");
agentSelect.setAttribute("aria-label", "Ping-Rechner");
agentSelect.title = "Welcher OPs-Agent diesen Host anpingt";
for (const agent of hostModalOpsAgents) {
const option = document.createElement("option");
option.value = String(agent.id);
option.textContent = agent.display_name;
agentSelect.appendChild(option);
}
fields.appendChild(agentSelect);
}
li.appendChild(fields);
const actions = document.createElement("span");
actions.className = "repo-config-actions";
const save = () =>
handleHostConfigSave(host, {
hostname: hostnameInput.value.trim(),
address: addressInput.value.trim(),
agent_id: agentSelect ? Number(agentSelect.value) : hostModalOpsAgents[0]?.id,
});
actions.appendChild(iconButton("confirm", "Speichern", save));
actions.appendChild(
iconButton("cancel", "Abbrechen", () => {
hostModalMode = null;
renderHostConfigModal();
})
);
li.appendChild(actions);
// Wie im Projekte-Formular: Enter wuerde sonst das <form method="dialog"> einreichen und
// das ganze Modal schliessen, statt zu speichern.
const saveOnEnter = (event) => {
if (event.key === "Enter") {
event.preventDefault();
save();
}
};
hostnameInput.addEventListener("keydown", saveOnEnter);
addressInput.addEventListener("keydown", saveOnEnter);
window.setTimeout(() => hostnameInput.focus(), 0);
return li;
}
async function handleHostConfigSave(existing, data) {
const token = hostModalToken;
if (!data.hostname || !data.address) {
hostModalError = 'Felder "Name" und "IP" sind erforderlich.';
renderHostConfigModal();
return;
}
if (!existing && !data.agent_id) {
hostModalError = "Kein OPs-Agent vorhanden, der diesen Host anpingen könnte.";
renderHostConfigModal();
return;
}
const ok = await withAdminKey(
(adminKey) => (existing ? updateHostConfig(existing.id, data, adminKey) : createHostConfig(data, adminKey)),
HOST_MODAL_ADMIN_UI
);
if (!isCurrentModal(token)) {
return;
}
if (!ok) {
renderHostConfigModal();
return;
}
hostModalMode = null;
await loadHostConfigModal(token);
}
async function handleHostConfigDelete(host) {
const token = hostModalToken;
if (
!window.confirm(
`Host "${host.hostname}" nicht mehr überwachen?\nDie Kachel im System-Monitor verschwindet mit.`
)
) {
return;
}
const ok = await withAdminKey((adminKey) => deleteHostConfig(host.id, adminKey), HOST_MODAL_ADMIN_UI);
if (!isCurrentModal(token)) {
return;
}
if (!ok) {
renderHostConfigModal();
return;
}
await loadHostConfigModal(token);
}
async function handleSystemDelete(system) {
const token = hostModalToken;
if (!window.confirm(`Kachel "${system.name}" aus dem System-Monitor entfernen?`)) {
return;
}
const ok = await withAdminKey((adminKey) => deleteSystem(system.id, adminKey), HOST_MODAL_ADMIN_UI);
if (!isCurrentModal(token)) {
return;
}
if (!ok) {
renderHostConfigModal();
return;
}
await loadHostConfigModal(token);
}
// ---------------------------------------------------------------------------------------
// Datenbeschaffung + Orchestrierung
// ---------------------------------------------------------------------------------------
@ -1812,6 +2203,9 @@ export function renderOfficeScene(container, data = {}) {
const wallGroup = renderMonitorWall(systems, events, displayKpis, {
onEventsClick: () => openModal(buildEventModalContent(events), renderEventModalBody),
onSystemsClick: () => openModal(buildSystemModalContent(systems), renderSystemModalBody),
// Issue #70: die aktuell gezeigten Systeme wandern mit ins Modal - daran erkennt es,
// welche Kacheln zu keinem konfigurierten Host mehr gehoeren.
onSystemsConfigClick: () => openHostConfigModal(systems),
onKpiClick: () => openModal(buildKpiModalContent(displayKpis), renderKpiModalBody),
});
const existingWall = svg.querySelector(".monitor-wall");

View file

@ -335,6 +335,25 @@ export function buildSystemModalContent(systems = []) {
};
}
/**
* Issue #70: Die Kacheln des System-Monitors, die zu keinem konfigurierten Ping-Host
* gehoeren - fuer den unteren Abschnitt der Host-Verwaltung. Sie melden sich entweder selbst
* per REST, oder ihr letzter Stand steht nur noch in der Datenbank, weil ihr Host laengst aus
* der Ueberwachung raus ist.
*
* Die Zuordnung laeuft ueber die vom Server mitgelieferten system_ids: Ein Host kann unter
* zwei Ids melden (Hostname aus dem status.txt-Import, Slug aus der REST-Meldung), und diese
* Ableitung hier nachzubauen hiesse, sie an zwei Stellen richtig halten zu muessen.
*
* @param {Array<{id: string}>} systems
* @param {Array<{system_ids?: Array<string>}>} hosts
*/
export function systemsWithoutHostConfig(systems = [], hosts = []) {
const known = new Set(hosts.flatMap((host) => host.system_ids || []));
return systems.filter((system) => !known.has(system.id));
}
/**
* Formatiert last_check_at eines Systems (GET /api/v1/systems, Issue #57: "wann der letzte
* erfolgreiche Ping war") fuers Modal. Zwei Quellformate kommen vor: ISO-8601 UTC (per API

View file

@ -20,6 +20,7 @@ import {
EVENT_LINE_CHAR_WIDTH,
buildEventModalContent,
buildSystemModalContent,
systemsWithoutHostConfig,
formatSystemCheckTime,
buildKpiSparklinePoints,
buildKpiTiles,
@ -869,3 +870,27 @@ test("nextCountdownSeconds zaehlt herunter und springt bei 1 auf das volle Inter
assert.equal(nextCountdownSeconds(2, 30), 1);
assert.equal(nextCountdownSeconds(1, 30), 30);
});
test("systemsWithoutHostConfig laesst nur Kacheln ohne konfigurierten Ping-Host uebrig", () => {
const systems = [
{ id: "nextcloud-lokal", name: "Nextcloud(lokal)" },
{ id: "Webserver", name: "Webserver" },
{ id: "fremd", name: "Selbstmelder" },
];
// system_ids kommen so von GET /api/v1/hosts: derselbe Host kann unter beiden Ids melden.
const hosts = [
{ hostname: "Nextcloud(lokal)", system_ids: ["Nextcloud(lokal)", "nextcloud-lokal"] },
{ hostname: "Webserver", system_ids: ["Webserver"] },
];
assert.deepEqual(
systemsWithoutHostConfig(systems, hosts).map((system) => system.id),
["fremd"]
);
});
test("systemsWithoutHostConfig kommt ohne Hosts und ohne system_ids zurecht", () => {
assert.deepEqual(systemsWithoutHostConfig([{ id: "a" }], []), [{ id: "a" }]);
assert.deepEqual(systemsWithoutHostConfig([{ id: "a" }], [{ hostname: "a" }]), [{ id: "a" }]);
assert.deepEqual(systemsWithoutHostConfig(), []);
});