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:
parent
3767c8dc13
commit
62e5cb0751
7 changed files with 408 additions and 13 deletions
55
API.md
55
API.md
|
|
@ -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
|
`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
|
`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
|
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
|
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
|
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.
|
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
|
## Tickets
|
||||||
|
|
||||||
Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`.
|
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
|
F7: Konkrete Kennzahlen sind noch offen ("kommt spaeter"). Phase 1 stellt nur die
|
||||||
generische Speicher-/Abfragestruktur bereit.
|
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
|
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
|
abgeleitet sind: "Agenten"/"Online"/"Problem" aus `GET /api/v1/agents` (Issue #39) und "Hooks"
|
||||||
rechts) aus [`GET /api/v1/webhooks/stats`](#get-apiv1webhooksstats--empfangene-webhooks-zaehlen-hooks-kpi),
|
(ganz unten) aus [`GET /api/v1/webhooks/stats`](#get-apiv1webhooksstats--empfangene-webhooks-zaehlen-hooks-kpi),
|
||||||
siehe Issue #38.
|
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
|
### `POST /api/v1/kpis/{key}` — KPI-Datenpunkt melden
|
||||||
|
|
||||||
Auth: `X-Api-Key`.
|
Auth: `X-Api-Key`.
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,14 @@ header.office-header h1 {
|
||||||
display: none;
|
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 {
|
#office-scene {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
/* min-height: 0 hebt die Flex-Default-Mindesthoehe (min-content) auf - sonst kann
|
/* min-height: 0 hebt die Flex-Default-Mindesthoehe (min-content) auf - sonst kann
|
||||||
|
|
@ -290,6 +298,18 @@ header.office-header h1 {
|
||||||
fill: #7dd3fc;
|
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 {
|
.desk-chair {
|
||||||
fill: var(--color-desk-chair);
|
fill: var(--color-desk-chair);
|
||||||
}
|
}
|
||||||
|
|
@ -316,6 +336,18 @@ header.office-header h1 {
|
||||||
font-weight: 400;
|
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 {
|
.desk-deco-star {
|
||||||
fill: #fbbf24;
|
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 {
|
.person-prop-cup {
|
||||||
fill: #f8fafc;
|
fill: #f8fafc;
|
||||||
stroke: #94a3b8;
|
stroke: #94a3b8;
|
||||||
|
|
@ -489,6 +566,16 @@ header.office-header h1 {
|
||||||
font-weight: 600;
|
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 {
|
.kpi-sparkline-bg {
|
||||||
fill: rgba(255, 255, 255, 0.04);
|
fill: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,9 @@ import {
|
||||||
mapAgentsToDesks,
|
mapAgentsToDesks,
|
||||||
normalizeAgentForDisplay,
|
normalizeAgentForDisplay,
|
||||||
resolveAgentVisualState,
|
resolveAgentVisualState,
|
||||||
isAgentPresent,
|
isAgentVisible,
|
||||||
|
agentProblemSeverity,
|
||||||
|
agentsWithProblems,
|
||||||
pickIdleActivity,
|
pickIdleActivity,
|
||||||
AnimationQueue,
|
AnimationQueue,
|
||||||
detectDoorVisits,
|
detectDoorVisits,
|
||||||
|
|
@ -396,7 +398,9 @@ function renderKpiMonitor(frame, kpis) {
|
||||||
text(`${tile.label}: ${tile.currentValue ?? "–"} ${tile.unit}`.trim(), {
|
text(`${tile.label}: ${tile.currentValue ?? "–"} ${tile.unit}`.trim(), {
|
||||||
x: frame.x + 16,
|
x: frame.x + 16,
|
||||||
y: rowTop + 14,
|
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;
|
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) {
|
function deskScreenPoseClass(pose) {
|
||||||
switch (pose) {
|
switch (pose) {
|
||||||
case "working":
|
case "working":
|
||||||
return "pose-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:
|
default:
|
||||||
return "pose-idle";
|
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(
|
g.appendChild(
|
||||||
text(agent.name, {
|
text(agent.name, {
|
||||||
x: width / 2,
|
x: width / 2,
|
||||||
y: 40 + DESK.h + 74,
|
y: 40 + DESK.h + 74,
|
||||||
class: "desk-name",
|
class: `desk-name ${problem}`.trim(),
|
||||||
"text-anchor": "middle",
|
"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" }));
|
nodes.push(el("rect", { x: -16, y: -48, width: 32, height: 18, rx: 1, class: "person-prop-newspaper" }));
|
||||||
} else if (pose === "talking") {
|
} else if (pose === "talking") {
|
||||||
nodes.push(el("circle", { cx: 20, cy: -40, r: 7, class: "person-prop-speech" }));
|
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;
|
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() {
|
function createAgentAvatarElement() {
|
||||||
const g = el("g", { class: "agent-avatar" });
|
const g = el("g", { class: "agent-avatar" });
|
||||||
g.appendChild(el("circle", { cx: 0, cy: -34, r: 16, class: "person-head" }));
|
g.appendChild(el("circle", { cx: 0, cy: -34, r: 16, class: "person-head" }));
|
||||||
|
|
@ -1482,6 +1515,10 @@ export function renderOfficeScene(container, data = {}) {
|
||||||
currentRegularPlacements = placements;
|
currentRegularPlacements = placements;
|
||||||
repaintAgentsLayer();
|
repaintAgentsLayer();
|
||||||
scheduleIdleCosmeticsForCurrentAgents(placements);
|
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
|
// 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;
|
let overflowIndex = 0;
|
||||||
for (const raw of agents) {
|
for (const raw of agents) {
|
||||||
if (seatedIds.has(raw.id)) continue;
|
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 agent = normalizeAgentForDisplay(raw);
|
||||||
const homeFeet = {
|
const homeFeet = {
|
||||||
x: 70 + (overflowIndex % 3) * 26,
|
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
|
// 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
|
// 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.
|
// herunter und startet danach von vorn - loest selbst keinen Reload aus.
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,12 @@ export function buildKpiSparklinePoints(history = [], width = 140, height = 32,
|
||||||
.join(" ");
|
.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
|
* 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,
|
label: kpi.label || kpi.key,
|
||||||
unit: kpi.unit || "",
|
unit: kpi.unit || "",
|
||||||
currentValue: kpi.current_value,
|
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 || []),
|
sparklinePoints: buildKpiSparklinePoints(kpi.history || []),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -408,6 +416,10 @@ const AGENT_STATE_VISUALS = {
|
||||||
reading_newspaper: { location: AGENT_LOCATIONS.DESK, pose: "reading_newspaper" },
|
reading_newspaper: { location: AGENT_LOCATIONS.DESK, pose: "reading_newspaper" },
|
||||||
at_coffee_corner: { location: AGENT_LOCATIONS.COFFEE_CORNER, pose: "coffee" },
|
at_coffee_corner: { location: AGENT_LOCATIONS.COFFEE_CORNER, pose: "coffee" },
|
||||||
talking_to_colleague: { location: AGENT_LOCATIONS.DESK, pose: "talking" },
|
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);
|
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
|
* Die beiden im KPI-Monitor erwarteten Kennzahlen "Agenten" (registrierte Agenten) und
|
||||||
* "Online" (davon innerhalb der letzten 10 Minuten aktiv, siehe isAgentPresent()) werden
|
* "Online" (davon innerhalb der letzten 10 Minuten aktiv, siehe isAgentPresent()) werden
|
||||||
|
|
@ -445,10 +518,21 @@ export function isAgentPresent(agent) {
|
||||||
export function buildAgentCountKpis(agents = []) {
|
export function buildAgentCountKpis(agents = []) {
|
||||||
const registered = agents.filter(Boolean);
|
const registered = agents.filter(Boolean);
|
||||||
const online = registered.filter(isAgentPresent).length;
|
const online = registered.filter(isAgentPresent).length;
|
||||||
|
const problems = countAgentProblems(registered);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ key: "agents_total", label: "Agenten", unit: "", current_value: registered.length, history: [] },
|
{ key: "agents_total", label: "Agenten", unit: "", current_value: registered.length, history: [] },
|
||||||
{ key: "agents_online", label: "Online", unit: "", current_value: online, 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
|
* 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
|
* 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 -
|
* 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 {Array<object>} agents
|
||||||
* @param {number} [deskCount]
|
* @param {number} [deskCount]
|
||||||
|
|
@ -468,7 +553,7 @@ export function buildAgentCountKpis(agents = []) {
|
||||||
export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT) {
|
export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT) {
|
||||||
const desks = new Array(deskCount).fill(null);
|
const desks = new Array(deskCount).fill(null);
|
||||||
for (const agent of agents) {
|
for (const agent of agents) {
|
||||||
if (!isAgentPresent(agent)) {
|
if (!isAgentVisible(agent)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const deskIndex = agent.desk_index;
|
const deskIndex = agent.desk_index;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@
|
||||||
|
|
||||||
<div id="api-status-banner" class="api-banner" role="alert" hidden>API nicht erreichbar!</div>
|
<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>
|
<main id="office-scene" aria-label="Buero-Szene"></main>
|
||||||
|
|
||||||
<!-- Laufzeit-Konfiguration (API-Basis-URL, Sync-Intervall fuer den kosmetischen
|
<!-- Laufzeit-Konfiguration (API-Basis-URL, Sync-Intervall fuer den kosmetischen
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Test-Tooling fuer das Vanilla-JS-Frontend (html/js/). Laufzeitcode selbst hat keine Abhaengigkeiten (siehe Visualization.md Abschnitt 5).",
|
"description": "Test-Tooling fuer das Vanilla-JS-Frontend (html/js/). Laufzeitcode selbst hat keine Abhaengigkeiten (siehe Visualization.md Abschnitt 5).",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test --experimental-test-coverage tests/frontend"
|
"test": "node --test --experimental-test-coverage \"tests/frontend/**/*.test.js\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ import {
|
||||||
mapAgentsToDesks,
|
mapAgentsToDesks,
|
||||||
normalizeAgentForDisplay,
|
normalizeAgentForDisplay,
|
||||||
isAgentPresent,
|
isAgentPresent,
|
||||||
|
isAgentVisible,
|
||||||
|
agentProblemSeverity,
|
||||||
|
countAgentProblems,
|
||||||
|
agentsWithProblems,
|
||||||
pickIdleActivity,
|
pickIdleActivity,
|
||||||
AnimationQueue,
|
AnimationQueue,
|
||||||
DOOR_VISIT_TYPES,
|
DOOR_VISIT_TYPES,
|
||||||
|
|
@ -339,9 +343,17 @@ test("buildAgentCountKpis liefert Anzahl registrierter und online-Agenten (Issue
|
||||||
{ id: 2, is_present: false },
|
{ id: 2, is_present: false },
|
||||||
{ id: 3, is_present: true },
|
{ 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[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[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", () => {
|
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);
|
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", () => {
|
test("normalizeAgentForDisplay leitet name/pose/location aus display_name/state ab", () => {
|
||||||
const normalized = normalizeAgentForDisplay({ id: 1, display_name: "Alice", state: "working" });
|
const normalized = normalizeAgentForDisplay({ id: 1, display_name: "Alice", state: "working" });
|
||||||
assert.equal(normalized.name, "Alice");
|
assert.equal(normalized.name, "Alice");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue