diff --git a/.gitignore b/.gitignore index 997ef86..1d4fa4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ api/vendor/ api/data/ .env +node_modules/ diff --git a/README.md b/README.md index d27679e..34187b5 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,52 @@ # 🖥️ monitor -**monitor** ist ein leichtgewichtiges Monitoring-Tool, das mehrere Systeme per **Ping** überwacht und die Ergebnisse in einer **statischen HTML-Webseite** anzeigt. -Ideal für kleine Netzwerke, Homelabs oder interne Statusseiten. +**monitor** überwacht mehrere Systeme (per Ping und/oder per REST-API-Meldungen entfernter +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 -- Überwacht beliebig viele Hosts per **ICMP-Ping** -- Generiert automatisch eine **statische HTML-Statusseite** -- Anzeige von: - - Antwortzeit (ms) - - Online-/Offline-Status - - Letztem Check-Zeitpunkt -- Einfache Konfiguration über JSON oder YAML -- Keine Datenbank, kein Serverprozess – alles rein statisch -- Optional: automatischer Refresh der Webseite +- 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) +- 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) --- ## 📁 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). + diff --git a/docker-compose.yml b/docker-compose.yml index bd15d0d..c18e797 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,3 +19,22 @@ services: CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610} CODEBERG_REPO: ${CODEBERG_REPO:-monitor} 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 diff --git a/html/css/office.css b/html/css/office.css new file mode 100644 index 0000000..e39bc76 --- /dev/null +++ b/html/css/office.css @@ -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; +} diff --git a/html/js/demo-data.js b/html/js/demo-data.js new file mode 100644 index 0000000..027996b --- /dev/null +++ b/html/js/demo-data.js @@ -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"; diff --git a/html/js/office-render.js b/html/js/office-render.js new file mode 100644 index 0000000..534a130 --- /dev/null +++ b/html/js/office-render.js @@ -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); + } +}); diff --git a/html/js/office-scene.js b/html/js/office-scene.js new file mode 100644 index 0000000..ddea160 --- /dev/null +++ b/html/js/office-scene.js @@ -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} 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} + */ +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} + */ +export function buildEventLines(events = []) { + return events.slice(0, MAX_EVENT_LINES); +} diff --git a/html/office.html b/html/office.html new file mode 100644 index 0000000..a6c07ab --- /dev/null +++ b/html/office.html @@ -0,0 +1,24 @@ + + + + + + + FDS Buero-Visualisierung + + + +
+

Buero

+

+ Statische Vorschau (Phase 2) mit Demo-Daten - Kanban-Board, Monitor-Wand und + Schreibtische werden ab den naechsten Phasen mit echten Daten aus der + REST-API gespeist und interaktiv (Klick auf Board/Monitore). +

+
+ +
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..1dccda1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js new file mode 100644 index 0000000..d52adbd --- /dev/null +++ b/tests/frontend/office-scene.test.js @@ -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); +});