monitor/html/js/office-scene.js
fds2610-bot 55d5c34ca6 Ueberwachte Hosts ueber ein Zahnrad am System-Monitor pflegen (Issue #70)
Wer einen Server aus der Ping-Ueberwachung nehmen wollte, musste sich bisher auf den
Rechner mit check_fds.sh einloggen und die hosts.conf editieren - und die Kachel blieb
danach trotzdem im System-Monitor stehen, weil ein einmal gemeldetes System nirgends
wieder geloescht werden konnte.

Die Liste steht jetzt in der Datenbank und ist ueber ein Zahnrad neben der Ueberschrift
"Systeme" pflegbar, analog zum Zahnrad am PO-Platz (Issue #27). Jeder Host gehoert dem
OPs-Agenten (Issue #66), der ihn anpingt: API und Ping-Check laufen nicht zwangslaeufig
auf demselben Rechner, die API kann die hosts.conf also nicht selbst schreiben.
check_fds.sh holt sie stattdessen bei jedem Lauf mit seinem Agenten-Key ab und schreibt
daraus die lokale Datei; ist die API nicht erreichbar, pingt es unveraendert nach der
zuletzt geschriebenen Liste weiter.

Beim ersten Lauf laedt der Check seine bestehende hosts.conf hoch, damit die bereits
ueberwachten Server nicht von Hand nachgetragen werden muessen. Dass die API das nur
einmal annimmt (Marker in host_config_imports) und "noch nie konfiguriert" per 204 von
"im Frontend geleert" per 200 unterscheidet, ist noetig, damit ein Ping-Rechner mit alter
lokaler Datei die dort geloeschten Hosts nicht wieder anlegt.

Loeschen raeumt mit auf: Ein entfernter Host nimmt seine Kachel mit - beide Ids, denn der
status.txt-Import benennt ihn nach dem Hostnamen, die REST-Meldung nach dessen Slug.
Kacheln ohne zugehoerigen Host (Selbstmelder oder Reste laengst entfernter Hosts) stehen
im selben Dialog unter "Weitere Kacheln im System-Monitor" und sind dort einzeln
loeschbar - dafuer gibt es neu DELETE /api/v1/systems/{id}.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vNjbfr8M3HSfWMee92GTE
2026-09-01 13:14:11 +02:00

1005 lines
41 KiB
JavaScript

// Reine Layout-/Datenlogik fuer die Buero-Visualisierung (Phase 2, siehe Visualization.md
// Abschnitt 9). Enthaelt keine DOM-Zugriffe, damit die Funktionen ohne Browser (z.B. mit dem
// Node-Testrunner) getestet werden koennen. Das eigentliche Zeichnen uebernimmt office-render.js.
// Spaltenreihenfolge exakt wie API.md ("Tickets" -> Spalten, F3).
export const BOARD_COLUMNS = [
{ key: "backlog", label: "Backlog" },
{ key: "bereit", label: "Bereit" },
{ key: "in_arbeit", label: "In Arbeit" },
{ key: "qa", label: "QA" },
{ key: "testing", label: "Testing" },
{ key: "fertig", label: "Fertig" },
{ key: "done", label: "Done" },
];
// Rollen exakt wie API.md ("Agenten", F1/F2). "user" hat nie einen Schreibtisch.
export const ROLES = {
entwickler: { label: "Entwickler:in", accentColor: "#3b82f6", deco: "star" },
product_owner: { label: "Product Owner:in", accentColor: "#ec4899", deco: "flower" },
qa: { label: "QA / Tester:in", accentColor: "#22c55e", deco: "check" },
ops: { label: "OPs", accentColor: "#8b5cf6", deco: "gear" },
user: { label: "User", accentColor: "#f59e0b", deco: "none" },
};
export const MAX_VISIBLE_NOTES_PER_COLUMN = 4;
export const MAX_SYSTEM_TILES = 9;
export const MAX_EVENT_LINES = 5;
export const DEFAULT_DESK_COUNT = 8;
// Ein Stapel wird aus so vielen versetzten Zetteln gezeichnet (siehe renderStackBadge()) -
// entsprechend viele Projektfarben kann er zeigen.
export const NOTE_STACK_LAYERS = 3;
/**
* Baut aus rohen Ticket-Zaehlern pro Spalte die Darstellung fuer das Board.
* Regel (Detailantworten F3): mehr als 5 Tickets in einer Spalte -> die aeltesten 4 werden
* einzeln dargestellt, der Rest als ein "Stapel" mit der Anzahl der zusaetzlichen Tickets.
* Regel (Detailantworten F2): die Spalte "done" wird immer als ein Stapel (Zeit-Historie)
* dargestellt, unabhaengig von der Anzahl.
*
* Die Farben stammen aus dem Quell-Projekt des Tickets (ticket.color, gepflegt im
* Projekte-Modal am PO, Issue #27) und sind optional: ohne groupedTickets - etwa im
* Demo-Modus, der nur Zaehler kennt - bleiben die Zettel einfarbig wie zuvor.
*
* @param {Record<string, number>} ticketCounts - Anzahl Tickets je Spalten-Key.
* @param {Record<string, Array<object>>} groupedTickets - Tickets je Spalte in derselben
* Reihenfolge wie gezaehlt (siehe groupTicketsByColumn()).
* @returns {Array<{key: string, label: string, count: number, visibleNotes: number,
* stackCount: number, noteColors: Array<string|null>, stackColors: Array<string>}>}
*/
export function buildBoardColumnLayout(ticketCounts = {}, groupedTickets = {}) {
return BOARD_COLUMNS.map(({ key, label }) => {
const count = Math.max(0, ticketCounts[key] ?? 0);
const tickets = groupedTickets[key] ?? [];
const withColors = (visibleNotes, stackCount) => ({
key,
label,
count,
visibleNotes,
stackCount,
noteColors: noteColors(tickets.slice(0, visibleNotes), visibleNotes),
stackColors: stackCount > 0 ? stackLayerColors(tickets.slice(visibleNotes)) : [],
});
if (key === "done") {
return withColors(0, count);
}
if (count <= MAX_VISIBLE_NOTES_PER_COLUMN + 1) {
return withColors(count, 0);
}
return withColors(MAX_VISIBLE_NOTES_PER_COLUMN, count - MAX_VISIBLE_NOTES_PER_COLUMN);
});
}
// null steht fuer "keine Projektfarbe" (Ticket ohne Quell-Projekt, z.B. per API gemeldet) -
// solche Zettel behalten die Standardfarbe aus dem Stylesheet.
function noteColors(tickets, length) {
return Array.from({ length }, (_, i) => tickets[i]?.color ?? null);
}
// Ein Stapel steht fuer viele Tickets, aber nur fuer NOTE_STACK_LAYERS Zettel: er zeigt
// deshalb die ersten verschiedenen Projektfarben darin, statt eine davon dreimal zu
// wiederholen. So ist auch an "Done" (immer ein Stapel) zu sehen, aus welchen Projekten die
// Tickets stammen.
//
// Reichen die gefundenen Farben nicht fuer alle Zettel, werden sie zyklisch wiederholt: ein
// ungefaerbter Rest-Zettel wuerde sonst wie ein weiteres Projekt aussehen, denn die
// Standardfarbe (--color-note) ist von einem gelben Projekt kaum zu unterscheiden. Nur wenn
// gar keine Farbe vorkommt, bleibt der Stapel komplett im Standard.
function stackLayerColors(tickets) {
const found = [];
for (const ticket of tickets) {
if (ticket.color && !found.includes(ticket.color)) {
found.push(ticket.color);
if (found.length === NOTE_STACK_LAYERS) break;
}
}
if (found.length === 0) {
return [];
}
return Array.from({ length: NOTE_STACK_LAYERS }, (_, i) => found[i % found.length]);
}
/**
* Ordnet Agenten-Demo-Daten den Schreibtischen zu. Unbesetzte Schreibtische bleiben leer.
* Ueberzaehlige Agenten (mehr als deskCount) werden ignoriert - Phase 2 zeigt nur den
* statischen Grundzustand, die "weitere Agenten kommen durch die Tuer"-Logik ist Teil von
* Phase 5 (Animation).
*
* @param {Array<{name: string, role: string, pose?: string}>} agents
* @param {number} deskCount
* @returns {Array<{deskIndex: number, agent: object|null}>}
*/
export function buildDeskLayout(agents = [], deskCount = DEFAULT_DESK_COUNT) {
const desks = [];
for (let deskIndex = 0; deskIndex < deskCount; deskIndex++) {
const agent = agents[deskIndex] ?? null;
desks.push({ deskIndex, agent: agent ? { pose: "idle", ...agent } : null });
}
return desks;
}
/**
* Bereitet Systemkacheln fuer den System-Monitor auf: Fehler zuerst (wie GET /api/v1/systems),
* maximal MAX_SYSTEM_TILES Kacheln (F6).
*
* @param {Array<{id: string, name: string, status: "ok"|"error"}>} systems
* @returns {Array<object>}
*/
export function buildSystemTiles(systems = []) {
const sorted = [...systems].sort((a, b) => {
if (a.status === b.status) return 0;
return a.status === "error" ? -1 : 1;
});
return sorted.slice(0, MAX_SYSTEM_TILES);
}
/**
* Bereitet die Zeilen des Event-Monitors auf: neuestes Event zuerst, maximal
* MAX_EVENT_LINES Zeilen (F5). Erwartet bereits absteigend nach Zeit sortierte Events
* (wie GET /api/v1/events sie liefert).
*
* @param {Array<{severity: "warning"|"error"}>} events
* @returns {Array<object>}
*/
export function buildEventLines(events = []) {
return events.slice(0, MAX_EVENT_LINES);
}
// ---------------------------------------------------------------------------------------
// Phase 3 - Kanban-Board live (siehe Visualization.md Abschnitt 9, API.md "Tickets")
// ---------------------------------------------------------------------------------------
/**
* Aggregiert eine rohe Ticketliste (GET /api/v1/tickets) zu Zaehlern je Spalte, wie sie
* buildBoardColumnLayout() erwartet. Unbekannte Spalten-Keys werden ignoriert (defensiv
* gegen zukuenftige/kaputte Daten).
*
* @param {Array<{column_name: string}>} tickets
* @returns {Record<string, number>}
*/
export function aggregateTicketsByColumn(tickets = []) {
const counts = Object.fromEntries(BOARD_COLUMNS.map(({ key }) => [key, 0]));
for (const ticket of tickets) {
if (ticket.column_name in counts) {
counts[ticket.column_name] += 1;
}
}
return counts;
}
/**
* Gruppiert eine rohe Ticketliste je Spalte. GET /api/v1/tickets liefert bereits pro Spalte
* nach Alter aufsteigend sortiert (siehe TicketRepository::all()), die Reihenfolge wird hier
* beibehalten.
*
* @param {Array<{column_name: string}>} tickets
* @returns {Record<string, Array<object>>}
*/
export function groupTicketsByColumn(tickets = []) {
const groups = Object.fromEntries(BOARD_COLUMNS.map(({ key }) => [key, []]));
for (const ticket of tickets) {
if (ticket.column_name in groups) {
groups[ticket.column_name].push(ticket);
}
}
return groups;
}
/**
* Baut das Inhaltsmodell fuer das Board-Spalten-Modal (Visualization.md 4.2/4.5): echter
* Titel/Beschreibung je Ticket, plus Link auf die Codeberg-Quelle sofern das Ticket per
* Codeberg-Sync angelegt wurde (F4). Seit F26 (Tickets aus mehreren Codeberg-Projekten)
* traegt jedes Ticket sein Quell-Repo ("owner/name") selbst in ticket.repo; opts dient nur
* noch als Fallback fuer aeltere Tickets ohne dieses Feld.
*
* @param {string} columnKey
* @param {Array<object>} tickets
* @param {{codebergOwner?: string, codebergRepo?: string}} [opts]
*/
export function buildTicketModalContent(columnKey, tickets = [], opts = {}) {
const { codebergOwner = "fds2610", codebergRepo = "monitor" } = opts;
const column = BOARD_COLUMNS.find((c) => c.key === columnKey);
return {
title: column ? column.label : columnKey,
items: tickets.map((ticket) => ({
id: ticket.id,
title: ticket.title,
description: ticket.description || "",
type: ticket.type || null,
color: ticket.color || null,
link:
ticket.source === "codeberg" && ticket.external_ref
? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/issues/${ticket.external_ref}`
: null,
})),
};
}
// ---------------------------------------------------------------------------------------
// Phase 4 - Monitor-Wand live (Events/Systeme/KPIs, siehe API.md)
// ---------------------------------------------------------------------------------------
/**
* Formatiert einen ISO-8601-Zeitstempel als lokale Uhrzeit (F16: keine eigene
* Zeitzonen-Auswahl, es gilt schlicht die Zeitzone des Browsers/Servers, in der Praxis
* CEST/CET).
*
* @param {string} isoString
* @returns {string}
*/
export function formatEventTime(isoString) {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) {
return "--:--";
}
return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
/**
* Uebersetzt ein rohes Event (GET /api/v1/events) in die kompakte Zeilendarstellung der
* Monitor-Wand ({time, system, message, severity}, wie sie renderEventMonitor erwartet).
*
* @param {{occurred_at: string, name: string, message: string, severity: string}} event
*/
export function mapEventForDisplay(event) {
return {
time: formatEventTime(event.occurred_at),
system: event.name,
message: event.message,
severity: event.severity,
};
}
/**
* Volltext einer Event-Zeile, wie er ungekuerzt im Tooltip steht.
*
* @param {{time: string, system: string, message: string}} eventLine
* @returns {string}
*/
export function formatEventLineText(eventLine) {
return `${eventLine.time} ${eventLine.system}: ${eventLine.message}`;
}
// Zeichenbreite der Monospace-Schrift des Event-Monitors (13px, siehe .monitor-event-line in
// office.css). Bei Monospace ist die Breite fuer alle Zeichen gleich; der Wert ist am
// breitesten der in Frage kommenden Schriften ausgerichtet (Courier New / DejaVu Sans Mono,
// ~0.6 em; Consolas ist schmaler), damit die Zeile auf keinem System ueberlaeuft. So bleibt
// die Abschaetzung ohne DOM-Messung auskommend und damit testbar.
export const EVENT_LINE_CHAR_WIDTH = 7.9;
/**
* Kuerzt einen Text auf die Breite, die im Monitor tatsaechlich zur Verfuegung steht, und
* markiert die Kuerzung mit einer Ellipse. Zu lange Zeilen liefen sonst ueber den
* Monitorrahmen hinaus in die Szene hinein; die volle Meldung zeigt der Tooltip
* (siehe renderEventMonitor()).
*
* @param {string} value
* @param {number} availableWidth - verfuegbare Breite in SVG-Einheiten
* @param {number} [charWidth]
* @returns {string}
*/
export function truncateToWidth(value, availableWidth, charWidth = EVENT_LINE_CHAR_WIDTH) {
const maxChars = Math.floor(availableWidth / charWidth);
if (maxChars <= 0) {
return "";
}
if (value.length <= maxChars) {
return value;
}
if (maxChars === 1) {
return "…";
}
return `${value.slice(0, maxChars - 1).trimEnd()}…`;
}
/**
* Inhaltsmodell fuer das Event-Monitor-Modal: volle Liste (kein MAX_EVENT_LINES-Limit).
*
* @param {Array<object>} events
*/
export function buildEventModalContent(events = []) {
return {
title: "Events",
items: events.map((event) => ({
time: formatEventTime(event.occurred_at),
name: event.name,
message: event.message,
severity: event.severity,
systemId: event.system_id ?? null,
})),
};
}
/**
* Inhaltsmodell fuer das System-Monitor-Modal.
*
* @param {Array<object>} systems
*/
export function buildSystemModalContent(systems = []) {
return {
title: "Systeme",
items: systems.map((system) => ({
id: system.id,
name: system.name,
status: system.status,
type: system.type || "host",
detail: system.detail || "",
lastCheckAt: formatSystemCheckTime(system.last_check_at),
})),
};
}
/**
* Issue #70: Die Kacheln des System-Monitors, die zu keinem konfigurierten Ping-Host
* gehoeren - fuer den unteren Abschnitt der Host-Verwaltung. Sie melden sich entweder selbst
* per REST, oder ihr letzter Stand steht nur noch in der Datenbank, weil ihr Host laengst aus
* der Ueberwachung raus ist.
*
* Die Zuordnung laeuft ueber die vom Server mitgelieferten system_ids: Ein Host kann unter
* zwei Ids melden (Hostname aus dem status.txt-Import, Slug aus der REST-Meldung), und diese
* Ableitung hier nachzubauen hiesse, sie an zwei Stellen richtig halten zu muessen.
*
* @param {Array<{id: string}>} systems
* @param {Array<{system_ids?: Array<string>}>} hosts
*/
export function systemsWithoutHostConfig(systems = [], hosts = []) {
const known = new Set(hosts.flatMap((host) => host.system_ids || []));
return systems.filter((system) => !known.has(system.id));
}
/**
* Formatiert last_check_at eines Systems (GET /api/v1/systems, Issue #57: "wann der letzte
* erfolgreiche Ping war") fuers Modal. Zwei Quellformate kommen vor: ISO-8601 UTC (per API
* gemeldete Systeme) und "YYYY-MM-DD HH:MM" ohne Zeitzone (Legacy-Import aus status.txt,
* geschrieben von check_fds.sh in Serverzeit) - beide werden von `Date` geparst, fehlende/
* ungueltige Werte liefern `null` statt eines irrefuehrenden Zeitstempels.
*
* @param {string|null|undefined} value
* @returns {string|null}
*/
export function formatSystemCheckTime(value) {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Erzeugt die Punkte-Liste (SVG <polyline points="...">) fuer eine einfache
* KPI-Sparkline - bewusst ohne Chart-Lib gezeichnet (F10). Die API liefert die Historie
* absteigend (neuester Wert zuerst, siehe KpiRepository::history()), fuer die Sparkline
* wird chronologisch (aeltester zuerst) von links nach rechts gezeichnet.
*
* @param {Array<{value: number}>} history
* @param {number} [width]
* @param {number} [height]
* @param {number} [padding]
* @returns {string}
*/
export function buildKpiSparklinePoints(history = [], width = 140, height = 32, padding = 4) {
if (history.length === 0) {
return "";
}
const chronological = [...history].reverse();
const values = chronological.map((point) => point.value);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const stepX = chronological.length > 1 ? (width - padding * 2) / (chronological.length - 1) : 0;
return chronological
.map((point, index) => {
const x = padding + index * stepX;
const y = height - padding - ((point.value - min) / range) * (height - padding * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
}
// Fuenf statt vier Kacheln, seit "Problem" (abgelaufene Agenten-Anmeldung) hinzugekommen ist:
// renderOfficeScene() reserviert bereits die letzte Kachel fuer "Hooks" (Issue #38), womit
// "Agenten"/"Online"/"Problem" die uebrigen drei fuellen wuerden - fuer die gemeldeten KPIs (F7)
// bliebe dann kein Platz mehr. Der KPI-Monitor ist hoch genug: eine Zeile ohne Sparkline braucht
// 24 px von 340 (siehe renderKpiMonitor() in office-render.js).
export const MAX_KPI_TILES = 5;
/**
* Bereitet bis zu MAX_KPI_TILES KPI-Kacheln fuer die Monitor-Wand auf. F7 ist weiterhin
* offen ("kommt spaeter") - ist noch kein KPI gemeldet, bleibt das Ergebnis leer und die
* Wand zeigt weiterhin den Platzhaltertext (keine erfundenen Kennzahlen).
*
* @param {Array<object>} kpis
*/
export function buildKpiTiles(kpis = []) {
return kpis.slice(0, MAX_KPI_TILES).map((kpi) => ({
key: kpi.key,
label: kpi.label || kpi.key,
unit: kpi.unit || "",
currentValue: kpi.current_value,
// Nur abgeleitete Kacheln setzen einen Schweregrad (aktuell "Problem", siehe
// buildAgentCountKpis()); gemeldete KPIs (F7) kennen kein solches Feld und bleiben neutral.
severity: kpi.severity ?? null,
sparklinePoints: buildKpiSparklinePoints(kpi.history || []),
}));
}
/**
* KPI-foermiger Eintrag fuer den Hooks-KPI (Issue #38): Anzahl der seit dem letzten Start des
* api-Containers empfangenen Webhooks (GET /api/v1/webhooks/stats). Anders als
* buildAgentCountKpis() nicht aus bereits ohnehin geladenen Daten ableitbar, sondern ein
* eigener serverseitiger Zaehler - daher eine eigene, injizierbare Funktion statt Teil davon.
* Soll laut Issue #38 immer als letzte (rechte) Kachel im KPI-Monitor erscheinen, siehe
* renderOfficeScene() in office-render.js, das dafuer eine Kachel reserviert statt sie der
* MAX_KPI_TILES-Kappung der uebrigen KPIs zu unterwerfen.
*
* @param {number} count
* @returns {object}
*/
export function buildHooksKpi(count) {
return { key: "hooks_received", label: "Hooks", unit: "", current_value: count, history: [] };
}
/**
* Inhaltsmodell fuer das KPI-Monitor-Modal (groessere Sparkline als die Wandkachel).
*
* @param {Array<object>} kpis
*/
export function buildKpiModalContent(kpis = []) {
return {
title: "KPIs",
items: kpis.map((kpi) => ({
key: kpi.key,
label: kpi.label || kpi.key,
unit: kpi.unit || "",
currentValue: kpi.current_value,
history: kpi.history || [],
sparklinePoints: buildKpiSparklinePoints(kpi.history || [], 320, 80),
})),
};
}
// ---------------------------------------------------------------------------------------
// Phase 5 - Agenten-Zustandsmaschine & Animation (siehe Visualization.md Abschnitt 4.4)
// ---------------------------------------------------------------------------------------
/** Orte, an denen ein Agent sichtbar sein kann. */
export const AGENT_LOCATIONS = {
DESK: "desk",
BOARD: "board",
MONITOR_EVENTS: "monitor-events",
MONITOR_SYSTEMS: "monitor-systems",
MONITOR_KPI: "monitor-kpi",
COFFEE_CORNER: "coffee-corner",
DOOR: "door",
};
// Zustandsmaschine wie in Visualization.md 4.4 vorgeschlagen: jeder von der API gemeldete
// "state" (POST /api/v1/agents/{id}/activity) wird auf einen Ort + eine Pose abgebildet.
// Unbekannte/fehlende Zustaende fallen defensiv auf "idle am Schreibtisch" zurueck.
const AGENT_STATE_VISUALS = {
idle: { location: AGENT_LOCATIONS.DESK, pose: "idle" },
working: { location: AGENT_LOCATIONS.DESK, pose: "working" },
walking_to_board: { location: AGENT_LOCATIONS.BOARD, pose: "walking" },
interacting_with_board: { location: AGENT_LOCATIONS.BOARD, pose: "interacting" },
walking_to_monitor: { location: AGENT_LOCATIONS.MONITOR_EVENTS, pose: "walking" },
interacting_with_monitor: { location: AGENT_LOCATIONS.MONITOR_EVENTS, pose: "interacting" },
walking_back: { location: AGENT_LOCATIONS.DESK, pose: "walking" },
reading_newspaper: { location: AGENT_LOCATIONS.DESK, pose: "reading_newspaper" },
at_coffee_corner: { location: AGENT_LOCATIONS.COFFEE_CORNER, pose: "coffee" },
talking_to_colleague: { location: AGENT_LOCATIONS.DESK, pose: "talking" },
// Abgelaufene Claude-Anmeldung (Problemzustaende, siehe AGENT_PROBLEM_STATES): der Agent
// bleibt an seinem Platz - er ist nicht weg, er kommt nur nicht mehr weiter.
auth_expiring: { location: AGENT_LOCATIONS.DESK, pose: "auth_expiring" },
auth_expired: { location: AGENT_LOCATIONS.DESK, pose: "auth_expired" },
};
/**
* @param {string} state - Wert aus Agent.state (API).
* @returns {{location: string, pose: string}}
*/
export function resolveAgentVisualState(state) {
return AGENT_STATE_VISUALS[state] ?? AGENT_STATE_VISUALS.idle;
}
// Praesenz (Issue #17, seit Issue #20 vom Server berechnet): ein registrierter Agent gilt nur
// noch als anwesend, solange er sich innerhalb eines Timeouts zuletzt gemeldet hat. Der Server
// kennt ueber gmdate() zuverlaessig UTC und liefert das Ergebnis direkt als is_present-Feld mit
// (siehe AgentRepository::isPresent(), API.md) - der Client macht dazu keine eigene
// Zeit-Arithmetik/-Parsing mehr, das hatte in der lokalen Zeitzone des Clients zu falschen
// Ergebnissen gefuehrt.
export function isAgentPresent(agent) {
if (!agent || typeof agent.is_present === "undefined") {
return true;
}
return Boolean(agent.is_present);
}
/**
* Zustaende, die ein menschliches Eingreifen erfordern: laeuft einem Agenten die
* Claude-Anmeldung aus, laeuft das Cron-Skript drumherum unbeeindruckt weiter - es meldet zu
* Beginn "working", ruft claude auf, bekommt nichts Brauchbares zurueck und meldet am Ende
* "idle". Ohne eigenen Zustand ist der Ausfall vom Normalbetrieb nicht zu unterscheiden; der
* Agent sieht beschaeftigt aus, waehrend Lauf fuer Lauf nichts entsteht.
*
* Der Agent meldet diese Zustaende ueber denselben Endpunkt wie jeden anderen (POST
* /api/v1/agents/{id}/activity) - sein Monitor-API-Key ist von der Claude-Anmeldung unabhaengig
* und bleibt gueltig. Der Zustand bleibt kleben, bis der Agent selbst etwas anderes meldet:
* bewusst kein Timeout, denn ein Blocker soll sichtbar bleiben, bis er behoben ist.
*/
export const AGENT_PROBLEM_STATES = {
auth_expiring: "warning",
auth_expired: "error",
};
/**
* @param {object} agent
* @returns {"warning"|"error"|null} Schweregrad des Problemzustands, oder null im Normalbetrieb.
* Die Werte entsprechen den Event-Schweregraden (API.md), damit Kacheln, Banner und
* Event-Zeilen dieselben Farben nutzen koennen.
*/
export function agentProblemSeverity(agent) {
if (!agent) {
return null;
}
return AGENT_PROBLEM_STATES[agent.state] ?? null;
}
/**
* Ob ein Agent in der Szene gezeigt wird. Bewusst getrennt von isAgentPresent(): hat ein Agent
* "auth_expired" gemeldet, bricht sein Skript den Lauf ab und meldet bis zum naechsten
* Cron-Start nichts mehr. Liegt der Cron-Abstand ueber dem Praesenz-Timeout
* (AgentRepository::PRESENCE_TIMEOUT_SECONDS), verschwaende der Agent genau dann vom
* Schreibtisch, wenn seine Warnung dort stehen soll.
*
* isAgentPresent() bleibt davon unberuehrt - sonst zaehlte die KPI "Online" einen blockierten
* Agenten als arbeitsfaehig mit.
*/
export function isAgentVisible(agent) {
return isAgentPresent(agent) || agentProblemSeverity(agent) !== null;
}
/**
* @param {Array<object|null>} agents
* @returns {number} Anzahl Agenten in einem Problemzustand (Grundlage der KPI-Kachel "Problem").
*/
export function countAgentProblems(agents = []) {
return agents.filter((agent) => agentProblemSeverity(agent) !== null).length;
}
/**
* @param {Array<object|null>} agents
* @returns {Array<object>} Agenten mit Problemzustand, gruppiert nach Schweregrad - Grundlage
* fuer den Header-Banner, der die betroffenen Namen nennt.
*/
export function agentsWithProblems(agents = []) {
return agents.filter((agent) => agentProblemSeverity(agent) !== null);
}
/**
* Die beiden im KPI-Monitor erwarteten Kennzahlen "Agenten" (registrierte Agenten) und
* "Online" (davon innerhalb der letzten 10 Minuten aktiv, siehe isAgentPresent()) werden
* anders als die per POST /api/v1/kpis/{key} gemeldeten Kennzahlen (F7, weiterhin offen)
* direkt aus der ohnehin geladenen Agentenliste abgeleitet statt separat gemeldet.
* Gibt KPI-foermige Eintraege zurueck (gleiche Form wie GET /api/v1/kpis), damit sie sich
* mit den gemeldeten KPIs vor buildKpiTiles()/buildKpiModalContent() zusammenfuehren lassen.
*
* @param {Array<object|null>} agents - z.B. leere Schreibtisch-Plaetze der Demo-Daten sind null.
* @returns {Array<object>}
*/
export function buildAgentCountKpis(agents = []) {
const registered = agents.filter(Boolean);
const online = registered.filter(isAgentPresent).length;
const problems = countAgentProblems(registered);
return [
{ key: "agents_total", label: "Agenten", unit: "", current_value: registered.length, history: [] },
{ key: "agents_online", label: "Online", unit: "", current_value: online, history: [] },
// "Problem" faerbt sich nur ein, wenn es wirklich eines gibt - eine dauerhaft rote Kachel
// mit einer 0 waere genau das Rauschen, das den Ernstfall untergehen liesse.
{
key: "agents_problem",
label: "Problem",
unit: "",
current_value: problems,
history: [],
severity: problems > 0 ? "error" : null,
},
];
}
/**
* Ordnet eine rohe Agentenliste (GET /api/v1/agents) den Schreibtischen zu - anders als
* buildDeskLayout()/Demo-Daten steht der Sitzplatz hier nicht in der Array-Reihenfolge,
* sondern im Feld desk_index (API.md "Agenten"). Agenten ohne Schreibtisch (role "user"
* oder alle Schreibtische belegt, desk_index === null) erscheinen hier nicht - sie kommen
* gemaess F2 nur anlassbezogen durch eine Tuer und haben keinen festen Platz. Agenten, die laut
* is_present-Feld nicht mehr als anwesend gelten (Issue #17/#20), erscheinen ebenfalls nicht -
* ihr Schreibtisch bleibt frei. Ausnahme sind die Problemzustaende (isAgentVisible()): ein
* blockierter Agent bleibt sitzen, auch wenn er sich laenger nicht gemeldet hat.
*
* @param {Array<object>} agents
* @param {number} [deskCount]
* @returns {Array<object|null>}
*/
export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT) {
const desks = new Array(deskCount).fill(null);
for (const agent of agents) {
if (!isAgentVisible(agent)) {
continue;
}
const deskIndex = agent.desk_index;
if (typeof deskIndex === "number" && deskIndex >= 0 && deskIndex < deskCount) {
desks[deskIndex] = agent;
}
}
return desks;
}
/**
* Bereitet einen rohen API-Agenten fuer die Darstellung auf: Anzeigename + Pose/Ort aus
* dem gemeldeten state ableiten (statt der Demo-only "pose").
*
* @param {object} agent
*/
export function normalizeAgentForDisplay(agent) {
const visual = resolveAgentVisualState(agent.state);
return {
...agent,
name: agent.display_name ?? agent.name,
pose: visual.pose,
location: visual.location,
};
}
const IDLE_ACTIVITIES = ["reading_newspaper", "at_coffee_corner", "talking_to_colleague"];
/**
* Waehlt zufaellig eine idle-Aktivitaet (F8: "Nur idle Aktivitaeten wie Kaffeetrinken oder
* Schwaetzchen halten steuert das System selber random" - rein kosmetisch, wird nicht an
* die API gemeldet). Der Zufallsgenerator ist injizierbar, damit die Auswahl testbar ist.
*
* @param {() => number} [random]
* @returns {string}
*/
export function pickIdleActivity(random = Math.random) {
const index = Math.min(Math.floor(random() * IDLE_ACTIVITIES.length), IDLE_ACTIVITIES.length - 1);
return IDLE_ACTIVITIES[index];
}
/**
* Serialisiert Animations-Trigger, damit bei sehr vielen gleichzeitigen Ereignissen (F9:
* "20 Tickets in einer Sekunde") nicht alle Animationen gleichzeitig starten, sondern in
* einer Warteschlange mit Mindestabstand abgearbeitet werden. Reine Logik ohne DOM-Zugriff,
* damit sie ohne Browser testbar ist - office-render.js haengt eine window.setInterval-
* Schleife an processDue() an.
*/
export class AnimationQueue {
/** @param {{minIntervalMs?: number, now?: () => number}} [opts] */
constructor(opts = {}) {
const { minIntervalMs = 150, now = () => Date.now() } = opts;
this.minIntervalMs = minIntervalMs;
this.now = now;
this.queue = [];
this.lastRunAt = -Infinity;
}
/** @param {() => void} task */
enqueue(task) {
this.queue.push(task);
}
get size() {
return this.queue.length;
}
/** Fuehrt so viele faellige Tasks wie moeglich aus, mindestens minIntervalMs auseinander. */
processDue() {
let ranCount = 0;
let current = this.now();
while (this.queue.length > 0 && current - this.lastRunAt >= this.minIntervalMs) {
const task = this.queue.shift();
task();
ranCount += 1;
this.lastRunAt = current;
current = this.now();
}
return ranCount;
}
}
// ---------------------------------------------------------------------------------------
// Phase 6 - Externe Rollen & Tueren (siehe Visualization.md Abschnitt 9, Detailantworten F2)
// ---------------------------------------------------------------------------------------
/**
* Anlaesse, zu denen die Rolle "user" laut Detailantworten F2 durch die Tuer hereinkommt.
* Der Text auf den Klebezetteln bleibt weiterhin absichtlich unlesbar - der Anlass dient nur
* dazu, den richtigen kurzen Auftritt (Board-Besuch) auszuloesen.
*/
export const DOOR_VISIT_TYPES = {
// Neues Ticket im Backlog, das nicht von einer Rolle dort abgelegt wurde (F4: Backlog wird
// per Codeberg-Sync befuellt) - der User "traegt" es symbolisch aufs Board.
NEW_BACKLOG_TICKET: "new_backlog_ticket",
// Ticket erreicht Spalte "fertig" - der User schaut es sich an.
TICKET_READY_FOR_REVIEW: "ticket_ready_for_review",
// Ticket erreicht Spalte "done" (geschlossen) - der User legt es auf den Done-Stapel.
TICKET_DONE: "ticket_done",
};
/**
* Erkennt Tuerbesuch-Anlaesse (F2) durch Vergleich zweier Ticketlisten-Snapshots
* (z. B. zwei aufeinanderfolgende GET /api/v1/tickets-Antworten). Reine Diff-Logik ohne
* DOM-/Zeit-Abhaengigkeit, daher hier statt in office-render.js - so bleibt sie mit dem
* Node-Testrunner pruefbar (siehe Teststrategie in Visualization.md Abschnitt 8).
*
* @param {Array<{id: number, column_name: string, source?: string}>} previousTickets
* @param {Array<{id: number, column_name: string, source?: string}>} currentTickets
* @returns {Array<{type: string, ticketId: number}>}
*/
export function detectDoorVisits(previousTickets = [], currentTickets = []) {
const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket]));
const visits = [];
for (const ticket of currentTickets) {
const before = previousById.get(ticket.id);
if (!before) {
if (ticket.column_name === "backlog" && ticket.source === "codeberg") {
visits.push({ type: DOOR_VISIT_TYPES.NEW_BACKLOG_TICKET, ticketId: ticket.id });
}
continue;
}
if (before.column_name === ticket.column_name) {
continue;
}
if (ticket.column_name === "fertig") {
visits.push({ type: DOOR_VISIT_TYPES.TICKET_READY_FOR_REVIEW, ticketId: ticket.id });
} else if (ticket.column_name === "done") {
visits.push({ type: DOOR_VISIT_TYPES.TICKET_DONE, ticketId: ticket.id });
}
}
return visits;
}
// Ablauf eines Tuerbesuchs (F2): zum Board laufen, dort kurz interagieren, wieder hinaus zur
// Tuer gehen. Der Anlass (DOOR_VISIT_TYPES) unterscheidet nur den Grund, nicht den Ablauf.
export const DOOR_VISIT_STAGES = ["walking_to_board", "interacting_with_board", "leaving"];
const DOOR_VISIT_STAGE_VISUALS = {
walking_to_board: { location: AGENT_LOCATIONS.BOARD, pose: "walking" },
interacting_with_board: { location: AGENT_LOCATIONS.BOARD, pose: "interacting" },
leaving: { location: AGENT_LOCATIONS.DOOR, pose: "walking" },
};
/**
* @param {string} stage - Wert aus DOOR_VISIT_STAGES.
* @returns {{location: string, pose: string}}
*/
export function resolveDoorVisitVisual(stage) {
return DOOR_VISIT_STAGE_VISUALS[stage] ?? DOOR_VISIT_STAGE_VISUALS[DOOR_VISIT_STAGES[0]];
}
/**
* Schaltet einen Tuerbesuch zur naechsten Stage weiter.
*
* @param {string} stage - aktuelle Stage aus DOOR_VISIT_STAGES.
* @returns {string|null} naechste Stage, oder null, wenn der Besuch abgeschlossen ist
* (Avatar verlaesst danach die Szene wieder durch die Tuer).
*/
export function advanceDoorVisitStage(stage) {
const index = DOOR_VISIT_STAGES.indexOf(stage);
if (index === -1 || index === DOOR_VISIT_STAGES.length - 1) {
return null;
}
return DOOR_VISIT_STAGES[index + 1];
}
// ---------------------------------------------------------------------------------------
// Product Owner (Issue #19): fester Platz + Board-Besuch bei Ticket-Statuswechseln, die noch
// keiner Rolle zugeordnet sind (Visualization.md Abschnitt 4.4/9, Analogon zu Phase 6/Tueren).
// ---------------------------------------------------------------------------------------
// Der PO ist keine echte Registrierung ueber POST /api/v1/agents, sondern repraesentiert die
// Produktverantwortung des Systems selbst - er bekommt daher keinen dynamisch zugewiesenen
// Schreibtisch aus dem 8er-Raster, sondern einen eigenen, festen Platz (siehe office-render.js,
// PO_DESK), und gilt als anwesend, sobald die API erreichbar ist (nicht ueber das
// is_present-Feld wie registrierte Agenten, siehe isAgentPresent()).
export const PO_AGENT_ID = "po";
export const PO_DISPLAY_NAME = "PO";
// Statuswechsel, die bereits einer anderen Rolle zugeordnet sind: neue Backlog-Tickets sowie
// der Wechsel nach "fertig"/"done" loesen bereits einen Tuerbesuch der Rolle "user" aus
// (siehe DOOR_VISIT_TYPES). Alles andere - z.B. der Wechsel nach "bereit" durch das Label
// "automate" (das genau dieses Issue #19 ausgeloest hat) - ist bislang niemandem zugeordnet
// und damit Sache des PO.
const PO_UNASSIGNED_EXCLUDED_COLUMNS = new Set(["fertig", "done"]);
// Issue #32: Wechsel von/zu "in_arbeit" (Label "wip") setzt bzw. entfernt der Agent selbst, der
// das Ticket nimmt bzw. abgibt - er meldet diese Bewegung ueber seinen eigenen state (POST
// /api/v1/agents/{id}/activity, siehe resolveAgentVisualState()), nicht der PO. Ohne diesen
// Ausschluss lief bislang faelschlich zusaetzlich der PO zum Board, sobald ein Agent ein Ticket
// nahm oder abgab.
const AGENT_OWNED_COLUMN = "in_arbeit";
/**
* Erkennt Ticket-Statuswechsel, die keiner Rolle zugeordnet sind (Gegenstueck zu
* detectDoorVisits() fuer die bereits zugeordneten Faelle, sowie zu den vom Agenten selbst
* gemeldeten "in_arbeit"-Wechseln, siehe AGENT_OWNED_COLUMN). Neu angelegte Tickets zaehlen hier
* nicht mit - die sind bereits Sache von detectDoorVisits()/NEW_BACKLOG_TICKET.
*
* @param {Array<{id: number, column_name: string}>} previousTickets
* @param {Array<{id: number, column_name: string}>} currentTickets
* @returns {Array<{ticketId: number, column: string}>}
*/
export function detectUnassignedTicketChanges(previousTickets = [], currentTickets = []) {
const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket]));
const changes = [];
for (const ticket of currentTickets) {
const before = previousById.get(ticket.id);
if (!before || before.column_name === ticket.column_name) {
continue;
}
if (PO_UNASSIGNED_EXCLUDED_COLUMNS.has(ticket.column_name)) {
continue;
}
if (before.column_name === AGENT_OWNED_COLUMN || ticket.column_name === AGENT_OWNED_COLUMN) {
continue;
}
changes.push({ ticketId: ticket.id, column: ticket.column_name });
}
return changes;
}
/**
* Issue #61: Gegenstueck zu detectUnassignedTicketChanges() fuer genau die dort ausgeschlossenen
* Wechsel von/zu "in_arbeit" (siehe AGENT_OWNED_COLUMN). Anders als Tuer-/PO-Besuche gibt es fuer
* diese Wechsel keine hier selbst getaktete Animation - der zustaendige Agent meldet seine
* Bewegung zum Board (state "walking_to_board"/"interacting_with_board") unabhaengig ueber seinen
* eigenen state. office-render.js nutzt das Ergebnis, um das Ticket so lange an seiner alten
* Stelle zu halten, bis hasAgentReachedBoard() fuer den zustaendigen Agenten zutrifft (siehe dort)
* - sonst wuerde das Ticket bereits die Spalte wechseln, waehrend der Entwickler sichtbar noch
* auf dem Weg zum Board ist.
*
* @param {Array<{id: number, column_name: string, assignee_agent_id?: number|string|null}>} previousTickets
* @param {Array<{id: number, column_name: string, assignee_agent_id?: number|string|null}>} currentTickets
* @returns {Array<{ticketId: number, agentId: number|string|null}>}
*/
export function detectAgentOwnedTicketChanges(previousTickets = [], currentTickets = []) {
const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket]));
const changes = [];
for (const ticket of currentTickets) {
const before = previousById.get(ticket.id);
if (!before || before.column_name === ticket.column_name) {
continue;
}
if (before.column_name !== AGENT_OWNED_COLUMN && ticket.column_name !== AGENT_OWNED_COLUMN) {
continue;
}
const agentId = ticket.assignee_agent_id ?? before.assignee_agent_id ?? null;
changes.push({ ticketId: ticket.id, agentId });
}
return changes;
}
// Stages aus AGENT_STATE_VISUALS, waehrend derer der zustaendige Agent laut eigenem state
// nachweislich noch NICHT am Board angekommen ist (Issue #61). Jeder andere gemeldete state -
// insbesondere "interacting_with_board" und "walking_back", aber auch ein voellig unbekannter/
// fehlender Agent - gilt als "Ankunft bereits erfolgt (oder nicht mehr feststellbar)", damit ein
// zurueckgehaltenes Ticket nicht dauerhaft an seiner alten Stelle haengen bleibt, nur weil eine
// Zwischen-Stage zwischen zwei Polls verpasst wurde.
const STATES_BEFORE_BOARD_ARRIVAL = new Set(["walking_to_board"]);
/**
* @param {string|undefined|null} agentState - zuletzt gemeldeter Agent.state (API).
* @returns {boolean} true, sobald das zurueckgehaltene Ticket sichtbar an die neue Spalte
* wechseln darf (Issue #61).
*/
export function hasAgentReachedBoard(agentState) {
return !STATES_BEFORE_BOARD_ARRIVAL.has(agentState);
}
/**
* Issue #55: Bislang wurde ein Ticket sofort an seinem neuen Platz gezeichnet, sobald eine
* Aenderung erkannt wurde - Tuerbesuch/PO-Besuch liefen davon unabhaengig erst danach als reine
* Kosmetik ab, wirkten also "hinterher". Richtig ist es andersherum: die Persona soll zuerst am
* Board ankommen, bevor sich das Ticket sichtbar an die neue Stelle bewegt.
*
* office-render.js haelt dafuer pro Ticket, dessen Persona noch unterwegs ist, den alten Stand
* in einer Map (ticketId -> alter Ticket-Stand, oder null bei einem brandneuen Ticket) und
* ersetzt damit den neuen Stand aus der API-Antwort, bis die Persona am Board angekommen ist
* (siehe DOOR_VISIT_STAGES/PO_VISIT_STAGES, Stage "interacting_with_board"). Reine Merge-Logik
* ohne DOM-/Zeit-Abhaengigkeit, daher hier statt in office-render.js (siehe Teststrategie in
* Visualization.md Abschnitt 8).
*
* @param {Array<object>} latestTickets - aktuellster Stand aus der API.
* @param {Map<number, object|null>} pendingMoves - ticketId -> alter Ticket-Stand (null = Ticket
* ist neu und wird bis zum Eintreffen der Persona ganz ausgeblendet).
* @returns {Array<object>} zur Anzeige bestimmte Ticketliste.
*/
export function applyPendingTicketMoves(latestTickets = [], pendingMoves) {
if (!pendingMoves || pendingMoves.size === 0) {
return latestTickets;
}
const result = [];
for (const ticket of latestTickets) {
if (!pendingMoves.has(ticket.id)) {
result.push(ticket);
continue;
}
const oldTicket = pendingMoves.get(ticket.id);
if (oldTicket) {
result.push(oldTicket);
}
}
return result;
}
// Ablauf eines PO-Board-Besuchs: vom festen Platz zum Board laufen, dort kurz interagieren,
// zurueck zum Platz. Nutzt bewusst dieselben state-Keys wie die Agenten-Zustandsmaschine
// (resolveAgentVisualState), damit keine zweite Ort/Pose-Zuordnung noetig ist.
export const PO_VISIT_STAGES = ["walking_to_board", "interacting_with_board", "walking_back"];
/**
* @param {string} stage - aktuelle Stage aus PO_VISIT_STAGES.
* @returns {string|null} naechste Stage, oder null, wenn der Besuch abgeschlossen ist (der PO
* ist danach wieder "idle" am eigenen Platz).
*/
export function advancePoVisitStage(stage) {
const index = PO_VISIT_STAGES.indexOf(stage);
if (index === -1 || index === PO_VISIT_STAGES.length - 1) {
return null;
}
return PO_VISIT_STAGES[index + 1];
}
// ---------------------------------------------------------------------------------------
// Reload-Countdown im Header (Issue #12): rein kosmetischer Sekundenzaehler, der die
// Sync-Frequenz des Backends (MONITOR_SYNC_INTERVAL, siehe .env.example) im Header
// widerspiegelt - ohne selbst irgendetwas neu zu laden (siehe office-render.js).
// ---------------------------------------------------------------------------------------
export const DEFAULT_RELOAD_INTERVAL_SECONDS = 600;
/**
* @param {unknown} rawValue - z.B. window.MONITOR_SYNC_INTERVAL (aus config.js).
* @returns {number} gueltige Intervall-Laenge in Sekunden, faellt auf den Default zurueck,
* wenn der Rohwert fehlt oder keine positive Zahl ist.
*/
export function resolveReloadIntervalSeconds(rawValue) {
const parsed = Number(rawValue);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : DEFAULT_RELOAD_INTERVAL_SECONDS;
}
/**
* @param {number} current - aktueller Zaehlerstand in Sekunden.
* @param {number} intervalSeconds - Intervall-Laenge, auf die nach Ablauf zurueckgesetzt wird.
* @returns {number} naechster Zaehlerstand (zaehlt herunter, springt bei <=1 zurueck auf den vollen Wert).
*/
export function nextCountdownSeconds(current, intervalSeconds) {
return current <= 1 ? intervalSeconds : current - 1;
}