Der PO laeuft auf dem Monitoring-Server selbst und repraesentiert dessen interne Prozesse. Bisher wurde er als "abwesend" gezeichnet, sobald der Browser die API kurzzeitig nicht erreichen konnte (isLive=false) - obwohl der Server und seine Prozesse in diesem Fall weiterliefen. Der feste PO-Platz zeigt den PO jetzt unabhaengig von isLive. Schliesst #97 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2727 lines
99 KiB
JavaScript
2727 lines
99 KiB
JavaScript
// 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,
|
||
recordingUrl,
|
||
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;
|
||
|
||
// Zeichenbreite der Servernamen im System-Monitor: .system-tile-name ist 11px Monospace,
|
||
// dort misst ein Zeichen rund 0.61 * Schriftgroesse (dieselbe Rechnung wie
|
||
// EVENT_LINE_CHAR_WIDTH fuer die 13px-Eventzeilen).
|
||
const SYSTEM_NAME_CHAR_WIDTH = 6.7;
|
||
|
||
// 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(text("☕", { x, y, 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<string, number>} ticketCounts
|
||
* @param {Record<string, Array<object>>} 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.
|
||
*/
|
||
// Issue #85: kleiner Zaehler-Kreis fuer offene Sprachaufnahmen auf dem Schreibtisch eines
|
||
// Agenten - gleiches Prinzip wie renderStackBadge() (Kreis + zentrierte Zahl), aber
|
||
// eigenstaendig und kleiner, weil er neben dem rollenabhaengigen Deko-Symbol Platz haben muss.
|
||
function renderRecordingBadge(cx, cy, count) {
|
||
const g = el("g", { class: "recording-badge" });
|
||
g.appendChild(el("circle", { cx, cy, r: 12, class: "recording-badge-circle" }));
|
||
g.appendChild(
|
||
text(String(count), { x: cx, y: cy + 4, class: "recording-badge-count", "text-anchor": "middle" })
|
||
);
|
||
return g;
|
||
}
|
||
|
||
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]
|
||
)
|
||
);
|
||
if (column.noteNumbers?.[i]) {
|
||
colGroup.appendChild(
|
||
text(column.noteNumbers[i], {
|
||
x: noteX + NOTE_W / 2,
|
||
y: noteY + NOTE_H / 2 + 6,
|
||
class: "note-number",
|
||
"text-anchor": "middle",
|
||
})
|
||
);
|
||
}
|
||
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 <title>-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;
|
||
}
|
||
|
||
// Statussymbol einer System-Kachel: gruener Haken bei "ok", rotes X bei "error" (die einzigen
|
||
// beiden Werte, die SystemRepository::STATUSES kennt). Gezeichnet als Pfad und nicht als
|
||
// Textglyphe - auf dem Wandmonitor faellt eine fehlende Schriftart sonst als leere Kachel auf.
|
||
// Das Symbol fuellt die Kachel bis auf einen schmalen Rand aus; alle Masse haengen an der
|
||
// Kantenlaenge, damit es jede Kachelgroesse mitgeht.
|
||
function renderSystemStatusMark(status, tileX, tileY, tileSize) {
|
||
const pad = tileSize * 0.15;
|
||
const size = tileSize - pad * 2;
|
||
const strokeWidth = Math.max(2.5, size * 0.16);
|
||
const bx = tileX + pad;
|
||
const by = tileY + pad;
|
||
// Die Endpunkte liegen um eine halbe Strichbreite innen, sonst ragen die runden Enden ueber
|
||
// die Flaeche hinaus.
|
||
const lo = strokeWidth / 2;
|
||
const hi = size - strokeWidth / 2;
|
||
const d =
|
||
status === "error"
|
||
? `M ${bx + lo} ${by + lo} L ${bx + hi} ${by + hi} M ${bx + hi} ${by + lo} L ${bx + lo} ${by + hi}`
|
||
: `M ${bx + lo} ${by + size * 0.55} L ${bx + size * 0.38} ${by + hi} L ${bx + hi} ${by + lo}`;
|
||
return el("path", { d, class: "system-tile-mark", "stroke-width": strokeWidth });
|
||
}
|
||
|
||
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 tileGap = 8;
|
||
const gridX = frame.x + 18;
|
||
const gridY = frame.y + 18;
|
||
// Halbe Kantenlaenge des frueheren dreispaltigen Rasters. Die Kacheln stehen jetzt
|
||
// untereinander, rechts daneben steht der Servername - MAX_SYSTEM_TILES (9) Zeilen passen
|
||
// damit in die Hoehe des Monitors (frame.h - 36).
|
||
const tileSize = (frame.w - 36 - tileGap * 2) / 6;
|
||
const nameX = gridX + tileSize + tileGap;
|
||
const nameWidth = frame.x + frame.w - 18 - nameX;
|
||
|
||
tiles.forEach((system, index) => {
|
||
const tileX = gridX;
|
||
const tileY = gridY + index * (tileSize + tileGap);
|
||
|
||
// Issue #73: Servernamen ueberlappten sich im Raster und blieben deshalb dem Tooltip
|
||
// vorbehalten. Untereinander stehen sie sich nicht mehr im Weg, der Name steht wieder in
|
||
// der Kachelzeile - auf nameWidth gekuerzt, damit er nicht ueber den Monitor hinausragt.
|
||
// Der Tooltip bleibt und zeigt den ungekuerzten Namen.
|
||
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: 4, class: "system-tile-bg" })
|
||
);
|
||
tileGroup.appendChild(renderSystemStatusMark(system.status, tileX, tileY, tileSize));
|
||
tileGroup.appendChild(
|
||
text(truncateToWidth(system.name, nameWidth, SYSTEM_NAME_CHAR_WIDTH), {
|
||
x: nameX,
|
||
y: tileY + tileSize / 2,
|
||
class: "system-tile-name",
|
||
})
|
||
);
|
||
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" }));
|
||
|
||
// Zaehler offener Sprachaufnahmen - oben links, damit er nicht mit der rollenabhaengigen
|
||
// Deko (star/flower/check/gear/mic) oben rechts kollidiert. Nur sichtbar, wenn tatsaechlich
|
||
// etwas in der Warteschlange liegt. Gefuellt wird das Feld nur beim Voice-Bot (siehe
|
||
// renderOfficeScene): die Warteschlange ist global, er ist der Einzige, der sie abholt.
|
||
if (agent && agent.pending_recordings > 0) {
|
||
g.appendChild(renderRecordingBadge(14, 4, agent.pending_recordings));
|
||
}
|
||
|
||
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" }),
|
||
])
|
||
);
|
||
} else if (role.deco === "mic") {
|
||
// Mikrofon = Kapsel, darunter ein Buegel auf einem kurzen Standfuss. Gleiche Groesse
|
||
// und Position wie das Zahnrad, damit alle Deko-Varianten denselben Platz belegen.
|
||
const cx = width - 45;
|
||
const cy = 16;
|
||
g.appendChild(
|
||
el("g", { class: "desk-deco desk-deco-mic" }, [
|
||
el("rect", { x: cx - 4, y: cy - 8, width: 8, height: 16, rx: 4, class: "desk-deco-mic-capsule" }),
|
||
el("path", { d: `M${cx - 8},${cy + 4} a8,8 0 0 0 16,0`, class: "desk-deco-mic-bracket" }),
|
||
el("path", { d: `M${cx},${cy + 12} l0,5`, class: "desk-deco-mic-bracket" }),
|
||
])
|
||
);
|
||
}
|
||
|
||
// 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}, immer gesetzt (der
|
||
* PO gilt unabhaengig von der API-Erreichbarkeit als anwesend, Issue #19/#97).
|
||
* @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);
|
||
|
||
// Nur die Figur des Voice-Bots fuehrt zur Aufnahmeseite: er ist der einzige, der die
|
||
// Warteschlange abholt. Frueher war jede Agentenfigur klickbar - dabei entstanden
|
||
// Aufnahmen in Warteschlangen, die nie jemand geleert hat.
|
||
const isVoiceBot = agent.role === "voice" && id !== PO_AGENT_ID;
|
||
|
||
let node = agentAvatarElements.get(id);
|
||
if (!node) {
|
||
node = createAgentAvatarElement();
|
||
if (id === PO_AGENT_ID) {
|
||
makePoAvatarClickable(node);
|
||
} else if (isVoiceBot) {
|
||
makeVoiceBotAvatarClickable(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 || isRealAgent) {
|
||
// updateAgentAvatarElement() setzt class komplett neu (Pose) - ohne diese Zeile
|
||
// verloere die Figur ihre 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);
|
||
}
|
||
|
||
// Ein Klick auf die Figur des Voice-Bots oeffnet die Aufnahme-Webseite in einem neuen Tab -
|
||
// die laufende Buero-Szene bleibt dabei unangetastet stehen, genau wie ein Modal es waere,
|
||
// nur dass record.html eine eigene Seite ist (braucht Mikrofon-Zugriff und ist fuers Handy
|
||
// gedacht). Die Aufnahme geht in eine Warteschlange fuer alle, nicht an einen Agenten.
|
||
function makeVoiceBotAvatarClickable(node) {
|
||
const title = document.createElementNS(SVG_NS, "title");
|
||
title.textContent = "Sprachnotiz aufnehmen";
|
||
node.appendChild(title);
|
||
node.addEventListener("click", () => {
|
||
window.open(recordingUrl(), "_blank", "noopener");
|
||
});
|
||
}
|
||
|
||
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 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;
|
||
|
||
// Der PO gilt immer als anwesend (Issue #97, siehe Kommentar bei poDeskAgent in
|
||
// renderOfficeScene()), poPlacement() liefert daher ohne eigene Bedingung eine Platzierung.
|
||
function poPlacement() {
|
||
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 (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 (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";
|
||
if (item.repo) {
|
||
const repoEl = document.createElement("span");
|
||
repoEl.className = "office-modal-repo";
|
||
repoEl.textContent = item.repo;
|
||
heading.appendChild(repoEl);
|
||
}
|
||
if (item.number) {
|
||
const numEl = item.link ? document.createElement("a") : document.createElement("span");
|
||
numEl.className = "office-modal-ticket-number";
|
||
numEl.textContent = `#${item.number}`;
|
||
if (item.link) {
|
||
numEl.href = item.link;
|
||
numEl.target = "_blank";
|
||
numEl.rel = "noopener noreferrer";
|
||
}
|
||
heading.appendChild(numEl);
|
||
}
|
||
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.className = "office-modal-description";
|
||
desc.textContent = item.description;
|
||
li.appendChild(desc);
|
||
}
|
||
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, agentsResult, systems] = await Promise.all([
|
||
fetchHostConfigs(),
|
||
fetchAgents(),
|
||
fetchSystems(),
|
||
]);
|
||
loaded = { hosts, agents: agentsResult.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, agentsResult, webhookStats] = await Promise.all([
|
||
fetchTickets(),
|
||
fetchEvents(),
|
||
fetchSystems(),
|
||
fetchKpis(),
|
||
fetchAgents(),
|
||
fetchWebhookStats(),
|
||
]);
|
||
return {
|
||
tickets,
|
||
events,
|
||
systems,
|
||
kpis,
|
||
agents: agentsResult.agents,
|
||
pendingRecordings: agentsResult.pendingRecordings,
|
||
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,
|
||
pendingRecordings = 0,
|
||
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";
|
||
}
|
||
|
||
// "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))
|
||
// Die Warteschlange gehoert keinem Agenten mehr, ihr Zaehler steht deshalb neben der
|
||
// Agentenliste (GET /api/v1/agents). Gezeigt wird er am Schreibtisch des Voice-Bots -
|
||
// dort, wo auch die Figur sitzt, ueber die man aufnimmt.
|
||
.map((agent) =>
|
||
agent && agent.role === "voice" ? { ...agent, pending_recordings: pendingRecordings } : agent
|
||
);
|
||
const deskLayout = buildDeskLayout(deskAgents);
|
||
// Der PO laeuft auf dem Monitoring-Server selbst und repraesentiert dessen interne Prozesse
|
||
// (Sync/API) - er gilt deshalb immer als anwesend, unabhaengig davon, ob der Browser die API
|
||
// gerade erreichen kann (isLive). Vor Issue #97 verschwand der PO faelschlich mitsamt der
|
||
// Live-Szene, sobald ausgerechnet der eigene Client offline war, obwohl der Server selbst
|
||
// weiterlief. Auch ohne erreichbare API bleibt der Platz zudem klickbar: das Modal sagt dann
|
||
// selbst, dass es die Projekte nicht laden konnte (Fehlerbericht zu Issue #27).
|
||
const poDeskAgent = { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" };
|
||
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() {
|
||
// Issue #94, zweiter Anlauf: der erste Fix (siehe Kommentar unten bei renderOfficeScene())
|
||
// sorgte zwar dafuer, dass der Banner ueberhaupt erscheint, hat aber "API nicht erreichbar"
|
||
// faelschlich auch dann gezeigt, wenn loadLiveData() laengst erfolgreich war und erst das
|
||
// anschliessende Verarbeiten/Zeichnen der Live-Daten scheiterte - Produktion meldete dann
|
||
// trotz per curl nachweislich erreichbarer API weiterhin Unerreichbarkeit. Deshalb jetzt
|
||
// zwei getrennte try-Bloecke: nur ein Fehlschlag von loadLiveData() selbst ist ein
|
||
// Erreichbarkeitsproblem und darf den Demo-Fallback samt Banner ausloesen.
|
||
let data;
|
||
try {
|
||
data = await loadLiveData();
|
||
} catch (err) {
|
||
console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", err);
|
||
// setApiBanner() steht in einem eigenen finally: renderOfficeScene() zeichnet bei jedem
|
||
// Refresh die komplette Szene neu und kann durch einen Fehler in noch unbekannten
|
||
// Datenkombinationen scheitern (siehe Issue #94 - Produktion zeigte trotz nicht
|
||
// erreichbarer API keinen Banner). Ohne das finally wuerde ein solcher Fehler die
|
||
// wichtigere Meldung "API nicht erreichbar" mit sich reissen, statt sie anzuzeigen.
|
||
try {
|
||
renderOfficeScene(container);
|
||
} finally {
|
||
setApiBanner(true);
|
||
}
|
||
return;
|
||
}
|
||
|
||
setApiBanner(false);
|
||
lastLiveData = data;
|
||
try {
|
||
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);
|
||
} catch (err) {
|
||
// Die API war erreichbar (loadLiveData() oben ist bereits durchgelaufen) - ein Fehler
|
||
// hier ist ein Render-/Verarbeitungsbug, kein Erreichbarkeitsproblem. Der Banner bleibt
|
||
// deshalb aus (siehe setApiBanner(false) oben); der naechste Poll-Zyklus versucht es
|
||
// erneut, sobald startRealtimeUpdates() ihn ausloest.
|
||
console.error("[office] Fehler beim Verarbeiten/Zeichnen der Live-Daten.", err);
|
||
}
|
||
}
|
||
|
||
requestSceneRefresh = refresh;
|
||
|
||
await refresh();
|
||
startRealtimeUpdates(refresh);
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
boot();
|
||
startReloadCountdown();
|
||
});
|