Phase 3-5 der Neuausrichtung: Board/Monitor-Wand live, SSE-Push, Agenten-Animation

Setzt Visualization.md Abschnitt 9, Phasen 3-5 vollstaendig um:

Phase 3 (Kanban-Board live): Board zeigt echte Ticket-Daten aus GET /api/v1/tickets
(office-api.js), Klick auf eine Spalte oeffnet ein Modal mit Titel/Beschreibung/Typ und
einem Link auf die Codeberg-Quelle bei per Sync angelegten Tickets (F4).

Phase 4 (Monitor-Wand live): Event-, System- und KPI-Monitor zeigen echte Daten,
Klick-Modals fuer alle drei. Neuer SSE-Endpoint GET /api/v1/stream (F13) meldet per
ChangeFeed-Fingerabdruck, welche Ressourcen sich geaendert haben; office-realtime.js nutzt
EventSource mit Polling-Fallback. KPI-Monitor zeichnet eine eigene SVG-Sparkline (F10, kein
Chart-Lib) sobald KPIs gemeldet wurden, sonst weiterhin der Phase-2-Platzhalter (F7 bleibt
inhaltlich offen). API-Antworten tragen jetzt CORS-Header, da Frontend (:8090) und API
(:8091) im Docker-Compose-Setup unterschiedliche Origins sind.

Phase 5 (Agenten & Animation): resolveAgentVisualState bildet gemeldete Agenten-Zustaende
auf Ort/Pose ab; Agenten-Avatare sind eine per CSS transition animierte, von Board/
Schreibtisch-Moebeln entkoppelte Schicht, die zwischen Schreibtisch, Board, Monitor-Wand und
einer neuen Kaffee-Ecke laufen. Idle-Varianten (Zeitung/Kaffee/Schwaetzchen) werden rein
kosmetisch client-seitig zufaellig ausgeloest (F8), eine AnimationQueue serialisiert
Update-Animationen bei vielen gleichzeitigen Ereignissen (F9).

29 Node-Tests (100% Line-Coverage office-scene.js) und neue PHPUnit-Tests fuer
Response::stream(), ChangeFeed, StreamController und die neuen Repository::fingerprint()-
Methoden. PHP/Docker-Ausfuehrung konnte in dieser Umgebung nicht verifiziert werden (kein
PHP verfuegbar) - vor dem Merge bitte `docker compose run --rm api composer test` pruefen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-07-28 17:50:44 +02:00
parent 954ef47302
commit 10ecad93d5
32 changed files with 2137 additions and 81 deletions

34
API.md
View file

@ -17,6 +17,7 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
- [Events](#events)
- [Systeme](#systeme)
- [KPIs](#kpis)
- [Live-Updates (SSE)](#live-updates-sse)
- [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone)
- [Fehlerformat](#fehlerformat)
@ -29,6 +30,9 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
- Lesende Endpunkte (`GET`) sind oeffentlich, da sie das Frontend bedienen.
- Schreibende Endpunkte (`POST`/`PATCH`) erfordern einen Agenten- oder Admin-Key
(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
@ -194,6 +198,36 @@ dem KPI inkl. Verlauf.
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)
`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
Agenten-Aktivität, mit statischen API-Keys pro Agent
- 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
Demo-Daten, siehe Visualization.md Abschnitt 9)
(Events/Systeme/KPIs), 8 Schreibtische - zeigt live die Daten aus der REST-API (Push per
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
eine Datenquelle für den System-Monitor; die alte Tabellenansicht (`html/mon.html`)
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
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 frontend # Node-Tests (Frontend-Layoutlogik)
```

View file

@ -231,20 +231,37 @@ externe Datenbankserver (nur PHP/Python-Standardbibliothek) auskommen, um die
## 9. Migrationsplan / Phasen
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,
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,
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
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,
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,
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.
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/
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
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\EventController;
use Monitor\Api\Controller\KpiController;
use Monitor\Api\Controller\StreamController;
use Monitor\Api\Controller\SystemController;
use Monitor\Api\Controller\TicketController;
use Monitor\Api\Db\Database;
@ -18,6 +19,7 @@ use Monitor\Api\Http\NotFoundException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Http\Router;
use Monitor\Api\Realtime\ChangeFeed;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository;
use Monitor\Api\Repository\KpiRepository;
@ -53,6 +55,11 @@ final class Application
$eventController = new EventController($events);
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
$kpiController = new KpiController($kpis);
$streamController = new StreamController(
new ChangeFeed($tickets, $events, $systems, $kpis, $agents),
$config->sseMaxIterations(),
$config->ssePollIntervalSeconds(),
);
$this->router = new Router();
@ -77,6 +84,10 @@ final class Application
fn (Request $r, array $p, array $agent) => $agentController->activity($r, $p, $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

View file

@ -73,4 +73,20 @@ final class Config
{
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
{
/** @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(
public readonly int $status,
private readonly mixed $data,
private readonly ?\Closure $streamWriter = null,
) {
}
@ -24,8 +30,55 @@ final class Response
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
{
// 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);
header('Content-Type: application/json; charset=utf-8');
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;
}
/** 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();
}
/**
* 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);
}
/** 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();
}
/** 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
{
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);
}
/**
* 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->adminApiKey());
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',
'MONITOR_ADMIN_API_KEY' => 'secret',
'MONITOR_DESK_COUNT' => '3',
'MONITOR_SSE_MAX_ITERATIONS' => '5',
'MONITOR_SSE_POLL_INTERVAL' => '1',
]);
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('secret', $config->adminApiKey());
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(['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);
}
}
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));
}
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());
}
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::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']);
}
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_STATUS_FILE: /status.txt
MONITOR_DESK_COUNT: 8
MONITOR_SSE_MAX_ITERATIONS: 30
MONITOR_SSE_POLL_INTERVAL: 2
CODEBERG_API_BASE: https://codeberg.org/api/v1
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}

View file

@ -249,3 +249,240 @@ header.office-header p {
.desk-empty .desk-chair {
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
// Vanilla-SVG in den Container #office-scene. Keine Animation, keine Klick-Interaktion
// (Board-/Monitor-Modals kommen erst mit den Live-Daten in Phase 3/4). Reine DOM-Glue-Logik -
// die eigentliche Layoutberechnung steckt testbar in office-scene.js.
// Zeichnet die Buero-Szene als Vanilla-SVG in den Container #office-scene und haelt sie mit
// echten Daten aktuell (Phase 3/4: Board/Monitor-Wand live, Phase 5: Agenten-Animation, siehe
// Visualization.md Abschnitt 9). Reine DOM-Glue-Logik - Layoutberechnung/Datenmodelle stecken
// 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 {
ROLES,
AGENT_LOCATIONS,
buildBoardColumnLayout,
buildDeskLayout,
buildSystemTiles,
buildEventLines,
aggregateTicketsByColumn,
groupTicketsByColumn,
buildTicketModalContent,
mapEventForDisplay,
buildEventModalContent,
buildSystemModalContent,
buildKpiTiles,
buildKpiModalContent,
mapAgentsToDesks,
normalizeAgentForDisplay,
resolveAgentVisualState,
pickIdleActivity,
AnimationQueue,
} from "./office-scene.js";
import {
DEMO_TICKET_COUNTS,
@ -17,6 +37,9 @@ import {
DEMO_EVENTS,
DEMO_KPI_PLACEHOLDER,
} 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";
@ -30,16 +53,29 @@ const DOOR = { w: 40, h: 220, y: 700 };
const DESK = { w: 220, h: 110 };
const DESK_ROW_Y = [540, 840];
const DESK_COL_X = [232, 644, 1056, 1468];
const COFFEE_CORNER = { x: 960, y: 780 };
const NOTE_W = 96;
const NOTE_H = 50;
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 = []) {
const node = document.createElementNS(SVG_NS, tag);
for (const [key, value] of Object.entries(attrs)) {
if (value !== null && value !== undefined) {
node.setAttribute(key, value);
}
}
for (const child of children) {
node.appendChild(child);
}
@ -62,12 +98,24 @@ function renderRoom() {
g.appendChild(
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;
}
function renderStackBadge(x, y, count) {
const g = el("g", { class: "note-stack" });
// Mehrere leicht versetzte Zettel als "Stapel"-Andeutung, darueber die Anzahl (F3).
[6, 3, 0].forEach((offset, i) => {
g.appendChild(
el("rect", {
@ -89,12 +137,14 @@ function renderStackBadge(x, y, count) {
return g;
}
function renderBoard(ticketCounts) {
/**
* @param {Record<string, number>} ticketCounts
* @param {(columnKey: string) => void} onColumnClick
*/
function renderBoard(ticketCounts, onColumnClick) {
const columns = buildBoardColumnLayout(ticketCounts);
const g = el("g", { class: "board" });
g.appendChild(
el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" })
);
g.appendChild(el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" }));
g.appendChild(
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) => {
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) {
colGroup.appendChild(
@ -147,7 +207,7 @@ function renderBoard(ticketCounts) {
}
function renderEventMonitor(frame, events) {
const lines = buildEventLines(events);
const lines = buildEventLines(events).map(mapEventForDisplay);
const g = el("g", { class: "monitor monitor-events" });
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" }));
@ -205,8 +265,6 @@ function renderSystemMonitor(frame, systems) {
tileGroup.appendChild(
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(
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;
}
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" });
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" }));
@ -239,9 +307,32 @@ function renderKpiMonitor(frame, placeholder) {
y: frame.y + 10,
width: frame.w - 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 lineHeight = 22;
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 gap = 20;
const monitorWidth = (MONITOR_WALL.w - gap * 2) / 3;
@ -284,21 +376,38 @@ function renderMonitorWall(systems, events) {
h: MONITOR_WALL.h,
}));
g.appendChild(renderEventMonitor(frames[0], events));
g.appendChild(renderSystemMonitor(frames[1], systems));
g.appendChild(renderKpiMonitor(frames[2], DEMO_KPI_PLACEHOLDER));
const eventsGroup = renderEventMonitor(frames[0], events);
eventsGroup.classList.add("clickable");
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;
}
function personPose(pose) {
// Rein statische Pose-Auswahl (kein Animationszustand) - Zustandsmaschine/Animation
// folgt erst in Phase 5.
function deskSeat(deskIndex) {
const col = deskIndex % DESK_COL_X.length;
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) {
case "reading_newspaper":
return "pose-newspaper";
case "coffee":
return "pose-coffee";
case "working":
return "pose-working";
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 }) {
const col = deskIndex % DESK_COL_X.length;
const row = Math.floor(deskIndex / DESK_COL_X.length);
const x = DESK_COL_X[col];
const y = DESK_ROW_Y[row];
const { origin, feet } = deskSeat(deskIndex);
const role = agent ? ROLES[agent.role] : null;
const g = el("g", {
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: 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) {
// Deko-Icon je Rolle (F1): Stern (Entwickler:in), Bluete (Product Owner:in), Haken (QA).
if (role.deco === "star") {
g.appendChild(
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(
text(agent.name, {
x: DESK.w / 2,
@ -387,38 +488,464 @@ function renderDesk({ deskIndex, agent }) {
return g;
}
function renderDesks(agents) {
function renderDesks(desks) {
const g = el("g", { class: "desks" });
buildDeskLayout(agents).forEach((desk) => g.appendChild(renderDesk(desk)));
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
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 = {}) {
const {
ticketCounts = DEMO_TICKET_COUNTS,
agents = DEMO_AGENTS,
systems = DEMO_SYSTEMS,
events = DEMO_EVENTS,
kpis = [],
tickets = [],
} = 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}`,
class: "office-svg",
role: "img",
"aria-label": "Buero-Visualisierung mit Kanban-Board, Monitor-Wand und Schreibtischen",
});
svg.appendChild(renderRoom());
svg.appendChild(renderBoard(ticketCounts));
svg.appendChild(renderMonitorWall(systems, events));
svg.appendChild(renderDesks(agents));
svg.appendChild(el("g", { class: "agents-layer" }));
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", () => {
const container = document.getElementById("office-scene");
if (container) {
renderOfficeScene(container);
}
boot();
});

View file

@ -103,3 +103,347 @@ export function buildSystemTiles(systems = []) {
export function buildEventLines(events = []) {
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">
<h1>Buero</h1>
<p>
Statische Vorschau (Phase 2) mit Demo-Daten - Kanban-Board, Monitor-Wand und
Schreibtische werden ab den naechsten Phasen mit echten Daten aus der
<a href="/API.md">REST-API</a> gespeist und interaktiv (Klick auf Board/Monitore).
Kanban-Board, Monitor-Wand und Schreibtische zeigen live die Daten aus der
<a href="/API.md">REST-API</a> (Push-Updates per Server-Sent Events, Fallback:
Polling). Klick auf das Board oder einen Monitor oeffnet die Detailansicht. Ist
die API nicht erreichbar, zeigt die Szene ersatzweise Demo-Daten.
</p>
</header>
<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>
</body>
</html>

View file

@ -9,6 +9,23 @@ import {
MAX_SYSTEM_TILES,
MAX_EVENT_LINES,
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";
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[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);
});