Lagebild, Stufe 0: Teamuebersicht in der WebUI (#711)

Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es,
auf einen Blick. Stufe 0 kommt ohne Einheiten aus; mit #683 werden die Agenten
zu aufklappbaren Containern, und die Ansicht faellt mit #688 zusammen.

Kern:
- AgentActivity: fuehrt die laufenden Laeufe (Beginn, Quelle, Aufgabe,
  Aufrufer) und die letzten Verbindungen (begrenzt auf 500), meldet Start,
  Ende und Verbindung als Ereignis. Vorher war nur ein Lauf ueber ChatAsync
  "laufend"; ein frischer Lauf (@new) war unsichtbar, und niemand hielt fest,
  wer wen gerufen hatte.
- Die Engine meldet beide Einstiege an; "arbeitet" beginnt hinter dem
  Agententor.

Anwendung:
- TeamOverview stellt den Lagebericht zusammen. Die Zustaende entscheidet der
  Code aus erfassten Tatsachen: laufender Lauf, offene Freigabe, Status des
  letzten Belegs, Laufzeit gegen den Median des Agenten (haengt ab dem
  Dreifachen, fruehestens nach 5 Minuten, ohne Verlauf nach 15). Rangfolge:
  haengt, wartet auf Freigabe, arbeitet; Problem und Budget nur, solange
  nichts Neues laeuft.

Web:
- GET /api/team, Ereignis "activity" (started, finished, link); das Ereignis
  "run" traegt jetzt die Agenten-Id.
- Ansicht #/team mit Cytoscape.js und ELK (bei Bedarf geladen): Layout aus
  der Struktur statt von Hand, Farbe und Symbol je Zustand, Kanten verblassen
  nach 30 s, Klick oeffnet eine Seitenleiste (laufende Laeufe, Freigaben,
  letzter Lauf im Wortlaut) mit "Freigaben oeffnen" vorne, danach Chat und
  Laeufe. Sprachpakete en/de.
- Ein Layout, das lief, solange die Flaeche noch 0 px breit war, wird neu
  gerechnet, sobald sie eine Breite hat (im Pruefgestell gefunden).

Geprueft im Browser mit einem Pruefgestell (API und Ereignisstrom durch
Attrappen ersetzt): sieben Agenten in allen Zustaenden, Seitenleiste,
Live-Kante und Verblassen.

Tests: AgentActivityTests (8), TeamOverviewTests (14). 1.283 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 12:34:46 +02:00
co-authored by Claude Opus 5.5
parent d587dc3f20
commit f627918729
17 changed files with 1153 additions and 1 deletions
@@ -1721,3 +1721,77 @@ div.klein,
td.klein {
font-size: 12px;
}
/* ─── Team overview (#711) ─── */
.team-ansicht {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
.team-zusammenfassung {
flex-wrap: wrap;
min-height: 24px;
}
.team-flaeche {
display: flex;
gap: 12px;
flex: 1;
min-height: 420px;
}
.team-graph {
flex: 1;
min-width: 0;
min-height: 420px;
background: var(--surface-subtil);
border: 1px solid var(--divider);
}
.team-seitenleiste {
width: 320px;
flex-shrink: 0;
padding: 14px;
background: var(--surface);
border: 1px solid var(--divider);
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
}
.team-seitenleiste-kopf {
justify-content: space-between;
}
.team-seitenleiste h3 {
margin: 8px 0 0;
}
.team-ergebnis {
white-space: pre-wrap;
word-break: break-word;
max-height: 160px;
overflow-y: auto;
font-family: var(--schrift-mono);
}
.team-aktionen {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.team-legende {
flex-wrap: wrap;
gap: 16px;
}
@media (max-width: 800px) {
.team-flaeche { flex-direction: column; }
.team-seitenleiste { width: auto; }
}
@@ -35,10 +35,12 @@ import { renderUsageRuns } from "./views/usage-runs.js";
import { renderUsageTimeline } from "./views/usage-timeline.js";
import { renderRunDetail } from "./views/run-detail.js";
import { renderPromptHistory } from "./views/prompt-history.js";
import { renderTeam, leaveTeam } from "./views/team.js";
// ─── 1. Routen registrieren ───
function verlasseAnsicht() {
leaveTeam();
trenneLogStream();
trenneAgentenChatsStream();
stoppeInstanzenPolling();
@@ -50,6 +52,7 @@ registriereRoute("/chat/:agentId", (p) => { verlasseAnsicht(); return rendereCha
registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); });
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
@@ -67,6 +67,28 @@
"nav.chat": "Chat",
"nav.agents": "Agenten",
"nav.tasks": "Aufgaben",
"nav.team": "Teamübersicht",
"team.title": "Teamübersicht",
"team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.",
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
"team.empty": "Diese Instanz hat keine Agenten.",
"team.legendLink": "hat erreicht (verblasst nach 30 s)",
"team.state.Idle": "bereit",
"team.state.Working": "arbeitet",
"team.state.Stuck": "hängt",
"team.state.WaitingForApproval": "wartet auf Freigabe",
"team.state.Problem": "Problem",
"team.state.BudgetExhausted": "Budget erschöpft",
"team.running": "Läuft gerade",
"team.since": "seit {time}",
"team.task": "Aufgabe {id}",
"team.calledBy": "gerufen von {agent}",
"team.stuckHint": "Läufe dieses Agenten dauern sonst etwa {minutes} Min.",
"team.approvals": "{n} Werkzeugaufruf/-aufrufe warten auf Freigabe",
"team.lastRun": "Letzter Lauf",
"team.openApprovals": "Freigaben öffnen",
"team.openChat": "Chat öffnen",
"team.openRuns": "Läufe anzeigen",
"nav.services": "Dienste",
"nav.group.analytics": "Analyse",
"nav.usage": "Tokennutzung",
@@ -67,6 +67,28 @@
"nav.chat": "Chat",
"nav.agents": "Agents",
"nav.tasks": "Tasks",
"nav.team": "Team overview",
"team.title": "Team overview",
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.",
"team.noInstance": "No instance selected. Choose an active instance in the header.",
"team.empty": "No agents in this instance.",
"team.legendLink": "reached (fades after 30 s)",
"team.state.Idle": "idle",
"team.state.Working": "working",
"team.state.Stuck": "stuck",
"team.state.WaitingForApproval": "waiting for approval",
"team.state.Problem": "problem",
"team.state.BudgetExhausted": "budget exhausted",
"team.running": "Running now",
"team.since": "since {time}",
"team.task": "task {id}",
"team.calledBy": "called by {agent}",
"team.stuckHint": "Runs of this agent usually take about {minutes} min.",
"team.approvals": "{n} tool call(s) waiting for approval",
"team.lastRun": "Last run",
"team.openApprovals": "Open approvals",
"team.openChat": "Open chat",
"team.openRuns": "Show runs",
"nav.services": "Services",
"nav.group.analytics": "Analytics",
"nav.usage": "Token Usage",
@@ -55,6 +55,12 @@
</svg>
<span class="nav-label" data-i18n="nav.agents">Agents</span>
</a>
<a href="#/team" class="nav-link" title="Team overview" data-i18n-title="nav.team">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 3h6v6H3z M15 3h6v6h-6z M9 15h6v6H9z M6 9v3h12V9 M12 12v3"/>
</svg>
<span class="nav-label" data-i18n="nav.team">Team overview</span>
</a>
<a href="#/aufgaben" class="nav-link" title="Tasks" data-i18n-title="nav.tasks">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
@@ -93,7 +93,7 @@ export function verbindeStrom() {
meldeStatus(false);
};
const bekannteEreignisse = ["chat", "step", "run", "instance", "status"];
const bekannteEreignisse = ["chat", "step", "run", "instance", "status", "activity"];
for (const art of bekannteEreignisse) {
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
}
@@ -0,0 +1,429 @@
// ClawdDotNet — Team overview (#711)
//
// Who is working, who reached whom in the last seconds, and where something is stuck — at a
// glance, like looking across an office. Stage 0: the agents of the active instance and the
// live links between them. With units (#683) they become collapsible containers.
//
// Three rules from the concept (Organisation-Konzept, 10a):
// - The layout comes from the structure, not from the hand: no free node editor.
// - The state is decided by the instance (code), the page only draws it.
// - A click opens a side panel, not the chat — most problems are solved by an approval.
import { api, holeAktiveInstanz } from "../modules/api.js";
import { erstelleElement, erstellePlakette, formatiereDatumZeit } from "../modules/dom.js";
import { abonnieren } from "../modules/events.js";
import { t } from "../modules/i18n.js";
// Loaded on demand: pages without a graph do not pay for ~2 MB of script.
const LIBRARIES = [
"vendor/cytoscape/cytoscape.min.js",
"vendor/elkjs/elk.bundled.js",
"vendor/cytoscape-elk/cytoscape-elk.js"
];
/** How long a link stays visible after it was observed; it fades out over this time. */
const LINK_VISIBLE_MS = 30_000;
/** How often the picture is refreshed without an event — for "stuck" and approvals. */
const REFRESH_MS = 30_000;
// Every state has a colour AND a symbol: colour alone is not enough.
const STATES = {
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
Working: { symbol: "▶", tone: "erfolg", colour: "--accent" },
Stuck: { symbol: "⧗", tone: "gefahr", colour: "--gefahr" },
WaitingForApproval: { symbol: "✋", tone: "warnung", colour: "--warnung" },
Problem: { symbol: "⚠", tone: "gefahr", colour: "--gefahr" },
BudgetExhausted: { symbol: "$", tone: "warnung", colour: "--warnung" }
};
let librariesLoaded = null;
let cy = null;
let snapshot = null;
let selectedAgent = null;
let panel = null;
let summary = null;
let unsubscribe = null;
let refreshTimer = null;
let fadeTimer = null;
let refreshPending = null;
let resizeObserver = null;
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
let layoutWidth = 0;
// ─── Loading ───
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.onload = resolve;
script.onerror = () => reject(new Error(`Could not load ${src}`));
document.head.append(script);
});
}
function loadLibraries() {
librariesLoaded ??= (async () => {
// In order: cytoscape-elk registers against the globals of the two before it.
for (const src of LIBRARIES) await loadScript(src);
window.cytoscape.use(window.cytoscapeElk);
})();
return librariesLoaded;
}
function token(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#888";
}
// ─── View ───
export async function renderTeam() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
leaveTeam();
const layout = erstelleElement("div", { klasse: "team-ansicht" });
layout.append(
erstelleElement("h1", { text: t("team.title") }),
erstelleElement("p", { klasse: "gedaempft", text: t("team.subtitle") })
);
summary = erstelleElement("div", { klasse: "team-zusammenfassung gruppe-horizontal" });
const canvas = erstelleElement("div", { klasse: "team-graph", id: "team-graph" });
panel = erstelleElement("aside", { klasse: "team-seitenleiste" });
panel.hidden = true;
layout.append(summary, erstelleElement("div", { klasse: "team-flaeche" }, canvas, panel), buildLegend());
container.replaceChildren(layout);
if (!holeAktiveInstanz()) {
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("team.noInstance") }));
return;
}
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
try {
await loadLibraries();
snapshot = await api("/team");
} catch (err) {
canvas.replaceChildren(erstelleElement("div", { klasse: "fehler-zentriert", text: err.message }));
return;
}
canvas.replaceChildren();
drawGraph(canvas);
unsubscribe = abonnieren("activity", onActivity);
refreshTimer = setInterval(refresh, REFRESH_MS);
fadeTimer = setInterval(fadeLinks, 1_000);
}
/** Called when the route changes: stop listening, stop timers, free the graph. */
export function leaveTeam() {
unsubscribe?.();
unsubscribe = null;
clearInterval(refreshTimer);
clearInterval(fadeTimer);
clearTimeout(refreshPending);
refreshTimer = fadeTimer = refreshPending = null;
resizeObserver?.disconnect();
resizeObserver = null;
cy?.destroy();
cy = null;
selectedAgent = null;
}
// ─── Graph ───
function drawGraph(canvas) {
cy = window.cytoscape({
container: canvas,
elements: [],
wheelSensitivity: 0.2,
minZoom: 0.3,
maxZoom: 2.5,
// Nodes are not dragged: the layout comes from the structure, not the hand.
autoungrabify: true,
style: [
{
selector: "node",
style: {
shape: "rectangle",
width: 190,
height: 58,
"background-color": token("--surface"),
"border-width": 2,
"border-color": "data(colour)",
label: "data(label)",
"text-wrap": "wrap",
"text-max-width": 176,
"text-valign": "center",
"text-halign": "center",
"font-family": "Barlow, sans-serif",
"font-size": 13,
color: token("--text")
}
},
{ selector: "node.aktiv", style: { "border-width": 4 } },
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
{
selector: "edge",
style: {
width: 3,
"line-color": token("--accent"),
"target-arrow-color": token("--accent"),
"target-arrow-shape": "triangle",
"curve-style": "bezier",
opacity: "data(opacity)"
}
}
]
});
cy.on("tap", "node", (event) => openPanel(event.target.id()));
cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
applySnapshot(true);
for (const link of snapshot.links ?? []) addLink(link);
observeSize(canvas);
}
/**
* The area can still be 0 px wide when the graph is first drawn — the page is being laid out
* around it. ELK then packs everything into one column, and nothing can be fitted. So: lay
* out again once the area has a width, and fit again whenever its size changes (the side
* panel opening, the window resizing).
*/
function observeSize(canvas) {
resizeObserver = new ResizeObserver(() => {
if (!cy) return;
cy.resize();
if (layoutWidth === 0 && canvas.clientWidth > 0) runLayout();
else cy.fit(undefined, 30);
});
resizeObserver.observe(canvas);
}
function runLayout() {
const area = cy.container();
layoutWidth = area.clientWidth;
const layout = cy.layout({
name: "elk",
fit: false,
// Stage 0 has no structure yet, so the agents are packed evenly into the shape of
// the area. rectpacking keeps their order, so a box does not jump around when a
// state changes. With units (#683) this becomes a layered layout of nested containers.
elk: {
algorithm: "rectpacking",
"elk.spacing.nodeNode": 40,
"elk.aspectRatio": area.clientWidth > 0 && area.clientHeight > 0
? Math.max(0.5, area.clientWidth / area.clientHeight)
: 2
}
});
// ELK computes asynchronously; fit once the positions are there.
layout.one("layoutstop", () => cy?.fit(undefined, 30));
layout.run();
}
function nodeLabel(member) {
const state = STATES[member.state] ?? STATES.Idle;
const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : "";
return `${state.symbol} ${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
}
function applySnapshot(relayout) {
if (!cy || !snapshot) return;
const present = new Set();
for (const member of snapshot.members) {
present.add(member.agentId);
const state = STATES[member.state] ?? STATES.Idle;
const data = { id: member.agentId, label: nodeLabel(member), colour: token(state.colour) };
const node = cy.getElementById(member.agentId);
if (node.nonempty()) {
node.data(data);
} else {
cy.add({ group: "nodes", data });
relayout = true;
}
cy.getElementById(member.agentId).toggleClass("aktiv", member.runs.length > 0);
}
cy.nodes().filter((n) => !present.has(n.id())).remove();
if (relayout) runLayout();
renderSummary();
if (selectedAgent) openPanel(selectedAgent);
}
// ─── Live ───
function forActiveInstance(data) {
// The manager merges the streams of all instances and stamps each event.
return !data?.instanz || data.instanz === holeAktiveInstanz();
}
function onActivity(data) {
if (!cy || !forActiveInstance(data)) return;
if (data.kind === "link" && data.link) addLink(data.link);
// Start and end change states; ask the instance instead of guessing here.
clearTimeout(refreshPending);
refreshPending = setTimeout(refresh, 400);
}
async function refresh() {
if (!cy) return;
try {
snapshot = await api("/team");
applySnapshot(false);
} catch {
// The next event or tick tries again; a failed refresh keeps the last picture.
}
}
function addLink(link) {
if (!cy) return;
const age = Date.now() - new Date(link.atUtc).getTime();
if (age > LINK_VISIBLE_MS) return;
if (cy.getElementById(link.from).empty() || cy.getElementById(link.to).empty()) return;
// One edge per pair; a new contact renews it.
const id = `link:${link.from}->${link.to}`;
const existing = cy.getElementById(id);
const data = { id, source: link.from, target: link.to, seenAt: new Date(link.atUtc).getTime(), opacity: 1 };
if (existing.nonempty()) existing.data(data);
else cy.add({ group: "edges", data });
}
function fadeLinks() {
if (!cy) return;
const now = Date.now();
cy.edges().forEach((edge) => {
const age = now - edge.data("seenAt");
if (age > LINK_VISIBLE_MS) edge.remove();
else edge.data("opacity", Math.max(0.15, 1 - age / LINK_VISIBLE_MS));
});
}
// ─── Summary and legend ───
function renderSummary() {
if (!summary || !snapshot) return;
const counts = {};
for (const member of snapshot.members) counts[member.state] = (counts[member.state] ?? 0) + 1;
const parts = Object.keys(STATES)
.filter((state) => counts[state])
.map((state) => erstellePlakette(`${STATES[state].symbol} ${counts[state]} ${t(`team.state.${state}`)}`, STATES[state].tone));
summary.replaceChildren(
...(parts.length ? parts : [erstelleElement("span", { klasse: "gedaempft", text: t("team.empty") })])
);
}
function buildLegend() {
return erstelleElement(
"div",
{ klasse: "team-legende gruppe-horizontal gedaempft text-klein" },
...Object.entries(STATES).map(([state, s]) =>
erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })),
erstelleElement("span", { text: `→ ${t("team.legendLink")}` })
);
}
// ─── Side panel ───
function openPanel(agentId) {
const member = snapshot?.members.find((m) => m.agentId === agentId);
if (!member || !panel) return;
selectedAgent = agentId;
cy?.getElementById(agentId).select();
const state = STATES[member.state] ?? STATES.Idle;
const rows = [
erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
erstelleElement("h2", { text: member.displayName || member.agentId }),
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: "×",
title: t("common.close"),
onclick: closePanel
})),
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
];
// Top layer: what is happening now.
if (member.runs.length) {
rows.push(erstelleElement("h3", { text: t("team.running") }));
for (const run of member.runs) {
const parts = [
t("team.since", { time: formatiereDatumZeit(run.startedAtUtc) }),
run.source,
run.taskId ? t("team.task", { id: run.taskId }) : null,
run.calledBy ? t("team.calledBy", { agent: run.calledBy }) : null
].filter(Boolean);
rows.push(erstelleElement("p", { klasse: "text-klein", text: parts.join(" · ") }));
}
if (member.state === "Stuck" && member.typicalRunMs > 0) {
rows.push(erstelleElement("p", {
klasse: "text-klein",
text: `⚠ ${t("team.stuckHint", { minutes: Math.max(1, Math.round(member.typicalRunMs / 60_000)) })}`
}));
}
}
if (member.pendingApprovals > 0) {
rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: member.pendingApprovals })}` }));
}
// Middle layer: how the last run ended.
rows.push(erstelleElement("h3", { text: t("team.lastRun") }));
if (member.lastRunStatus) {
rows.push(erstelleElement("p", { klasse: "text-klein" },
erstellePlakette(member.lastRunStatus, member.lastRunStatus === "Completed" ? "erfolg" : "warnung"),
` ${formatiereDatumZeit(member.lastRunAtUtc)}`));
if (member.lastRunResult) {
rows.push(erstelleElement("p", { klasse: "text-klein team-ergebnis", text: member.lastRunResult }));
}
} else {
rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" }));
}
// The actions — the approval first, it is what most problems need.
const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" });
if (member.pendingApprovals > 0) {
actions.append(link(`#/staging`, t("team.openApprovals"), "primaer"));
}
actions.append(
link(`#/chat/${encodeURIComponent(member.agentId)}`, t("team.openChat")),
// Bottom layer: every run with its raw transcript.
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
);
rows.push(actions);
panel.replaceChildren(...rows);
panel.hidden = false;
}
function link(href, text, style = "flach") {
return erstelleElement("button", {
type: "button",
klasse: `${style} klein`,
text,
onclick: () => { window.location.hash = href; }
});
}
function closePanel() {
selectedAgent = null;
cy?.elements().unselect();
if (panel) panel.hidden = true;
}