Phase 2 der Neuausrichtung: Statische Buero-Szene (SVG, Demo-Daten)

Setzt Phase 2 aus Visualization.md um: Vanilla-SVG-Buero-Szene (Waende, 2 Tueren,
8 Schreibtische, Kanban-Board-Rahmen mit 7 Spalten, Monitor-Wand mit Event-/System-/
KPI-Monitor) ohne Animation, gespeist mit statischen Demo-Daten im Format der
Phase-1-API-Responses. Board-Stapel-Logik (>5 Tickets, Done-Spalte als Dauerstapel)
folgt Detailantworten.md F2/F3.

Reine Layout-Logik (html/js/office-scene.js) ist ausgelagert und mit dem Node-
Testrunner abgedeckt (100% Zeilenabdeckung, tests/frontend/), das DOM-Rendering
(office-render.js) bleibt bewusst duenne Glue-Logik. Docker-Compose um web- (nginx,
serviert html/) und frontend-Service (node, "npm test") erweitert, analog zum
bestehenden api-Service und zum NC-Gantt-Vorbild: kein PHP/Node auf dem Host noetig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-07-28 00:19:22 +02:00
parent 68a57b2e1f
commit 37693a09a1
10 changed files with 1015 additions and 11 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
api/vendor/ api/vendor/
api/data/ api/data/
.env .env
node_modules/

View file

@ -1,24 +1,52 @@
# 🖥️ monitor # 🖥️ monitor
**monitor** ist ein leichtgewichtiges Monitoring-Tool, das mehrere Systeme per **Ping** überwacht und die Ergebnisse in einer **statischen HTML-Webseite** anzeigt. **monitor** überwacht mehrere Systeme (per Ping und/oder per REST-API-Meldungen entfernter
Ideal für kleine Netzwerke, Homelabs oder interne Statusseiten. Agenten) und zeigt den Zustand als interaktive **Büro-Visualisierung** (Kanban-Board +
Monitor-Wand mit Agenten an Schreibtischen).
Der vollständige Umbauplan steht in [Visualization.md](Visualization.md) (Roadmap Phase 0-7),
die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und
[Detailantworten.md](Detailantworten.md).
--- ---
## 🚀 Features ## 🚀 Features
- Überwacht beliebig viele Hosts per **ICMP-Ping** - REST-API (`api/`, siehe [API.md](API.md)) für Tickets, Events, Systeme, KPIs und
- Generiert automatisch eine **statische HTML-Statusseite** Agenten-Aktivität, mit statischen API-Keys pro Agent
- Anzeige von: - Büro-Visualisierung (`html/office.html`) als Vanilla-SVG-Szene: Kanban-Board, Monitor-Wand
- Antwortzeit (ms) (Events/Systeme/KPIs), 8 Schreibtische - aktuell Phase 2 (statisches Layout mit
- Online-/Offline-Status Demo-Daten, siehe Visualization.md Abschnitt 9)
- Letztem Check-Zeitpunkt - Weiterhin: klassische Ping-Überwachung (`check_fds.sh`, `hosts.conf`, `mail_fds.py`) als
- Einfache Konfiguration über JSON oder YAML eine Datenquelle für den System-Monitor; die alte Tabellenansicht (`html/mon.html`)
- Keine Datenbank, kein Serverprozess – alles rein statisch bleibt bis zur Migration in einer späteren Phase bestehen (F16)
- Optional: automatischer Refresh der Webseite
--- ---
## 📁 Projektstruktur ## 📁 Projektstruktur
- `api/` — REST-API (PHP, SQLite), siehe [api/README.md](api/README.md)
- `html/` — Frontend: `office.html` (neue Büro-Visualisierung), `mon.html` (alte
Tabellenansicht), `js/`, `css/`
- `tests/frontend/` — Node-Tests für die Frontend-Layoutlogik (`node --test`)
- `check_fds.sh`, `mail_fds.py`, `hosts-sample.conf` — bestehende Ping-Überwachung
- `docker-compose.yml` — komplette Entwicklungsumgebung (siehe unten)
---
## 🐳 Entwicklung (nur Docker, kein lokales PHP/Node nötig)
Analog zum NC-Gantt-Projekt läuft die gesamte Ausführung (Bauen, Entwickeln, Testen) in
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 run --rm api composer test # PHPUnit (API)
docker compose run --rm frontend # Node-Tests (Frontend-Layoutlogik)
```
Details zur API: [api/README.md](api/README.md). Details zu den Endpunkten: [API.md](API.md).

View file

@ -19,3 +19,22 @@ services:
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610} CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
CODEBERG_REPO: ${CODEBERG_REPO:-monitor} CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-} CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
# Statisches Frontend (html/) - Phase 2 der Neuausrichtung (Buero-Visualisierung).
# Nur zur lokalen Ansicht/Entwicklung, kein Build-Schritt noetig (Vanilla HTML/CSS/JS).
web:
image: nginx:alpine
volumes:
- ./html:/usr/share/nginx/html:ro
- ./API.md:/usr/share/nginx/html/API.md:ro
ports:
- "8081:80"
# Fuehrt die Node-basierten Frontend-Tests (tests/frontend/) aus. Node/npm sind nur im
# Container noetig, nicht auf dem Host (analog zum api-Service mit PHP/Composer).
frontend:
image: node:20-alpine
working_dir: /app
volumes:
- .:/app
command: npm test

251
html/css/office.css Normal file
View file

@ -0,0 +1,251 @@
/* Buero-Visualisierung (Phase 2, statische Szene). Bewusst ohne UI-Framework, siehe
Visualization.md Abschnitt 5. Farbschema hell/freundlich (F9: "Zeichentrick"-Anmutung). */
:root {
--color-floor: #eef2f7;
--color-wall: #cfe0f0;
--color-wall-side: #bcd3ea;
--color-door: #8fb8dd;
--color-board-frame: #94622f;
--color-note: #fde68a;
--color-note-border: #d9b34f;
--color-note-stack-badge: #d97706;
--color-monitor-frame: #2b2f38;
--color-monitor-screen: #10131a;
--color-monitor-placeholder: #1a2436;
--color-desk-table: #b98a54;
--color-desk-chair: #6b7280;
--color-desk-screen-idle: #3a3f4b;
--color-text: #1f2937;
--color-text-inverse: #f8fafc;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", Verdana, Arial, sans-serif;
background: #dbe7f3;
color: var(--color-text);
}
header.office-header {
padding: 16px 24px;
display: flex;
align-items: baseline;
gap: 16px;
}
header.office-header h1 {
margin: 0;
font-size: 1.4rem;
}
header.office-header p {
margin: 0;
color: #475569;
font-size: 0.9rem;
}
#office-scene {
width: 100%;
max-width: 1920px;
margin: 0 auto;
padding: 0 16px 32px;
}
.office-svg {
width: 100%;
height: auto;
display: block;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.25);
}
.floor {
fill: var(--color-floor);
}
.wall {
fill: var(--color-wall);
}
.wall-side {
fill: var(--color-wall-side);
}
.door {
fill: var(--color-door);
stroke: #5f86ab;
stroke-width: 3;
}
.wall-label {
fill: #f8fafc;
font-size: 22px;
font-weight: 600;
}
/* Kanban-Board */
.board-frame {
fill: #7a5230;
stroke: var(--color-board-frame);
stroke-width: 6;
}
.board-column-label {
fill: var(--color-text-inverse);
font-size: 16px;
font-weight: 600;
}
.board-column-divider {
stroke: rgba(255, 255, 255, 0.25);
stroke-width: 2;
}
.note {
fill: var(--color-note);
stroke: var(--color-note-border);
stroke-width: 2;
}
.note-stack-badge {
fill: var(--color-note-stack-badge);
stroke: #92400e;
stroke-width: 2;
}
.note-stack-count {
fill: #ffffff;
font-size: 20px;
font-weight: 700;
}
/* Monitor-Wand */
.monitor-frame {
fill: var(--color-monitor-frame);
}
.monitor-screen {
fill: var(--color-monitor-screen);
}
.monitor-screen-placeholder {
fill: var(--color-monitor-placeholder);
}
.monitor-event-line {
font-family: "Consolas", "Courier New", monospace;
font-size: 13px;
}
.severity-warning {
fill: #facc15;
}
.severity-error {
fill: #f87171;
}
.system-tile-bg {
fill: #1e293b;
}
.system-tile-bar {
fill: #334155;
}
.system-tile-led {
fill: #64748b;
}
.status-ok .system-tile-led {
fill: #4ade80;
}
.status-error .system-tile-led {
fill: #f87171;
}
.system-tile-label {
fill: var(--color-text-inverse);
font-size: 12px;
}
.monitor-placeholder-text {
fill: #64748b;
font-size: 15px;
}
/* Schreibtische */
.desk-table {
fill: var(--color-desk-table);
stroke: #8a6238;
stroke-width: 3;
}
.desk-screen {
fill: var(--color-desk-screen-idle);
stroke: #1f2430;
stroke-width: 2;
}
.desk-screen.pose-working {
fill: #7dd3fc;
}
.desk-chair {
fill: var(--color-desk-chair);
}
.person-head {
fill: #f3d9b1;
stroke: #d8b98a;
stroke-width: 1.5;
}
.person-body {
fill: var(--role-color, #94a3b8);
}
.desk-name {
fill: #334155;
font-size: 14px;
font-weight: 600;
}
.desk-name-empty {
fill: #94a3b8;
font-style: italic;
font-weight: 400;
}
.desk-deco-star {
fill: #fbbf24;
}
.desk-deco-flower circle {
fill: #f9a8d4;
}
.desk-deco-flower-center {
fill: #fde047 !important;
}
.desk-deco-check {
fill: none;
stroke: #22c55e;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.desk-empty .desk-chair {
opacity: 0.5;
}

52
html/js/demo-data.js Normal file
View file

@ -0,0 +1,52 @@
// Statische Demo-Daten fuer Phase 2 ("Statische Buero-Szene", Visualization.md Abschnitt 9).
// Die Form entspricht bewusst den echten API-Responses (siehe API.md), damit ab Phase 3 nur
// noch die Quelle (fetch() statt dieses Moduls) getauscht werden muss.
// Entspricht der Struktur von GET /api/v1/tickets, hier aber bereits zu Zaehlern je Spalte
// aggregiert (die eigentliche Aggregation aus Einzel-Tickets kommt mit der Live-Anbindung
// in Phase 3).
export const DEMO_TICKET_COUNTS = {
backlog: 8,
bereit: 3,
in_arbeit: 2,
qa: 1,
testing: 0,
fertig: 2,
done: 12,
};
// Entspricht GET /api/v1/agents, "pose" ist reine Darstellungs-Deko fuer Phase 2 (ohne
// Animation) und kein Feld der echten API.
export const DEMO_AGENTS = [
{ name: "Alice", role: "entwickler", pose: "working" },
{ name: "Bob", role: "entwickler", pose: "reading_newspaper" },
{ name: "Petra", role: "product_owner", pose: "working" },
{ name: "Quentin", role: "qa", pose: "idle" },
{ name: "Dirk", role: "entwickler", pose: "working" },
{ name: "Nadja", role: "qa", pose: "coffee" },
null,
null,
];
// Entspricht GET /api/v1/systems.
export const DEMO_SYSTEMS = [
{ id: "srv1", name: "Webserver", status: "ok" },
{ id: "srv2", name: "Mailserver", status: "ok" },
{ id: "srv3", name: "Datenbank", status: "error" },
{ id: "srv4", name: "Backup", status: "ok" },
{ id: "srv5", name: "VPN", status: "ok" },
{ id: "srv6", name: "DNS", status: "ok" },
];
// Entspricht GET /api/v1/events, bereits absteigend nach Zeit sortiert (neuestes zuerst).
export const DEMO_EVENTS = [
{ time: "14:32", system: "Datenbank", message: "Verbindung fehlgeschlagen", severity: "error" },
{ time: "14:20", system: "Backup", message: "Backup dauert laenger als ueblich", severity: "warning" },
{ time: "13:58", system: "Webserver", message: "Hohe Antwortzeit", severity: "warning" },
{ time: "12:10", system: "VPN", message: "Client getrennt", severity: "warning" },
{ time: "09:05", system: "Datenbank", message: "Reconnect erfolgreich", severity: "warning" },
];
// KPI-Monitor: F7 ist noch offen ("kommt spaeter") - Phase 2 zeigt daher nur einen leeren
// Rahmen mit Platzhaltertext, bewusst keine erfundenen Kennzahlen.
export const DEMO_KPI_PLACEHOLDER = "KPI-Monitor – Kennzahlen folgen in einer späteren Phase";

424
html/js/office-render.js Normal file
View file

@ -0,0 +1,424 @@
// 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.
import {
ROLES,
buildBoardColumnLayout,
buildDeskLayout,
buildSystemTiles,
buildEventLines,
} from "./office-scene.js";
import {
DEMO_TICKET_COUNTS,
DEMO_AGENTS,
DEMO_SYSTEMS,
DEMO_EVENTS,
DEMO_KPI_PLACEHOLDER,
} from "./demo-data.js";
const SVG_NS = "http://www.w3.org/2000/svg";
const VIEW_W = 1920;
const VIEW_H = 1080;
const WALL_BAND_H = 420;
const BOARD_FRAME = { x: 60, y: 40, w: 1120, h: 340 };
const MONITOR_WALL = { x: 1240, y: 40, w: 620, h: 340 };
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 NOTE_W = 96;
const NOTE_H = 50;
const NOTE_STEP_Y = 56;
function el(tag, attrs = {}, children = []) {
const node = document.createElementNS(SVG_NS, tag);
for (const [key, value] of Object.entries(attrs)) {
node.setAttribute(key, value);
}
for (const child of children) {
node.appendChild(child);
}
return node;
}
function text(content, attrs = {}) {
const node = el("text", attrs);
node.textContent = content;
return node;
}
function renderRoom() {
const g = el("g", { class: "office-room" });
g.appendChild(el("rect", { x: 0, y: 0, width: VIEW_W, height: VIEW_H, class: "floor" }));
g.appendChild(el("rect", { x: 0, y: 0, width: VIEW_W, height: WALL_BAND_H, class: "wall" }));
g.appendChild(el("rect", { x: 0, y: 0, width: 40, height: VIEW_H, class: "wall wall-side" }));
g.appendChild(el("rect", { x: VIEW_W - 40, y: 0, width: 40, height: VIEW_H, class: "wall wall-side" }));
g.appendChild(el("rect", { x: 0, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door" }));
g.appendChild(
el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door" })
);
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", {
x: x - offset,
y: y - offset,
width: NOTE_W,
height: NOTE_H,
rx: 6,
class: `note note-stack-layer-${i}`,
})
);
});
const cx = x + NOTE_W / 2;
const cy = y + NOTE_H / 2;
g.appendChild(el("circle", { cx, cy, r: 20, class: "note-stack-badge" }));
g.appendChild(
text(String(count), { x: cx, y: cy + 7, class: "note-stack-count", "text-anchor": "middle" })
);
return g;
}
function renderBoard(ticketCounts) {
const columns = buildBoardColumnLayout(ticketCounts);
const g = el("g", { class: "board" });
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" })
);
const columnWidth = BOARD_FRAME.w / columns.length;
columns.forEach((column, index) => {
const columnX = BOARD_FRAME.x + index * columnWidth;
const colGroup = el("g", { class: `board-column board-column-${column.key}` });
if (index > 0) {
colGroup.appendChild(
el("line", {
x1: columnX,
y1: BOARD_FRAME.y,
x2: columnX,
y2: BOARD_FRAME.y + BOARD_FRAME.h,
class: "board-column-divider",
})
);
}
colGroup.appendChild(
text(column.label, {
x: columnX + columnWidth / 2,
y: BOARD_FRAME.y + 26,
class: "board-column-label",
"text-anchor": "middle",
})
);
const noteX = columnX + (columnWidth - NOTE_W) / 2;
let noteY = BOARD_FRAME.y + 44;
for (let i = 0; i < column.visibleNotes; i++) {
colGroup.appendChild(
el("rect", { x: noteX, y: noteY, width: NOTE_W, height: NOTE_H, rx: 6, class: "note" })
);
noteY += NOTE_STEP_Y;
}
if (column.stackCount > 0) {
colGroup.appendChild(renderStackBadge(noteX, noteY, column.stackCount));
}
g.appendChild(colGroup);
});
return g;
}
function renderEventMonitor(frame, events) {
const lines = buildEventLines(events);
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" }));
g.appendChild(
el("rect", {
x: frame.x + 10,
y: frame.y + 10,
width: frame.w - 20,
height: frame.h - 20,
class: "monitor-screen",
})
);
lines.forEach((eventLine, index) => {
g.appendChild(
text(`${eventLine.time} ${eventLine.system}: ${eventLine.message}`, {
x: frame.x + 18,
y: frame.y + 38 + index * 26,
class: `monitor-event-line severity-${eventLine.severity}`,
})
);
});
return g;
}
function renderSystemMonitor(frame, systems) {
const tiles = buildSystemTiles(systems);
const g = el("g", { class: "monitor monitor-systems" });
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
g.appendChild(text("Systeme", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
g.appendChild(
el("rect", {
x: frame.x + 10,
y: frame.y + 10,
width: frame.w - 20,
height: frame.h - 20,
class: "monitor-screen",
})
);
const cols = 3;
const tileMargin = 8;
const gridX = frame.x + 18;
const gridY = frame.y + 18;
const tileSize = (frame.w - 36 - tileMargin * (cols - 1)) / cols;
tiles.forEach((system, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
const tileX = gridX + col * (tileSize + tileMargin);
const tileY = gridY + row * (tileSize + tileMargin);
const tileGroup = el("g", { class: `system-tile status-${system.status}` });
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" })
);
tileGroup.appendChild(
el("rect", { x: tileX + 10, y: tileY + 30, width: tileSize - 20, height: 14, rx: 2, class: "system-tile-bar" })
);
tileGroup.appendChild(el("circle", { cx: tileX + 18, cy: tileY + 17, r: 3, class: "system-tile-led" }));
tileGroup.appendChild(el("circle", { cx: tileX + 18, cy: tileY + 37, r: 3, class: "system-tile-led" }));
tileGroup.appendChild(
text(system.name, {
x: tileX + tileSize / 2,
y: tileY + tileSize - 8,
class: "system-tile-label",
"text-anchor": "middle",
})
);
g.appendChild(tileGroup);
});
return g;
}
function renderKpiMonitor(frame, placeholder) {
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" }));
g.appendChild(
el("rect", {
x: frame.x + 10,
y: frame.y + 10,
width: frame.w - 20,
height: frame.h - 20,
class: "monitor-screen monitor-screen-placeholder",
})
);
const words = placeholder.split(" ");
const lineHeight = 22;
const maxCharsPerLine = 16;
const lines = [];
let current = "";
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (candidate.length > maxCharsPerLine && current) {
lines.push(current);
current = word;
} else {
current = candidate;
}
}
if (current) lines.push(current);
const startY = frame.y + frame.h / 2 - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, index) => {
g.appendChild(
text(line, {
x: frame.x + frame.w / 2,
y: startY + index * lineHeight,
class: "monitor-placeholder-text",
"text-anchor": "middle",
})
);
});
return g;
}
function renderMonitorWall(systems, events) {
const g = el("g", { class: "monitor-wall" });
const gap = 20;
const monitorWidth = (MONITOR_WALL.w - gap * 2) / 3;
const frames = [0, 1, 2].map((i) => ({
x: MONITOR_WALL.x + i * (monitorWidth + gap),
y: MONITOR_WALL.y,
w: monitorWidth,
h: MONITOR_WALL.h,
}));
g.appendChild(renderEventMonitor(frames[0], events));
g.appendChild(renderSystemMonitor(frames[1], systems));
g.appendChild(renderKpiMonitor(frames[2], DEMO_KPI_PLACEHOLDER));
return g;
}
function personPose(pose) {
// Rein statische Pose-Auswahl (kein Animationszustand) - Zustandsmaschine/Animation
// folgt erst in Phase 5.
switch (pose) {
case "reading_newspaper":
return "pose-newspaper";
case "coffee":
return "pose-coffee";
case "working":
return "pose-working";
default:
return "pose-idle";
}
}
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 role = agent ? ROLES[agent.role] : null;
const g = el("g", {
class: `desk ${agent ? "desk-occupied" : "desk-empty"}`,
transform: `translate(${x}, ${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) : ""}` })
);
g.appendChild(el("circle", { cx: DESK.w / 2, cy: 40 + DESK.h + 30, 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", {
d: "M170,20 l4,10 11,0 -9,7 3,11 -9,-7 -9,7 3,-11 -9,-7 11,0 z",
class: "desk-deco desk-deco-star",
})
);
} else if (role.deco === "flower") {
const cx = 175;
const cy = 22;
g.appendChild(
el("g", { class: "desk-deco desk-deco-flower" }, [
el("circle", { cx: cx - 7, cy, r: 6 }),
el("circle", { cx: cx + 7, cy, r: 6 }),
el("circle", { cx, cy: cy - 7, r: 6 }),
el("circle", { cx, cy: cy + 7, r: 6 }),
el("circle", { cx, cy, r: 4, class: "desk-deco-flower-center" }),
])
);
} else if (role.deco === "check") {
g.appendChild(
el("path", { d: "M162,24 l8,8 16,-18", class: "desk-deco desk-deco-check" })
);
}
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,
y: 40 + DESK.h + 74,
class: "desk-name",
"text-anchor": "middle",
})
);
} else {
g.appendChild(
text("frei", {
x: DESK.w / 2,
y: 40 + DESK.h + 74,
class: "desk-name desk-name-empty",
"text-anchor": "middle",
})
);
}
return g;
}
function renderDesks(agents) {
const g = el("g", { class: "desks" });
buildDeskLayout(agents).forEach((desk) => g.appendChild(renderDesk(desk)));
return g;
}
export function renderOfficeScene(container, data = {}) {
const {
ticketCounts = DEMO_TICKET_COUNTS,
agents = DEMO_AGENTS,
systems = DEMO_SYSTEMS,
events = DEMO_EVENTS,
} = data;
const 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));
container.replaceChildren(svg);
}
document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("office-scene");
if (container) {
renderOfficeScene(container);
}
});

105
html/js/office-scene.js Normal file
View file

@ -0,0 +1,105 @@
// Reine Layout-/Datenlogik fuer die Buero-Visualisierung (Phase 2, siehe Visualization.md
// Abschnitt 9). Enthaelt keine DOM-Zugriffe, damit die Funktionen ohne Browser (z.B. mit dem
// Node-Testrunner) getestet werden koennen. Das eigentliche Zeichnen uebernimmt office-render.js.
// Spaltenreihenfolge exakt wie API.md ("Tickets" -> Spalten, F3).
export const BOARD_COLUMNS = [
{ key: "backlog", label: "Backlog" },
{ key: "bereit", label: "Bereit" },
{ key: "in_arbeit", label: "In Arbeit" },
{ key: "qa", label: "QA" },
{ key: "testing", label: "Testing" },
{ key: "fertig", label: "Fertig" },
{ key: "done", label: "Done" },
];
// Rollen exakt wie API.md ("Agenten", F1/F2). "user" hat nie einen Schreibtisch.
export const ROLES = {
entwickler: { label: "Entwickler:in", accentColor: "#3b82f6", deco: "star" },
product_owner: { label: "Product Owner:in", accentColor: "#ec4899", deco: "flower" },
qa: { label: "QA / Tester:in", accentColor: "#22c55e", deco: "check" },
user: { label: "User", accentColor: "#f59e0b", deco: "none" },
};
export const MAX_VISIBLE_NOTES_PER_COLUMN = 4;
export const MAX_SYSTEM_TILES = 9;
export const MAX_EVENT_LINES = 5;
export const DEFAULT_DESK_COUNT = 8;
/**
* Baut aus rohen Ticket-Zaehlern pro Spalte die Darstellung fuer das Board.
* Regel (Detailantworten F3): mehr als 5 Tickets in einer Spalte -> die aeltesten 4 werden
* einzeln dargestellt, der Rest als ein "Stapel" mit der Anzahl der zusaetzlichen Tickets.
* Regel (Detailantworten F2): die Spalte "done" wird immer als ein Stapel (Zeit-Historie)
* dargestellt, unabhaengig von der Anzahl.
*
* @param {Record<string, number>} ticketCounts - Anzahl Tickets je Spalten-Key.
* @returns {Array<{key: string, label: string, count: number, visibleNotes: number, stackCount: number}>}
*/
export function buildBoardColumnLayout(ticketCounts = {}) {
return BOARD_COLUMNS.map(({ key, label }) => {
const count = Math.max(0, ticketCounts[key] ?? 0);
if (key === "done") {
return { key, label, count, visibleNotes: 0, stackCount: count };
}
if (count <= MAX_VISIBLE_NOTES_PER_COLUMN + 1) {
return { key, label, count, visibleNotes: count, stackCount: 0 };
}
return {
key,
label,
count,
visibleNotes: MAX_VISIBLE_NOTES_PER_COLUMN,
stackCount: count - MAX_VISIBLE_NOTES_PER_COLUMN,
};
});
}
/**
* Ordnet Agenten-Demo-Daten den Schreibtischen zu. Unbesetzte Schreibtische bleiben leer.
* Ueberzaehlige Agenten (mehr als deskCount) werden ignoriert - Phase 2 zeigt nur den
* statischen Grundzustand, die "weitere Agenten kommen durch die Tuer"-Logik ist Teil von
* Phase 5 (Animation).
*
* @param {Array<{name: string, role: string, pose?: string}>} agents
* @param {number} deskCount
* @returns {Array<{deskIndex: number, agent: object|null}>}
*/
export function buildDeskLayout(agents = [], deskCount = DEFAULT_DESK_COUNT) {
const desks = [];
for (let deskIndex = 0; deskIndex < deskCount; deskIndex++) {
const agent = agents[deskIndex] ?? null;
desks.push({ deskIndex, agent: agent ? { pose: "idle", ...agent } : null });
}
return desks;
}
/**
* Bereitet Systemkacheln fuer den System-Monitor auf: Fehler zuerst (wie GET /api/v1/systems),
* maximal MAX_SYSTEM_TILES Kacheln (F6).
*
* @param {Array<{id: string, name: string, status: "ok"|"error"}>} systems
* @returns {Array<object>}
*/
export function buildSystemTiles(systems = []) {
const sorted = [...systems].sort((a, b) => {
if (a.status === b.status) return 0;
return a.status === "error" ? -1 : 1;
});
return sorted.slice(0, MAX_SYSTEM_TILES);
}
/**
* Bereitet die Zeilen des Event-Monitors auf: neuestes Event zuerst, maximal
* MAX_EVENT_LINES Zeilen (F5). Erwartet bereits absteigend nach Zeit sortierte Events
* (wie GET /api/v1/events sie liefert).
*
* @param {Array<{severity: "warning"|"error"}>} events
* @returns {Array<object>}
*/
export function buildEventLines(events = []) {
return events.slice(0, MAX_EVENT_LINES);
}

24
html/office.html Normal file
View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/favicon.png">
<title>FDS Buero-Visualisierung</title>
<link rel="stylesheet" href="css/office.css">
</head>
<body>
<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).
</p>
</header>
<main id="office-scene" aria-label="Buero-Szene"></main>
<script type="module" src="js/office-render.js"></script>
</body>
</html>

9
package.json Normal file
View file

@ -0,0 +1,9 @@
{
"name": "monitor-frontend",
"private": true,
"type": "module",
"description": "Test-Tooling fuer das Vanilla-JS-Frontend (html/js/). Laufzeitcode selbst hat keine Abhaengigkeiten (siehe Visualization.md Abschnitt 5).",
"scripts": {
"test": "node --test --experimental-test-coverage tests/frontend"
}
}

View file

@ -0,0 +1,91 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
BOARD_COLUMNS,
buildBoardColumnLayout,
buildDeskLayout,
buildSystemTiles,
buildEventLines,
MAX_SYSTEM_TILES,
MAX_EVENT_LINES,
DEFAULT_DESK_COUNT,
} from "../../html/js/office-scene.js";
test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => {
const layout = buildBoardColumnLayout({});
assert.deepEqual(
layout.map((c) => c.key),
BOARD_COLUMNS.map((c) => c.key)
);
});
test("buildBoardColumnLayout zeigt Tickets bis einschliesslich 5 einzeln an", () => {
const layout = buildBoardColumnLayout({ bereit: 5 });
const bereit = layout.find((c) => c.key === "bereit");
assert.equal(bereit.visibleNotes, 5);
assert.equal(bereit.stackCount, 0);
});
test("buildBoardColumnLayout stapelt ab dem 5. Ticket (F3): aelteste 4 einzeln + Rest als Stapel", () => {
const layout = buildBoardColumnLayout({ backlog: 8 });
const backlog = layout.find((c) => c.key === "backlog");
assert.equal(backlog.visibleNotes, 4);
assert.equal(backlog.stackCount, 4);
assert.equal(backlog.count, 8);
});
test("buildBoardColumnLayout stellt die Spalte done immer als Stapel dar (F2), unabhaengig von der Anzahl", () => {
const wenige = buildBoardColumnLayout({ done: 2 }).find((c) => c.key === "done");
assert.equal(wenige.visibleNotes, 0);
assert.equal(wenige.stackCount, 2);
const viele = buildBoardColumnLayout({ done: 42 }).find((c) => c.key === "done");
assert.equal(viele.visibleNotes, 0);
assert.equal(viele.stackCount, 42);
});
test("buildBoardColumnLayout behandelt fehlende/negative Zaehler als 0", () => {
const layout = buildBoardColumnLayout({ qa: -3 });
const qa = layout.find((c) => c.key === "qa");
assert.equal(qa.count, 0);
assert.equal(qa.visibleNotes, 0);
});
test("buildDeskLayout erzeugt genau DEFAULT_DESK_COUNT Schreibtische", () => {
const desks = buildDeskLayout([]);
assert.equal(desks.length, DEFAULT_DESK_COUNT);
assert.ok(desks.every((d) => d.agent === null));
});
test("buildDeskLayout ordnet Agenten der Reihe nach Schreibtischen zu und ignoriert Ueberschuss", () => {
const agents = Array.from({ length: DEFAULT_DESK_COUNT + 2 }, (_, i) => ({
name: `Agent${i}`,
role: "entwickler",
}));
const desks = buildDeskLayout(agents);
assert.equal(desks.length, DEFAULT_DESK_COUNT);
assert.equal(desks[0].agent.name, "Agent0");
assert.equal(desks[DEFAULT_DESK_COUNT - 1].agent.name, `Agent${DEFAULT_DESK_COUNT - 1}`);
});
test("buildDeskLayout ergaenzt fehlende pose auf idle", () => {
const desks = buildDeskLayout([{ name: "Alice", role: "entwickler" }], 1);
assert.equal(desks[0].agent.pose, "idle");
});
test("buildSystemTiles sortiert Fehler vor ok und kappt bei MAX_SYSTEM_TILES", () => {
const systems = Array.from({ length: MAX_SYSTEM_TILES + 3 }, (_, i) => ({
id: `s${i}`,
status: i === MAX_SYSTEM_TILES ? "error" : "ok",
}));
const tiles = buildSystemTiles(systems);
assert.equal(tiles.length, MAX_SYSTEM_TILES);
assert.equal(tiles[0].status, "error");
});
test("buildEventLines kappt bei MAX_EVENT_LINES und behaelt die Reihenfolge bei", () => {
const events = Array.from({ length: MAX_EVENT_LINES + 4 }, (_, i) => ({ id: i }));
const lines = buildEventLines(events);
assert.equal(lines.length, MAX_EVENT_LINES);
assert.equal(lines[0].id, 0);
});