Abgelaufene Claude-Anmeldung eines Agenten sichtbar machen

Laeuft einem Agenten die Claude-Anmeldung aus, laeuft das Cron-Skript um ihn
herum unbeeindruckt weiter: es meldet "working", ruft claude auf, bekommt nichts
Brauchbares zurueck und meldet am Ende "idle". last_seen_at ist frisch, der
Agent sieht beschaeftigt aus - waehrend Lauf fuer Lauf nichts entsteht. Der
Ausfall fiel bisher erst auf, wenn jemand merkte, dass keine PRs mehr kommen.

Agenten melden den Fall jetzt ueber zwei neue state-Werte (auth_expiring,
auth_expired) am bestehenden Activity-Endpunkt. state ist serverseitig nicht
eingeschraenkt, daher ohne Migration und ohne Aenderung an der API.

Dargestellt in drei Stufen der Aufmerksamkeit:
- Schloss ueber dem Avatar (bei auth_expired pulsierend), entsaettigter Koerper,
  eingefaerbter Bildschirm und Namensschild am Platz
- Header-Banner mit den betroffenen Agenten, gelb bei Vorwarnung, rot bei
  Ausfall - auch aus einigen Metern Entfernung noch lesbar
- KPI-Kachel "Problem: n", die sich nur einfaerbt, wenn n > 0

Kern ist isAgentVisible(), bewusst getrennt von isAgentPresent(): ein
blockierter Agent bleibt sichtbar, obwohl er sich nicht mehr meldet, zaehlt aber
nicht als "Online" mit. Ohne das verschwaende er nach dem Praesenz-Timeout genau
dann vom Schreibtisch, wenn seine Warnung dort stehen soll.

MAX_KPI_TILES von 4 auf 5, sonst verdraengen Agenten/Online/Problem/Hooks die
gemeldeten KPIs (F7) vollstaendig.

Nebenbei: "npm test" lief seit Node 24 nicht mehr (das Verzeichnis-Argument wird
nicht mehr als Testverzeichnis aufgeloest), jetzt per Glob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-07 09:51:45 +02:00
parent 3767c8dc13
commit 62e5cb0751
7 changed files with 408 additions and 13 deletions

55
API.md
View file

@ -105,7 +105,8 @@ curl -s "$MONITOR_API_BASE/agents" | jq '.agents[] | {id, display_name, last_see
`is_present` gilt `true`, solange `last_seen_at` hoechstens zehn Minuten zurueckliegt, sonst
`false` (`AgentRepository::isPresent()`, Issue #17/#20) - laeuft der Timeout ab, bleibt der
Schreibtisch des Agenten leer, obwohl er weiterhin registriert ist. Berechnet wird das
Schreibtisch des Agenten leer, obwohl er weiterhin registriert ist (Ausnahme sind die
[Problemzustaende](#problemzustaende-abgelaufene-claude-anmeldung), die sichtbar bleiben). Berechnet wird das
serverseitig via `gmdate()`/`time()` (also unabhaengig von der Zeitzone des Clients); das
Frontend (`isAgentPresent`, `html/js/office-scene.js`) liest `is_present` nur noch direkt aus
der API, statt selbst aus `last_seen_at` zu rechnen - vorher fuehrte das je nach
@ -130,6 +131,47 @@ melden, sonst `403`).
Response 200: aktualisiertes Agent-Objekt.
`state` ist bewusst nicht serverseitig eingeschraenkt - welche Zustaende es gibt, entscheidet
allein die Darstellung (`AGENT_STATE_VISUALS` in `html/js/office-scene.js`), unbekannte Werte
fallen dort auf "idle am Schreibtisch" zurueck. Gebraeuchlich sind `idle`, `working` sowie die
Lauf-/Interaktionszustaende der Zustandsmaschine (`walking_to_board`, `interacting_with_board`,
`walking_to_monitor`, `interacting_with_monitor`, `walking_back`).
#### Problemzustaende: abgelaufene Claude-Anmeldung
Zwei Zustaende sind Sonderfaelle, weil sie ein menschliches Eingreifen erfordern:
| `state` | Bedeutung | Darstellung |
| --- | --- | --- |
| `auth_expiring` | Die Claude-Anmeldung des Agenten laeuft demnaechst ab, er arbeitet noch. | gelbes Schloss am Avatar, gelbes Namensschild, gelber Header-Banner |
| `auth_expired` | Die Anmeldung ist abgelaufen, der Agent kommt nicht mehr weiter. | rot pulsierendes Schloss, rotes Namensschild, roter Header-Banner |
Hintergrund: Laeuft die Anmeldung aus, laeuft das Cron-Skript um den Agenten herum
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: `last_seen_at` ist frisch, der Agent sieht beschaeftigt
aus, waehrend Lauf fuer Lauf nichts entsteht. Der Monitor-API-Key ist von der Claude-Anmeldung
unabhaengig und bleibt gueltig - der Agent kann den Zustand also weiterhin melden.
Zwei Besonderheiten:
- **Sie ueberleben den Praesenz-Timeout.** Hat ein Agent `auth_expired` gemeldet, bricht sein
Skript den Lauf ab und meldet bis zum naechsten Cron-Start nichts mehr. Das Frontend zeigt ihn
trotzdem weiter (`isAgentVisible()`), sonst verschwaende er genau dann vom Schreibtisch, wenn
seine Warnung dort stehen soll. `is_present`/`agents_online` bleiben davon unberuehrt - ein
blockierter Agent zaehlt nicht als online.
- **Sie loesen sich nur durch eine neue Meldung.** Es gibt bewusst kein Timeout; der naechste
erfolgreiche Lauf meldet ohnehin `working`/`idle` und raeumt den Zustand damit auf.
Ergaenzend gehoert ein Event (`POST /api/v1/events`, severity `error` bzw. `warning`) dazu - der
Zustand allein hat kein Datum, das Event beantwortet "seit wann".
```bash
curl -X POST "$MONITOR_API_BASE/agents/$ID/activity" \
-H "Content-Type: application/json" -H "X-Api-Key: $KEY" \
-d '{"state": "auth_expired", "current_ticket": null}'
```
## Tickets
Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`.
@ -224,12 +266,17 @@ Response: `{ "systems": [ { "id", "name", "type", "status", "detail", "source",
F7: Konkrete Kennzahlen sind noch offen ("kommt spaeter"). Phase 1 stellt nur die
generische Speicher-/Abfragestruktur bereit.
Zusaetzlich zu hier gemeldeten Kennzahlen zeigt der KPI-Monitor im Frontend drei weitere,
Zusaetzlich zu hier gemeldeten Kennzahlen zeigt der KPI-Monitor im Frontend vier weitere,
nicht per `POST /api/v1/kpis/{key}` gemeldete Kacheln, die direkt aus anderen Endpunkten
abgeleitet sind: "Agenten"/"Online" aus `GET /api/v1/agents` (Issue #39) und "Hooks" (ganz
rechts) aus [`GET /api/v1/webhooks/stats`](#get-apiv1webhooksstats--empfangene-webhooks-zaehlen-hooks-kpi),
abgeleitet sind: "Agenten"/"Online"/"Problem" aus `GET /api/v1/agents` (Issue #39) und "Hooks"
(ganz unten) aus [`GET /api/v1/webhooks/stats`](#get-apiv1webhooksstats--empfangene-webhooks-zaehlen-hooks-kpi),
siehe Issue #38.
"Problem" zaehlt die Agenten in einem
[Problemzustand](#problemzustaende-abgelaufene-claude-anmeldung) und faerbt sich nur rot, wenn
der Wert ueber 0 liegt - eine dauerhaft rote Kachel mit einer 0 waere genau das Rauschen, das den
Ernstfall untergehen liesse.
### `POST /api/v1/kpis/{key}` — KPI-Datenpunkt melden
Auth: `X-Api-Key`.

View file

@ -89,6 +89,14 @@ header.office-header h1 {
display: none;
}
/* Reine Vorwarnung (Anmeldung laeuft demnaechst ab, der Agent arbeitet noch): gelb statt rot,
damit das Rot dem tatsaechlichen Stillstand vorbehalten bleibt. Dunkle Schrift, weil Weiss
auf Gelb kaum zu lesen ist. */
.api-banner.api-banner-warning {
background: #facc15;
color: #422006;
}
#office-scene {
flex: 1 1 auto;
/* min-height: 0 hebt die Flex-Default-Mindesthoehe (min-content) auf - sonst kann
@ -290,6 +298,18 @@ header.office-header h1 {
fill: #7dd3fc;
}
/* Blockierter Platz (Claude-Anmeldung abgelaufen): der Bildschirm traegt den Zustand mit,
damit er auch dann am Platz sichtbar bleibt, wenn der Avatar gerade nicht dort steht. */
.desk-screen.pose-auth_expired {
fill: #7f1d1d;
stroke: #f87171;
}
.desk-screen.pose-auth_expiring {
fill: #713f12;
stroke: #facc15;
}
.desk-chair {
fill: var(--color-desk-chair);
}
@ -316,6 +336,18 @@ header.office-header h1 {
font-weight: 400;
}
/* Der Name eines Agenten am Schreibtisch steht am Tisch, nicht am Avatar (updateAgentsLayer()
blendet .avatar-name am Platz aus) - der Problemzustand faerbt deshalb hier. */
.desk-name.desk-name-problem-error {
fill: #b91c1c;
font-weight: 700;
}
.desk-name.desk-name-problem-warning {
fill: #a16207;
font-weight: 700;
}
.desk-deco-star {
fill: #fbbf24;
}
@ -457,6 +489,51 @@ header.office-header h1 {
}
}
/* Blockierter Agent (AGENT_PROBLEM_STATES): der Koerper verliert seine Rollenfarbe, damit die
Figur als "steht still" lesbar wird, ohne aus der Szene zu verschwinden. */
.agent-avatar.pose-auth_expired .person-body,
.agent-avatar.pose-auth_expiring .person-body {
fill: #94a3b8;
}
.person-prop-lock-body {
fill: #f87171;
stroke: #7f1d1d;
stroke-width: 1.5;
}
.person-prop-lock-shackle {
fill: none;
stroke: #f87171;
stroke-width: 3;
stroke-linecap: round;
}
.pose-auth_expiring .person-prop-lock-body {
fill: #facc15;
stroke: #713f12;
}
.pose-auth_expiring .person-prop-lock-shackle {
stroke: #facc15;
}
/* Nur der harte Ausfall pulsiert - eine pulsierende Vorwarnung waere auf einem Dauermonitor
Rauschen, das man nach einer Stunde nicht mehr sieht. */
.agent-avatar.pose-auth_expired .person-prop-lock {
animation: office-lock-pulse 1.4s ease-in-out infinite;
}
@keyframes office-lock-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.person-prop-cup {
fill: #f8fafc;
stroke: #94a3b8;
@ -489,6 +566,16 @@ header.office-header h1 {
font-weight: 600;
}
/* Kombiniert notiert, weil .kpi-tile-label weiter unten steht als .severity-* und die Farbe
sonst bei gleicher Spezifitaet ueber die Reihenfolge zurueckgewinnt. */
.kpi-tile-label.severity-error {
fill: #f87171;
}
.kpi-tile-label.severity-warning {
fill: #facc15;
}
.kpi-sparkline-bg {
fill: rgba(255, 255, 255, 0.04);
}

View file

@ -30,7 +30,9 @@ import {
mapAgentsToDesks,
normalizeAgentForDisplay,
resolveAgentVisualState,
isAgentPresent,
isAgentVisible,
agentProblemSeverity,
agentsWithProblems,
pickIdleActivity,
AnimationQueue,
detectDoorVisits,
@ -396,7 +398,9 @@ function renderKpiMonitor(frame, kpis) {
text(`${tile.label}: ${tile.currentValue ?? "–"} ${tile.unit}`.trim(), {
x: frame.x + 16,
y: rowTop + 14,
class: "kpi-tile-label",
// 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;
@ -486,10 +490,20 @@ function deskSeat(deskIndex) {
};
}
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";
}
@ -547,11 +561,16 @@ function renderDeskAt(origin, agent, width = DESK.w) {
);
}
// 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",
class: `desk-name ${problem}`.trim(),
"text-anchor": "middle",
})
);
@ -674,10 +693,24 @@ function poseExtras(pose) {
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" }));
@ -1482,6 +1515,10 @@ export function renderOfficeScene(container, data = {}) {
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
@ -1519,7 +1556,9 @@ function agentPlacements(agents, deskLayout) {
let overflowIndex = 0;
for (const raw of agents) {
if (seatedIds.has(raw.id)) continue;
if (!isAgentPresent(raw)) 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,
@ -1543,6 +1582,34 @@ function setApiBanner(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.

View file

@ -325,7 +325,12 @@ export function buildKpiSparklinePoints(history = [], width = 140, height = 32,
.join(" ");
}
export const MAX_KPI_TILES = 4;
// 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
@ -340,6 +345,9 @@ export function buildKpiTiles(kpis = []) {
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 || []),
}));
}
@ -408,6 +416,10 @@ const AGENT_STATE_VISUALS = {
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" },
};
/**
@ -431,6 +443,67 @@ export function isAgentPresent(agent) {
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
@ -445,10 +518,21 @@ export function isAgentPresent(agent) {
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,
},
];
}
@ -459,7 +543,8 @@ export function buildAgentCountKpis(agents = []) {
* 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.
* 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]
@ -468,7 +553,7 @@ export function buildAgentCountKpis(agents = []) {
export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT) {
const desks = new Array(deskCount).fill(null);
for (const agent of agents) {
if (!isAgentPresent(agent)) {
if (!isAgentVisible(agent)) {
continue;
}
const deskIndex = agent.desk_index;

View file

@ -15,6 +15,11 @@
<div id="api-status-banner" class="api-banner" role="alert" hidden>API nicht erreichbar!</div>
<!-- Agent blockiert (Claude-Anmeldung abgelaufen): anders als der API-Banner mit
dynamischem Text, weil er die betroffenen Agenten nennt. Siehe
setAgentProblemBanner() in js/office-render.js. -->
<div id="agent-problem-banner" class="api-banner" role="alert" hidden></div>
<main id="office-scene" aria-label="Buero-Szene"></main>
<!-- Laufzeit-Konfiguration (API-Basis-URL, Sync-Intervall fuer den kosmetischen

View file

@ -4,6 +4,6 @@
"type": "module",
"description": "Test-Tooling fuer das Vanilla-JS-Frontend (html/js/). Laufzeitcode selbst hat keine Abhaengigkeiten (siehe Visualization.md Abschnitt 5).",
"scripts": {
"test": "node --test --experimental-test-coverage tests/frontend"
"test": "node --test --experimental-test-coverage \"tests/frontend/**/*.test.js\""
}
}

View file

@ -28,6 +28,10 @@ import {
mapAgentsToDesks,
normalizeAgentForDisplay,
isAgentPresent,
isAgentVisible,
agentProblemSeverity,
countAgentProblems,
agentsWithProblems,
pickIdleActivity,
AnimationQueue,
DOOR_VISIT_TYPES,
@ -339,9 +343,17 @@ test("buildAgentCountKpis liefert Anzahl registrierter und online-Agenten (Issue
{ id: 2, is_present: false },
{ id: 3, is_present: true },
]);
assert.equal(kpis.length, 2);
assert.equal(kpis.length, 3);
assert.deepEqual(kpis[0], { key: "agents_total", label: "Agenten", unit: "", current_value: 3, history: [] });
assert.deepEqual(kpis[1], { key: "agents_online", label: "Online", unit: "", current_value: 2, history: [] });
assert.deepEqual(kpis[2], {
key: "agents_problem",
label: "Problem",
unit: "",
current_value: 0,
history: [],
severity: null,
});
});
test("buildAgentCountKpis ignoriert leere Schreibtisch-Plaetze (null) und faellt ohne is_present auf anwesend zurueck", () => {
@ -422,6 +434,98 @@ test("mapAgentsToDesks laesst den Schreibtisch eines Agenten frei, der laut is_p
assert.equal(desks[0], null);
});
// ---------------------------------------------------------------------------------------
// Problemzustaende: abgelaufene Claude-Anmeldung (auth_expiring/auth_expired)
// ---------------------------------------------------------------------------------------
test("agentProblemSeverity ordnet den beiden Auth-Zustaenden einen Schweregrad zu", () => {
assert.equal(agentProblemSeverity({ state: "auth_expiring" }), "warning");
assert.equal(agentProblemSeverity({ state: "auth_expired" }), "error");
});
test("agentProblemSeverity meldet fuer Normalbetrieb und unbekannte Zustaende kein Problem", () => {
assert.equal(agentProblemSeverity({ state: "working" }), null);
assert.equal(agentProblemSeverity({ state: "was-auch-immer" }), null);
assert.equal(agentProblemSeverity({ id: 1 }), null);
assert.equal(agentProblemSeverity(null), null);
});
test("resolveAgentVisualState laesst blockierte Agenten am Schreibtisch, mit eigener Pose", () => {
assert.deepEqual(resolveAgentVisualState("auth_expired"), {
location: AGENT_LOCATIONS.DESK,
pose: "auth_expired",
});
assert.deepEqual(resolveAgentVisualState("auth_expiring"), {
location: AGENT_LOCATIONS.DESK,
pose: "auth_expiring",
});
});
test("isAgentVisible haelt einen blockierten Agenten sichtbar, obwohl er nicht mehr praesent ist", () => {
const blocked = { id: 1, is_present: false, state: "auth_expired" };
assert.equal(isAgentPresent(blocked), false);
assert.equal(isAgentVisible(blocked), true);
});
test("isAgentVisible blendet einen abwesenden Agenten ohne Problemzustand weiterhin aus", () => {
assert.equal(isAgentVisible({ id: 1, is_present: false, state: "idle" }), false);
assert.equal(isAgentVisible({ id: 1, is_present: true, state: "working" }), true);
});
test("mapAgentsToDesks behaelt einen blockierten Agenten auf seinem Platz, auch ohne frische Meldung", () => {
const desks = mapAgentsToDesks(
[
{ id: 1, desk_index: 0, is_present: false, state: "auth_expired" },
{ id: 2, desk_index: 1, is_present: false, state: "idle" },
],
2
);
assert.equal(desks[0].id, 1);
assert.equal(desks[1], null);
});
test("countAgentProblems zaehlt beide Schweregrade und ignoriert leere Plaetze", () => {
assert.equal(
countAgentProblems([
{ id: 1, state: "auth_expired" },
{ id: 2, state: "working" },
null,
{ id: 3, state: "auth_expiring" },
]),
2
);
assert.equal(countAgentProblems([]), 0);
});
test("agentsWithProblems liefert die betroffenen Agenten fuer den Banner", () => {
const problems = agentsWithProblems([
{ id: 1, display_name: "Alice", state: "auth_expired" },
{ id: 2, display_name: "Bob", state: "working" },
]);
assert.equal(problems.length, 1);
assert.equal(problems[0].display_name, "Alice");
});
test("buildAgentCountKpis zaehlt einen blockierten Agenten als Problem, nicht als online", () => {
const kpis = buildAgentCountKpis([
{ id: 1, is_present: true, state: "working" },
{ id: 2, is_present: false, state: "auth_expired" },
]);
assert.equal(kpis[0].current_value, 2, "registriert");
assert.equal(kpis[1].current_value, 1, "online");
assert.equal(kpis[2].current_value, 1, "Problem");
assert.equal(kpis[2].severity, "error");
});
test("buildKpiTiles reicht den Schweregrad durch und laesst gemeldete KPIs neutral", () => {
const tiles = buildKpiTiles([
{ key: "agents_problem", label: "Problem", current_value: 2, history: [], severity: "error" },
{ key: "open_tickets", label: "Offene Tickets", current_value: 4, history: [] },
]);
assert.equal(tiles[0].severity, "error");
assert.equal(tiles[1].severity, null);
});
test("normalizeAgentForDisplay leitet name/pose/location aus display_name/state ab", () => {
const normalized = normalizeAgentForDisplay({ id: 1, display_name: "Alice", state: "working" });
assert.equal(normalized.name, "Alice");