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:
co-authored by
Claude Opus 5.5
parent
d587dc3f20
commit
f627918729
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user