Merge pull request 'Phase 3-5 der Neuausrichtung: Board/Monitor-Wand live, SSE-Push, Agenten-Animation' (#8) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/8
This commit is contained in:
fds2610 2026-07-28 18:33:34 +02:00
commit f383ce2d33
33 changed files with 2223 additions and 88 deletions

34
API.md
View file

@ -17,6 +17,7 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
- [Events](#events) - [Events](#events)
- [Systeme](#systeme) - [Systeme](#systeme)
- [KPIs](#kpis) - [KPIs](#kpis)
- [Live-Updates (SSE)](#live-updates-sse)
- [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone) - [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone)
- [Fehlerformat](#fehlerformat) - [Fehlerformat](#fehlerformat)
@ -29,6 +30,9 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
- Lesende Endpunkte (`GET`) sind oeffentlich, da sie das Frontend bedienen. - Lesende Endpunkte (`GET`) sind oeffentlich, da sie das Frontend bedienen.
- Schreibende Endpunkte (`POST`/`PATCH`) erfordern einen Agenten- oder Admin-Key - Schreibende Endpunkte (`POST`/`PATCH`) erfordern einen Agenten- oder Admin-Key
(siehe [Authentifizierung](#authentifizierung)). (siehe [Authentifizierung](#authentifizierung)).
- Alle Antworten tragen `Access-Control-Allow-Origin: *` (CORS), da Frontend und API im
Docker-Compose-Setup auf unterschiedlichen Ports laufen (siehe
[docker-compose.yml](docker-compose.yml): Frontend `:8090`, API `:8091`).
## Authentifizierung ## Authentifizierung
@ -194,6 +198,36 @@ dem KPI inkl. Verlauf.
Response: `{ "kpis": [ { "key", "label", "unit", "current_value", "history": [ { "value", "recorded_at" }, ... ] }, ... ] }` Response: `{ "kpis": [ { "key", "label", "unit", "current_value", "history": [ { "value", "recorded_at" }, ... ] }, ... ] }`
## Live-Updates (SSE)
F13: Das Frontend haelt sich per [Server-Sent Events](https://developer.mozilla.org/de/docs/Web/API/Server-sent_events)
ueber Aenderungen auf dem Laufenden, statt staendig zu pollen.
### `GET /api/v1/stream` — Aenderungs-Feed
Auth: keine (oeffentlich wie die anderen `GET`-Endpunkte). Antwort:
`Content-Type: text/event-stream`.
Die Verbindung bleibt fuer `MONITOR_SSE_MAX_ITERATIONS * MONITOR_SSE_POLL_INTERVAL` Sekunden
offen (Default: `30 * 2s` = ca. 1 Minute) und prueft alle `MONITOR_SSE_POLL_INTERVAL` Sekunden
serverseitig, ob sich Tickets, Events, Systeme, KPIs oder Agenten veraendert haben (billiger
"Fingerabdruck" aus Anzahl + juengstem Zeitstempel je Ressource, keine vollstaendige
Änderungshistorie). Danach schliesst der Server die Verbindung; `EventSource` im Browser
verbindet automatisch neu (Standardverhalten).
Bei einer Aenderung wird ein `update`-Event gesendet, das nur benennt, **welche** Ressourcen
sich geaendert haben - das Frontend laedt die betroffenen Endpunkte anschliessend regulaer per
`GET` neu:
```
event: update
data: {"changed": ["tickets", "events"]}
```
Ohne Aenderung sendet der Server stattdessen alle `MONITOR_SSE_POLL_INTERVAL` Sekunden einen
Kommentar (`: heartbeat`) als Keep-Alive, damit Proxys die Verbindung nicht vorzeitig
schliessen.
## Codeberg-Sync (Backlog/Done) ## Codeberg-Sync (Backlog/Done)
`api/bin/sync-codeberg.php` (per Cron oder manuell auszufuehren) liest offene und `api/bin/sync-codeberg.php` (per Cron oder manuell auszufuehren) liest offene und

View file

@ -15,8 +15,11 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und
- REST-API (`api/`, siehe [API.md](API.md)) für Tickets, Events, Systeme, KPIs und - REST-API (`api/`, siehe [API.md](API.md)) für Tickets, Events, Systeme, KPIs und
Agenten-Aktivität, mit statischen API-Keys pro Agent Agenten-Aktivität, mit statischen API-Keys pro Agent
- Büro-Visualisierung (`html/office.html`) als Vanilla-SVG-Szene: Kanban-Board, Monitor-Wand - Büro-Visualisierung (`html/office.html`) als Vanilla-SVG-Szene: Kanban-Board, Monitor-Wand
(Events/Systeme/KPIs), 8 Schreibtische - aktuell Phase 2 (statisches Layout mit (Events/Systeme/KPIs), 8 Schreibtische - zeigt live die Daten aus der REST-API (Push per
Demo-Daten, siehe Visualization.md Abschnitt 9) Server-Sent Events, Polling-Fallback), Klick auf Board/Monitore öffnet Detail-Modals,
Agenten laufen animiert zwischen Schreibtisch/Board/Monitor-Wand/Kaffee-Ecke (Phase 3-5,
siehe Visualization.md Abschnitt 9). Ist die API nicht erreichbar, zeigt die Szene
ersatzweise Demo-Daten.
- Weiterhin: klassische Ping-Überwachung (`check_fds.sh`, `hosts.conf`, `mail_fds.py`) als - Weiterhin: klassische Ping-Überwachung (`check_fds.sh`, `hosts.conf`, `mail_fds.py`) als
eine Datenquelle für den System-Monitor; die alte Tabellenansicht (`html/mon.html`) eine Datenquelle für den System-Monitor; die alte Tabellenansicht (`html/mon.html`)
bleibt bis zur Migration in einer späteren Phase bestehen (F16) bleibt bis zur Migration in einer späteren Phase bestehen (F16)
@ -42,7 +45,7 @@ Docker-Compose-Containern; auf dem Host muss nichts installiert werden.
```bash ```bash
cp .env.example .env # einmalig, MONITOR_ADMIN_API_KEY anpassen cp .env.example .env # einmalig, MONITOR_ADMIN_API_KEY anpassen
docker compose up --build # API auf :8080, Buero-Visualisierung auf :8081 docker compose up --build # API auf :8091, Buero-Visualisierung auf :8090
docker compose run --rm api composer test # PHPUnit (API) docker compose run --rm api composer test # PHPUnit (API)
docker compose run --rm frontend # Node-Tests (Frontend-Layoutlogik) docker compose run --rm frontend # Node-Tests (Frontend-Layoutlogik)
``` ```

View file

@ -231,20 +231,37 @@ externe Datenbankserver (nur PHP/Python-Standardbibliothek) auskommen, um die
## 9. Migrationsplan / Phasen ## 9. Migrationsplan / Phasen
1. **Phase 0 – Grundlagen & Klärung**: Detailfragen klären ([Detailfragen.md](Detailfragen.md)), 1. **Phase 0 – Grundlagen & Klärung**: Detailfragen klären ([Detailfragen.md](Detailfragen.md)),
Tech-Entscheidungen fixieren (Backend-Sprache, Persistenz, Chart-Lib). Tech-Entscheidungen fixieren (Backend-Sprache, Persistenz, Chart-Lib). ✅ erledigt.
2. **Phase 1 – REST-API**: Datenmodell, Endpunkte, Auth, Persistenz, `API.md`-Dokumentation, 2. **Phase 1 – REST-API**: Datenmodell, Endpunkte, Auth, Persistenz, `API.md`-Dokumentation,
Tests (≥60 % Coverage). Tests (≥60 % Coverage). ✅ erledigt (PR #5).
3. **Phase 2 – Statische Büro-Szene**: SVG/Canvas-Grundlayout (Wände, Türen, 8 Schreibtische, 3. **Phase 2 – Statische Büro-Szene**: SVG/Canvas-Grundlayout (Wände, Türen, 8 Schreibtische,
Board-Rahmen, 3 Monitor-Rahmen) ohne Animation, gespeist mit statischen/Demo-Daten. Board-Rahmen, 3 Monitor-Rahmen) ohne Animation, gespeist mit statischen/Demo-Daten.
✅ erledigt (PR #7).
4. **Phase 3 – Kanban-Board live**: Board zeigt echte Ticket-Daten aus der API, Klick-Modal mit 4. **Phase 3 – Kanban-Board live**: Board zeigt echte Ticket-Daten aus der API, Klick-Modal mit
Issue-Details. Issue-Details. ✅ erledigt: `html/js/office-api.js` laedt `GET /api/v1/tickets`,
`office-scene.js` aggregiert/gruppiert sie je Spalte, Klick auf eine Spalte oeffnet ein
Modal mit Titel/Beschreibung/Codeberg-Link je Ticket (F4).
5. **Phase 4 – Monitor-Wand live**: Event-, System- und KPI-Monitor zeigen echte Daten, 5. **Phase 4 – Monitor-Wand live**: Event-, System- und KPI-Monitor zeigen echte Daten,
Klick-Modals. Klick-Modals. ✅ erledigt: Events/Systeme/KPIs kommen live von der API, ein neuer
SSE-Endpoint (`GET /api/v1/stream`, F13) stoesst Frontend-Updates an (Polling-Fallback in
`office-realtime.js`), KPI-Monitor zeichnet eine eigene SVG-Sparkline (F10, kein Chart-Lib)
sobald KPIs gemeldet wurden - vorher weiterhin der Platzhalter aus Phase 2 (F7 ist
inhaltlich weiterhin offen).
6. **Phase 5 – Agenten & Animation**: Zustandsmaschine, Laufwege, Tipp-/Lese-/Kaffee-Animation, 6. **Phase 5 – Agenten & Animation**: Zustandsmaschine, Laufwege, Tipp-/Lese-/Kaffee-Animation,
Reaktion auf neue Ticket-/Event-/Systemstatus-Meldungen. Reaktion auf neue Ticket-/Event-/Systemstatus-Meldungen. ✅ erledigt: `resolveAgentVisualState`
bildet die von der API gemeldeten Zustaende auf Ort/Pose ab, Agenten-Avatare sind eine
eigenstaendige, per CSS `transition` animierte Schicht (`office-render.js`), idle-Varianten
(Zeitung/Kaffee-Ecke/Schwaetzchen) werden rein kosmetisch client-seitig zufaellig ausgeloest
(F8), eine `AnimationQueue` serialisiert Update-Animationen bei vielen gleichzeitigen
Ereignissen (F9).
7. **Phase 6 – Externe Rollen & Türen**: Betreten/Verlassen durch Türen für Kunden/User-Events. 7. **Phase 6 – Externe Rollen & Türen**: Betreten/Verlassen durch Türen für Kunden/User-Events.
Noch offen - Phase 5 platziert Agenten ohne Schreibtisch (Rolle `user`, Ueberlauf) bereits
an einem Ruheplatz vor der linken Tuer, ein echtes Betreten/Verlassen mit Tuer-Animation
anlaesslich konkreter Backlog/Fertig/Done-Ereignisse (F2) ist aber noch nicht umgesetzt.
8. **Phase 7 – Tests & Feinschliff**: Unit-/E2E-Testabdeckung komplettieren, Migration/ 8. **Phase 7 – Tests & Feinschliff**: Unit-/E2E-Testabdeckung komplettieren, Migration/
Abschaltung der alten Tabellenansicht klären ([F16]). Abschaltung der alten Tabellenansicht klären ([F16]). Noch offen - insbesondere die in
Abschnitt 8 vorgesehenen Playwright-E2E-Tests fuer Board-/Monitor-Interaktion fehlen noch,
`html/mon.html` besteht laut F16 weiterhin bis zur expliziten Abschaltung.
Jede Phase liefert ein für sich lauffähiges Zwischenergebnis, sodass die Umwidmung Jede Phase liefert ein für sich lauffähiges Zwischenergebnis, sodass die Umwidmung
schrittweise (und überprüfbar per PR) erfolgen kann, statt in einem Großumbau. schrittweise (und überprüfbar per PR) erfolgen kann, statt in einem Großumbau.

View file

@ -10,6 +10,7 @@ use Monitor\Api\Auth\AgentAuthenticator;
use Monitor\Api\Controller\AgentController; use Monitor\Api\Controller\AgentController;
use Monitor\Api\Controller\EventController; use Monitor\Api\Controller\EventController;
use Monitor\Api\Controller\KpiController; use Monitor\Api\Controller\KpiController;
use Monitor\Api\Controller\StreamController;
use Monitor\Api\Controller\SystemController; use Monitor\Api\Controller\SystemController;
use Monitor\Api\Controller\TicketController; use Monitor\Api\Controller\TicketController;
use Monitor\Api\Db\Database; use Monitor\Api\Db\Database;
@ -18,6 +19,7 @@ use Monitor\Api\Http\NotFoundException;
use Monitor\Api\Http\Request; use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response; use Monitor\Api\Http\Response;
use Monitor\Api\Http\Router; use Monitor\Api\Http\Router;
use Monitor\Api\Realtime\ChangeFeed;
use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository; use Monitor\Api\Repository\EventRepository;
use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\KpiRepository;
@ -53,6 +55,11 @@ final class Application
$eventController = new EventController($events); $eventController = new EventController($events);
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
$kpiController = new KpiController($kpis); $kpiController = new KpiController($kpis);
$streamController = new StreamController(
new ChangeFeed($tickets, $events, $systems, $kpis, $agents),
$config->sseMaxIterations(),
$config->ssePollIntervalSeconds(),
);
$this->router = new Router(); $this->router = new Router();
@ -77,6 +84,10 @@ final class Application
fn (Request $r, array $p, array $agent) => $agentController->activity($r, $p, $agent), fn (Request $r, array $p, array $agent) => $agentController->activity($r, $p, $agent),
'agent' 'agent'
); );
// SSE-Push (F13): oeffentlich wie die anderen GET-Endpunkte, liefert nur bereits
// oeffentliche Daten (welche Ressourcen sich geaendert haben, keine Inhalte).
$this->router->add('GET', '/api/v1/stream', fn (Request $r, array $p) => $streamController->stream($r));
} }
public function handle(Request $request): Response public function handle(Request $request): Response

View file

@ -73,4 +73,20 @@ final class Config
{ {
return (int) $this->get('MONITOR_DESK_COUNT', '8'); return (int) $this->get('MONITOR_DESK_COUNT', '8');
} }
/**
* Wie viele Polling-Runden der SSE-Endpoint (F13, StreamController) maximal offen bleibt,
* bevor er die Verbindung schliesst - EventSource im Frontend verbindet danach automatisch
* neu. Mit dem Default (30 * 2s) bleibt eine Verbindung ca. eine Minute offen.
*/
public function sseMaxIterations(): int
{
return (int) $this->get('MONITOR_SSE_MAX_ITERATIONS', '30');
}
/** Wartezeit zwischen zwei Aenderungs-Checks des SSE-Endpoints in Sekunden. */
public function ssePollIntervalSeconds(): int
{
return (int) $this->get('MONITOR_SSE_POLL_INTERVAL', '2');
}
} }

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Controller;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Realtime\ChangeFeed;
/**
* SSE-Push-Endpoint (F13: "Frontend via PHP Push aktualisieren (Server-Sent)", Phase 4).
* Der eingebaute PHP-Entwicklungsserver (siehe docker-compose.yml) kennt keinen echten
* Daemon-Prozess, daher ist der "Push" als kurzlebige Polling-Schleife umgesetzt: Die
* Verbindung bleibt bis zu maxIterations * pollIntervalSeconds offen und meldet per SSE-Event,
* sobald sich ein ChangeFeed-Fingerabdruck aendert. EventSource im Frontend verbindet nach
* Ablauf automatisch neu (Standard-SSE-Reconnect-Verhalten), siehe html/js/office-realtime.js.
*/
final class StreamController
{
/** @param callable(int): void $sleep */
public function __construct(
private readonly ChangeFeed $changeFeed,
private readonly int $maxIterations = 30,
private readonly int $pollIntervalSeconds = 2,
private $sleep = 'sleep',
) {
}
public function stream(Request $request): Response
{
return Response::stream(function (): void {
$previous = $this->changeFeed->currentFingerprint();
echo ': verbunden' . "\n\n";
for ($i = 0; $i < $this->maxIterations; $i++) {
($this->sleep)($this->pollIntervalSeconds);
if (function_exists('connection_aborted') && connection_aborted()) {
return;
}
$current = $this->changeFeed->currentFingerprint();
$changed = $this->changeFeed->diff($previous, $current);
if ($changed !== []) {
echo "event: update\n";
echo 'data: ' . json_encode(['changed' => $changed], JSON_UNESCAPED_SLASHES) . "\n\n";
$previous = $current;
} else {
echo ': heartbeat' . "\n\n";
}
}
});
}
}

View file

@ -6,10 +6,16 @@ namespace Monitor\Api\Http;
final class Response final class Response
{ {
/** @param mixed $data */ /**
* @param mixed $data
* @param \Closure|null $streamWriter - gesetzt fuer SSE-Antworten (F13, Phase 4): statt
* eines JSON-Bodys schreibt dieser Callback selbst Bytes an den Client (siehe
* StreamController). $data bleibt in dem Fall null.
*/
private function __construct( private function __construct(
public readonly int $status, public readonly int $status,
private readonly mixed $data, private readonly mixed $data,
private readonly ?\Closure $streamWriter = null,
) { ) {
} }
@ -24,8 +30,55 @@ final class Response
return new self($status, ['error' => $message]); return new self($status, ['error' => $message]);
} }
/**
* @param \Closure $writer - wird von send() mit gesetzten SSE-Headern aufgerufen und
* schreibt selbst per echo/flush() an den Client (typischerweise eine Polling-Schleife).
*/
public static function stream(\Closure $writer): self
{
return new self(200, null, $writer);
}
public function isStream(): bool
{
return $this->streamWriter !== null;
}
/** Fuer Tests: den Stream-Callback ohne echte HTTP-Header/Output-Handling ausfuehren. */
public function writer(): ?\Closure
{
return $this->streamWriter;
}
public function send(): void public function send(): void
{ {
// Frontend (html/, Port 8090 im Docker-Compose-Setup) und API (Port 8091) laufen auf
// unterschiedlichen Origins - ohne CORS wuerde der Browser sowohl die fetch()-Aufrufe
// aus office-api.js als auch den EventSource-Stream (F13) blockieren. Alle Endpunkte
// liefern ohnehin nur oeffentliche GET-Daten bzw. verlangen fuer POST/PATCH einen
// gueltigen API-Key, ein offener Origin ist daher unkritisch.
header('Access-Control-Allow-Origin: *');
if ($this->streamWriter !== null) {
http_response_code($this->status);
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
// Verhindert, dass ein vorgeschalteter nginx die Antwort puffert und SSE-Events
// erst nach Verbindungsende ausliefert.
header('X-Accel-Buffering: no');
if (function_exists('ob_implicit_flush')) {
ob_implicit_flush(true);
}
while (ob_get_level() > 0) {
ob_end_flush();
}
($this->streamWriter)();
return;
}
http_response_code($this->status); http_response_code($this->status);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Realtime;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository;
use Monitor\Api\Repository\KpiRepository;
use Monitor\Api\Repository\SystemRepository;
use Monitor\Api\Repository\TicketRepository;
/**
* Erkennt, ob sich seit dem letzten Blick etwas an Tickets/Events/Systemen/KPIs/Agenten
* veraendert hat - Grundlage fuer den SSE-Push-Endpoint (F13, Phase 4:
* "Frontend via PHP Push aktualisieren (Server-Sent)"). Bewusst als billiger "Fingerabdruck"
* (COUNT + juengster Zeitstempel je Ressource, siehe Repository::fingerprint()) statt echtem
* Event-Log umgesetzt - reicht aus, um dem Frontend zu sagen "Ressource X hat sich geaendert,
* bitte neu laden", ohne eine Nachrichten-Warteschlange einzufuehren.
*/
final class ChangeFeed
{
public function __construct(
private readonly TicketRepository $tickets,
private readonly EventRepository $events,
private readonly SystemRepository $systems,
private readonly KpiRepository $kpis,
private readonly AgentRepository $agents,
) {
}
/** @return array<string, string> */
public function currentFingerprint(): array
{
return [
'tickets' => $this->tickets->fingerprint(),
'events' => $this->events->fingerprint(),
'systems' => $this->systems->fingerprint(),
'kpis' => $this->kpis->fingerprint(),
'agents' => $this->agents->fingerprint(),
];
}
/**
* @param array<string, string> $previous
* @param array<string, string> $current
* @return string[] Keys der Ressourcen, die sich geaendert haben (Reihenfolge wie
* currentFingerprint()).
*/
public function diff(array $previous, array $current): array
{
$changed = [];
foreach ($current as $key => $value) {
if (($previous[$key] ?? null) !== $value) {
$changed[] = $key;
}
}
return $changed;
}
}

View file

@ -146,4 +146,12 @@ final class AgentRepository
return $row; return $row;
} }
/** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
public function fingerprint(): string
{
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(last_seen_at) AS m FROM agents')->fetch();
return $row['c'] . ':' . ($row['m'] ?? '');
}
} }

View file

@ -70,4 +70,15 @@ final class EventRepository
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). Events sind unveraenderlich
* (kein UPDATE), daher reicht Anzahl + hoechste ID, um ein neu gemeldetes Event zu erkennen.
*/
public function fingerprint(): string
{
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(id) AS m FROM events')->fetch();
return $row['c'] . ':' . ($row['m'] ?? '');
}
} }

View file

@ -77,4 +77,12 @@ final class KpiRepository
return array_map(fn (string $key) => $this->find($key), $keys); return array_map(fn (string $key) => $this->find($key), $keys);
} }
/** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
public function fingerprint(): string
{
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(recorded_at) AS m FROM kpi_values')->fetch();
return $row['c'] . ':' . ($row['m'] ?? '');
}
} }

View file

@ -102,4 +102,12 @@ final class SystemRepository
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
public function fingerprint(): string
{
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(last_check_at) AS m FROM systems')->fetch();
return $row['c'] . ':' . ($row['m'] ?? '');
}
} }

View file

@ -155,6 +155,17 @@ final class TicketRepository
]); ]);
} }
/**
* Billiger "Fingerabdruck" (Anzahl + juengster Aenderungszeitpunkt) fuer den SSE-Endpoint
* (Realtime\ChangeFeed, F13) - aendert sich bei jedem Anlegen/Verschieben eines Tickets.
*/
public function fingerprint(): string
{
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(updated_at) AS m FROM tickets')->fetch();
return $row['c'] . ':' . ($row['m'] ?? '');
}
private function assertValidColumn(mixed $column): void private function assertValidColumn(mixed $column): void
{ {
if (!is_string($column) || !in_array($column, self::COLUMNS, true)) { if (!is_string($column) || !in_array($column, self::COLUMNS, true)) {

View file

@ -118,4 +118,18 @@ final class ApplicationTest extends TestCase
self::assertSame(404, $response->status); self::assertSame(404, $response->status);
} }
/**
* Der SSE-Endpoint (F13) ist wie die anderen GET-Endpunkte oeffentlich und liefert eine
* Stream-Antwort - der eigentliche Polling-Loop wird hier bewusst NICHT ausgefuehrt
* (siehe StreamControllerTest fuer die Schleifenlogik selbst), das wuerde echte sleep()-
* Aufrufe ausloesen.
*/
public function testStreamRouteIsPublicAndReturnsAStreamResponse(): void
{
$response = $this->app->handle(Request::create('GET', '/api/v1/stream'));
self::assertSame(200, $response->status);
self::assertTrue($response->isStream());
}
} }

View file

@ -19,6 +19,8 @@ final class ConfigTest extends TestCase
self::assertNull($config->codebergToken()); self::assertNull($config->codebergToken());
self::assertNull($config->adminApiKey()); self::assertNull($config->adminApiKey());
self::assertSame(8, $config->deskCount()); self::assertSame(8, $config->deskCount());
self::assertSame(30, $config->sseMaxIterations());
self::assertSame(2, $config->ssePollIntervalSeconds());
} }
/** /**
@ -46,6 +48,8 @@ final class ConfigTest extends TestCase
'CODEBERG_TOKEN' => 'tok', 'CODEBERG_TOKEN' => 'tok',
'MONITOR_ADMIN_API_KEY' => 'secret', 'MONITOR_ADMIN_API_KEY' => 'secret',
'MONITOR_DESK_COUNT' => '3', 'MONITOR_DESK_COUNT' => '3',
'MONITOR_SSE_MAX_ITERATIONS' => '5',
'MONITOR_SSE_POLL_INTERVAL' => '1',
]); ]);
self::assertSame('https://example.org/api/v1', $config->codebergApiBase()); self::assertSame('https://example.org/api/v1', $config->codebergApiBase());
@ -54,5 +58,7 @@ final class ConfigTest extends TestCase
self::assertSame('tok', $config->codebergToken()); self::assertSame('tok', $config->codebergToken());
self::assertSame('secret', $config->adminApiKey()); self::assertSame('secret', $config->adminApiKey());
self::assertSame(3, $config->deskCount()); self::assertSame(3, $config->deskCount());
self::assertSame(5, $config->sseMaxIterations());
self::assertSame(1, $config->ssePollIntervalSeconds());
} }
} }

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\StreamController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
use Monitor\Api\Realtime\ChangeFeed;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository;
use Monitor\Api\Repository\KpiRepository;
use Monitor\Api\Repository\SystemRepository;
use Monitor\Api\Repository\TicketRepository;
use PHPUnit\Framework\TestCase;
final class StreamControllerTest extends TestCase
{
private TicketRepository $tickets;
private ChangeFeed $changeFeed;
protected function setUp(): void
{
$db = Database::inMemory();
$this->tickets = new TicketRepository($db);
$this->changeFeed = new ChangeFeed(
$this->tickets,
new EventRepository($db),
new SystemRepository($db),
new KpiRepository($db),
new AgentRepository($db)
);
}
public function testStreamSendsHeartbeatsWhenNothingChanges(): void
{
$controller = new StreamController(
$this->changeFeed,
maxIterations: 2,
pollIntervalSeconds: 0,
sleep: function (int $seconds): void {
},
);
$response = $controller->stream(Request::create('GET', '/api/v1/stream'));
self::assertTrue($response->isStream());
$output = $this->captureWriterOutput($response->writer());
self::assertStringContainsString(': heartbeat', $output);
self::assertStringNotContainsString('event: update', $output);
}
public function testStreamEmitsUpdateEventWhenAResourceChangesBetweenPolls(): void
{
$tickets = $this->tickets;
$pollCount = 0;
$controller = new StreamController(
$this->changeFeed,
maxIterations: 2,
pollIntervalSeconds: 0,
sleep: function (int $seconds) use (&$pollCount, $tickets): void {
$pollCount++;
if ($pollCount === 1) {
$tickets->create(['title' => 'Neu waehrend des Wartens']);
}
},
);
$response = $controller->stream(Request::create('GET', '/api/v1/stream'));
$output = $this->captureWriterOutput($response->writer());
self::assertStringContainsString('event: update', $output);
self::assertStringContainsString('"tickets"', $output);
self::assertSame(2, $pollCount);
}
public function testStreamStopsAfterMaxIterations(): void
{
$pollCount = 0;
$controller = new StreamController(
$this->changeFeed,
maxIterations: 3,
pollIntervalSeconds: 0,
sleep: function (int $seconds) use (&$pollCount): void {
$pollCount++;
},
);
$this->captureWriterOutput($controller->stream(Request::create('GET', '/api/v1/stream'))->writer());
self::assertSame(3, $pollCount);
}
private function captureWriterOutput(\Closure $writer): string
{
ob_start();
$writer();
return (string) ob_get_clean();
}
}

View file

@ -24,4 +24,27 @@ final class ResponseTest extends TestCase
self::assertSame(404, $response->status); self::assertSame(404, $response->status);
self::assertSame(['error' => 'nope'], $response->body()); self::assertSame(['error' => 'nope'], $response->body());
} }
public function testJsonResponseIsNotAStream(): void
{
$response = Response::json(['foo' => 'bar']);
self::assertFalse($response->isStream());
self::assertNull($response->writer());
}
public function testStreamResponseExposesTheWriterWithoutBody(): void
{
$response = Response::stream(function (): void {
echo 'hi';
});
self::assertTrue($response->isStream());
self::assertSame(200, $response->status);
self::assertNull($response->body());
ob_start();
($response->writer())();
self::assertSame('hi', ob_get_clean());
}
} }

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Realtime;
use Monitor\Api\Db\Database;
use Monitor\Api\Realtime\ChangeFeed;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository;
use Monitor\Api\Repository\KpiRepository;
use Monitor\Api\Repository\SystemRepository;
use Monitor\Api\Repository\TicketRepository;
use PHPUnit\Framework\TestCase;
final class ChangeFeedTest extends TestCase
{
private ChangeFeed $changeFeed;
private TicketRepository $tickets;
private EventRepository $events;
protected function setUp(): void
{
$db = Database::inMemory();
$this->tickets = new TicketRepository($db);
$this->events = new EventRepository($db);
$systems = new SystemRepository($db);
$kpis = new KpiRepository($db);
$agents = new AgentRepository($db);
$this->changeFeed = new ChangeFeed($this->tickets, $this->events, $systems, $kpis, $agents);
}
public function testCurrentFingerprintCoversAllFiveResources(): void
{
$fingerprint = $this->changeFeed->currentFingerprint();
self::assertSame(['tickets', 'events', 'systems', 'kpis', 'agents'], array_keys($fingerprint));
}
public function testDiffIsEmptyWithoutChanges(): void
{
$fingerprint = $this->changeFeed->currentFingerprint();
self::assertSame([], $this->changeFeed->diff($fingerprint, $fingerprint));
}
public function testDiffReportsOnlyTheChangedResource(): void
{
$before = $this->changeFeed->currentFingerprint();
$this->tickets->create(['title' => 'Neu']);
$after = $this->changeFeed->currentFingerprint();
self::assertSame(['tickets'], $this->changeFeed->diff($before, $after));
}
public function testDiffReportsMultipleChangedResources(): void
{
$before = $this->changeFeed->currentFingerprint();
$this->tickets->create(['title' => 'Neu']);
$this->events->create(['name' => 'Ping', 'message' => 'down', 'severity' => 'error']);
$after = $this->changeFeed->currentFingerprint();
$changed = $this->changeFeed->diff($before, $after);
sort($changed);
self::assertSame(['events', 'tickets'], $changed);
}
}

View file

@ -112,4 +112,12 @@ final class AgentRepositoryTest extends TestCase
self::assertArrayNotHasKey('api_key_hash', $agent); self::assertArrayNotHasKey('api_key_hash', $agent);
} }
} }
public function testFingerprintChangesWhenAnAgentIsRegistered(): void
{
$before = $this->repository->fingerprint();
$this->repository->register('Alice', 'entwickler');
self::assertNotSame($before, $this->repository->fingerprint());
}
} }

View file

@ -69,4 +69,12 @@ final class EventRepositoryTest extends TestCase
self::assertCount(1, $this->repository->latest(0)); self::assertCount(1, $this->repository->latest(0));
} }
public function testFingerprintChangesWhenAnEventIsCreated(): void
{
$before = $this->repository->fingerprint();
$this->repository->create(['name' => 'A', 'message' => 'a', 'severity' => 'warning']);
self::assertNotSame($before, $this->repository->fingerprint());
}
} }

View file

@ -58,4 +58,12 @@ final class KpiRepositoryTest extends TestCase
self::assertCount(2, $this->repository->all()); self::assertCount(2, $this->repository->all());
} }
public function testFingerprintChangesWhenAValueIsReported(): void
{
$before = $this->repository->fingerprint();
$this->repository->report('open_tickets', 1.0, null, null, '2026-01-01T00:00:00Z');
self::assertNotSame($before, $this->repository->fingerprint());
}
} }

View file

@ -78,4 +78,12 @@ final class SystemRepositoryTest extends TestCase
self::assertCount(SystemRepository::MAX_TILES, $all); self::assertCount(SystemRepository::MAX_TILES, $all);
self::assertSame('error', $all[0]['status']); self::assertSame('error', $all[0]['status']);
} }
public function testFingerprintChangesWhenStatusIsReported(): void
{
$before = $this->repository->fingerprint();
$this->repository->reportStatus('srv1', ['status' => 'ok']);
self::assertNotSame($before, $this->repository->fingerprint());
}
} }

View file

@ -132,4 +132,19 @@ final class TicketRepositoryTest extends TestCase
self::assertSame('in_arbeit', $result['column_name']); self::assertSame('in_arbeit', $result['column_name']);
} }
public function testFingerprintChangesWhenATicketIsCreated(): void
{
$before = $this->repository->fingerprint();
$this->repository->create(['title' => 'X']);
self::assertNotSame($before, $this->repository->fingerprint());
}
public function testFingerprintIsStableWithoutChanges(): void
{
$this->repository->create(['title' => 'X']);
self::assertSame($this->repository->fingerprint(), $this->repository->fingerprint());
}
} }

View file

@ -15,6 +15,8 @@ services:
MONITOR_ADMIN_API_KEY: ${MONITOR_ADMIN_API_KEY:-dev-admin-key} MONITOR_ADMIN_API_KEY: ${MONITOR_ADMIN_API_KEY:-dev-admin-key}
MONITOR_STATUS_FILE: /status.txt MONITOR_STATUS_FILE: /status.txt
MONITOR_DESK_COUNT: 8 MONITOR_DESK_COUNT: 8
MONITOR_SSE_MAX_ITERATIONS: 30
MONITOR_SSE_POLL_INTERVAL: 2
CODEBERG_API_BASE: https://codeberg.org/api/v1 CODEBERG_API_BASE: https://codeberg.org/api/v1
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610} CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
CODEBERG_REPO: ${CODEBERG_REPO:-monitor} CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
@ -29,6 +31,8 @@ services:
# Ueberlagert den Platzhalter html/API.md - der muss existieren, weil in # Ueberlagert den Platzhalter html/API.md - der muss existieren, weil in
# einem read-only gemounteten Verzeichnis kein Mountpoint angelegt werden kann. # einem read-only gemounteten Verzeichnis kein Mountpoint angelegt werden kann.
- ./API.md:/usr/share/nginx/html/API.md:ro - ./API.md:/usr/share/nginx/html/API.md:ro
# Setzt office.html als Einstiegsseite (sonst 403 auf der Basis-URL).
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
ports: ports:
- "8090:80" - "8090:80"

15
docker/nginx/default.conf Normal file
View file

@ -0,0 +1,15 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
# Einstiegsseite ist die Buero-Visualisierung - ohne diese Direktive liefert
# nginx beim Aufruf der Basis-URL ein 403 (Directory-Listing ist deaktiviert,
# und eine index.html gibt es nicht).
index office.html;
location / {
try_files $uri $uri/ =404;
}
}

View file

@ -24,15 +24,32 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* Die Szene soll immer vollstaendig sichtbar sein - auch im normalen Fenstermodus,
ohne Scrollen. Dazu spannt der body eine Flex-Spalte ueber die Viewport-Hoehe auf:
Header nimmt seine natuerliche Hoehe, #office-scene bekommt den Rest, und das SVG
skaliert sich per aspect-ratio in diesen Rest hinein (siehe unten). */
html {
height: 100%;
}
body { body {
margin: 0; margin: 0;
font-family: "Segoe UI", Verdana, Arial, sans-serif; font-family: "Segoe UI", Verdana, Arial, sans-serif;
background: #dbe7f3; background: #dbe7f3;
color: var(--color-text); color: var(--color-text);
/* height (nicht min-height): Nur eine definite Hoehe laesst die prozentualen
max-height-Angaben weiter unten aufloesen. dvh rechnet mobile Browserleisten
mit, vh ist der Fallback fuer aeltere Engines. */
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
} }
header.office-header { header.office-header {
padding: 16px 24px; flex: 0 0 auto;
padding: 12px 24px;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 16px; gap: 16px;
@ -50,20 +67,65 @@ header.office-header p {
} }
#office-scene { #office-scene {
width: 100%; flex: 1 1 auto;
max-width: 1920px; /* min-height: 0 hebt die Flex-Default-Mindesthoehe (min-content) auf - sonst kann
margin: 0 auto; das Kind den Container nicht unterschreiten und die Szene laeuft aus dem Bild. */
padding: 0 16px 32px; min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px 16px;
} }
/* Das SVG soll sich an der jeweils knapperen Achse ausrichten. Welche das ist, haengt
vom Seitenverhaeltnis des Fensters ab - daher die beiden Media-Queries unten. Die
max-*-Grenzen sind das Sicherheitsnetz fuer den Grenzbereich (die Header-Hoehe geht
in das Verhaeltnis der Szenenflaeche ein, in die Media-Query aber nicht): Dort wird
die Elementbox notfalls leicht gestaucht - der Szeneninhalt bleibt dank
preserveAspectRatio unverzerrt und vollstaendig sichtbar. */
.office-svg { .office-svg {
width: 100%; max-width: 100%;
height: auto; max-height: 100%;
aspect-ratio: 1920 / 1080;
display: block; display: block;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.25); box-shadow: 0 10px 30px rgba(15, 23, 42, 0.25);
} }
/* Fenster breiter als 16:9 -> die Hoehe ist der Engpass. */
@media (min-aspect-ratio: 16 / 9) {
.office-svg {
height: 100%;
width: auto;
}
}
/* Fenster schmaler als 16:9 -> die Breite ist der Engpass. */
@media (max-aspect-ratio: 16 / 9) {
.office-svg {
width: 100%;
height: auto;
}
}
/* Bei knappem Platz weicht zuerst der erklaerende Fliesstext - er kostet in flachen
oder schmalen Fenstern mehrere Zeilen Hoehe, die der Szene fehlen. */
@media (max-height: 640px), (max-width: 760px) {
header.office-header p {
display: none;
}
}
@media (max-height: 640px) {
header.office-header {
padding: 8px 16px;
}
header.office-header h1 {
font-size: 1.1rem;
}
}
.floor { .floor {
fill: var(--color-floor); fill: var(--color-floor);
} }
@ -249,3 +311,240 @@ header.office-header p {
.desk-empty .desk-chair { .desk-empty .desk-chair {
opacity: 0.5; opacity: 0.5;
} }
/* Klickbare Bereiche (Phase 3/4: Board-Spalten + Monitore oeffnen ein Modal) */
.clickable {
cursor: pointer;
}
.board-column-hit {
fill: transparent;
pointer-events: all;
}
.just-updated .board-frame,
.just-updated .monitor-frame {
animation: office-flash 0.9s ease-out;
}
@keyframes office-flash {
0% {
filter: drop-shadow(0 0 0 rgba(250, 204, 21, 0));
}
25% {
filter: drop-shadow(0 0 12px rgba(250, 204, 21, 0.9));
}
100% {
filter: drop-shadow(0 0 0 rgba(250, 204, 21, 0));
}
}
/* Kaffee-Ecke (Phase 5, Raum-Prop zwischen den Schreibtischreihen) */
.coffee-table {
fill: #8a6238;
stroke: #6b4a28;
stroke-width: 2;
}
.coffee-cup {
fill: #f8fafc;
stroke: #94a3b8;
stroke-width: 1.5;
}
.coffee-icon {
font-size: 20px;
}
/* Agenten-Avatare (Phase 5: bewegliche Schicht, siehe office-render.js) */
.agent-avatar {
transition: transform 1.1s ease-in-out;
}
.agent-avatar .person-head {
fill: #f3d9b1;
stroke: #d8b98a;
stroke-width: 1.5;
}
.agent-avatar .person-body {
fill: var(--role-color, #94a3b8);
}
.agent-avatar.pose-walking .person-leg {
fill: #475569;
transform-box: fill-box;
transform-origin: top center;
animation: office-leg-swing 0.6s ease-in-out infinite alternate;
}
.agent-avatar.pose-walking .person-leg-right {
animation-delay: 0.3s;
}
@keyframes office-leg-swing {
from {
transform: rotate(-18deg);
}
to {
transform: rotate(18deg);
}
}
.person-prop-cup {
fill: #f8fafc;
stroke: #94a3b8;
}
.person-prop-newspaper {
fill: #f8fafc;
stroke: #94a3b8;
stroke-width: 1;
}
.person-prop-speech {
fill: #f8fafc;
stroke: #94a3b8;
stroke-width: 1;
}
.avatar-name {
fill: #334155;
font-size: 13px;
font-weight: 600;
text-anchor: middle;
}
/* KPI-Sparklines (Phase 4, F10: kein Chart-Lib, eigenes SVG-Zeichnen) */
.kpi-tile-label {
fill: var(--color-text-inverse);
font-size: 13px;
font-weight: 600;
}
.kpi-sparkline-bg {
fill: rgba(255, 255, 255, 0.04);
}
.kpi-sparkline-line {
fill: none;
stroke: #7dd3fc;
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
/* Modal (Phase 3/4: Board-/Monitor-Detailansicht, Visualization.md 4.5) */
.office-modal {
border: none;
border-radius: 12px;
padding: 0;
width: min(640px, 92vw);
max-height: 80vh;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.4);
}
.office-modal::backdrop {
background: rgba(15, 23, 42, 0.55);
}
.office-modal-form {
display: flex;
flex-direction: column;
max-height: 80vh;
}
.office-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
}
.office-modal-title {
margin: 0;
font-size: 1.1rem;
}
.office-modal-close {
border: none;
background: transparent;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
color: #64748b;
}
.office-modal-body {
padding: 16px 20px;
overflow-y: auto;
}
.office-modal-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.office-modal-item {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
}
.office-modal-item p {
margin: 6px 0 0;
color: #475569;
font-size: 0.9rem;
}
.office-modal-item-heading {
display: flex;
align-items: center;
gap: 8px;
}
.office-modal-badge {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
color: #1f2937;
}
.office-modal-item.severity-error {
border-color: #fca5a5;
background: #fef2f2;
}
.office-modal-item.severity-warning {
border-color: #fde68a;
background: #fffbeb;
}
.office-modal-item.status-error {
border-color: #fca5a5;
background: #fef2f2;
}
.office-modal-empty {
color: #64748b;
font-style: italic;
}
.kpi-modal-sparkline {
width: 100%;
height: 80px;
margin-top: 8px;
background: #10131a;
border-radius: 6px;
}

52
html/js/office-api.js Normal file
View file

@ -0,0 +1,52 @@
// Duenne fetch()-Wrapper fuer die Phase-1-REST-API (siehe API.md). Reine Daten-Ebene, kein
// DOM-Zugriff - office-render.js ruft diese Funktionen auf und reicht die Ergebnisse an die
// Layoutlogik (office-scene.js) weiter. Basis-URL ist per window.MONITOR_API_BASE
// ueberschreibbar (z.B. wenn Frontend und API auf unterschiedlichen Ports laufen, siehe
// docker-compose.yml: web auf :8090, api auf :8091).
const DEFAULT_API_BASE = "/api/v1";
function apiBase() {
return (typeof window !== "undefined" && window.MONITOR_API_BASE) || DEFAULT_API_BASE;
}
async function getJson(path) {
const response = await fetch(`${apiBase()}${path}`, { headers: { Accept: "application/json" } });
if (!response.ok) {
throw new Error(`GET ${path} fehlgeschlagen: HTTP ${response.status}`);
}
return response.json();
}
export async function fetchTickets() {
const { tickets } = await getJson("/tickets");
return tickets;
}
export async function fetchEvents(limit = 200) {
const { events } = await getJson(`/events?limit=${encodeURIComponent(limit)}`);
return events;
}
export async function fetchSystems() {
const { systems } = await getJson("/systems");
return systems;
}
export async function fetchKpis() {
const { kpis } = await getJson("/kpis");
return kpis;
}
export async function fetchAgents() {
const { agents } = await getJson("/agents");
return agents;
}
/**
* Baut die Stream-URL fuer den SSE-Push-Endpoint (F13, Phase 4). Wird von office-realtime.js
* an EventSource() uebergeben.
*/
export function streamUrl() {
return `${apiBase()}/stream`;
}

59
html/js/office-modal.js Normal file
View file

@ -0,0 +1,59 @@
// Generisches Modal fuer Board-/Monitor-Detailansichten (Visualization.md 4.5: "Modals sind
// reines HTML/CSS/JS ohne fremde UI-Bibliothek, per <dialog>-Element ... umgesetzt"). Dieses
// Modul kuemmert sich nur um Erzeugen/Oeffnen/Schliessen des <dialog>-Elements; welchen Inhalt
// es zeigt, entscheidet der Aufrufer (office-render.js) per renderBody-Callback.
let dialogEl = null;
function ensureDialog() {
if (dialogEl) {
return dialogEl;
}
dialogEl = document.createElement("dialog");
dialogEl.className = "office-modal";
dialogEl.innerHTML = `
<form method="dialog" class="office-modal-form">
<header class="office-modal-header">
<h2 class="office-modal-title"></h2>
<button type="submit" class="office-modal-close" aria-label="Schliessen">&times;</button>
</header>
<div class="office-modal-body"></div>
</form>
`;
document.body.appendChild(dialogEl);
return dialogEl;
}
/**
* Oeffnet das Modal mit einem Titel und laesst den Aufrufer den Body fuellen.
*
* @param {{title: string}} content
* @param {(body: HTMLElement, content: object) => void} renderBody
*/
export function openModal(content, renderBody) {
const dialog = ensureDialog();
dialog.querySelector(".office-modal-title").textContent = content.title;
const body = dialog.querySelector(".office-modal-body");
body.replaceChildren();
renderBody(body, content);
if (typeof dialog.showModal === "function") {
dialog.showModal();
} else {
// Fallback fuer Umgebungen ohne <dialog>-Unterstuetzung (z.B. sehr alte Browser).
dialog.setAttribute("open", "");
}
}
export function closeModal() {
if (dialogEl === null) {
return;
}
if (typeof dialogEl.close === "function") {
dialogEl.close();
} else {
dialogEl.removeAttribute("open");
}
}

View file

@ -0,0 +1,45 @@
// Verbindet das Frontend per Server-Sent Events mit dem Push-Endpoint der API (F13:
// "Frontend via PHP Push aktualisieren (Server-Sent)", Phase 4). Faellt auf Polling zurueck,
// wenn EventSource nicht verfuegbar ist oder der Stream dauerhaft nicht funktioniert (z.B.
// ein Proxy ohne SSE-Unterstuetzung) - reine DOM-/Browser-API-Glue, daher ungetestet wie
// office-render.js (siehe [[project-monitor-visualization]]-Notizen zur Teststrategie).
import { streamUrl } from "./office-api.js";
const FALLBACK_POLL_INTERVAL_MS = 8000;
const SAFETY_NET_POLL_INTERVAL_MS = 30000;
/**
* @param {() => void} onChange - wird aufgerufen, wenn sich serverseitig etwas geaendert hat
* (oder im Polling-Fallback einfach regelmaessig).
* @returns {() => void} Aufraeumfunktion (Stream schliessen/Timer stoppen).
*/
export function startRealtimeUpdates(onChange) {
if (typeof window === "undefined" || typeof window.EventSource !== "function") {
return startPolling(onChange, FALLBACK_POLL_INTERVAL_MS);
}
let source;
try {
source = new window.EventSource(streamUrl());
} catch {
return startPolling(onChange, FALLBACK_POLL_INTERVAL_MS);
}
source.addEventListener("update", () => onChange());
// EventSource verbindet nach Verbindungsabbruch automatisch neu. Als Sicherheitsnetz laeuft
// zusaetzlich ein sehr seltenes Polling mit, falls der Stream (z.B. wegen eines Proxys ohne
// SSE-Unterstuetzung) dauerhaft keine Events liefert.
const safetyNet = window.setInterval(onChange, SAFETY_NET_POLL_INTERVAL_MS);
return () => {
source.close();
window.clearInterval(safetyNet);
};
}
function startPolling(onChange, intervalMs) {
const timer = window.setInterval(onChange, intervalMs);
return () => window.clearInterval(timer);
}

View file

@ -1,14 +1,34 @@
// Zeichnet die statische Buero-Szene (Phase 2, siehe Visualization.md Abschnitt 9) als // Zeichnet die Buero-Szene als Vanilla-SVG in den Container #office-scene und haelt sie mit
// Vanilla-SVG in den Container #office-scene. Keine Animation, keine Klick-Interaktion // echten Daten aktuell (Phase 3/4: Board/Monitor-Wand live, Phase 5: Agenten-Animation, siehe
// (Board-/Monitor-Modals kommen erst mit den Live-Daten in Phase 3/4). Reine DOM-Glue-Logik - // Visualization.md Abschnitt 9). Reine DOM-Glue-Logik - Layoutberechnung/Datenmodelle stecken
// die eigentliche Layoutberechnung steckt testbar in office-scene.js. // testbar in office-scene.js.
//
// Architektur-Hinweis fuer die Animation (Phase 5): Board, Monitor-Wand und Schreibtisch-
// Moebel werden bei jedem Update komplett neu gezeichnet (kein Animationsbedarf dort), die
// Agenten-Avatare dagegen sind stabile DOM-Knoten pro Agent-ID, deren transform-Attribut nur
// aktualisiert wird - so kann CSS transition den Laufweg animieren, statt bei jedem Update
// hart zu springen.
import { import {
ROLES, ROLES,
AGENT_LOCATIONS,
buildBoardColumnLayout, buildBoardColumnLayout,
buildDeskLayout, buildDeskLayout,
buildSystemTiles, buildSystemTiles,
buildEventLines, buildEventLines,
aggregateTicketsByColumn,
groupTicketsByColumn,
buildTicketModalContent,
mapEventForDisplay,
buildEventModalContent,
buildSystemModalContent,
buildKpiTiles,
buildKpiModalContent,
mapAgentsToDesks,
normalizeAgentForDisplay,
resolveAgentVisualState,
pickIdleActivity,
AnimationQueue,
} from "./office-scene.js"; } from "./office-scene.js";
import { import {
DEMO_TICKET_COUNTS, DEMO_TICKET_COUNTS,
@ -17,6 +37,9 @@ import {
DEMO_EVENTS, DEMO_EVENTS,
DEMO_KPI_PLACEHOLDER, DEMO_KPI_PLACEHOLDER,
} from "./demo-data.js"; } from "./demo-data.js";
import { fetchTickets, fetchEvents, fetchSystems, fetchKpis, fetchAgents } from "./office-api.js";
import { startRealtimeUpdates } from "./office-realtime.js";
import { openModal } from "./office-modal.js";
const SVG_NS = "http://www.w3.org/2000/svg"; const SVG_NS = "http://www.w3.org/2000/svg";
@ -30,16 +53,29 @@ const DOOR = { w: 40, h: 220, y: 700 };
const DESK = { w: 220, h: 110 }; const DESK = { w: 220, h: 110 };
const DESK_ROW_Y = [540, 840]; const DESK_ROW_Y = [540, 840];
const DESK_COL_X = [232, 644, 1056, 1468]; const DESK_COL_X = [232, 644, 1056, 1468];
const COFFEE_CORNER = { x: 960, y: 780 };
const NOTE_W = 96; const NOTE_W = 96;
const NOTE_H = 50; const NOTE_H = 50;
const NOTE_STEP_Y = 56; const NOTE_STEP_Y = 56;
// Feste "Aufenthaltsorte" fuer die Agenten-Avatare (Phase 5), abgeleitet aus den Rahmen der
// jeweiligen Wandelemente - der Avatar steht direkt davor.
const BOARD_FEET = { x: BOARD_FRAME.x + BOARD_FRAME.w / 2, y: BOARD_FRAME.y + BOARD_FRAME.h + 40 };
const MONITOR_FEET = { x: MONITOR_WALL.x + MONITOR_WALL.w / 2, y: MONITOR_WALL.y + MONITOR_WALL.h + 40 };
const COFFEE_FEET = { x: COFFEE_CORNER.x, y: COFFEE_CORNER.y + 26 };
const IDLE_MIN_DELAY_MS = 15000;
const IDLE_MAX_DELAY_MS = 40000;
const IDLE_ACTIVITY_DURATION_MS = 8000;
function el(tag, attrs = {}, children = []) { function el(tag, attrs = {}, children = []) {
const node = document.createElementNS(SVG_NS, tag); const node = document.createElementNS(SVG_NS, tag);
for (const [key, value] of Object.entries(attrs)) { for (const [key, value] of Object.entries(attrs)) {
if (value !== null && value !== undefined) {
node.setAttribute(key, value); node.setAttribute(key, value);
} }
}
for (const child of children) { for (const child of children) {
node.appendChild(child); node.appendChild(child);
} }
@ -62,12 +98,24 @@ function renderRoom() {
g.appendChild( g.appendChild(
el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door" }) el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door" })
); );
g.appendChild(renderCoffeeCorner());
return g;
}
// Kaffee-Ecke als fester Raum-Prop zwischen den Schreibtischreihen (Ziel fuer den
// "at_coffee_corner"-Zustand, Vision-Text: "gehen in eine Kaffee-Ecke").
function renderCoffeeCorner() {
const { x, y } = COFFEE_CORNER;
const g = el("g", { class: "coffee-corner" });
g.appendChild(el("rect", { x: x - 34, y: y - 20, width: 68, height: 40, rx: 8, class: "coffee-table" }));
g.appendChild(el("circle", { cx: x - 14, cy: y - 26, r: 8, class: "coffee-cup" }));
g.appendChild(el("circle", { cx: x + 14, cy: y - 26, r: 8, class: "coffee-cup" }));
g.appendChild(text("☕", { x, y: y - 34, class: "coffee-icon", "text-anchor": "middle" }));
return g; return g;
} }
function renderStackBadge(x, y, count) { function renderStackBadge(x, y, count) {
const g = el("g", { class: "note-stack" }); const g = el("g", { class: "note-stack" });
// Mehrere leicht versetzte Zettel als "Stapel"-Andeutung, darueber die Anzahl (F3).
[6, 3, 0].forEach((offset, i) => { [6, 3, 0].forEach((offset, i) => {
g.appendChild( g.appendChild(
el("rect", { el("rect", {
@ -89,12 +137,14 @@ function renderStackBadge(x, y, count) {
return g; return g;
} }
function renderBoard(ticketCounts) { /**
* @param {Record<string, number>} ticketCounts
* @param {(columnKey: string) => void} onColumnClick
*/
function renderBoard(ticketCounts, onColumnClick) {
const columns = buildBoardColumnLayout(ticketCounts); const columns = buildBoardColumnLayout(ticketCounts);
const g = el("g", { class: "board" }); const g = el("g", { class: "board" });
g.appendChild( g.appendChild(el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" }));
el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" })
);
g.appendChild( g.appendChild(
text("Kanban-Board", { x: BOARD_FRAME.x + 16, y: BOARD_FRAME.y - 14, class: "wall-label" }) text("Kanban-Board", { x: BOARD_FRAME.x + 16, y: BOARD_FRAME.y - 14, class: "wall-label" })
); );
@ -103,7 +153,17 @@ function renderBoard(ticketCounts) {
columns.forEach((column, index) => { columns.forEach((column, index) => {
const columnX = BOARD_FRAME.x + index * columnWidth; const columnX = BOARD_FRAME.x + index * columnWidth;
const colGroup = el("g", { class: `board-column board-column-${column.key}` }); const colGroup = el("g", { class: `board-column board-column-${column.key} clickable` });
colGroup.appendChild(
el("rect", {
x: columnX,
y: BOARD_FRAME.y,
width: columnWidth,
height: BOARD_FRAME.h,
class: "board-column-hit",
})
);
colGroup.addEventListener("click", () => onColumnClick(column.key));
if (index > 0) { if (index > 0) {
colGroup.appendChild( colGroup.appendChild(
@ -147,7 +207,7 @@ function renderBoard(ticketCounts) {
} }
function renderEventMonitor(frame, events) { function renderEventMonitor(frame, events) {
const lines = buildEventLines(events); const lines = buildEventLines(events).map(mapEventForDisplay);
const g = el("g", { class: "monitor monitor-events" }); const g = el("g", { class: "monitor monitor-events" });
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" })); g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
g.appendChild(text("Events", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" })); g.appendChild(text("Events", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
@ -205,8 +265,6 @@ function renderSystemMonitor(frame, systems) {
tileGroup.appendChild( tileGroup.appendChild(
el("rect", { x: tileX, y: tileY, width: tileSize, height: tileSize, rx: 6, class: "system-tile-bg" }) el("rect", { x: tileX, y: tileY, width: tileSize, height: tileSize, rx: 6, class: "system-tile-bg" })
); );
// Einfaches "Server"-Icon: zwei Balken mit je einer Status-Leuchte (F6: ein Icon-Typ
// fuer alle Systeme, unabhaengig von der Art).
tileGroup.appendChild( tileGroup.appendChild(
el("rect", { x: tileX + 10, y: tileY + 10, width: tileSize - 20, height: 14, rx: 2, class: "system-tile-bar" }) el("rect", { x: tileX + 10, y: tileY + 10, width: tileSize - 20, height: 14, rx: 2, class: "system-tile-bar" })
); );
@ -229,7 +287,17 @@ function renderSystemMonitor(frame, systems) {
return g; return g;
} }
function renderKpiMonitor(frame, placeholder) { function renderSparkline(points, width, height, x, y) {
const g = el("g", { class: "kpi-sparkline", transform: `translate(${x}, ${y})` });
g.appendChild(el("rect", { x: 0, y: 0, width, height, class: "kpi-sparkline-bg" }));
if (points) {
g.appendChild(el("polyline", { points, class: "kpi-sparkline-line" }));
}
return g;
}
function renderKpiMonitor(frame, kpis) {
const tiles = buildKpiTiles(kpis);
const g = el("g", { class: "monitor monitor-kpi" }); const g = el("g", { class: "monitor monitor-kpi" });
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" })); g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
g.appendChild(text("KPIs", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" })); g.appendChild(text("KPIs", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
@ -239,9 +307,32 @@ function renderKpiMonitor(frame, placeholder) {
y: frame.y + 10, y: frame.y + 10,
width: frame.w - 20, width: frame.w - 20,
height: frame.h - 20, height: frame.h - 20,
class: "monitor-screen monitor-screen-placeholder", class: `monitor-screen ${tiles.length === 0 ? "monitor-screen-placeholder" : ""}`,
}) })
); );
if (tiles.length === 0) {
renderPlaceholderText(g, frame, DEMO_KPI_PLACEHOLDER);
return g;
}
const rowH = (frame.h - 30) / tiles.length;
tiles.forEach((tile, index) => {
const rowY = frame.y + 20 + index * rowH;
g.appendChild(
text(`${tile.label}: ${tile.currentValue ?? "–"} ${tile.unit}`.trim(), {
x: frame.x + 16,
y: rowY + 14,
class: "kpi-tile-label",
})
);
g.appendChild(renderSparkline(tile.sparklinePoints, frame.w - 32, rowH - 26, frame.x + 16, rowY + 20));
});
return g;
}
function renderPlaceholderText(g, frame, placeholder) {
const words = placeholder.split(" "); const words = placeholder.split(" ");
const lineHeight = 22; const lineHeight = 22;
const maxCharsPerLine = 16; const maxCharsPerLine = 16;
@ -269,11 +360,12 @@ function renderKpiMonitor(frame, placeholder) {
}) })
); );
}); });
return g;
} }
function renderMonitorWall(systems, events) { /**
* @param {{onEventsClick: () => void, onSystemsClick: () => void, onKpiClick: () => void}} handlers
*/
function renderMonitorWall(systems, events, kpis, handlers) {
const g = el("g", { class: "monitor-wall" }); const g = el("g", { class: "monitor-wall" });
const gap = 20; const gap = 20;
const monitorWidth = (MONITOR_WALL.w - gap * 2) / 3; const monitorWidth = (MONITOR_WALL.w - gap * 2) / 3;
@ -284,21 +376,38 @@ function renderMonitorWall(systems, events) {
h: MONITOR_WALL.h, h: MONITOR_WALL.h,
})); }));
g.appendChild(renderEventMonitor(frames[0], events)); const eventsGroup = renderEventMonitor(frames[0], events);
g.appendChild(renderSystemMonitor(frames[1], systems)); eventsGroup.classList.add("clickable");
g.appendChild(renderKpiMonitor(frames[2], DEMO_KPI_PLACEHOLDER)); eventsGroup.addEventListener("click", handlers.onEventsClick);
const systemsGroup = renderSystemMonitor(frames[1], systems);
systemsGroup.classList.add("clickable");
systemsGroup.addEventListener("click", handlers.onSystemsClick);
const kpiGroup = renderKpiMonitor(frames[2], kpis);
kpiGroup.classList.add("clickable");
kpiGroup.addEventListener("click", handlers.onKpiClick);
g.appendChild(eventsGroup);
g.appendChild(systemsGroup);
g.appendChild(kpiGroup);
return g; return g;
} }
function personPose(pose) { function deskSeat(deskIndex) {
// Rein statische Pose-Auswahl (kein Animationszustand) - Zustandsmaschine/Animation const col = deskIndex % DESK_COL_X.length;
// folgt erst in Phase 5. const row = Math.floor(deskIndex / DESK_COL_X.length);
const x = DESK_COL_X[col];
const y = DESK_ROW_Y[row];
return {
origin: { x, y },
feet: { x: x + DESK.w / 2, y: y + 40 + DESK.h + 30 },
};
}
function deskScreenPoseClass(pose) {
switch (pose) { switch (pose) {
case "reading_newspaper":
return "pose-newspaper";
case "coffee":
return "pose-coffee";
case "working": case "working":
return "pose-working"; return "pose-working";
default: default:
@ -306,26 +415,32 @@ function personPose(pose) {
} }
} }
// Zeichnet nur die Moebel/Deko + den Namen des Schreibtisch-Besitzers - die eigentliche
// Person ist ein eigenstaendiger, beweglicher Avatar (siehe renderAgentAvatar/
// updateAgentsLayer), damit sie zum Board/Monitor "laufen" kann, ohne den Schreibtisch neu
// zeichnen zu muessen.
function renderDesk({ deskIndex, agent }) { function renderDesk({ deskIndex, agent }) {
const col = deskIndex % DESK_COL_X.length; const { origin, feet } = deskSeat(deskIndex);
const row = Math.floor(deskIndex / DESK_COL_X.length);
const x = DESK_COL_X[col];
const y = DESK_ROW_Y[row];
const role = agent ? ROLES[agent.role] : null; const role = agent ? ROLES[agent.role] : null;
const g = el("g", { const g = el("g", {
class: `desk ${agent ? "desk-occupied" : "desk-empty"}`, class: `desk ${agent ? "desk-occupied" : "desk-empty"}`,
transform: `translate(${x}, ${y})`, transform: `translate(${origin.x}, ${origin.y})`,
}); });
g.appendChild(el("rect", { x: 0, y: 40, width: DESK.w, height: DESK.h, rx: 6, class: "desk-table" })); g.appendChild(el("rect", { x: 0, y: 40, width: DESK.w, height: DESK.h, rx: 6, class: "desk-table" }));
g.appendChild( g.appendChild(
el("rect", { x: 20, y: 10, width: 60, height: 40, rx: 4, class: `desk-screen ${agent ? personPose(agent.pose) : ""}` }) el("rect", {
x: 20,
y: 10,
width: 60,
height: 40,
rx: 4,
class: `desk-screen ${agent ? deskScreenPoseClass(agent.pose) : ""}`,
})
); );
g.appendChild(el("circle", { cx: DESK.w / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" })); g.appendChild(el("circle", { cx: DESK.w / 2, cy: feet.y - origin.y, r: 22, class: "desk-chair" }));
if (role) { if (role) {
// Deko-Icon je Rolle (F1): Stern (Entwickler:in), Bluete (Product Owner:in), Haken (QA).
if (role.deco === "star") { if (role.deco === "star") {
g.appendChild( g.appendChild(
el("path", { el("path", {
@ -351,20 +466,6 @@ function renderDesk({ deskIndex, agent }) {
); );
} }
g.appendChild(
el("circle", { cx: DESK.w / 2, cy: 40 + DESK.h + 30 - 34, r: 16, class: "person-head" })
);
g.appendChild(
el("rect", {
x: DESK.w / 2 - 22,
y: 40 + DESK.h + 30 - 20,
width: 44,
height: 34,
rx: 12,
class: "person-body",
style: `--role-color: ${role.accentColor}`,
})
);
g.appendChild( g.appendChild(
text(agent.name, { text(agent.name, {
x: DESK.w / 2, x: DESK.w / 2,
@ -387,38 +488,464 @@ function renderDesk({ deskIndex, agent }) {
return g; return g;
} }
function renderDesks(agents) { function renderDesks(desks) {
const g = el("g", { class: "desks" }); const g = el("g", { class: "desks" });
buildDeskLayout(agents).forEach((desk) => g.appendChild(renderDesk(desk))); desks.forEach((desk) => g.appendChild(renderDesk(desk)));
return g; return g;
} }
// ---------------------------------------------------------------------------------------
// Phase 5 - Agenten-Avatare (bewegliche Schicht, siehe Architektur-Hinweis oben)
// ---------------------------------------------------------------------------------------
const agentAvatarElements = new Map();
const idleCosmeticTimers = new Map();
function feetForLocation(location, homeFeet) {
switch (location) {
case AGENT_LOCATIONS.BOARD:
return BOARD_FEET;
case AGENT_LOCATIONS.MONITOR_EVENTS:
case AGENT_LOCATIONS.MONITOR_SYSTEMS:
case AGENT_LOCATIONS.MONITOR_KPI:
return MONITOR_FEET;
case AGENT_LOCATIONS.COFFEE_CORNER:
return COFFEE_FEET;
case AGENT_LOCATIONS.DESK:
default:
return homeFeet;
}
}
function poseExtras(pose) {
const nodes = [];
if (pose === "walking") {
nodes.push(el("rect", { x: -10, y: 14, width: 8, height: 18, rx: 3, class: "person-leg person-leg-left" }));
nodes.push(el("rect", { x: 2, y: 14, width: 8, height: 18, rx: 3, class: "person-leg person-leg-right" }));
} else if (pose === "coffee") {
nodes.push(el("rect", { x: 16, y: -30, width: 10, height: 10, rx: 2, class: "person-prop-cup" }));
} else if (pose === "reading_newspaper") {
nodes.push(el("rect", { x: -16, y: -48, width: 32, height: 18, rx: 1, class: "person-prop-newspaper" }));
} else if (pose === "talking") {
nodes.push(el("circle", { cx: 20, cy: -40, r: 7, class: "person-prop-speech" }));
}
return nodes;
}
function createAgentAvatarElement() {
const g = el("g", { class: "agent-avatar" });
g.appendChild(el("circle", { cx: 0, cy: -34, r: 16, class: "person-head" }));
g.appendChild(el("rect", { x: -22, y: -20, width: 44, height: 34, rx: 12, class: "person-body" }));
g.appendChild(el("g", { class: "person-extras" }));
g.appendChild(text("", { x: 0, y: 46, class: "avatar-name", "text-anchor": "middle" }));
return g;
}
function updateAgentAvatarElement(g, agent, feet, showName) {
g.setAttribute("transform", `translate(${feet.x}, ${feet.y})`);
g.setAttribute("class", `agent-avatar pose-${agent.pose}`);
g.dataset.state = agent.state || "idle";
const role = ROLES[agent.role];
const body = g.querySelector(".person-body");
body.style.setProperty("--role-color", role ? role.accentColor : "#94a3b8");
const extras = g.querySelector(".person-extras");
extras.replaceChildren(...poseExtras(agent.pose));
const nameEl = g.querySelector(".avatar-name");
nameEl.textContent = showName ? agent.name : "";
}
/**
* Aktualisiert die Agenten-Avatar-Schicht per DOM-Diff (statt Neuzeichnen), damit CSS
* transition das transform-Attribut zwischen zwei Aufenthaltsorten weich animiert
* (Detailantworten F9: "Laufwege und Zustandswechsel realistisch animieren").
*
* @param {SVGGElement} layer
* @param {Array<{agent: object, homeFeet: {x:number,y:number}}>} placements
*/
function updateAgentsLayer(layer, placements) {
const seen = new Set();
placements.forEach(({ agent, homeFeet }, index) => {
const id = String(agent.id ?? `guest-${index}`);
seen.add(id);
let node = agentAvatarElements.get(id);
if (!node) {
node = createAgentAvatarElement();
agentAvatarElements.set(id, node);
layer.appendChild(node);
}
const feet = feetForLocation(agent.location, homeFeet);
const showName = agent.location !== AGENT_LOCATIONS.DESK;
updateAgentAvatarElement(node, agent, feet, showName);
});
for (const [id, node] of agentAvatarElements) {
if (!seen.has(id)) {
node.remove();
agentAvatarElements.delete(id);
clearIdleCosmeticTimer(id);
}
}
}
function clearIdleCosmeticTimer(agentId) {
const handle = idleCosmeticTimers.get(agentId);
if (handle) {
window.clearTimeout(handle.timeoutId);
idleCosmeticTimers.delete(agentId);
}
}
/**
* Rein kosmetische, zufaellige idle-Aktivitaeten (F8: "Nur idle Aktivitaeten ... steuert das
* System selber random" - wird bewusst NICHT an die API gemeldet, ein echter Zustandswechsel
* kommt ausschliesslich vom Agenten selbst). Greift nur, solange die API weiterhin "idle"
* meldet; sobald ein echter Zustand reinkommt, wird der Timer verworfen (siehe
* scheduleIdleCosmeticsForCurrentAgents()).
*/
function scheduleIdleCosmetic(agentId, node) {
if (idleCosmeticTimers.has(agentId)) {
return;
}
const delay = IDLE_MIN_DELAY_MS + Math.random() * (IDLE_MAX_DELAY_MS - IDLE_MIN_DELAY_MS);
const timeoutId = window.setTimeout(() => {
if (!agentAvatarElements.has(agentId) || node.dataset.state !== "idle") {
idleCosmeticTimers.delete(agentId);
return;
}
const activity = pickIdleActivity();
const visual = resolveAgentVisualState(activity);
const homeFeet = node.__homeFeet;
node.setAttribute("transform", `translate(${feetForLocation(visual.location, homeFeet).x}, ${feetForLocation(visual.location, homeFeet).y})`);
node.setAttribute("class", `agent-avatar pose-${visual.pose}`);
node.querySelector(".person-extras").replaceChildren(...poseExtras(visual.pose));
node.querySelector(".avatar-name").textContent = node.__agentName || "";
window.setTimeout(() => {
if (agentAvatarElements.has(agentId) && node.dataset.state === "idle") {
const restingPose = node.__idlePose || "idle";
node.setAttribute("transform", `translate(${homeFeet.x}, ${homeFeet.y})`);
node.setAttribute("class", `agent-avatar pose-${restingPose}`);
node.querySelector(".person-extras").replaceChildren(...poseExtras(restingPose));
node.querySelector(".avatar-name").textContent = "";
}
idleCosmeticTimers.delete(agentId);
}, IDLE_ACTIVITY_DURATION_MS);
}, delay);
idleCosmeticTimers.set(agentId, { timeoutId });
}
function scheduleIdleCosmeticsForCurrentAgents(placements) {
placements.forEach(({ agent, homeFeet }, index) => {
const id = String(agent.id ?? `guest-${index}`);
const node = agentAvatarElements.get(id);
if (!node) {
return;
}
node.__homeFeet = homeFeet;
node.__agentName = agent.name;
node.__idlePose = agent.pose || "idle";
if (agent.state === "idle" || !agent.state) {
scheduleIdleCosmetic(id, node);
} else {
clearIdleCosmeticTimer(id);
}
});
}
// ---------------------------------------------------------------------------------------
// Modal-Inhalte (Phase 3/4 - Board-/Monitor-Klick, siehe Visualization.md 4.5)
// ---------------------------------------------------------------------------------------
function renderTicketModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Keine Tickets in dieser Spalte."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = "office-modal-item";
const heading = document.createElement("div");
heading.className = "office-modal-item-heading";
const titleEl = document.createElement("strong");
titleEl.textContent = item.title;
heading.appendChild(titleEl);
if (item.type) {
const badge = document.createElement("span");
badge.className = "office-modal-badge";
badge.textContent = item.type;
if (item.color) badge.style.backgroundColor = item.color;
heading.appendChild(badge);
}
li.appendChild(heading);
if (item.description) {
const desc = document.createElement("p");
desc.textContent = item.description;
li.appendChild(desc);
}
if (item.link) {
const a = document.createElement("a");
a.href = item.link;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = "Auf Codeberg öffnen";
li.appendChild(a);
}
list.appendChild(li);
}
body.appendChild(list);
}
function renderEventModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Keine Events vorhanden."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = `office-modal-item severity-${item.severity}`;
li.textContent = `${item.time} · ${item.name}${item.systemId ? ` (${item.systemId})` : ""}: ${item.message}`;
list.appendChild(li);
}
body.appendChild(list);
}
function renderSystemModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Keine Systeme gemeldet."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = `office-modal-item status-${item.status}`;
li.textContent = `${item.name} (${item.type}) – ${item.status === "ok" ? "OK" : "Fehler"}${item.detail ? `: ${item.detail}` : ""}`;
list.appendChild(li);
}
body.appendChild(list);
}
function renderKpiModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Noch keine KPIs gemeldet – kommt in einer späteren Phase."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = "office-modal-item";
const label = document.createElement("div");
label.textContent = `${item.label}: ${item.currentValue ?? "–"} ${item.unit}`;
li.appendChild(label);
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 320 80");
svg.setAttribute("class", "kpi-modal-sparkline");
svg.appendChild(el("polyline", { points: item.sparklinePoints, class: "kpi-sparkline-line" }));
li.appendChild(svg);
list.appendChild(li);
}
body.appendChild(list);
}
function emptyMessage(message) {
const p = document.createElement("p");
p.className = "office-modal-empty";
p.textContent = message;
return p;
}
// ---------------------------------------------------------------------------------------
// Datenbeschaffung + Orchestrierung
// ---------------------------------------------------------------------------------------
async function loadLiveData() {
const [tickets, events, systems, kpis, agents] = await Promise.all([
fetchTickets(),
fetchEvents(),
fetchSystems(),
fetchKpis(),
fetchAgents(),
]);
return { tickets, events, systems, kpis, agents, isLive: true };
}
const animationQueue = new AnimationQueue({ minIntervalMs: 120 });
window.setInterval(() => animationQueue.processDue(), 50);
function flash(node) {
animationQueue.enqueue(() => {
if (!node.isConnected) return;
node.classList.add("just-updated");
window.setTimeout(() => node.classList.remove("just-updated"), 900);
});
}
export function renderOfficeScene(container, data = {}) { export function renderOfficeScene(container, data = {}) {
const { const {
ticketCounts = DEMO_TICKET_COUNTS, ticketCounts = DEMO_TICKET_COUNTS,
agents = DEMO_AGENTS, agents = DEMO_AGENTS,
systems = DEMO_SYSTEMS, systems = DEMO_SYSTEMS,
events = DEMO_EVENTS, events = DEMO_EVENTS,
kpis = [],
tickets = [],
} = data; } = data;
const svg = el("svg", { let svg = container.querySelector(".office-svg");
let firstRender = false;
if (!svg) {
firstRender = true;
svg = el("svg", {
viewBox: `0 0 ${VIEW_W} ${VIEW_H}`, viewBox: `0 0 ${VIEW_W} ${VIEW_H}`,
class: "office-svg", class: "office-svg",
role: "img", role: "img",
"aria-label": "Buero-Visualisierung mit Kanban-Board, Monitor-Wand und Schreibtischen", "aria-label": "Buero-Visualisierung mit Kanban-Board, Monitor-Wand und Schreibtischen",
}); });
svg.appendChild(renderRoom()); svg.appendChild(renderRoom());
svg.appendChild(renderBoard(ticketCounts)); svg.appendChild(el("g", { class: "agents-layer" }));
svg.appendChild(renderMonitorWall(systems, events));
svg.appendChild(renderDesks(agents));
container.replaceChildren(svg); container.replaceChildren(svg);
agentAvatarElements.clear();
idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId));
idleCosmeticTimers.clear();
}
const groupedTickets = groupTicketsByColumn(tickets);
const boardGroup = renderBoard(ticketCounts, (columnKey) => {
openModal(
buildTicketModalContent(columnKey, groupedTickets[columnKey] || []),
renderTicketModalBody
);
});
const existingBoard = svg.querySelector(".board");
if (existingBoard) {
existingBoard.replaceWith(boardGroup);
if (!firstRender) flash(boardGroup);
} else {
svg.insertBefore(boardGroup, svg.querySelector(".agents-layer"));
}
const wallGroup = renderMonitorWall(systems, events, kpis, {
onEventsClick: () => openModal(buildEventModalContent(events), renderEventModalBody),
onSystemsClick: () => openModal(buildSystemModalContent(systems), renderSystemModalBody),
onKpiClick: () => openModal(buildKpiModalContent(kpis), renderKpiModalBody),
});
const existingWall = svg.querySelector(".monitor-wall");
if (existingWall) {
existingWall.replaceWith(wallGroup);
if (!firstRender) flash(wallGroup);
} else {
svg.insertBefore(wallGroup, svg.querySelector(".agents-layer"));
}
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
const desksGroup = renderDesks(deskLayout);
const existingDesks = svg.querySelector(".desks");
if (existingDesks) {
existingDesks.replaceWith(desksGroup);
} else {
svg.insertBefore(desksGroup, svg.querySelector(".agents-layer"));
}
const placements = agentPlacements(agents, deskLayout);
const agentsLayer = svg.querySelector(".agents-layer");
updateAgentsLayer(agentsLayer, placements);
scheduleIdleCosmeticsForCurrentAgents(placements);
}
// Demo-Agenten (Phase 2) tragen bereits {name, role, pose} ohne desk_index/state - diese
// Helfer machen buildDeskLayout()/renderAgentAvatar() fuer beide Formen (Demo- und
// Live-API-Agenten) nutzbar, ohne die Demo-Daten anfassen zu muessen.
function normalizedDeskAgents(agents) {
const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined");
if (!hasDeskIndex) {
return agents;
}
return mapAgentsToDesks(agents).map((agent) => (agent ? normalizeAgentForDisplay(agent) : null));
}
function agentPlacements(agents, deskLayout) {
const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined");
if (!hasDeskIndex) {
// Demo-Daten: Positionsindex in der Liste == Schreibtisch-Index (Phase 2-Verhalten).
return deskLayout
.filter((d) => d.agent)
.map((d) => ({ agent: { ...d.agent, location: AGENT_LOCATIONS.DESK, state: "idle" }, homeFeet: deskSeat(d.deskIndex).feet }));
}
const placements = [];
const seatedIds = new Set();
deskLayout.forEach((desk) => {
if (desk.agent) {
seatedIds.add(desk.agent.id);
placements.push({ agent: desk.agent, homeFeet: deskSeat(desk.deskIndex).feet });
}
});
// Agenten ohne Schreibtisch (Rolle "user" oder alle Plaetze belegt, F2) bekommen einen
// Ruheplatz vor der linken Tuer zugewiesen, gestaffelt nebeneinander.
let overflowIndex = 0;
for (const raw of agents) {
if (seatedIds.has(raw.id)) continue;
const agent = normalizeAgentForDisplay(raw);
const homeFeet = {
x: 70 + (overflowIndex % 3) * 26,
y: DOOR.y + 40 + Math.floor(overflowIndex / 3) * 40,
};
placements.push({ agent, homeFeet });
overflowIndex += 1;
}
return placements;
}
async function boot() {
const container = document.getElementById("office-scene");
if (!container) {
return;
}
async function refresh() {
try {
const data = await loadLiveData();
renderOfficeScene(container, {
ticketCounts: aggregateTicketsByColumn(data.tickets),
tickets: data.tickets,
agents: data.agents,
systems: data.systems,
events: data.events,
kpis: data.kpis,
});
} catch (err) {
console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", err);
renderOfficeScene(container);
}
}
await refresh();
startRealtimeUpdates(refresh);
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("office-scene"); boot();
if (container) {
renderOfficeScene(container);
}
}); });

View file

@ -103,3 +103,347 @@ export function buildSystemTiles(systems = []) {
export function buildEventLines(events = []) { export function buildEventLines(events = []) {
return events.slice(0, MAX_EVENT_LINES); return events.slice(0, MAX_EVENT_LINES);
} }
// ---------------------------------------------------------------------------------------
// Phase 3 - Kanban-Board live (siehe Visualization.md Abschnitt 9, API.md "Tickets")
// ---------------------------------------------------------------------------------------
/**
* Aggregiert eine rohe Ticketliste (GET /api/v1/tickets) zu Zaehlern je Spalte, wie sie
* buildBoardColumnLayout() erwartet. Unbekannte Spalten-Keys werden ignoriert (defensiv
* gegen zukuenftige/kaputte Daten).
*
* @param {Array<{column_name: string}>} tickets
* @returns {Record<string, number>}
*/
export function aggregateTicketsByColumn(tickets = []) {
const counts = Object.fromEntries(BOARD_COLUMNS.map(({ key }) => [key, 0]));
for (const ticket of tickets) {
if (ticket.column_name in counts) {
counts[ticket.column_name] += 1;
}
}
return counts;
}
/**
* Gruppiert eine rohe Ticketliste je Spalte. GET /api/v1/tickets liefert bereits pro Spalte
* nach Alter aufsteigend sortiert (siehe TicketRepository::all()), die Reihenfolge wird hier
* beibehalten.
*
* @param {Array<{column_name: string}>} tickets
* @returns {Record<string, Array<object>>}
*/
export function groupTicketsByColumn(tickets = []) {
const groups = Object.fromEntries(BOARD_COLUMNS.map(({ key }) => [key, []]));
for (const ticket of tickets) {
if (ticket.column_name in groups) {
groups[ticket.column_name].push(ticket);
}
}
return groups;
}
/**
* Baut das Inhaltsmodell fuer das Board-Spalten-Modal (Visualization.md 4.2/4.5): echter
* Titel/Beschreibung je Ticket, plus Link auf die Codeberg-Quelle sofern das Ticket per
* Codeberg-Sync angelegt wurde (F4).
*
* @param {string} columnKey
* @param {Array<object>} tickets
* @param {{codebergOwner?: string, codebergRepo?: string}} [opts]
*/
export function buildTicketModalContent(columnKey, tickets = [], opts = {}) {
const { codebergOwner = "fds2610", codebergRepo = "monitor" } = opts;
const column = BOARD_COLUMNS.find((c) => c.key === columnKey);
return {
title: column ? column.label : columnKey,
items: tickets.map((ticket) => ({
id: ticket.id,
title: ticket.title,
description: ticket.description || "",
type: ticket.type || null,
color: ticket.color || null,
link:
ticket.source === "codeberg" && ticket.external_ref
? `https://codeberg.org/${codebergOwner}/${codebergRepo}/issues/${ticket.external_ref}`
: null,
})),
};
}
// ---------------------------------------------------------------------------------------
// Phase 4 - Monitor-Wand live (Events/Systeme/KPIs, siehe API.md)
// ---------------------------------------------------------------------------------------
/**
* Formatiert einen ISO-8601-Zeitstempel als lokale Uhrzeit (F16: keine eigene
* Zeitzonen-Auswahl, es gilt schlicht die Zeitzone des Browsers/Servers, in der Praxis
* CEST/CET).
*
* @param {string} isoString
* @returns {string}
*/
export function formatEventTime(isoString) {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) {
return "--:--";
}
return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
/**
* Uebersetzt ein rohes Event (GET /api/v1/events) in die kompakte Zeilendarstellung der
* Monitor-Wand ({time, system, message, severity}, wie sie renderEventMonitor erwartet).
*
* @param {{occurred_at: string, name: string, message: string, severity: string}} event
*/
export function mapEventForDisplay(event) {
return {
time: formatEventTime(event.occurred_at),
system: event.name,
message: event.message,
severity: event.severity,
};
}
/**
* Inhaltsmodell fuer das Event-Monitor-Modal: volle Liste (kein MAX_EVENT_LINES-Limit).
*
* @param {Array<object>} events
*/
export function buildEventModalContent(events = []) {
return {
title: "Events",
items: events.map((event) => ({
time: formatEventTime(event.occurred_at),
name: event.name,
message: event.message,
severity: event.severity,
systemId: event.system_id ?? null,
})),
};
}
/**
* Inhaltsmodell fuer das System-Monitor-Modal.
*
* @param {Array<object>} systems
*/
export function buildSystemModalContent(systems = []) {
return {
title: "Systeme",
items: systems.map((system) => ({
id: system.id,
name: system.name,
status: system.status,
type: system.type || "host",
detail: system.detail || "",
lastCheckAt: system.last_check_at,
})),
};
}
/**
* Erzeugt die Punkte-Liste (SVG <polyline points="...">) fuer eine einfache
* KPI-Sparkline - bewusst ohne Chart-Lib gezeichnet (F10). Die API liefert die Historie
* absteigend (neuester Wert zuerst, siehe KpiRepository::history()), fuer die Sparkline
* wird chronologisch (aeltester zuerst) von links nach rechts gezeichnet.
*
* @param {Array<{value: number}>} history
* @param {number} [width]
* @param {number} [height]
* @param {number} [padding]
* @returns {string}
*/
export function buildKpiSparklinePoints(history = [], width = 140, height = 32, padding = 4) {
if (history.length === 0) {
return "";
}
const chronological = [...history].reverse();
const values = chronological.map((point) => point.value);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const stepX = chronological.length > 1 ? (width - padding * 2) / (chronological.length - 1) : 0;
return chronological
.map((point, index) => {
const x = padding + index * stepX;
const y = height - padding - ((point.value - min) / range) * (height - padding * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
}
export const MAX_KPI_TILES = 3;
/**
* Bereitet bis zu MAX_KPI_TILES KPI-Kacheln fuer die Monitor-Wand auf. F7 ist weiterhin
* offen ("kommt spaeter") - ist noch kein KPI gemeldet, bleibt das Ergebnis leer und die
* Wand zeigt weiterhin den Platzhaltertext (keine erfundenen Kennzahlen).
*
* @param {Array<object>} kpis
*/
export function buildKpiTiles(kpis = []) {
return kpis.slice(0, MAX_KPI_TILES).map((kpi) => ({
key: kpi.key,
label: kpi.label || kpi.key,
unit: kpi.unit || "",
currentValue: kpi.current_value,
sparklinePoints: buildKpiSparklinePoints(kpi.history || []),
}));
}
/**
* Inhaltsmodell fuer das KPI-Monitor-Modal (groessere Sparkline als die Wandkachel).
*
* @param {Array<object>} kpis
*/
export function buildKpiModalContent(kpis = []) {
return {
title: "KPIs",
items: kpis.map((kpi) => ({
key: kpi.key,
label: kpi.label || kpi.key,
unit: kpi.unit || "",
currentValue: kpi.current_value,
history: kpi.history || [],
sparklinePoints: buildKpiSparklinePoints(kpi.history || [], 320, 80),
})),
};
}
// ---------------------------------------------------------------------------------------
// Phase 5 - Agenten-Zustandsmaschine & Animation (siehe Visualization.md Abschnitt 4.4)
// ---------------------------------------------------------------------------------------
/** Orte, an denen ein Agent sichtbar sein kann. */
export const AGENT_LOCATIONS = {
DESK: "desk",
BOARD: "board",
MONITOR_EVENTS: "monitor-events",
MONITOR_SYSTEMS: "monitor-systems",
MONITOR_KPI: "monitor-kpi",
COFFEE_CORNER: "coffee-corner",
};
// Zustandsmaschine wie in Visualization.md 4.4 vorgeschlagen: jeder von der API gemeldete
// "state" (POST /api/v1/agents/{id}/activity) wird auf einen Ort + eine Pose abgebildet.
// Unbekannte/fehlende Zustaende fallen defensiv auf "idle am Schreibtisch" zurueck.
const AGENT_STATE_VISUALS = {
idle: { location: AGENT_LOCATIONS.DESK, pose: "idle" },
working: { location: AGENT_LOCATIONS.DESK, pose: "working" },
walking_to_board: { location: AGENT_LOCATIONS.BOARD, pose: "walking" },
interacting_with_board: { location: AGENT_LOCATIONS.BOARD, pose: "interacting" },
walking_to_monitor: { location: AGENT_LOCATIONS.MONITOR_EVENTS, pose: "walking" },
interacting_with_monitor: { location: AGENT_LOCATIONS.MONITOR_EVENTS, pose: "interacting" },
walking_back: { location: AGENT_LOCATIONS.DESK, pose: "walking" },
reading_newspaper: { location: AGENT_LOCATIONS.DESK, pose: "reading_newspaper" },
at_coffee_corner: { location: AGENT_LOCATIONS.COFFEE_CORNER, pose: "coffee" },
talking_to_colleague: { location: AGENT_LOCATIONS.DESK, pose: "talking" },
};
/**
* @param {string} state - Wert aus Agent.state (API).
* @returns {{location: string, pose: string}}
*/
export function resolveAgentVisualState(state) {
return AGENT_STATE_VISUALS[state] ?? AGENT_STATE_VISUALS.idle;
}
/**
* Ordnet eine rohe Agentenliste (GET /api/v1/agents) den Schreibtischen zu - anders als
* buildDeskLayout()/Demo-Daten steht der Sitzplatz hier nicht in der Array-Reihenfolge,
* sondern im Feld desk_index (API.md "Agenten"). Agenten ohne Schreibtisch (role "user"
* oder alle Schreibtische belegt, desk_index === null) erscheinen hier nicht - sie kommen
* gemaess F2 nur anlassbezogen durch eine Tuer und haben keinen festen Platz.
*
* @param {Array<object>} agents
* @param {number} [deskCount]
* @returns {Array<object|null>}
*/
export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT) {
const desks = new Array(deskCount).fill(null);
for (const agent of agents) {
const deskIndex = agent.desk_index;
if (typeof deskIndex === "number" && deskIndex >= 0 && deskIndex < deskCount) {
desks[deskIndex] = agent;
}
}
return desks;
}
/**
* Bereitet einen rohen API-Agenten fuer die Darstellung auf: Anzeigename + Pose/Ort aus
* dem gemeldeten state ableiten (statt der Demo-only "pose").
*
* @param {object} agent
*/
export function normalizeAgentForDisplay(agent) {
const visual = resolveAgentVisualState(agent.state);
return {
...agent,
name: agent.display_name ?? agent.name,
pose: visual.pose,
location: visual.location,
};
}
const IDLE_ACTIVITIES = ["reading_newspaper", "at_coffee_corner", "talking_to_colleague"];
/**
* Waehlt zufaellig eine idle-Aktivitaet (F8: "Nur idle Aktivitaeten wie Kaffeetrinken oder
* Schwaetzchen halten steuert das System selber random" - rein kosmetisch, wird nicht an
* die API gemeldet). Der Zufallsgenerator ist injizierbar, damit die Auswahl testbar ist.
*
* @param {() => number} [random]
* @returns {string}
*/
export function pickIdleActivity(random = Math.random) {
const index = Math.min(Math.floor(random() * IDLE_ACTIVITIES.length), IDLE_ACTIVITIES.length - 1);
return IDLE_ACTIVITIES[index];
}
/**
* Serialisiert Animations-Trigger, damit bei sehr vielen gleichzeitigen Ereignissen (F9:
* "20 Tickets in einer Sekunde") nicht alle Animationen gleichzeitig starten, sondern in
* einer Warteschlange mit Mindestabstand abgearbeitet werden. Reine Logik ohne DOM-Zugriff,
* damit sie ohne Browser testbar ist - office-render.js haengt eine window.setInterval-
* Schleife an processDue() an.
*/
export class AnimationQueue {
/** @param {{minIntervalMs?: number, now?: () => number}} [opts] */
constructor(opts = {}) {
const { minIntervalMs = 150, now = () => Date.now() } = opts;
this.minIntervalMs = minIntervalMs;
this.now = now;
this.queue = [];
this.lastRunAt = -Infinity;
}
/** @param {() => void} task */
enqueue(task) {
this.queue.push(task);
}
get size() {
return this.queue.length;
}
/** Fuehrt so viele faellige Tasks wie moeglich aus, mindestens minIntervalMs auseinander. */
processDue() {
let ranCount = 0;
let current = this.now();
while (this.queue.length > 0 && current - this.lastRunAt >= this.minIntervalMs) {
const task = this.queue.shift();
task();
ranCount += 1;
this.lastRunAt = current;
current = this.now();
}
return ranCount;
}
}

View file

@ -11,14 +11,25 @@
<header class="office-header"> <header class="office-header">
<h1>Buero</h1> <h1>Buero</h1>
<p> <p>
Statische Vorschau (Phase 2) mit Demo-Daten - Kanban-Board, Monitor-Wand und Kanban-Board, Monitor-Wand und Schreibtische zeigen live die Daten aus der
Schreibtische werden ab den naechsten Phasen mit echten Daten aus der <a href="/API.md">REST-API</a> (Push-Updates per Server-Sent Events, Fallback:
<a href="/API.md">REST-API</a> gespeist und interaktiv (Klick auf Board/Monitore). Polling). Klick auf das Board oder einen Monitor oeffnet die Detailansicht. Ist
die API nicht erreichbar, zeigt die Szene ersatzweise Demo-Daten.
</p> </p>
</header> </header>
<main id="office-scene" aria-label="Buero-Szene"></main> <main id="office-scene" aria-label="Buero-Szene"></main>
<script>
// Docker-Compose-Dev-Setup (siehe docker-compose.yml): Frontend auf Port 8090, API auf
// Port 8091 - unterschiedliche Origins, daher die explizite Basis-URL (die API liefert
// dafuer CORS-Header, siehe api/src/Http/Response.php). Bei einem Produktions-Setup mit
// gemeinsamem Origin/Reverse-Proxy kann dieser Block entfernt werden, Default ist dann
// das gleiche Origin unter /api/v1 (siehe html/js/office-api.js).
if (location.port === "8090") {
window.MONITOR_API_BASE = `${location.protocol}//${location.hostname}:8091/api/v1`;
}
</script>
<script type="module" src="js/office-render.js"></script> <script type="module" src="js/office-render.js"></script>
</body> </body>
</html> </html>

View file

@ -9,6 +9,23 @@ import {
MAX_SYSTEM_TILES, MAX_SYSTEM_TILES,
MAX_EVENT_LINES, MAX_EVENT_LINES,
DEFAULT_DESK_COUNT, DEFAULT_DESK_COUNT,
aggregateTicketsByColumn,
groupTicketsByColumn,
buildTicketModalContent,
formatEventTime,
mapEventForDisplay,
buildEventModalContent,
buildSystemModalContent,
buildKpiSparklinePoints,
buildKpiTiles,
buildKpiModalContent,
MAX_KPI_TILES,
AGENT_LOCATIONS,
resolveAgentVisualState,
mapAgentsToDesks,
normalizeAgentForDisplay,
pickIdleActivity,
AnimationQueue,
} from "../../html/js/office-scene.js"; } from "../../html/js/office-scene.js";
test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => { test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => {
@ -89,3 +106,207 @@ test("buildEventLines kappt bei MAX_EVENT_LINES und behaelt die Reihenfolge bei"
assert.equal(lines.length, MAX_EVENT_LINES); assert.equal(lines.length, MAX_EVENT_LINES);
assert.equal(lines[0].id, 0); assert.equal(lines[0].id, 0);
}); });
// ---------------------------------------------------------------------------------------
// Phase 3 - Kanban-Board live
// ---------------------------------------------------------------------------------------
test("aggregateTicketsByColumn zaehlt Tickets je Spalte und ignoriert unbekannte Spalten", () => {
const counts = aggregateTicketsByColumn([
{ column_name: "backlog" },
{ column_name: "backlog" },
{ column_name: "qa" },
{ column_name: "unbekannt" },
]);
assert.equal(counts.backlog, 2);
assert.equal(counts.qa, 1);
assert.equal(counts.done, 0);
assert.equal(Object.keys(counts).length, BOARD_COLUMNS.length);
});
test("groupTicketsByColumn gruppiert und behaelt die Reihenfolge je Spalte bei", () => {
const groups = groupTicketsByColumn([
{ id: 1, column_name: "backlog" },
{ id: 2, column_name: "done" },
{ id: 3, column_name: "backlog" },
]);
assert.deepEqual(
groups.backlog.map((t) => t.id),
[1, 3]
);
assert.deepEqual(
groups.done.map((t) => t.id),
[2]
);
assert.deepEqual(groups.qa, []);
});
test("buildTicketModalContent traegt Titel/Spaltenlabel und baut Codeberg-Links nur fuer source=codeberg", () => {
const content = buildTicketModalContent("bereit", [
{ id: 1, title: "A", description: "", source: "codeberg", external_ref: "42" },
{ id: 2, title: "B", description: "", source: "api", external_ref: null },
]);
assert.equal(content.title, "Bereit");
assert.equal(content.items[0].link, "https://codeberg.org/fds2610/monitor/issues/42");
assert.equal(content.items[1].link, null);
});
test("buildTicketModalContent respektiert benutzerdefinierten owner/repo", () => {
const content = buildTicketModalContent(
"backlog",
[{ id: 1, title: "A", source: "codeberg", external_ref: "7" }],
{ codebergOwner: "acme", codebergRepo: "widgets" }
);
assert.equal(content.items[0].link, "https://codeberg.org/acme/widgets/issues/7");
});
// ---------------------------------------------------------------------------------------
// Phase 4 - Monitor-Wand live
// ---------------------------------------------------------------------------------------
test("formatEventTime liefert HH:MM und faengt ungueltige Zeitstempel ab", () => {
assert.match(formatEventTime("2026-07-27T10:05:00Z"), /^\d{2}:\d{2}$/);
assert.equal(formatEventTime("nicht-valide"), "--:--");
});
test("mapEventForDisplay uebersetzt ein rohes Event in die Wand-Zeilendarstellung", () => {
const line = mapEventForDisplay({
occurred_at: "2026-07-27T10:05:00Z",
name: "Ping",
message: "Host down",
severity: "error",
});
assert.equal(line.system, "Ping");
assert.equal(line.message, "Host down");
assert.equal(line.severity, "error");
assert.match(line.time, /^\d{2}:\d{2}$/);
});
test("buildEventModalContent zeigt die volle Liste (kein MAX_EVENT_LINES-Limit)", () => {
const events = Array.from({ length: MAX_EVENT_LINES + 3 }, (_, i) => ({
occurred_at: "2026-07-27T10:00:00Z",
name: `Event${i}`,
message: "m",
severity: "warning",
system_id: "srv1",
}));
const content = buildEventModalContent(events);
assert.equal(content.items.length, events.length);
assert.equal(content.items[0].systemId, "srv1");
});
test("buildSystemModalContent uebernimmt Status/Detail je System", () => {
const content = buildSystemModalContent([
{ id: "srv1", name: "Webserver", status: "ok", type: "host", detail: null, last_check_at: "t" },
]);
assert.equal(content.items[0].status, "ok");
assert.equal(content.items[0].detail, "");
});
test("buildKpiSparklinePoints liefert leeren String ohne Historie und einen Punkt pro Messwert sonst", () => {
assert.equal(buildKpiSparklinePoints([]), "");
const points = buildKpiSparklinePoints([{ value: 3 }, { value: 1 }, { value: 2 }], 100, 20);
assert.equal(points.split(" ").length, 3);
});
test("buildKpiSparklinePoints behandelt konstante Werte ohne Division durch 0", () => {
const points = buildKpiSparklinePoints([{ value: 5 }, { value: 5 }], 100, 20);
assert.equal(points.split(" ").length, 2);
assert.ok(points.length > 0);
});
test("buildKpiTiles kappt bei MAX_KPI_TILES", () => {
const kpis = Array.from({ length: MAX_KPI_TILES + 2 }, (_, i) => ({
key: `k${i}`,
current_value: i,
history: [],
}));
const tiles = buildKpiTiles(kpis);
assert.equal(tiles.length, MAX_KPI_TILES);
});
test("buildKpiModalContent zeigt alle KPIs (kein Limit) inkl. Historie", () => {
const content = buildKpiModalContent([
{ key: "open_tickets", label: "Offene Tickets", unit: "count", current_value: 4, history: [{ value: 4 }] },
]);
assert.equal(content.items.length, 1);
assert.equal(content.items[0].label, "Offene Tickets");
});
// ---------------------------------------------------------------------------------------
// Phase 5 - Agenten-Zustandsmaschine & Animation
// ---------------------------------------------------------------------------------------
test("resolveAgentVisualState bildet bekannte Zustaende auf Ort/Pose ab", () => {
assert.deepEqual(resolveAgentVisualState("working"), { location: AGENT_LOCATIONS.DESK, pose: "working" });
assert.deepEqual(resolveAgentVisualState("walking_to_board"), {
location: AGENT_LOCATIONS.BOARD,
pose: "walking",
});
assert.deepEqual(resolveAgentVisualState("at_coffee_corner"), {
location: AGENT_LOCATIONS.COFFEE_CORNER,
pose: "coffee",
});
});
test("resolveAgentVisualState faellt bei unbekanntem/fehlendem Zustand auf idle-am-Schreibtisch zurueck", () => {
assert.deepEqual(resolveAgentVisualState("was-auch-immer"), { location: AGENT_LOCATIONS.DESK, pose: "idle" });
assert.deepEqual(resolveAgentVisualState(undefined), { location: AGENT_LOCATIONS.DESK, pose: "idle" });
});
test("mapAgentsToDesks platziert Agenten anhand von desk_index, nicht Array-Reihenfolge", () => {
const desks = mapAgentsToDesks(
[
{ id: 1, desk_index: 2 },
{ id: 2, desk_index: 0 },
],
4
);
assert.equal(desks[0].id, 2);
assert.equal(desks[2].id, 1);
assert.equal(desks[1], null);
assert.equal(desks[3], null);
});
test("mapAgentsToDesks ignoriert Agenten ohne Schreibtisch (desk_index null, Rolle user)", () => {
const desks = mapAgentsToDesks([{ id: 1, desk_index: null, role: "user" }], 2);
assert.ok(desks.every((d) => d === null));
});
test("normalizeAgentForDisplay leitet name/pose/location aus display_name/state ab", () => {
const normalized = normalizeAgentForDisplay({ id: 1, display_name: "Alice", state: "working" });
assert.equal(normalized.name, "Alice");
assert.equal(normalized.pose, "working");
assert.equal(normalized.location, AGENT_LOCATIONS.DESK);
});
test("pickIdleActivity nutzt den injizierten Zufallsgenerator deterministisch", () => {
assert.equal(pickIdleActivity(() => 0), "reading_newspaper");
assert.equal(pickIdleActivity(() => 0.999), "talking_to_colleague");
});
test("AnimationQueue fuehrt Tasks mit Mindestabstand aus (Warteschlange bei Buerst-Events, F9)", () => {
let now = 0;
const queue = new AnimationQueue({ minIntervalMs: 100, now: () => now });
const ran = [];
queue.enqueue(() => ran.push("a"));
queue.enqueue(() => ran.push("b"));
queue.enqueue(() => ran.push("c"));
queue.processDue();
assert.deepEqual(ran, ["a"]);
assert.equal(queue.size, 2);
now = 50;
queue.processDue();
assert.deepEqual(ran, ["a"]);
now = 120;
queue.processDue();
assert.deepEqual(ran, ["a", "b"]);
now = 500;
queue.processDue();
assert.deepEqual(ran, ["a", "b", "c"]);
assert.equal(queue.size, 0);
});