// 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, formatEventLineText, truncateToWidth, buildEventModalContent, buildSystemModalContent, buildKpiTiles, buildKpiModalContent, buildAgentCountKpis, buildHooksKpi, MAX_KPI_TILES, mapAgentsToDesks, normalizeAgentForDisplay, resolveAgentVisualState, isAgentVisible, agentProblemSeverity, agentsWithProblems, pickIdleActivity, AnimationQueue, detectDoorVisits, resolveDoorVisitVisual, advanceDoorVisitStage, DOOR_VISIT_STAGES, detectUnassignedTicketChanges, advancePoVisitStage, PO_VISIT_STAGES, applyPendingTicketMoves, detectAgentOwnedTicketChanges, hasAgentReachedBoard, isAgentReportingBoardVisit, PO_AGENT_ID, PO_DISPLAY_NAME, resolveReloadIntervalSeconds, nextCountdownSeconds, systemsWithoutHostConfig, } from "./office-scene.js"; import { DEMO_TICKET_COUNTS, DEMO_AGENTS, DEMO_SYSTEMS, DEMO_EVENTS, DEMO_KPI_PLACEHOLDER, } from "./demo-data.js"; import { fetchTickets, fetchEvents, fetchSystems, fetchKpis, fetchAgents, fetchWebhookStats, fetchRepoConfigs, createRepoConfig, updateRepoConfig, deleteRepoConfig, checkRepoConfig, resyncTickets, fetchHostConfigs, createHostConfig, updateHostConfig, deleteHostConfig, deleteSystem, getStoredAdminKey, setStoredAdminKey, } from "./office-api.js"; import { startRealtimeUpdates } from "./office-realtime.js"; import { openModal, isCurrentModal } from "./office-modal.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 COFFEE_CORNER = { x: 960, y: 780 }; // Ein Event belegt im Event-Monitor zwei Zeilen (Kopfzeile Zeit/System, darunter die // Meldung); EVENT_BLOCK_H ist der Abstand von Event zu Event, EVENT_MESSAGE_DY der der // beiden Zeilen zueinander. MAX_EVENT_LINES (5) Bloecke passen damit in MONITOR_WALL.h. const EVENT_BLOCK_H = 44; const EVENT_MESSAGE_DY = 18; // Fester Platz des Product Owner (Issue #19): bewusst ausserhalb des dynamischen 8er-Rasters // (DESK_COL_X/DESK_ROW_Y), in der freien Nische zwischen Board/Monitor-Wand und der ersten // Schreibtischreihe - so kollidiert er nie mit einem echten, ueber die API registrierten Agenten. const PO_DESK = { x: 870, y: 380, w: 180 }; 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 }; // Ruheplatz der Tuerbesucher (Phase 6, F2): direkt vor der linken Tuer, wie schon der // Ueberlaufplatz fuer Agenten ohne Schreibtisch (siehe agentPlacements()). const DOOR_FEET = { x: 20, y: DOOR.y + DOOR.h / 2 }; // Ruheplatz des PO an seinem festen Platz (Issue #19), analog zu den Fuss-Positionen der // regulaeren Schreibtische (deskSeat().feet). const PO_DESK_FEET = { x: PO_DESK.x + PO_DESK.w / 2, y: PO_DESK.y + 40 + DESK.h + 30 }; const IDLE_MIN_DELAY_MS = 15000; const IDLE_MAX_DELAY_MS = 40000; const IDLE_ACTIVITY_DURATION_MS = 8000; const DOOR_VISIT_STAGE_DURATION_MS = 2200; // Issue #55: sobald die Persona am Board angekommen ist ("interacting_with_board") und das // Ticket an seine neue Stelle gewechselt hat, wartet sie genau diese Spanne, bevor sie // (Tuerbesucher: "leaving", PO: "walking_back") wieder losgeht. const TICKET_PLACED_WAIT_MS = 1000; 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); } 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 door-left" })); g.appendChild( el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door door-right" }) ); 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; } // Faerbt einen Zettel in der Farbe seines Quell-Projekts (Issue #26/#27: "welcher Farbe // diese zugeordnet werden"). Ohne Farbe - Ticket ohne Projekt oder Demo-Modus - bleibt es // bei der Standardfarbe aus dem Stylesheet. Die Farbe ist Nutzereingabe aus dem // Projekte-Modal und landet deshalb ueber die style-Property im DOM, nie in einem // zusammengesetzten Attribut-String. function paintNote(rect, color) { if (color) { rect.style.fill = color; // Die gelbe Standardkontur passt nur zum gelben Standardzettel - siehe .note-colored. rect.classList.add("note-colored"); } return rect; } function renderStackBadge(x, y, count, stackColors = []) { const g = el("g", { class: "note-stack" }); [6, 3, 0].forEach((offset, i) => { g.appendChild( paintNote( el("rect", { x: x - offset, y: y - offset, width: NOTE_W, height: NOTE_H, rx: 6, class: `note note-stack-layer-${i}`, }), stackColors[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; } /** * @param {Record} ticketCounts * @param {Record>} groupedTickets - liefert die Projektfarbe je Zettel. * @param {(columnKey: string) => void} onColumnClick * @param {() => void} onReloadClick - Issue #51: Klick auf das Reload-Symbol neben der * Ueberschrift stoesst ein komplettes Neueinlesen des Boards an. */ function renderBoard(ticketCounts, groupedTickets, onColumnClick, onReloadClick) { const columns = buildBoardColumnLayout(ticketCounts, groupedTickets); 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" }) ); g.appendChild(renderBoardReloadHint(onReloadClick)); 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} 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( 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( paintNote( el("rect", { x: noteX, y: noteY, width: NOTE_W, height: NOTE_H, rx: 6, class: "note" }), column.noteColors[i] ) ); noteY += NOTE_STEP_Y; } if (column.stackCount > 0) { colGroup.appendChild( renderStackBadge(noteX, noteY, column.stackCount, column.stackColors) ); } g.appendChild(colGroup); }); return g; } // Issue #51: das Reload-Symbol sitzt neben der Board-Ueberschrift, analog zum Zahnrad am // PO-Platz (renderPoConfigHint) - selbe "aus Grundformen statt Icon-Bibliothek gebaute // Silhouette" plus Hover-Hervorhebung, damit beide Hinweise als ein visuelles System wirken. function renderBoardReloadHint(onClick) { const cx = BOARD_FRAME.x + 210; const cy = BOARD_FRAME.y - 21; const g = el("g", { class: "board-reload-hint clickable", transform: `translate(${cx}, ${cy})` }); const title = document.createElementNS(SVG_NS, "title"); title.textContent = "Kanban-Board komplett neu einlesen"; g.appendChild(title); g.appendChild(el("circle", { cx: 0, cy: 0, r: 13, class: "board-reload-hint-bg" })); // Kreisbogen mit Luecke (grosser Bogen, eine Richtung) plus Pfeilspitze an dessen Anfang - // ergibt in dieser Groesse ein erkennbares "Reload"-Symbol. g.appendChild( el("path", { d: "M 6.5 -4 A 7.5 7.5 0 1 1 -7.2 -2.6", class: "board-reload-hint-arrow", }) ); g.appendChild( el("polygon", { points: "-7.2,-8.4 -1.4,-2.6 -9.8,-0.4", class: "board-reload-hint-arrowhead" }) ); // Groessere unsichtbare Trefferflaeche, analog zu .po-desk-hit - das sichtbare Symbol ist // fuer einen zuverlaessigen Klick zu klein. g.appendChild(el("rect", { x: -16, y: -16, width: 32, height: 32, class: "board-reload-hint-hit" })); g.addEventListener("click", (event) => { event.stopPropagation(); onClick(); }); return g; } function renderEventMonitor(frame, 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" })); g.appendChild( el("rect", { x: frame.x + 10, y: frame.y + 10, width: frame.w - 20, height: frame.h - 20, class: "monitor-screen", }) ); // Meldungen sind beliebig lang, der Monitor ist es nicht - einzeilig lief die Zeile ueber // den Rahmen hinaus in die Szene. Jedes Event bekommt deshalb zwei Zeilen: Zeit + System // oben, die Meldung eingerueckt darunter. Beide werden auf die Innenbreite gekuerzt, den // Volltext zeigt der -Tooltip beim Hover ueber das Event. const textX = frame.x + 18; const availableWidth = frame.w - 36; const messageIndent = 12; lines.forEach((eventLine, index) => { const blockY = frame.y + 38 + index * EVENT_BLOCK_H; const entry = el("g", { class: `monitor-event severity-${eventLine.severity}` }); const title = el("title"); title.textContent = formatEventLineText(eventLine); entry.appendChild(title); entry.appendChild( text(truncateToWidth(`${eventLine.time} ${eventLine.system}`, availableWidth), { x: textX, y: blockY, class: "monitor-event-line monitor-event-meta", }) ); entry.appendChild( text(truncateToWidth(eventLine.message, availableWidth - messageIndent), { x: textX + messageIndent, y: blockY + EVENT_MESSAGE_DY, class: "monitor-event-line monitor-event-message", }) ); g.appendChild(entry); }); return g; } function renderSystemMonitor(frame, systems, onConfigClick) { const tiles = buildSystemTiles(systems); const g = el("g", { class: "monitor monitor-systems" }); g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" })); g.appendChild(text("Systeme", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" })); g.appendChild(renderSystemConfigHint(frame, onConfigClick)); g.appendChild( el("rect", { x: frame.x + 10, 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); // Issue #73: Servernamen ueberlappten sich bei mehreren Kacheln, deshalb kein fest // eingezeichneter Text mehr - der Name erscheint nur noch als <title>-Tooltip, der ueber // der ganzen Kachel (system-tile-bg deckt sie komplett ab) beim Hoover angezeigt wird. const tileGroup = el("g", { class: `system-tile status-${system.status}` }); const tileTitle = el("title"); tileTitle.textContent = system.name; tileGroup.appendChild(tileTitle); tileGroup.appendChild( el("rect", { x: tileX, y: tileY, width: tileSize, height: tileSize, rx: 6, class: "system-tile-bg" }) ); 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" })); g.appendChild(tileGroup); }); return g; } 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" })); g.appendChild( el("rect", { x: frame.x + 10, y: frame.y + 10, width: frame.w - 20, height: frame.h - 20, class: `monitor-screen ${tiles.length === 0 ? "monitor-screen-placeholder" : ""}`, }) ); if (tiles.length === 0) { renderPlaceholderText(g, frame, DEMO_KPI_PLACEHOLDER); return g; } // Die Zeilen stehen direkt untereinander, statt die Monitorhoehe gleichmaessig unter den // Kacheln aufzuteilen: Zaehler-Kacheln wie "Agenten"/"Online" (Issue #39) und "Hooks" // (Issue #38) haben history: [] und damit keine Sparkline - verteilt man die Hoehe trotzdem, // klaffen zwischen den Werten grosse leere Sparkline-Kaesten. Ein Verlauf bekommt seinen // Platz deshalb nur, wenn es ihn wirklich gibt. const LABEL_HEIGHT = 20; // Passt zur Punkteberechnung in buildKpiSparklinePoints() (Default-Hoehe 32) - so fuellt die // Linie den Kasten genau aus. const SPARKLINE_HEIGHT = 32; const ROW_GAP = 4; let rowTop = frame.y + 20; tiles.forEach((tile) => { g.appendChild( text(`${tile.label}: ${tile.currentValue ?? "–"} ${tile.unit}`.trim(), { x: frame.x + 16, y: rowTop + 14, // Abgeleitete Kacheln koennen einen Schweregrad tragen ("Problem", siehe // buildAgentCountKpis) - dieselben Klassen wie die Event-Zeilen, also dieselben Farben. class: `kpi-tile-label${tile.severity ? ` severity-${tile.severity}` : ""}`, }) ); rowTop += LABEL_HEIGHT; if (tile.sparklinePoints) { g.appendChild(renderSparkline(tile.sparklinePoints, frame.w - 32, SPARKLINE_HEIGHT, frame.x + 16, rowTop)); rowTop += SPARKLINE_HEIGHT; } rowTop += ROW_GAP; }); return g; } function renderPlaceholderText(g, frame, 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", }) ); }); } /** * @param {{onEventsClick: () => void, onSystemsClick: () => void, onKpiClick: () => void, * onSystemsConfigClick: () => 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; const frames = [0, 1, 2].map((i) => ({ x: MONITOR_WALL.x + i * (monitorWidth + gap), y: MONITOR_WALL.y, w: monitorWidth, h: MONITOR_WALL.h, })); const eventsGroup = renderEventMonitor(frames[0], events); eventsGroup.classList.add("clickable"); eventsGroup.addEventListener("click", handlers.onEventsClick); const systemsGroup = renderSystemMonitor(frames[1], systems, handlers.onSystemsConfigClick); 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 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 }, }; } const DESK_NAME_PROBLEM_CLASS = { auth_expired: "desk-name-problem-error", auth_expiring: "desk-name-problem-warning", }; function deskScreenPoseClass(pose) { switch (pose) { case "working": return "pose-working"; case "auth_expired": case "auth_expiring": // Der Bildschirm faerbt sich mit - ein Schloss allein am Avatar waere weg, sobald der // Agent laeuft; der Platz selbst soll den Zustand ebenfalls tragen. return `pose-${pose}`; default: return "pose-idle"; } } // 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. Parametrisiert ueber origin/width, damit sowohl die regulaeren // Schreibtische (deskSeat()) als auch der feste PO-Platz (PO_DESK, Issue #19) dieselbe // Zeichenlogik/Deko (inkl. rollenabhaengiger desk.deco) nutzen koennen. function renderDeskAt(origin, agent, width = DESK.w) { const role = agent ? ROLES[agent.role] : null; const g = el("g", { class: `desk ${agent ? "desk-occupied" : "desk-empty"}`, transform: `translate(${origin.x}, ${origin.y})`, }); g.appendChild(el("rect", { x: 0, y: 40, width, 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 ? deskScreenPoseClass(agent.pose) : ""}`, }) ); g.appendChild(el("circle", { cx: width / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" })); if (role) { if (role.deco === "star") { g.appendChild( el("path", { d: `M${width - 50},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 = width - 45; 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: `M${width - 58},24 l8,8 16,-18`, class: "desk-deco desk-deco-check" }) ); } else if (role.deco === "gear") { const cx = width - 45; const cy = 22; // Zahnrad = Ring plus sechs Zaehne, jeder um 60 Grad um die Mitte gedreht. const teeth = []; for (let i = 0; i < 6; i++) { teeth.push( el("rect", { x: cx - 2.5, y: cy - 15, width: 5, height: 7, rx: 1, transform: `rotate(${i * 60}, ${cx}, ${cy})`, }) ); } g.appendChild( el("g", { class: "desk-deco desk-deco-gear" }, [ ...teeth, el("circle", { cx, cy, r: 9 }), el("circle", { cx, cy, r: 3.5, class: "desk-deco-gear-center" }), ]) ); } // Blockierte Agenten faerben ihr Namensschild ein. Der Zustand steckt hier in der Pose, weil // renderDeskAt() den rohen Agenten gar nicht sieht - normalizeAgentForDisplay() hat state // bereits auf pose abgebildet. const problem = DESK_NAME_PROBLEM_CLASS[agent.pose] ?? ""; g.appendChild( text(agent.name, { x: width / 2, y: 40 + DESK.h + 74, class: `desk-name ${problem}`.trim(), "text-anchor": "middle", }) ); } else { g.appendChild( text("frei", { x: width / 2, y: 40 + DESK.h + 74, class: "desk-name desk-name-empty", "text-anchor": "middle", }) ); } return g; } function renderDesk({ deskIndex, agent }) { return renderDeskAt(deskSeat(deskIndex).origin, agent); } /** * @param {Array<object>} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1). * @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer * API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19). * @param {() => void} onPoClick - oeffnet das Projekte-Modal (Issue #27). */ function renderDesks(desks, poAgent, onPoClick) { const g = el("g", { class: "desks" }); desks.forEach((desk) => g.appendChild(renderDesk(desk))); const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w); poDeskGroup.classList.add("po-desk", "clickable"); poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten"); poDeskGroup.appendChild(renderPoConfigHint()); // Eine <g> selbst faengt keine Klicks - nur ihre gefuellten Kinder. Zwischen Tischplatte, // Stuhl und Namensschild liegen Luecken, in denen ein Klick "auf den PO" beim Boden landete // (Fehlerbericht zu Issue #27). Diese unsichtbare Flaeche deckt den ganzen Platz ab, analog // zu .board-column-hit bei den Board-Spalten. poDeskGroup.appendChild( el("rect", { x: -10, y: 0, width: PO_DESK.w + 20, height: 40 + DESK.h + 90, class: "po-desk-hit", }) ); poDeskGroup.addEventListener("click", onPoClick); g.appendChild(poDeskGroup); return g; } // Dass hinter dem PO die Projektkonfiguration steckt, war bisher nirgends zu sehen - der // Fehlerbericht zu Issue #27 ("damit ich den Konfig Dialog irgendwie oeffnen kann") war // deshalb auch eine Frage der Auffindbarkeit. Das Zahnrad sitzt auf der Tischplatte des // PO-Platzes; klickbar ist weiterhin der ganze Platz (und der PO selbst). <title> ist der // SVG-eigene Tooltip. function renderPoConfigHint() { const cx = PO_DESK.w - 34; const cy = 40 + DESK.h / 2; const g = el("g", { class: "po-config-hint", transform: `translate(${cx}, ${cy})` }); const title = document.createElementNS(SVG_NS, "title"); title.textContent = "Projekte konfigurieren"; g.appendChild(title); gearHintShapes().forEach((node) => g.appendChild(node)); return g; } /** * Zahnrad aus acht Zaehnen plus Nabe - in dieser Groesse von einem exakten Zahnradpfad nicht * zu unterscheiden und ohne fremde Icon-Bibliothek zu haben. Gemeinsame Silhouette fuer alle * Konfig-Hinweise (PO-Platz, Issue #27; Systeme-Monitor, Issue #70): Dass ueberall dasselbe * Symbol fuer "hier laesst sich etwas einstellen" steht, ist der halbe Wiedererkennungswert. * Die Farben kommen ueber die Klassen config-hint-*; das Hover-Verhalten haengt dagegen an * der umgebenden Gruppe, weil es sich unterscheidet (der PO reagiert auf den ganzen * Schreibtisch, das Monitor-Zahnrad nur auf sich selbst). * * @param {number} scale - 1 = die Groesse am PO-Platz (Radius 15). */ function gearHintShapes(scale = 1) { const nodes = [el("circle", { cx: 0, cy: 0, r: 15 * scale, class: "config-hint-bg" })]; for (let i = 0; i < 8; i++) { const angle = (i * Math.PI) / 4; nodes.push( el("line", { x1: Math.cos(angle) * 5.5 * scale, y1: Math.sin(angle) * 5.5 * scale, x2: Math.cos(angle) * 9.5 * scale, y2: Math.sin(angle) * 9.5 * scale, class: "config-hint-tooth", }) ); } nodes.push(el("circle", { cx: 0, cy: 0, r: 5 * scale, class: "config-hint-hub" })); return nodes; } /** * Issue #70: Zahnrad neben der Ueberschrift "Systeme" - oeffnet die Verwaltung der * ueberwachten Hosts. Sitzt an derselben Stelle wie das Reload-Symbol am Board * (renderBoardReloadHint), damit beide Bedienelemente ein System ergeben. */ function renderSystemConfigHint(frame, onClick) { const g = el("g", { class: "system-config-hint clickable", transform: `translate(${frame.x + 128}, ${frame.y - 15})`, }); const title = document.createElementNS(SVG_NS, "title"); title.textContent = "Überwachte Hosts konfigurieren"; g.appendChild(title); // 13/15 = etwas kleiner als am PO-Platz, passend zum Reload-Symbol am Board. gearHintShapes(13 / 15).forEach((node) => g.appendChild(node)); // Groessere unsichtbare Trefferflaeche, analog zu .board-reload-hint-hit. g.appendChild(el("rect", { x: -16, y: -16, width: 32, height: 32, class: "system-config-hint-hit" })); g.addEventListener("click", (event) => { // Der ganze Monitor traegt einen Klick-Handler (Detailliste der Systeme) - ohne das // Stoppen laege ueber der frisch geoeffneten Host-Verwaltung sofort das Detail-Modal. event.stopPropagation(); onClick(); }); return g; } // --------------------------------------------------------------------------------------- // 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.DOOR: return DOOR_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" })); } else if (pose === "auth_expired" || pose === "auth_expiring") { nodes.push(renderAuthLock()); } return nodes; } // Schloss ueber dem Kopf: der Agent sitzt noch da, kommt aber nicht mehr rein (abgelaufene // Claude-Anmeldung, siehe AGENT_PROBLEM_STATES). Bewusst ein Symbol statt nur einer Einfaerbung - // auf dem Wandmonitor aus einigen Metern Entfernung ist eine Silhouette schneller zu erfassen // als ein Farbton. Farbe und Pulsieren kommen ueber die Pose-Klasse aus dem Stylesheet. function renderAuthLock() { const g = el("g", { class: "person-prop-lock", transform: "translate(0, -74)" }); // Buegel: Halbkreis oben, offen nach unten - sitzt auf dem Korpus auf. g.appendChild(el("path", { d: "M-6,0 v-5 a6,6 0 0 1 12,0 v5", class: "person-prop-lock-shackle" })); g.appendChild(el("rect", { x: -9, y: 0, width: 18, height: 14, rx: 2, class: "person-prop-lock-body" })); return g; } 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(); if (id === PO_AGENT_ID) { makePoAvatarClickable(node); } 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); if (id === PO_AGENT_ID) { // updateAgentAvatarElement() setzt class komplett neu (Pose) - ohne diese Zeile // verloere der PO die Klickbarkeit beim ersten Zustandswechsel. node.classList.add("clickable"); } }); for (const [id, node] of agentAvatarElements) { if (!seen.has(id)) { node.remove(); agentAvatarElements.delete(id); clearIdleCosmeticTimer(id); } } } // Das Ticket sagt woertlich "bei Klick auf den PO" - gemeint ist die Figur, nicht ihr // Schreibtisch. Der Avatar liegt in einer eigenen Schicht ueber der Szene und hatte bisher // keinen Klick-Handler; ein Klick auf den PO selbst lief damit ins Leere. Beides ist jetzt // klickbar, auch wenn der PO gerade am Board steht statt an seinem Platz. function makePoAvatarClickable(node) { const title = document.createElementNS(SVG_NS, "title"); title.textContent = "Projekte konfigurieren"; node.appendChild(title); node.addEventListener("click", openRepoConfigModal); } 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); } }); } // --------------------------------------------------------------------------------------- // Phase 6 - Tuerbesucher (F2: Rolle "user" kommt nur anlassbezogen durch die linke Tuer, // siehe detectDoorVisits() in office-scene.js). Laeuft unabhaengig vom Daten-Refresh-Takt // ueber eigene Timer, damit ein Besuch auch dann sauber zu Ende animiert, wenn zwischendurch // ein neuer SSE-/Polling-Refresh hereinkommt (siehe repaintAgentsLayer()). // --------------------------------------------------------------------------------------- let currentAgentsLayer = null; let currentRegularPlacements = []; const doorVisitors = new Map(); let doorVisitCounter = 0; function doorVisitVisual(visitor) { if (visitor.atDoor) { return { location: AGENT_LOCATIONS.DOOR, pose: "walking" }; } return resolveDoorVisitVisual(visitor.stage); } function doorVisitorPlacements() { return Array.from(doorVisitors.values()).map((visitor) => { const visual = doorVisitVisual(visitor); return { agent: { id: visitor.id, name: "User", role: "user", pose: visual.pose, location: visual.location }, homeFeet: DOOR_FEET, }; }); } // Kurzes visuelles Feedback (Tuer "leuchtet" auf), waehrend mindestens ein Besucher am // Tuerplatz steht - rein kosmetisch, siehe .door-active in office.css. function updateDoorActiveClass() { if (!currentAgentsLayer) return; const svg = currentAgentsLayer.closest("svg"); const doorLeft = svg && svg.querySelector(".door-left"); if (!doorLeft) return; const anyAtDoor = Array.from(doorVisitors.values()).some( (visitor) => doorVisitVisual(visitor).location === AGENT_LOCATIONS.DOOR ); doorLeft.classList.toggle("door-active", anyAtDoor); } // --------------------------------------------------------------------------------------- // Board-Besuch des zustaendigen Agenten bei "in_arbeit"-Wechseln (Issue #76): analog zum // PO-Besuch (triggerPoVisit()/advancePoVisit()), aber pro Agent statt eines einzelnen globalen // Zustands, weil mehrere Agenten gleichzeitig eigene Tickets verschieben koennen. Nutzt bewusst // dieselben Stages/denselben Ablauf wie PO_VISIT_STAGES/advancePoVisitStage (siehe // office-scene.js) - ein eigenes, inhaltsgleiches Stage-Array waere nur Duplikat. // // agentId -> {agent: {id, name, role}, homeFeet, stage, onArrived} const agentBoardVisitors = new Map(); function agentVisitorPlacements() { return Array.from(agentBoardVisitors.values()).map((visitor) => { const visual = resolveAgentVisualState(visitor.stage); return { agent: { ...visitor.agent, pose: visual.pose, location: visual.location }, homeFeet: visitor.homeFeet, }; }); } function advanceAgentBoardVisit(agentId) { const visitor = agentBoardVisitors.get(agentId); if (!visitor) { return; } const nextStage = advancePoVisitStage(visitor.stage); if (nextStage === null) { agentBoardVisitors.delete(agentId); repaintAgentsLayer(); return; } visitor.stage = nextStage; // Wie beim PO-/Tuerbesuch (Issue #55): erst am Board wird die zurueckgehaltene Ticket- // Aenderung sichtbar gemacht, danach wartet die Persona TICKET_PLACED_WAIT_MS, statt sofort // weiterzulaufen. if (visitor.stage === PO_VISIT_STAGES[1]) { const onArrived = visitor.onArrived; visitor.onArrived = null; onArrived?.(); repaintAgentsLayer(); window.setTimeout(() => advanceAgentBoardVisit(agentId), TICKET_PLACED_WAIT_MS); return; } repaintAgentsLayer(); window.setTimeout(() => advanceAgentBoardVisit(agentId), DOOR_VISIT_STAGE_DURATION_MS); } /** * Stoesst den sichtbaren Board-Besuch eines Entwickler-Agenten an (Issue #76). Laeuft bereits * einer fuer denselben Agenten (z.B. mehrere Tickets wechseln kurz hintereinander), wird kein * zweiter Besuch gestartet - onArrived wird sofort nachgeholt, damit die zurueckgehaltene(n) * Ticket-Aenderung(en) nicht dauerhaft verborgen bleiben. * * @param {number|string} agentId * @param {{id: number|string, name: string, role: string}} agentInfo * @param {{x: number, y: number}} homeFeet - der eigene Schreibtisch des Agenten. * @param {() => void} [onArrived] - wird aufgerufen, sobald der Agent am Board ankommt. */ function triggerAgentBoardVisit(agentId, agentInfo, homeFeet, onArrived) { if (agentBoardVisitors.has(agentId)) { onArrived?.(); return; } // Ein bereits laufender idle-Kosmetik-Ausflug (Kaffee/Zeitung/Schwaetzchen, siehe // scheduleIdleCosmetic()) wuerde sonst mitten im Board-Besuch die Pose/Position ueberschreiben. clearIdleCosmeticTimer(String(agentId)); agentBoardVisitors.set(agentId, { agent: agentInfo, homeFeet, stage: PO_VISIT_STAGES[0], onArrived }); repaintAgentsLayer(); window.setTimeout(() => advanceAgentBoardVisit(agentId), DOOR_VISIT_STAGE_DURATION_MS); } function repaintAgentsLayer() { if (!currentAgentsLayer) return; updateAgentsLayer(currentAgentsLayer, [ ...currentRegularPlacements, ...poPlacement(), ...doorVisitorPlacements(), ...agentVisitorPlacements(), ]); updateDoorActiveClass(); } // --------------------------------------------------------------------------------------- // Product Owner (Issue #19): fester Platz + Avatar, der bei "niemandem zugeordneten" // Ticket-Statuswechseln zum Board laeuft (siehe detectUnassignedTicketChanges() in // office-scene.js). Anders als die Tuerbesucher (mehrere gleichzeitig moeglich, Phase 6) gibt // es nur einen PO - poState ist daher ein einzelner Zustand statt einer Map. // --------------------------------------------------------------------------------------- let poIsLive = false; let poState = "idle"; let poVisitTimeoutId = null; // Issue #55: wird aufgerufen, sobald der laufende Besuch das Board erreicht - das ist der // richtige Zeitpunkt, um die bis dahin zurueckgehaltenen Ticket-Aenderungen sichtbar zu machen. let poOnArrived = null; function poPlacement() { if (!poIsLive) { return []; } const visual = resolveAgentVisualState(poState); return [ { agent: { id: PO_AGENT_ID, name: PO_DISPLAY_NAME, role: "product_owner", pose: visual.pose, location: visual.location }, homeFeet: PO_DESK_FEET, }, ]; } function advancePoVisit() { const nextStage = advancePoVisitStage(poState); if (nextStage === null) { poState = "idle"; repaintAgentsLayer(); return; } poState = nextStage; // Issue #55: erst wenn der PO am Board angekommen ist, wird das/die zurueckgehaltene(n) // Ticket(s) an ihre neue Stelle bewegt - danach wartet er TICKET_PLACED_WAIT_MS, bevor er // wieder losgeht, statt sofort weiterzulaufen. if (poState === PO_VISIT_STAGES[1]) { const onArrived = poOnArrived; poOnArrived = null; onArrived?.(); repaintAgentsLayer(); poVisitTimeoutId = window.setTimeout(advancePoVisit, TICKET_PLACED_WAIT_MS); return; } repaintAgentsLayer(); poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS); } // Mehrere gleichzeitige unzugeordnete Aenderungen (z.B. mehrere Tickets erhalten kurz // hintereinander das Label "automate") loesen bewusst nur einen Besuch aus, statt den PO fuer // jede einzelne Aenderung hin- und herlaufen zu lassen - ein laufender Besuch wird zu Ende // animiert, statt unterbrochen zu werden. // // @param {() => void} [onArrived] - wird aufgerufen, sobald der PO am Board ankommt (Issue #55). // Laeuft bereits ein Besuch, gibt es dafuer keine eigene Animation mehr - onArrived wird dann // sofort aufgerufen, damit die zurueckgehaltene Aenderung nicht dauerhaft verborgen bleibt. function triggerPoVisit(onArrived) { if (!poIsLive || poState !== "idle") { onArrived?.(); return; } poState = PO_VISIT_STAGES[0]; poOnArrived = onArrived; repaintAgentsLayer(); poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS); } // Issue #51: Board-Besuch fuer den manuellen Reload-Knopf. Anders als triggerPoVisit()/ // advancePoVisit() (feste Dauer je Stage) bleibt der PO in "interacting_with_board", bis die // uebergebene Aktion abgeschlossen ist - genau das macht die Standzeit am Board zur sichtbaren // Anzeige, wie lange das Neueinlesen tatsaechlich gedauert hat (Issue-Text: "daran kann man // erkennen, wie lange er dafuer benoetigt"). "walking_to_board" und "walking_back" behalten // weiterhin ihre feste Animationsdauer. // // Ist der PO gerade anderweitig unterwegs (poState !== "idle"), wird kein zweiter Besuch // angestossen - die Aktion laeuft trotzdem, nur ohne eigene Animation. function triggerPoReloadVisit(action) { if (!poIsLive || poState !== "idle") { action(); return; } poState = PO_VISIT_STAGES[0]; // walking_to_board repaintAgentsLayer(); poVisitTimeoutId = window.setTimeout(async () => { poState = PO_VISIT_STAGES[1]; // interacting_with_board repaintAgentsLayer(); try { await action(); } finally { poState = PO_VISIT_STAGES[2]; // walking_back repaintAgentsLayer(); poVisitTimeoutId = window.setTimeout(() => { poState = "idle"; repaintAgentsLayer(); }, DOOR_VISIT_STAGE_DURATION_MS); } }, DOOR_VISIT_STAGE_DURATION_MS); } // Wird von boot() gesetzt: laedt den Board-Zustand sofort neu, statt auf den naechsten // SSE-Push/Sicherheitsnetz-Poll zu warten (siehe office-realtime.js) - nach einem manuellen // Reload soll das Board unmittelbar den neuen Stand zeigen. let requestSceneRefresh = null; let boardResyncInFlight = false; async function handleBoardResync() { if (boardResyncInFlight) { return; } let adminKey = getStoredAdminKey(); if (!adminKey) { adminKey = window.prompt("Admin-Key für das Neueinlesen des Boards:") || ""; if (!adminKey) { return; } } boardResyncInFlight = true; triggerPoReloadVisit(async () => { try { await resyncTickets(adminKey); setStoredAdminKey(adminKey); await requestSceneRefresh?.(); } catch (err) { if (err && err.isAuthError) { setStoredAdminKey(""); } window.alert(err instanceof Error ? err.message : "Board konnte nicht neu eingelesen werden."); } finally { boardResyncInFlight = false; } }); } function advanceDoorVisit(id) { const visitor = doorVisitors.get(id); if (!visitor) { return; } if (visitor.atDoor) { visitor.atDoor = false; repaintAgentsLayer(); window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS); return; } const nextStage = advanceDoorVisitStage(visitor.stage); if (nextStage === null) { doorVisitors.delete(id); repaintAgentsLayer(); return; } visitor.stage = nextStage; // Issue #55: erst am Board ("interacting_with_board") wird das zurueckgehaltene Ticket an // seine neue Stelle bewegt - danach wartet die Persona TICKET_PLACED_WAIT_MS, bevor sie // ("leaving") wieder zur Tuer geht, statt sofort weiterzulaufen. if (visitor.stage === DOOR_VISIT_STAGES[1]) { visitor.onArrived?.(); visitor.onArrived = null; repaintAgentsLayer(); window.setTimeout(() => advanceDoorVisit(id), TICKET_PLACED_WAIT_MS); return; } repaintAgentsLayer(); window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS); } /** * @param {{type: string, ticketId: number}} visit * @param {() => void} [onArrived] - wird aufgerufen, sobald diese Persona am Board ankommt * (Issue #55) - der richtige Zeitpunkt, um das zurueckgehaltene Ticket sichtbar zu verschieben. */ function triggerDoorVisit(visit, onArrived) { doorVisitCounter += 1; const id = `door-visit-${visit.type}-${visit.ticketId}-${doorVisitCounter}`; doorVisitors.set(id, { id, stage: DOOR_VISIT_STAGES[0], atDoor: true, onArrived }); repaintAgentsLayer(); window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS); } // --------------------------------------------------------------------------------------- // 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}`; const isReachable = item.status === "ok"; const lastCheckText = isReachable && item.lastCheckAt ? ` · zuletzt erfolgreich geprüft: ${item.lastCheckAt}` : ""; li.textContent = `${item.name} (${item.type}) – ${isReachable ? "OK" : "Fehler"}${item.detail ? `: ${item.detail}` : ""}${lastCheckText}`; 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; } // --------------------------------------------------------------------------------------- // Projekte-Modal (Issue #27: Klick auf den PO -> Codeberg-Projekte des Sync verwalten) // // Anders als die uebrigen Modals (reine Anzeige eines einmal gebauten content-Objekts) ist // dieses Modal interaktiv (Hinzufuegen/Bearbeiten/Loeschen mit Server-Rundtrip) und muss sich // nach jeder Aenderung selbst neu zeichnen, ohne das <dialog> zu schliessen. openModal() ruft // den renderBody-Callback dafuer nur einmal mit dem echten Body-Element auf; das Element wird // hier in repoModalBodyEl gemerkt und fuer spaetere Refreshes wiederverwendet. // --------------------------------------------------------------------------------------- const REPO_CONFIG_ICONS = { add: "M12 5v14M5 12h14", edit: "M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z", delete: "M4 7h16M9 7V4.5A1.5 1.5 0 0 1 10.5 3h3A1.5 1.5 0 0 1 15 4.5V7m2 0-.8 12.1a2 2 0 0 1-2 1.9H9.8a2 2 0 0 1-2-1.9L7 7", confirm: "M20 6 9 17l-5-5", cancel: "M18 6 6 18M6 6l12 12", }; // Issue #53: Symbole fuer die Konfig-Pruefung (Schreibrechte + Webhook) rechts neben den // Bearbeiten-/Loeschen-Symbolen einer Projektzeile - gruener Haken bzw. rotes Kreuz je Check. const REPO_CHECK_ICONS = { ok: "M20 6 9 17l-5-5", fail: "M18 6 6 18M6 6l12 12", }; let repoModalBodyEl = null; let repoModalToken = null; let repoModalRepos = []; let repoModalMode = null; // null | "new" | <id des bearbeiteten Projekts> let repoModalError = ""; // Issue #53: Ergebnis der Konfig-Pruefung je Projekt-Id, ausschliesslich fuer die Dauer der // Modal-Sitzung (kein Server-State) - { write_access: {ok, message}, webhook: {ok, message} }. // Fehlt ein Eintrag, wurde das Projekt in dieser Sitzung noch nicht (erfolgreich) geprueft. let repoModalChecks = {}; function iconButton(kind, title, onClick, extraClass = "") { const button = document.createElement("button"); button.type = "button"; button.className = `icon-button ${extraClass}`.trim(); button.title = title; button.setAttribute("aria-label", title); button.appendChild( el("svg", { viewBox: "0 0 24 24", class: "icon-button-svg", "aria-hidden": "true" }, [ el("path", { d: REPO_CONFIG_ICONS[kind], fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", }), ]) ); button.addEventListener("click", onClick); return button; } function openRepoConfigModal() { repoModalRepos = []; repoModalMode = null; repoModalError = ""; repoModalChecks = {}; repoModalToken = openModal({ title: "Projekte" }, (body) => { repoModalBodyEl = body; renderRepoConfigModal(); }); loadRepoConfigModal(repoModalToken); } async function loadRepoConfigModal(token) { let repos = null; let error = ""; try { repos = await fetchRepoConfigs(); } catch { error = "Projekte konnten nicht geladen werden."; } if (!isCurrentModal(token)) { return; } if (repos !== null) { repoModalRepos = repos; } repoModalError = error; renderRepoConfigModal(); } function renderRepoConfigModal() { const body = repoModalBodyEl; if (!body) return; body.replaceChildren(); if (repoModalError) { const errorEl = document.createElement("p"); errorEl.className = "office-modal-error"; errorEl.textContent = repoModalError; body.appendChild(errorEl); } if (repoModalRepos.length === 0 && repoModalMode !== "new") { body.appendChild(emptyMessage("Keine Projekte konfiguriert.")); } else { const list = document.createElement("ul"); list.className = "office-modal-list repo-config-list"; for (const repo of repoModalRepos) { list.appendChild( repoModalMode === repo.id ? renderRepoConfigForm(repo) : renderRepoConfigRow(repo) ); } body.appendChild(list); } if (repoModalMode === "new") { let list = body.querySelector(".repo-config-list"); if (!list) { list = document.createElement("ul"); list.className = "office-modal-list repo-config-list"; body.appendChild(list); } list.appendChild(renderRepoConfigForm(null)); } else { const addButton = iconButton("add", "Projekt hinzufügen", () => { repoModalMode = "new"; renderRepoConfigModal(); }); addButton.classList.add("repo-config-add-button"); body.appendChild(addButton); } } function renderRepoConfigRow(repo) { const li = document.createElement("li"); li.className = "office-modal-item repo-config-item"; const swatch = document.createElement("span"); swatch.className = "repo-config-swatch"; swatch.style.backgroundColor = repo.color; li.appendChild(swatch); const label = document.createElement("span"); label.className = "repo-config-slug"; label.textContent = `${repo.owner}/${repo.repo}`; li.appendChild(label); const actions = document.createElement("span"); actions.className = "repo-config-actions"; actions.appendChild( iconButton("edit", "Projekt bearbeiten", () => { repoModalMode = repo.id; renderRepoConfigModal(); }) ); actions.appendChild( iconButton("delete", "Projekt löschen", () => handleRepoConfigDelete(repo)) ); li.appendChild(actions); const checks = repoModalChecks[repo.id]; if (checks && checks.status === "done") { const status = document.createElement("span"); status.className = "repo-config-status"; status.appendChild(statusIcon("Schreibrechte", checks.writeAccess)); status.appendChild(statusIcon("Webhook", checks.webhook)); li.appendChild(status); } return li; } /** * Issue #53: einzelnes Ampel-Symbol (Haken/Kreuz) fuer eine Konfig-Pruefung. Der Tooltip * (title/aria-label) zeigt im Erfolgsfall, welcher Service geprueft wurde, im Fehlerfall den * konkreten Grund - beides liefert bereits RepoHealthChecker als "message". * * @param {string} label * @param {{ok: boolean, message: string}} result */ function statusIcon(label, result) { const span = document.createElement("span"); span.className = `repo-config-status-icon ${result.ok ? "is-ok" : "is-fail"}`; const tooltip = `${label}: ${result.message}`; span.title = tooltip; span.setAttribute("aria-label", tooltip); span.appendChild( el("svg", { viewBox: "0 0 24 24", class: "icon-button-svg", "aria-hidden": "true" }, [ el("path", { d: result.ok ? REPO_CHECK_ICONS.ok : REPO_CHECK_ICONS.fail, fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", }), ]) ); return span; } function renderRepoConfigForm(repo) { const li = document.createElement("li"); li.className = "office-modal-item repo-config-item repo-config-form"; const ownerInput = document.createElement("input"); ownerInput.type = "text"; ownerInput.placeholder = "Owner, z. B. fds2610"; ownerInput.value = repo?.owner ?? ""; ownerInput.setAttribute("aria-label", "Owner"); const repoInput = document.createElement("input"); repoInput.type = "text"; repoInput.placeholder = "Repo, z. B. monitor"; repoInput.value = repo?.repo ?? ""; repoInput.setAttribute("aria-label", "Repo-Name"); const colorInput = document.createElement("input"); colorInput.type = "color"; colorInput.value = repo?.color ?? "#999999"; colorInput.setAttribute("aria-label", "Farbe"); colorInput.title = "Farbe"; const fields = document.createElement("span"); fields.className = "repo-config-form-fields"; fields.append(ownerInput, repoInput, colorInput); li.appendChild(fields); const actions = document.createElement("span"); actions.className = "repo-config-actions"; const save = () => handleRepoConfigSave(repo, { owner: ownerInput.value.trim(), repo: repoInput.value.trim(), color: colorInput.value, }); actions.appendChild(iconButton("confirm", "Speichern", save)); actions.appendChild( iconButton("cancel", "Abbrechen", () => { repoModalMode = null; renderRepoConfigModal(); }) ); li.appendChild(actions); // Enter in einem der Textfelder wuerde sonst das <form method="dialog"> ueber dessen // regulaeren Submit-Button (den Schliessen-"×" im Modal-Header) einreichen und das ganze // Modal schliessen, statt nur dieses Formular zu speichern. const saveOnEnter = (event) => { if (event.key === "Enter") { event.preventDefault(); save(); } }; ownerInput.addEventListener("keydown", saveOnEnter); repoInput.addEventListener("keydown", saveOnEnter); window.setTimeout(() => ownerInput.focus(), 0); return li; } async function handleRepoConfigSave(existing, data) { const token = repoModalToken; if (!data.owner || !data.repo) { repoModalError = 'Felder "Owner" und "Repo" sind erforderlich.'; renderRepoConfigModal(); return; } const saved = await withAdminKeyResult((adminKey) => existing ? updateRepoConfig(existing.id, data, adminKey) : createRepoConfig(data, adminKey) ); if (!isCurrentModal(token)) { return; } if (!saved) { renderRepoConfigModal(); return; } repoModalMode = null; await loadRepoConfigModal(token); // Issue #53: nach dem Speichern pruefen, ob fds2610-bot Schreibrechte hat und der Webhook // korrekt eingerichtet ist - Ergebnis erscheint als Haken/Kreuz rechts neben den Symbolen. await runRepoConfigCheck(saved.id); } /** * Issue #53: fuehrt die Konfig-Pruefung (Schreibrechte + Webhook) fuer ein Projekt aus und * merkt das Ergebnis in repoModalChecks fuer renderRepoConfigRow(). Schlaegt schon der * Admin-Key-Rundtrip fehl (z.B. Prompt abgebrochen), bleibt das Projekt einfach ungeprueft * (kein Eintrag) statt faelschlich ein rotes Kreuz zur Projekt-Gesundheit zu zeigen. */ async function runRepoConfigCheck(repoId) { const token = repoModalToken; const checks = await withAdminKeyResult((adminKey) => checkRepoConfig(repoId, adminKey)); if (!isCurrentModal(token)) { return; } if (checks) { repoModalChecks[repoId] = { status: "done", writeAccess: checks.write_access, webhook: checks.webhook }; } renderRepoConfigModal(); } async function handleRepoConfigDelete(repo) { const token = repoModalToken; if (!window.confirm(`Projekt "${repo.owner}/${repo.repo}" wirklich entfernen?`)) { return; } const ok = await withAdminKey((adminKey) => deleteRepoConfig(repo.id, adminKey)); if (!isCurrentModal(token)) { return; } if (!ok) { renderRepoConfigModal(); return; } await loadRepoConfigModal(token); } /** * Fuehrt eine schreibende Aktion mit dem Admin-Key aus (aus dem lokalen Speicher oder frisch * per Prompt erfragt, siehe office-api.js). Bei 401 wird der gemerkte Key verworfen, damit * der naechste Versuch erneut fragt statt denselben ungueltigen Wert wiederzuverwenden. * * @param {(adminKey: string) => Promise<unknown>} action * @param {{setError: (message: string) => void, prompt: string}} [ui] - wohin die * Fehlermeldung gehoert und wonach der Prompt fragt. Beides haengt am aufrufenden Modal * (Projekte, Issue #27 - Hosts, Issue #70), die Key-Logik ist fuer beide dieselbe. * @returns {Promise<boolean>} */ async function withAdminKey(action, ui = REPO_MODAL_ADMIN_UI) { let adminKey = getStoredAdminKey(); if (!adminKey) { adminKey = window.prompt(ui.prompt) || ""; if (!adminKey) { return false; } } try { await action(adminKey); setStoredAdminKey(adminKey); return true; } catch (err) { if (err && err.isAuthError) { setStoredAdminKey(""); ui.setError("Admin-Key ungültig."); } else { ui.setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen."); } return false; } } const REPO_MODAL_ADMIN_UI = { setError: (message) => { repoModalError = message; }, prompt: "Admin-Key für Änderungen an Projekten:", }; const HOST_MODAL_ADMIN_UI = { setError: (message) => { hostModalError = message; }, prompt: "Admin-Key für Änderungen an den überwachten Hosts:", }; /** * Wie withAdminKey(), gibt bei Erfolg aber den Rueckgabewert von action() zurueck statt nur * true - fuer Aufrufer, die (anders als Speichern/Loeschen, die den Zustand ohnehin per * loadRepoConfigModal() neu laden) das Ergebnis selbst brauchen (Issue #53: Konfig-Pruefung). * * @template T * @param {(adminKey: string) => Promise<T>} action * @returns {Promise<T|undefined>} */ async function withAdminKeyResult(action, ui = REPO_MODAL_ADMIN_UI) { let adminKey = getStoredAdminKey(); if (!adminKey) { adminKey = window.prompt(ui.prompt) || ""; if (!adminKey) { return undefined; } } try { const value = await action(adminKey); setStoredAdminKey(adminKey); return value; } catch (err) { if (err && err.isAuthError) { setStoredAdminKey(""); ui.setError("Admin-Key ungültig."); } else { ui.setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen."); } return undefined; } } // --------------------------------------------------------------------------------------- // Hosts-Modal (Issue #70: Zahnrad am Wandmonitor "Systeme" -> Ping-Ueberwachung verwalten) // // Aufbau wie das Projekte-Modal (Issue #27): interaktiv, mit Server-Rundtrip, zeichnet sich // nach jeder Aenderung selbst neu. Zwei Abschnitte, weil es zwei Arten von Kacheln gibt: // oben die Hosts, die ein OPs-Agent per Ping prueft (die hier wirklich gepflegt werden), // unten die uebrigen Kacheln des System-Monitors - fuer die kann die GUI nur aufraeumen, // denn ihre Meldung kommt von woanders. // --------------------------------------------------------------------------------------- let hostModalBodyEl = null; let hostModalToken = null; let hostModalHosts = []; let hostModalSystems = []; // Nur Agenten der Rolle "ops" - nur sie pingen, alle anderen haetten keine hosts.conf. let hostModalOpsAgents = []; let hostModalMode = null; // null | "new" | <id des bearbeiteten Hosts> let hostModalError = ""; function openHostConfigModal(systems = []) { hostModalHosts = []; hostModalSystems = Array.isArray(systems) ? systems : []; hostModalOpsAgents = []; hostModalMode = null; hostModalError = ""; hostModalToken = openModal({ title: "Überwachte Hosts" }, (body) => { hostModalBodyEl = body; renderHostConfigModal(); }); loadHostConfigModal(hostModalToken); } async function loadHostConfigModal(token) { let loaded = null; let error = ""; try { const [hosts, agents, systems] = await Promise.all([fetchHostConfigs(), fetchAgents(), fetchSystems()]); loaded = { hosts, agents, systems }; } catch { error = "Hosts konnten nicht geladen werden."; } if (!isCurrentModal(token)) { return; } if (loaded !== null) { hostModalHosts = loaded.hosts; hostModalOpsAgents = loaded.agents.filter((agent) => agent.role === "ops"); hostModalSystems = loaded.systems; } hostModalError = error; renderHostConfigModal(); } function renderHostConfigModal() { const body = hostModalBodyEl; if (!body) return; body.replaceChildren(); if (hostModalError) { const errorEl = document.createElement("p"); errorEl.className = "office-modal-error"; errorEl.textContent = hostModalError; body.appendChild(errorEl); } // Ohne registrierten OPs-Agenten gibt es niemanden, der die Liste abholt und pingt - ein // hier angelegter Host waere wirkungslos. Das sagt das Modal lieber, als ein Formular zu // zeigen, das serverseitig ohnehin an der Rollenpruefung scheitert. if (hostModalOpsAgents.length === 0) { const hint = document.createElement("p"); hint.className = "office-modal-empty"; hint.textContent = "Kein OPs-Agent registriert. Der Ping-Check meldet sich mit einem eigenen Agenten an – " + "einzurichten mit register-ops-agent.sh auf dem Rechner, der die Hosts anpingt."; body.appendChild(hint); } if (hostModalHosts.length === 0 && hostModalMode !== "new") { body.appendChild(emptyMessage("Keine Hosts konfiguriert.")); } else { const list = document.createElement("ul"); list.className = "office-modal-list repo-config-list host-config-list"; for (const host of hostModalHosts) { list.appendChild(hostModalMode === host.id ? renderHostConfigForm(host) : renderHostConfigRow(host)); } body.appendChild(list); } if (hostModalMode === "new") { let list = body.querySelector(".host-config-list"); if (!list) { list = document.createElement("ul"); list.className = "office-modal-list repo-config-list host-config-list"; body.appendChild(list); } list.appendChild(renderHostConfigForm(null)); } else if (hostModalOpsAgents.length > 0) { const addButton = iconButton("add", "Host hinzufügen", () => { hostModalMode = "new"; renderHostConfigModal(); }); addButton.classList.add("repo-config-add-button"); body.appendChild(addButton); } renderOrphanSystems(body); } function renderOrphanSystems(body) { const orphans = systemsWithoutHostConfig(hostModalSystems, hostModalHosts); if (orphans.length === 0) { return; } const heading = document.createElement("h3"); heading.className = "host-config-subheading"; heading.textContent = "Weitere Kacheln im System-Monitor"; body.appendChild(heading); const note = document.createElement("p"); note.className = "office-modal-empty"; note.textContent = "Gehören zu keinem hier gepflegten Host: Sie melden sich selbst per REST – oder ihr " + "letzter Stand steht noch in der Datenbank, obwohl sie niemand mehr prüft."; body.appendChild(note); const list = document.createElement("ul"); list.className = "office-modal-list repo-config-list host-config-list"; for (const system of orphans) { const li = document.createElement("li"); li.className = `office-modal-item repo-config-item status-${system.status}`; const label = document.createElement("span"); label.className = "repo-config-slug"; label.textContent = system.detail ? `${system.name} (${system.detail})` : system.name; li.appendChild(label); const actions = document.createElement("span"); actions.className = "repo-config-actions"; actions.appendChild(iconButton("delete", "Kachel entfernen", () => handleSystemDelete(system))); li.appendChild(actions); list.appendChild(li); } body.appendChild(list); } function renderHostConfigRow(host) { const li = document.createElement("li"); li.className = "office-modal-item repo-config-item"; const label = document.createElement("span"); label.className = "repo-config-slug"; label.textContent = `${host.hostname} · ${host.address}`; li.appendChild(label); // Wer den Host anpingt, steht nur dann dabei, wenn es ueberhaupt mehrere Ping-Rechner // gibt - sonst waere es bei jeder Zeile dieselbe Auskunft. if (hostModalOpsAgents.length > 1) { const agent = document.createElement("span"); agent.className = "host-config-agent"; agent.textContent = host.agent_name; li.appendChild(agent); } const actions = document.createElement("span"); actions.className = "repo-config-actions"; actions.appendChild( iconButton("edit", "Host bearbeiten", () => { hostModalMode = host.id; renderHostConfigModal(); }) ); actions.appendChild(iconButton("delete", "Host entfernen", () => handleHostConfigDelete(host))); li.appendChild(actions); return li; } function renderHostConfigForm(host) { const li = document.createElement("li"); li.className = "office-modal-item repo-config-item repo-config-form"; const hostnameInput = document.createElement("input"); hostnameInput.type = "text"; hostnameInput.placeholder = "Name, z. B. Webserver"; hostnameInput.value = host?.hostname ?? ""; hostnameInput.setAttribute("aria-label", "Hostname"); const addressInput = document.createElement("input"); addressInput.type = "text"; addressInput.placeholder = "IP oder Adresse, z. B. 192.168.178.10"; addressInput.value = host?.address ?? ""; addressInput.setAttribute("aria-label", "IP-Adresse"); const fields = document.createElement("span"); fields.className = "repo-config-form-fields"; fields.append(hostnameInput, addressInput); // Den Ping-Rechner waehlt man nur beim Anlegen und nur, wenn es mehrere gibt; ein Wechsel // im Nachhinein ginge sonst still an der alten hosts.conf vorbei (siehe // HostConfigRepository::update). let agentSelect = null; if (host === null && hostModalOpsAgents.length > 1) { agentSelect = document.createElement("select"); agentSelect.setAttribute("aria-label", "Ping-Rechner"); agentSelect.title = "Welcher OPs-Agent diesen Host anpingt"; for (const agent of hostModalOpsAgents) { const option = document.createElement("option"); option.value = String(agent.id); option.textContent = agent.display_name; agentSelect.appendChild(option); } fields.appendChild(agentSelect); } li.appendChild(fields); const actions = document.createElement("span"); actions.className = "repo-config-actions"; const save = () => handleHostConfigSave(host, { hostname: hostnameInput.value.trim(), address: addressInput.value.trim(), agent_id: agentSelect ? Number(agentSelect.value) : hostModalOpsAgents[0]?.id, }); actions.appendChild(iconButton("confirm", "Speichern", save)); actions.appendChild( iconButton("cancel", "Abbrechen", () => { hostModalMode = null; renderHostConfigModal(); }) ); li.appendChild(actions); // Wie im Projekte-Formular: Enter wuerde sonst das <form method="dialog"> einreichen und // das ganze Modal schliessen, statt zu speichern. const saveOnEnter = (event) => { if (event.key === "Enter") { event.preventDefault(); save(); } }; hostnameInput.addEventListener("keydown", saveOnEnter); addressInput.addEventListener("keydown", saveOnEnter); window.setTimeout(() => hostnameInput.focus(), 0); return li; } async function handleHostConfigSave(existing, data) { const token = hostModalToken; if (!data.hostname || !data.address) { hostModalError = 'Felder "Name" und "IP" sind erforderlich.'; renderHostConfigModal(); return; } if (!existing && !data.agent_id) { hostModalError = "Kein OPs-Agent vorhanden, der diesen Host anpingen könnte."; renderHostConfigModal(); return; } const ok = await withAdminKey( (adminKey) => (existing ? updateHostConfig(existing.id, data, adminKey) : createHostConfig(data, adminKey)), HOST_MODAL_ADMIN_UI ); if (!isCurrentModal(token)) { return; } if (!ok) { renderHostConfigModal(); return; } hostModalMode = null; await loadHostConfigModal(token); } async function handleHostConfigDelete(host) { const token = hostModalToken; if ( !window.confirm( `Host "${host.hostname}" nicht mehr überwachen?\nDie Kachel im System-Monitor verschwindet mit.` ) ) { return; } const ok = await withAdminKey((adminKey) => deleteHostConfig(host.id, adminKey), HOST_MODAL_ADMIN_UI); if (!isCurrentModal(token)) { return; } if (!ok) { renderHostConfigModal(); return; } await loadHostConfigModal(token); } async function handleSystemDelete(system) { const token = hostModalToken; if (!window.confirm(`Kachel "${system.name}" aus dem System-Monitor entfernen?`)) { return; } const ok = await withAdminKey((adminKey) => deleteSystem(system.id, adminKey), HOST_MODAL_ADMIN_UI); if (!isCurrentModal(token)) { return; } if (!ok) { renderHostConfigModal(); return; } await loadHostConfigModal(token); } // --------------------------------------------------------------------------------------- // Datenbeschaffung + Orchestrierung // --------------------------------------------------------------------------------------- async function loadLiveData() { const [tickets, events, systems, kpis, agents, webhookStats] = await Promise.all([ fetchTickets(), fetchEvents(), fetchSystems(), fetchKpis(), fetchAgents(), fetchWebhookStats(), ]); return { tickets, events, systems, kpis, agents, hooksCount: webhookStats.count, 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 = [], hooksCount = null, isLive = false, } = data; 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(el("g", { class: "agents-layer" })); container.replaceChildren(svg); agentAvatarElements.clear(); idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId)); idleCosmeticTimers.clear(); doorVisitors.clear(); agentBoardVisitors.clear(); if (poVisitTimeoutId) window.clearTimeout(poVisitTimeoutId); poVisitTimeoutId = null; poState = "idle"; } // Der PO gilt als anwesend, sobald die API erreichbar ist (isLive) - nicht ueber das // is_present-Feld wie registrierte Agenten (Issue #19). poIsLive = isLive; // "Agenten"/"Online" (Issue #39) stehen vor den gemeldeten KPIs (F7), damit sie auf der // Wandkachel (MAX_KPI_TILES) immer sichtbar sind, statt von gemeldeten Kennzahlen verdraengt // zu werden. "Hooks" (Issue #38) reserviert sich zusaetzlich die letzte (rechte) Kachel - // hooksCount ist nur im Live-Betrieb gesetzt (loadLiveData()), im Demo-Fallback bleibt die // Kachel bewusst weg statt eine erfundene Zahl zu zeigen (wie DEMO_KPI_PLACEHOLDER es fuer // gemeldete KPIs bereits vormacht). const hooksKpi = typeof hooksCount === "number" ? buildHooksKpi(hooksCount) : null; const baseSlots = hooksKpi ? MAX_KPI_TILES - 1 : MAX_KPI_TILES; const baseKpis = [...buildAgentCountKpis(agents), ...kpis].slice(0, baseSlots); const displayKpis = hooksKpi ? [...baseKpis, hooksKpi] : baseKpis; const groupedTickets = groupTicketsByColumn(tickets); const boardGroup = renderBoard( ticketCounts, groupedTickets, (columnKey) => { openModal( buildTicketModalContent(columnKey, groupedTickets[columnKey] || []), renderTicketModalBody ); }, handleBoardResync ); 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, displayKpis, { onEventsClick: () => openModal(buildEventModalContent(events), renderEventModalBody), onSystemsClick: () => openModal(buildSystemModalContent(systems), renderSystemModalBody), // Issue #70: die aktuell gezeigten Systeme wandern mit ins Modal - daran erkennt es, // welche Kacheln zu keinem konfigurierten Host mehr gehoeren. onSystemsConfigClick: () => openHostConfigModal(systems), onKpiClick: () => openModal(buildKpiModalContent(displayKpis), renderKpiModalBody), }); const existingWall = svg.querySelector(".monitor-wall"); if (existingWall) { existingWall.replaceWith(wallGroup); if (!firstRender) flash(wallGroup); } else { svg.insertBefore(wallGroup, svg.querySelector(".agents-layer")); } // Issue #76: waehrend ein Agent sichtbar zum Board unterwegs ist (siehe agentBoardVisitors // oben), soll sein Schreibtisch leer wirken statt weiterhin besetzt - genau wie bei einem // Tuer-/PO-Besuch verlaesst die Person sichtbar ihren Platz. const visitingAgentIds = new Set(agentBoardVisitors.keys()); const deskAgents = normalizedDeskAgents(agents).map((agent) => agent && visitingAgentIds.has(agent.id) ? null : agent ); const deskLayout = buildDeskLayout(deskAgents); const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null; // Auch ohne erreichbare API klickbar: das Modal sagt dann selbst, dass es die Projekte // nicht laden konnte. Ein toter PO waere in genau der Lage, in der man nachsehen will, // die schlechtere Antwort (Fehlerbericht zu Issue #27). const desksGroup = renderDesks(deskLayout, poDeskAgent, openRepoConfigModal); const existingDesks = svg.querySelector(".desks"); if (existingDesks) { existingDesks.replaceWith(desksGroup); } else { svg.insertBefore(desksGroup, svg.querySelector(".agents-layer")); } const placements = agentPlacements(agents, deskLayout, visitingAgentIds); currentAgentsLayer = svg.querySelector(".agents-layer"); currentRegularPlacements = placements; repaintAgentsLayer(); scheduleIdleCosmeticsForCurrentAgents(placements); // Demo-Agenten kennen kein state-Feld und loesen den Banner damit nie aus - eine erfundene // Anmelde-Warnung waere im Fallback so falsch wie eine erfundene Kennzahl. setAgentProblemBanner(agents); } // 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)); } // excludeIds (Issue #76): Agenten, die gerade sichtbar zum Board unterwegs sind (siehe // agentBoardVisitors) - agentVisitorPlacements() steuert ihre Platzierung, sie duerfen hier // nicht zusaetzlich (z.B. als Ruheplatz-Ueberlauf mit ihrem regulaeren state) auftauchen, sonst // gaebe es denselben Agenten doppelt in der Szene. function agentPlacements(agents, deskLayout, excludeIds = new Set()) { 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; if (excludeIds.has(raw.id)) continue; // isAgentVisible statt isAgentPresent, wie bei den Schreibtischen (mapAgentsToDesks): auch // ein blockierter Agent ohne festen Platz muss sichtbar bleiben. if (!isAgentVisible(raw)) 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; } // Der Demo-Fallback unten sieht auf den ersten Blick wie eine echte Szene aus (fremde // Agenten-Namen, erfundene Ticketzahlen, aber leere Detail-Modals). Damit das auf einem // Produktiv-Monitor nicht fuer echte Daten gehalten wird, blendet der Fallback zusaetzlich // einen roten Banner ein - und wieder aus, sobald die API antwortet. function setApiBanner(visible) { const banner = document.getElementById("api-status-banner"); if (banner) { banner.hidden = !visible; } } // Ein blockierter Agent ist der einzige Zustand, den der Monitor nicht aussitzen kann: er loest // sich nicht von selbst, sondern erst, wenn sich jemand neu anmeldet. Deshalb zusaetzlich zum // Schloss am Avatar ein Banner im Header - der ist auch dann noch zu lesen, wenn der Monitor // quer durch den Raum haengt und die Szene nur noch als Muster wahrgenommen wird. function setAgentProblemBanner(agents) { const banner = document.getElementById("agent-problem-banner"); if (!banner) { return; } const names = (severity) => agentsWithProblems(agents) .filter((agent) => agentProblemSeverity(agent) === severity) .map((agent) => agent.display_name ?? agent.name ?? `Agent ${agent.id}`) .join(", "); const expired = names("error"); const expiring = names("warning"); const lines = []; if (expired) lines.push(`Anmeldung abgelaufen: ${expired}`); if (expiring) lines.push(`Anmeldung läuft ab: ${expiring}`); banner.textContent = lines.join(" · "); banner.hidden = lines.length === 0; banner.classList.toggle("api-banner-warning", !expired && Boolean(expiring)); } // Rein kosmetischer Countdown im Header (Issue #12): zaehlt unabhaengig vom tatsaechlichen // SSE-/Polling-Takt von MONITOR_SYNC_INTERVAL (aus config.js, siehe office.html) auf 0 // herunter und startet danach von vorn - loest selbst keinen Reload aus. function startReloadCountdown() { const countdownEl = document.getElementById("reload-countdown"); if (!countdownEl) { return; } const intervalSeconds = resolveReloadIntervalSeconds(window.MONITOR_SYNC_INTERVAL); let remaining = intervalSeconds; countdownEl.textContent = `${remaining}s`; window.setInterval(() => { remaining = nextCountdownSeconds(remaining, intervalSeconds); countdownEl.textContent = `${remaining}s`; }, 1000); } async function boot() { const container = document.getElementById("office-scene"); if (!container) { return; } // Vorheriger Ticket-Snapshot fuer detectDoorVisits() (Phase 6, F2) - bleibt null im // Demo-Fallback (kein "echter" Anlass ohne Live-API) und wird nur bei erreichbarer API // gepflegt, damit die Tuerbesuche ausschliesslich auf echten Zustandswechseln beruhen. let previousTickets = null; // Issue #55: letzter vollstaendige API-Stand, damit commitTicketMoves() die Szene ohne neuen // Fetch neu zeichnen kann, sobald eine Persona am Board ankommt. let lastLiveData = null; // ticketId -> alter Ticket-Stand (null bei einem brandneuen Ticket, siehe // applyPendingTicketMoves() in office-scene.js). Ein Eintrag bleibt bestehen, bis die // zugehoerige Persona (Tuerbesucher oder PO) das Board erreicht hat - vorher wird das Ticket // weiterhin an seiner alten Stelle (bzw. bei neuen Tickets: gar nicht) angezeigt. const pendingTicketMoves = new Map(); // Issue #61/#76: ticketId -> zustaendiger agentId fuer zurueckgehaltene "in_arbeit"-Wechsel // (siehe detectAgentOwnedTicketChanges() in office-scene.js), fuer die ausnahmsweise KEIN // selbst getakteter Board-Besuch laeuft (triggerAgentBoardVisit() unten deckt den Regelfall // ab) - entweder weil der Agent seine Bewegung bereits selbst meldet // (isAgentReportingBoardVisit()) oder weil kein Schreibtisch fuer eine Animation bekannt ist. // Dafuer gibt es keinen eigenen onArrived-Callback - der Agent meldet seine Ankunft am Board // unabhaengig ueber seinen eigenen state, daher wird bei jedem refresh() erneut geprueft // (siehe resolvePendingAgentTicketMoves()). const pendingAgentTicketMoves = new Map(); // Issue #61: entlaesst genau die zurueckgehaltenen "in_arbeit"-Wechsel, deren zustaendiger // Agent laut aktuellstem Poll bereits am Board angekommen ist (oder nicht mehr feststellbar // ist) - siehe hasAgentReachedBoard(). Laeuft bei jedem refresh(), nicht nur beim Erkennen der // Aenderung, da die Ankunft typischerweise erst in einem spaeteren Poll sichtbar wird. function resolvePendingAgentTicketMoves(agents) { if (pendingAgentTicketMoves.size === 0) { return; } const agentsById = new Map(agents.map((agent) => [agent.id, agent])); for (const [ticketId, agentId] of pendingAgentTicketMoves) { const agent = agentsById.get(agentId); if (hasAgentReachedBoard(agent?.state)) { pendingTicketMoves.delete(ticketId); pendingAgentTicketMoves.delete(ticketId); } } } function renderLiveScene(data) { const displayTickets = applyPendingTicketMoves(data.tickets, pendingTicketMoves); renderOfficeScene(container, { ticketCounts: aggregateTicketsByColumn(displayTickets), tickets: displayTickets, agents: data.agents, systems: data.systems, events: data.events, kpis: data.kpis, hooksCount: data.hooksCount, isLive: true, }); } // Wird als onArrived-Callback an triggerDoorVisit()/triggerPoVisit() gereicht: macht die bis // dahin zurueckgehaltene(n) Ticket-Aenderung(en) sichtbar, sobald die Persona am Board steht. function commitTicketMoves(ticketIds) { ticketIds.forEach((id) => pendingTicketMoves.delete(id)); if (lastLiveData) { renderLiveScene(lastLiveData); } } async function refresh() { try { const data = await loadLiveData(); lastLiveData = data; if (previousTickets) { const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket])); detectDoorVisits(previousTickets, data.tickets).forEach((visit) => { pendingTicketMoves.set(visit.ticketId, previousById.get(visit.ticketId) ?? null); triggerDoorVisit(visit, () => commitTicketMoves([visit.ticketId])); }); const unassignedChanges = detectUnassignedTicketChanges(previousTickets, data.tickets); if (unassignedChanges.length > 0) { const ticketIds = unassignedChanges.map((change) => change.ticketId); ticketIds.forEach((ticketId) => { pendingTicketMoves.set(ticketId, previousById.get(ticketId) ?? null); }); triggerPoVisit(() => commitTicketMoves(ticketIds)); } // Issue #76: Wechsel von/zu "in_arbeit" (siehe AGENT_OWNED_COLUMN in office-scene.js) // loesen einen selbst getakteten Board-Besuch des zustaendigen Agenten aus, analog zum // PO-/Tuerbesuch - der Agent meldete diese Bewegung in der Praxis so gut wie nie selbst // (Issue #61 gescheitert). Mehrere Aenderungen desselben Agenten im selben Poll teilen // sich einen Besuch statt mehrfach hin- und herzulaufen. const agentsById = new Map(data.agents.map((agent) => [agent.id, agent])); const agentVisitPlans = new Map(); detectAgentOwnedTicketChanges(previousTickets, data.tickets).forEach((change) => { pendingTicketMoves.set(change.ticketId, previousById.get(change.ticketId) ?? null); const agent = agentsById.get(change.agentId); const homeFeet = agent && typeof agent.desk_index === "number" ? deskSeat(agent.desk_index).feet : null; if (!homeFeet || isAgentReportingBoardVisit(agent.state)) { // Kein bekannter Schreibtisch (z.B. Agent nicht mehr registriert), oder der Agent // meldet seine Bewegung zum Board bereits selbst (Loesungsrichtung 1 aus Issue #76) // - dann bleibt der selbst getaktete Besuch unten aus, die echte Meldung (bzw. die // bestehende Ankunfts-Erkennung ueber hasAgentReachedBoard()) hat Vorrang. pendingAgentTicketMoves.set(change.ticketId, change.agentId); return; } const plan = agentVisitPlans.get(change.agentId) ?? { agentInfo: { id: agent.id, name: agent.display_name ?? agent.name, role: agent.role }, homeFeet, ticketIds: [], }; plan.ticketIds.push(change.ticketId); agentVisitPlans.set(change.agentId, plan); }); agentVisitPlans.forEach(({ agentInfo, homeFeet, ticketIds }, agentId) => { triggerAgentBoardVisit(agentId, agentInfo, homeFeet, () => commitTicketMoves(ticketIds)); }); } resolvePendingAgentTicketMoves(data.agents); previousTickets = data.tickets; renderLiveScene(data); setApiBanner(false); } catch (err) { console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", err); renderOfficeScene(container); setApiBanner(true); } } requestSceneRefresh = refresh; await refresh(); startRealtimeUpdates(refresh); } document.addEventListener("DOMContentLoaded", () => { boot(); startReloadCountdown(); });