Lagebild, Stufe 1: Einheiten als aufklappbare Container (#711)
- TeamOverview.RollUp: je Einheit die Zustaende aller Agenten darin und
darunter, offene Freigaben und der dringlichste Zustand (Urgency:
haengt > Problem > Freigabe > Budget > arbeitet > frei). Gezaehlt wird in
der Instanz, die Seite zeichnet nur.
- /api/team liefert Einheiten und je Agent Einheit und Leitung.
- team.js: Einheiten als Container, zugeklappt als Kasten mit
Zusammenfassung ("6 agents · ⚠ 1 · ✋ 1 · ▶ 2") in der Farbe des
dringlichsten Zustands. Kanten laufen zum sichtbaren Stellvertreter und
werden gebuendelt (×n); was in einer zugeklappten Einheit bleibt, wird
nicht gezeichnet. Bis 15 Agenten alles offen, darueber nur die oberste
Ebene; der Zustand je Instanz im localStorage.
- Seitenleiste fuer Einheiten: Pfad, Ziel, Mitglieder (Leitung zuerst),
Untereinheiten, Auf-/Zuklappen, Link zur Organisation.
- Layout: rectpacking trifft das Seitenverhaeltnis nur grob; es werden
bis zu vier Vorgaben probiert und die zur Flaeche passendste behalten.
- Sprachschluessel en/de.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5b8656d969
commit
920d3fed64
@@ -114,7 +114,8 @@ public sealed class AppHost : IAsyncDisposable
|
|||||||
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
|
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
|
||||||
|
|
||||||
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
|
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
|
||||||
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents);
|
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents,
|
||||||
|
organisation: () => Core.Organisation.OrgChart.From(Instance));
|
||||||
|
|
||||||
public TelegramClientManager? TelegramClient { get; private init; }
|
public TelegramClientManager? TelegramClient { get; private init; }
|
||||||
public OpenRouterStatusService? Status { get; private init; }
|
public OpenRouterStatusService? Status { get; private init; }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Activity;
|
|||||||
using ClawdDotNet.Core.Audit;
|
using ClawdDotNet.Core.Audit;
|
||||||
using ClawdDotNet.Core.Config;
|
using ClawdDotNet.Core.Config;
|
||||||
using ClawdDotNet.Core.Engine;
|
using ClawdDotNet.Core.Engine;
|
||||||
|
using ClawdDotNet.Core.Organisation;
|
||||||
using ClawdDotNet.Core.Staging;
|
using ClawdDotNet.Core.Staging;
|
||||||
|
|
||||||
namespace ClawdDotNet.App.Services;
|
namespace ClawdDotNet.App.Services;
|
||||||
@@ -40,10 +41,33 @@ public sealed record TeamMember(
|
|||||||
string? LastRunStatus,
|
string? LastRunStatus,
|
||||||
DateTime? LastRunAtUtc,
|
DateTime? LastRunAtUtc,
|
||||||
string? LastRunResult,
|
string? LastRunResult,
|
||||||
long TypicalRunMs);
|
long TypicalRunMs,
|
||||||
|
string? UnitId = null,
|
||||||
|
bool IsLead = false);
|
||||||
|
|
||||||
/// <summary>The whole picture at one moment: every agent, and who reached whom recently.</summary>
|
/// <summary>
|
||||||
public sealed record TeamSnapshot(IReadOnlyList<TeamMember> Members, IReadOnlyList<AgentLink> RecentLinks, DateTime AtUtc);
|
/// One unit in the team overview (#711, stage 1), with what happens inside it rolled up —
|
||||||
|
/// its own members and those of every unit below. A collapsed container shows this, so a
|
||||||
|
/// problem three levels down is still visible from the top.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TeamUnit(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string? ParentId,
|
||||||
|
string Path,
|
||||||
|
string? LeaderAgentId,
|
||||||
|
string? Goal,
|
||||||
|
int Agents,
|
||||||
|
IReadOnlyDictionary<TeamState, int> States,
|
||||||
|
int PendingApprovals,
|
||||||
|
TeamState Worst);
|
||||||
|
|
||||||
|
/// <summary>The whole picture at one moment: every agent and unit, and who reached whom recently.</summary>
|
||||||
|
public sealed record TeamSnapshot(
|
||||||
|
IReadOnlyList<TeamMember> Members,
|
||||||
|
IReadOnlyList<AgentLink> RecentLinks,
|
||||||
|
DateTime AtUtc,
|
||||||
|
IReadOnlyList<TeamUnit>? Units = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #711: The team overview — who is working, who reached whom, where something is stuck.
|
/// #711: The team overview — who is working, who reached whom, where something is stuck.
|
||||||
@@ -63,7 +87,8 @@ public sealed class TeamOverview(
|
|||||||
IAuditRepository? audit,
|
IAuditRepository? audit,
|
||||||
IUsageRepository? usage,
|
IUsageRepository? usage,
|
||||||
Func<IReadOnlyList<AgentConfig>> agents,
|
Func<IReadOnlyList<AgentConfig>> agents,
|
||||||
TimeProvider? clock = null)
|
TimeProvider? clock = null,
|
||||||
|
Func<OrgChart>? organisation = null)
|
||||||
{
|
{
|
||||||
/// <summary>How far back the last receipt of each agent is looked for.</summary>
|
/// <summary>How far back the last receipt of each agent is looked for.</summary>
|
||||||
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
|
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
|
||||||
@@ -106,6 +131,8 @@ public sealed class TeamOverview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var org = organisation?.Invoke() ?? new OrgChart([], configured);
|
||||||
|
|
||||||
var members = new List<TeamMember>();
|
var members = new List<TeamMember>();
|
||||||
foreach (var agent in configured)
|
foreach (var agent in configured)
|
||||||
{
|
{
|
||||||
@@ -120,10 +147,54 @@ public sealed class TeamOverview(
|
|||||||
var state = Classify(runs, approvals, last?.Status, typical, now);
|
var state = Classify(runs, approvals, last?.Status, typical, now);
|
||||||
members.Add(new TeamMember(
|
members.Add(new TeamMember(
|
||||||
agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals,
|
agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals,
|
||||||
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds));
|
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds,
|
||||||
|
org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TeamSnapshot(members, links, now);
|
return new TeamSnapshot(members, links, now, RollUp(org, members));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How much a state needs someone — the higher, the sooner. A container takes the most
|
||||||
|
/// urgent state of everything inside it, so "somewhere in here" points at the right thing.
|
||||||
|
/// </summary>
|
||||||
|
public static int Urgency(TeamState state) => state switch
|
||||||
|
{
|
||||||
|
TeamState.Stuck => 5,
|
||||||
|
TeamState.Problem => 4,
|
||||||
|
TeamState.WaitingForApproval => 3,
|
||||||
|
TeamState.BudgetExhausted => 2,
|
||||||
|
TeamState.Working => 1,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every unit with the states of all agents in it and below. Pure, like
|
||||||
|
/// <see cref="Classify"/>: the page draws the numbers, it does not add them up.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<TeamUnit> RollUp(OrgChart org, IReadOnlyList<TeamMember> members)
|
||||||
|
{
|
||||||
|
var result = new List<TeamUnit>();
|
||||||
|
foreach (var unit in org.Units.OrderBy(u => org.PathOf(u.Id), StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Everyone whose unit is this one or lies below it. Ancestors() stops at a
|
||||||
|
// cycle, so a broken tree cannot hang the overview.
|
||||||
|
var inside = members
|
||||||
|
.Where(m => m.UnitId is { } id
|
||||||
|
&& (string.Equals(id, unit.Id, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| org.Ancestors(id).Any(a => string.Equals(a.Id, unit.Id, StringComparison.OrdinalIgnoreCase))))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var states = inside
|
||||||
|
.GroupBy(m => m.State)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
var worst = inside.Select(m => m.State).DefaultIfEmpty(TeamState.Idle).MaxBy(Urgency);
|
||||||
|
|
||||||
|
result.Add(new TeamUnit(
|
||||||
|
unit.Id, unit.Name, unit.ParentId, org.PathOf(unit.Id), org.LeaderOf(unit.Id)?.AgentId, unit.Goal,
|
||||||
|
inside.Count, states, inside.Sum(m => m.PendingApprovals), worst));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1791,6 +1791,12 @@ td.klein {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.team-eintrag {
|
||||||
|
text-align: left;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.team-flaeche { flex-direction: column; }
|
.team-flaeche { flex-direction: column; }
|
||||||
.team-seitenleiste { width: auto; }
|
.team-seitenleiste { width: auto; }
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
"org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
|
"org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
|
||||||
"org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
|
"org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
|
||||||
"team.title": "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.subtitle": "Wer arbeitet, wer hat wen in den letzten Sekunden erreicht und wo klemmt es. Einheiten zeigen, was in ihnen passiert; ein Klick öffnet sie oder zeigt Einzelheiten.",
|
||||||
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
|
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
|
||||||
"team.empty": "Diese Instanz hat keine Agenten.",
|
"team.empty": "Diese Instanz hat keine Agenten.",
|
||||||
"team.legendLink": "hat erreicht (verblasst nach 30 s)",
|
"team.legendLink": "hat erreicht (verblasst nach 30 s)",
|
||||||
@@ -148,6 +148,17 @@
|
|||||||
"team.openApprovals": "Freigaben öffnen",
|
"team.openApprovals": "Freigaben öffnen",
|
||||||
"team.openChat": "Chat öffnen",
|
"team.openChat": "Chat öffnen",
|
||||||
"team.openRuns": "Läufe anzeigen",
|
"team.openRuns": "Läufe anzeigen",
|
||||||
|
"team.unitAgentsOne": "1 Agent",
|
||||||
|
"team.unitAgents": "{n} Agenten",
|
||||||
|
"team.legendLead": "Leitung der Einheit",
|
||||||
|
"team.legendUnit": "Einheit, Klick öffnet sie",
|
||||||
|
"team.leads": "Leitet",
|
||||||
|
"team.memberOf": "Mitglied von",
|
||||||
|
"team.members": "Mitglieder",
|
||||||
|
"team.subUnits": "Untereinheiten",
|
||||||
|
"team.collapse": "Einheit zuklappen",
|
||||||
|
"team.expand": "Einheit aufklappen",
|
||||||
|
"team.openOrganisation": "Organisation bearbeiten",
|
||||||
"nav.services": "Dienste",
|
"nav.services": "Dienste",
|
||||||
"nav.group.analytics": "Analyse",
|
"nav.group.analytics": "Analyse",
|
||||||
"nav.usage": "Tokennutzung",
|
"nav.usage": "Tokennutzung",
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
"org.toolsLostConfirm": "Remove the tools and apply",
|
"org.toolsLostConfirm": "Remove the tools and apply",
|
||||||
"org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.",
|
"org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.",
|
||||||
"team.title": "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.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Units show what happens inside them; click to open them or to see details.",
|
||||||
"team.noInstance": "No instance selected. Choose an active instance in the header.",
|
"team.noInstance": "No instance selected. Choose an active instance in the header.",
|
||||||
"team.empty": "No agents in this instance.",
|
"team.empty": "No agents in this instance.",
|
||||||
"team.legendLink": "reached (fades after 30 s)",
|
"team.legendLink": "reached (fades after 30 s)",
|
||||||
@@ -148,6 +148,17 @@
|
|||||||
"team.openApprovals": "Open approvals",
|
"team.openApprovals": "Open approvals",
|
||||||
"team.openChat": "Open chat",
|
"team.openChat": "Open chat",
|
||||||
"team.openRuns": "Show runs",
|
"team.openRuns": "Show runs",
|
||||||
|
"team.unitAgentsOne": "1 agent",
|
||||||
|
"team.unitAgents": "{n} agents",
|
||||||
|
"team.legendLead": "lead of the unit",
|
||||||
|
"team.legendUnit": "unit, click to open",
|
||||||
|
"team.leads": "Leads",
|
||||||
|
"team.memberOf": "Member of",
|
||||||
|
"team.members": "Members",
|
||||||
|
"team.subUnits": "Sub-units",
|
||||||
|
"team.collapse": "Collapse unit",
|
||||||
|
"team.expand": "Expand unit",
|
||||||
|
"team.openOrganisation": "Edit organisation",
|
||||||
"nav.services": "Services",
|
"nav.services": "Services",
|
||||||
"nav.group.analytics": "Analytics",
|
"nav.group.analytics": "Analytics",
|
||||||
"nav.usage": "Token Usage",
|
"nav.usage": "Token Usage",
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// ClawdDotNet — Team overview (#711)
|
// ClawdDotNet — Team overview (#711)
|
||||||
//
|
//
|
||||||
// Who is working, who reached whom in the last seconds, and where something is stuck — at a
|
// 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
|
// glance, like looking across an office. Stage 0 drew the agents and the live links between
|
||||||
// live links between them. With units (#683) they become collapsible containers.
|
// them. Stage 1 (with #683) draws units as collapsible containers: a collapsed unit shows
|
||||||
|
// what happens inside it, so a problem three levels down is still visible from the top.
|
||||||
//
|
//
|
||||||
// Three rules from the concept (Organisation-Konzept, 10a):
|
// Three rules from the concept (Organisation-Konzept, 10a):
|
||||||
// - The layout comes from the structure, not from the hand: no free node editor.
|
// - 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.
|
// - The state is decided by the instance (code), the page only draws it. That includes the
|
||||||
|
// rolled-up counts of a unit — the page does not add them up.
|
||||||
// - A click opens a side panel, not the chat — most problems are solved by an approval.
|
// - A click opens a side panel, not the chat — most problems are solved by an approval.
|
||||||
|
|
||||||
import { api, holeAktiveInstanz } from "../modules/api.js";
|
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||||
@@ -27,6 +29,15 @@ const LINK_VISIBLE_MS = 30_000;
|
|||||||
/** How often the picture is refreshed without an event — for "stuck" and approvals. */
|
/** How often the picture is refreshed without an event — for "stuck" and approvals. */
|
||||||
const REFRESH_MS = 30_000;
|
const REFRESH_MS = 30_000;
|
||||||
|
|
||||||
|
/** Up to this many agents, every unit starts expanded; above it, only the top level shows. */
|
||||||
|
const EXPAND_ALL_UP_TO = 15;
|
||||||
|
|
||||||
|
/** Unit node ids are prefixed so a unit and an agent may share a name. */
|
||||||
|
const UNIT_PREFIX = "unit:";
|
||||||
|
|
||||||
|
/** Parent chains longer than this are a cycle — the instance reports it, the page must not hang. */
|
||||||
|
const MAX_DEPTH = 64;
|
||||||
|
|
||||||
// Every state has a colour AND a symbol: colour alone is not enough.
|
// Every state has a colour AND a symbol: colour alone is not enough.
|
||||||
const STATES = {
|
const STATES = {
|
||||||
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
|
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
|
||||||
@@ -40,7 +51,11 @@ const STATES = {
|
|||||||
let librariesLoaded = null;
|
let librariesLoaded = null;
|
||||||
let cy = null;
|
let cy = null;
|
||||||
let snapshot = null;
|
let snapshot = null;
|
||||||
let selectedAgent = null;
|
let unitsById = new Map();
|
||||||
|
let membersById = new Map();
|
||||||
|
let expanded = null;
|
||||||
|
let structureKey = "";
|
||||||
|
let selected = null;
|
||||||
let panel = null;
|
let panel = null;
|
||||||
let summary = null;
|
let summary = null;
|
||||||
let unsubscribe = null;
|
let unsubscribe = null;
|
||||||
@@ -49,6 +64,9 @@ let fadeTimer = null;
|
|||||||
let refreshPending = null;
|
let refreshPending = null;
|
||||||
let resizeObserver = null;
|
let resizeObserver = null;
|
||||||
|
|
||||||
|
/** Links as observed, agent to agent: "from->to" → { from, to, seenAt, count }. */
|
||||||
|
const links = new Map();
|
||||||
|
|
||||||
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
|
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
|
||||||
let layoutWidth = 0;
|
let layoutWidth = 0;
|
||||||
|
|
||||||
@@ -114,11 +132,14 @@ export async function renderTeam() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canvas.replaceChildren();
|
canvas.replaceChildren();
|
||||||
|
indexSnapshot();
|
||||||
|
expanded = loadExpanded();
|
||||||
|
for (const link of snapshot.links ?? []) recordLink(link);
|
||||||
drawGraph(canvas);
|
drawGraph(canvas);
|
||||||
|
|
||||||
unsubscribe = abonnieren("activity", onActivity);
|
unsubscribe = abonnieren("activity", onActivity);
|
||||||
refreshTimer = setInterval(refresh, REFRESH_MS);
|
refreshTimer = setInterval(refresh, REFRESH_MS);
|
||||||
fadeTimer = setInterval(fadeLinks, 1_000);
|
fadeTimer = setInterval(drawLinks, 1_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called when the route changes: stop listening, stop timers, free the graph. */
|
/** Called when the route changes: stop listening, stop timers, free the graph. */
|
||||||
@@ -133,7 +154,84 @@ export function leaveTeam() {
|
|||||||
resizeObserver = null;
|
resizeObserver = null;
|
||||||
cy?.destroy();
|
cy?.destroy();
|
||||||
cy = null;
|
cy = null;
|
||||||
selectedAgent = null;
|
selected = null;
|
||||||
|
structureKey = "";
|
||||||
|
layoutWidth = 0;
|
||||||
|
links.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── The tree ───
|
||||||
|
|
||||||
|
function indexSnapshot() {
|
||||||
|
unitsById = new Map((snapshot.units ?? []).map((u) => [u.id, u]));
|
||||||
|
membersById = new Map(snapshot.members.map((m) => [m.agentId, m]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The unit and every unit above it, nearest first; stops at a cycle. */
|
||||||
|
function chain(unitId) {
|
||||||
|
const result = [];
|
||||||
|
for (let unit = unitsById.get(unitId); unit && result.length < MAX_DEPTH; unit = unitsById.get(unit.parentId)) {
|
||||||
|
if (result.includes(unit)) break;
|
||||||
|
result.push(unit);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the unit and everything above it are expanded — then its content is drawn. */
|
||||||
|
function isOpen(unitId) {
|
||||||
|
return chain(unitId).every((u) => expanded.has(u.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the unit itself is drawn, as a box or as a container. */
|
||||||
|
function isVisible(unit) {
|
||||||
|
return !unit.parentId || !unitsById.has(unit.parentId) || isOpen(unit.parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an agent is drawn: itself if its unit is open, otherwise the topmost collapsed unit
|
||||||
|
* on its path. Links are drawn between these, so a collapsed unit collects the links of
|
||||||
|
* everyone inside it.
|
||||||
|
*/
|
||||||
|
function representative(agentId) {
|
||||||
|
const member = membersById.get(agentId);
|
||||||
|
if (!member) return null;
|
||||||
|
let rep = null;
|
||||||
|
for (const unit of chain(member.unitId)) {
|
||||||
|
if (!expanded.has(unit.id)) rep = unit.id;
|
||||||
|
}
|
||||||
|
return rep ? UNIT_PREFIX + rep : agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey() {
|
||||||
|
return `clawd.team.expanded.${holeAktiveInstanz()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadExpanded() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(storageKey());
|
||||||
|
if (stored) return new Set(JSON.parse(stored));
|
||||||
|
} catch {
|
||||||
|
// Private window or blocked storage: fall back to the default below.
|
||||||
|
}
|
||||||
|
// A small team is shown whole; a large one starts at the departments.
|
||||||
|
return snapshot.members.length <= EXPAND_ALL_UP_TO
|
||||||
|
? new Set(unitsById.keys())
|
||||||
|
: new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveExpanded() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey(), JSON.stringify([...expanded]));
|
||||||
|
} catch {
|
||||||
|
// Only a convenience — the default applies next time.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUnit(unitId, open) {
|
||||||
|
if (open ?? !expanded.has(unitId)) expanded.add(unitId);
|
||||||
|
else expanded.delete(unitId);
|
||||||
|
saveExpanded();
|
||||||
|
applySnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Graph ───
|
// ─── Graph ───
|
||||||
@@ -143,8 +241,9 @@ function drawGraph(canvas) {
|
|||||||
container: canvas,
|
container: canvas,
|
||||||
elements: [],
|
elements: [],
|
||||||
wheelSensitivity: 0.2,
|
wheelSensitivity: 0.2,
|
||||||
minZoom: 0.3,
|
minZoom: 0.2,
|
||||||
maxZoom: 2.5,
|
// A handful of collapsed units would otherwise fill the area with giant boxes.
|
||||||
|
maxZoom: 1.6,
|
||||||
// Nodes are not dragged: the layout comes from the structure, not the hand.
|
// Nodes are not dragged: the layout comes from the structure, not the hand.
|
||||||
autoungrabify: true,
|
autoungrabify: true,
|
||||||
style: [
|
style: [
|
||||||
@@ -168,26 +267,74 @@ function drawGraph(canvas) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ selector: "node.aktiv", style: { "border-width": 4 } },
|
{ selector: "node.aktiv", style: { "border-width": 4 } },
|
||||||
|
{ selector: "node.leitung", style: { "font-weight": "bold" } },
|
||||||
|
// A collapsed unit: a box that stands for everyone inside it.
|
||||||
|
{
|
||||||
|
selector: "node.einheit",
|
||||||
|
style: {
|
||||||
|
shape: "round-rectangle",
|
||||||
|
width: 210,
|
||||||
|
height: 70,
|
||||||
|
"border-width": 3,
|
||||||
|
"border-style": "double",
|
||||||
|
"background-color": token("--surface-subtil"),
|
||||||
|
"font-weight": "bold"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ selector: "node.einheit.aktiv", style: { "border-width": 5 } },
|
||||||
|
// An expanded unit: a container around its members and sub-units.
|
||||||
|
{
|
||||||
|
selector: "node:parent",
|
||||||
|
style: {
|
||||||
|
shape: "round-rectangle",
|
||||||
|
padding: 20,
|
||||||
|
"border-width": 2,
|
||||||
|
"border-style": "dashed",
|
||||||
|
"background-color": token("--surface-subtil"),
|
||||||
|
"background-opacity": 0.5,
|
||||||
|
"text-valign": "top",
|
||||||
|
"text-halign": "center",
|
||||||
|
"text-margin-y": -4,
|
||||||
|
"text-max-width": 400,
|
||||||
|
"font-weight": "bold"
|
||||||
|
}
|
||||||
|
},
|
||||||
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
|
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
|
||||||
{
|
{
|
||||||
selector: "edge",
|
selector: "edge",
|
||||||
style: {
|
style: {
|
||||||
width: 3,
|
width: "data(width)",
|
||||||
"line-color": token("--accent"),
|
"line-color": token("--accent"),
|
||||||
"target-arrow-color": token("--accent"),
|
"target-arrow-color": token("--accent"),
|
||||||
"target-arrow-shape": "triangle",
|
"target-arrow-shape": "triangle",
|
||||||
"curve-style": "bezier",
|
"curve-style": "bezier",
|
||||||
opacity: "data(opacity)"
|
opacity: "data(opacity)",
|
||||||
|
label: "data(label)",
|
||||||
|
"font-size": 11,
|
||||||
|
color: token("--text"),
|
||||||
|
"text-background-color": token("--surface"),
|
||||||
|
"text-background-opacity": 1,
|
||||||
|
"text-background-padding": 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.on("tap", "node", (event) => openPanel(event.target.id()));
|
cy.on("tap", "node", (event) => {
|
||||||
|
const id = event.target.id();
|
||||||
|
if (!id.startsWith(UNIT_PREFIX)) {
|
||||||
|
openPanel({ agent: id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const unitId = id.slice(UNIT_PREFIX.length);
|
||||||
|
// A collapsed unit opens on the first click — looking inside is what the click is for.
|
||||||
|
if (!expanded.has(unitId)) toggleUnit(unitId, true);
|
||||||
|
openPanel({ unit: unitId });
|
||||||
|
});
|
||||||
|
cy.on("dbltap", "node:parent", (event) => toggleUnit(event.target.id().slice(UNIT_PREFIX.length), false));
|
||||||
cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
|
cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
|
||||||
|
|
||||||
applySnapshot(true);
|
applySnapshot();
|
||||||
for (const link of snapshot.links ?? []) addLink(link);
|
|
||||||
observeSize(canvas);
|
observeSize(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,58 +354,164 @@ function observeSize(canvas) {
|
|||||||
resizeObserver.observe(canvas);
|
resizeObserver.observe(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runLayout() {
|
/** Counts layouts, so a layout overtaken by a newer one stops trying. */
|
||||||
const area = cy.container();
|
let layoutGeneration = 0;
|
||||||
layoutWidth = area.clientWidth;
|
|
||||||
|
/** Runs one ELK layout with the given aspect ratio and returns the ratio it achieved. */
|
||||||
|
function layoutOnce(aspectRatio) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
const layout = cy.layout({
|
const layout = cy.layout({
|
||||||
name: "elk",
|
name: "elk",
|
||||||
fit: false,
|
fit: false,
|
||||||
// Stage 0 has no structure yet, so the agents are packed evenly into the shape of
|
// The agents are packed evenly into the shape of the area, inside each container as
|
||||||
// the area. rectpacking keeps their order, so a box does not jump around when a
|
// on the top level. rectpacking keeps the order they were added in (units by path,
|
||||||
// state changes. With units (#683) this becomes a layered layout of nested containers.
|
// the lead first), so a box does not jump around when a state changes.
|
||||||
elk: {
|
elk: {
|
||||||
algorithm: "rectpacking",
|
algorithm: "rectpacking",
|
||||||
"elk.spacing.nodeNode": 40,
|
"elk.spacing.nodeNode": 40,
|
||||||
"elk.aspectRatio": area.clientWidth > 0 && area.clientHeight > 0
|
"elk.aspectRatio": aspectRatio
|
||||||
? Math.max(0.5, area.clientWidth / area.clientHeight)
|
},
|
||||||
: 2
|
// Room for the container's own padding and its label above the members.
|
||||||
|
nodeLayoutOptions: (node) => node.isParent()
|
||||||
|
? {
|
||||||
|
"elk.algorithm": "rectpacking",
|
||||||
|
"elk.padding": "[top=44,left=24,bottom=24,right=24]",
|
||||||
|
"elk.spacing.nodeNode": 24
|
||||||
}
|
}
|
||||||
|
: {}
|
||||||
|
});
|
||||||
|
// ELK computes asynchronously; measure once the positions are there.
|
||||||
|
layout.one("layoutstop", () => {
|
||||||
|
const box = cy?.elements().boundingBox();
|
||||||
|
resolve(box && box.h > 0 ? box.w / box.h : 1);
|
||||||
});
|
});
|
||||||
// ELK computes asynchronously; fit once the positions are there.
|
|
||||||
layout.one("layoutstop", () => cy?.fit(undefined, 30));
|
|
||||||
layout.run();
|
layout.run();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeLabel(member) {
|
/**
|
||||||
|
* Lays the graph out in the shape of the area. rectpacking treats the aspect ratio as a
|
||||||
|
* hint and, with a few large containers on the top level, jumps between a handful of
|
||||||
|
* shapes — asked for 1.4 it may deliver 0.6 or 2.6. So a few hints are tried and the
|
||||||
|
* result closest to the area is kept; with the fit afterwards, that is the one that shows
|
||||||
|
* the boxes largest.
|
||||||
|
*/
|
||||||
|
async function runLayout() {
|
||||||
|
const area = cy.container();
|
||||||
|
layoutWidth = area.clientWidth;
|
||||||
|
const target = area.clientWidth > 0 && area.clientHeight > 0
|
||||||
|
? Math.max(0.5, area.clientWidth / area.clientHeight)
|
||||||
|
: 2;
|
||||||
|
const generation = ++layoutGeneration;
|
||||||
|
const miss = (ratio) => Math.abs(Math.log(ratio / target));
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
for (const hint of [target, target * 1.2, target * 1.45, target * 0.8]) {
|
||||||
|
const ratio = await layoutOnce(hint);
|
||||||
|
if (!cy || generation !== layoutGeneration) return;
|
||||||
|
if (!best || miss(ratio) < best.miss) best = { hint, miss: miss(ratio), last: true };
|
||||||
|
else best.last = false;
|
||||||
|
if (best.miss < 0.15) break;
|
||||||
|
}
|
||||||
|
// The best one may not be the last one run.
|
||||||
|
if (!best.last) await layoutOnce(best.hint);
|
||||||
|
if (cy && generation === layoutGeneration) cy.fit(undefined, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentLabel(member) {
|
||||||
const state = STATES[member.state] ?? STATES.Idle;
|
const state = STATES[member.state] ?? STATES.Idle;
|
||||||
const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : "";
|
const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : "";
|
||||||
return `${state.symbol} ${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
|
const lead = member.isLead ? "★ " : "";
|
||||||
|
return `${state.symbol} ${lead}${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshot(relayout) {
|
/** "5 agents · ▶ 2 · ⚠ 1" — everything that is not idle, most urgent first. */
|
||||||
|
function unitCounts(unit) {
|
||||||
|
const parts = [unit.agents === 1 ? t("team.unitAgentsOne") : t("team.unitAgents", { n: unit.agents })];
|
||||||
|
for (const state of ["Stuck", "Problem", "WaitingForApproval", "BudgetExhausted", "Working"]) {
|
||||||
|
const n = unit.states?.[state];
|
||||||
|
if (n) parts.push(`${STATES[state].symbol} ${n}`);
|
||||||
|
}
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitLabel(unit, open) {
|
||||||
|
return open
|
||||||
|
? `▾ ${unit.name} · ${unitCounts(unit)}`
|
||||||
|
: `▸ ${unit.name}\n${unitCounts(unit)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nodes as they should be now, parents before children and in a stable order: agents
|
||||||
|
* without a unit, then the units by path, each with its lead first.
|
||||||
|
*/
|
||||||
|
function desiredNodes() {
|
||||||
|
const nodes = [];
|
||||||
|
const byName = (a, b) => (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId);
|
||||||
|
const colour = (state) => token((STATES[state] ?? STATES.Idle).colour);
|
||||||
|
|
||||||
|
for (const member of snapshot.members.filter((m) => !m.unitId || !unitsById.has(m.unitId)).sort(byName)) {
|
||||||
|
nodes.push({ id: member.agentId, member, colour: colour(member.state) });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const unit of snapshot.units ?? []) {
|
||||||
|
if (!isVisible(unit)) continue;
|
||||||
|
const open = expanded.has(unit.id);
|
||||||
|
const parent = unit.parentId && unitsById.has(unit.parentId) ? UNIT_PREFIX + unit.parentId : undefined;
|
||||||
|
nodes.push({ id: UNIT_PREFIX + unit.id, parent, unit, open, colour: colour(unit.worst) });
|
||||||
|
if (!open) continue;
|
||||||
|
|
||||||
|
const members = snapshot.members
|
||||||
|
.filter((m) => m.unitId === unit.id)
|
||||||
|
.sort((a, b) => (b.isLead - a.isLead) || byName(a, b));
|
||||||
|
for (const member of members) {
|
||||||
|
nodes.push({ id: member.agentId, parent: UNIT_PREFIX + unit.id, member, colour: colour(member.state) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings the graph up to date. When only states changed, the nodes are updated in place and
|
||||||
|
* nothing moves. When the structure changed — a unit opened or closed, an agent moved — the
|
||||||
|
* nodes are rebuilt and laid out again: Cytoscape cannot turn a box into a container.
|
||||||
|
*/
|
||||||
|
function applySnapshot() {
|
||||||
if (!cy || !snapshot) return;
|
if (!cy || !snapshot) return;
|
||||||
|
|
||||||
const present = new Set();
|
const nodes = desiredNodes();
|
||||||
for (const member of snapshot.members) {
|
const key = nodes.map((n) => `${n.id}<${n.parent ?? ""}${n.open ? "+" : ""}`).join("|");
|
||||||
present.add(member.agentId);
|
const data = (n) => ({
|
||||||
const state = STATES[member.state] ?? STATES.Idle;
|
id: n.id,
|
||||||
const data = { id: member.agentId, label: nodeLabel(member), colour: token(state.colour) };
|
parent: n.parent,
|
||||||
const node = cy.getElementById(member.agentId);
|
colour: n.colour,
|
||||||
if (node.nonempty()) {
|
label: n.unit ? unitLabel(n.unit, n.open) : agentLabel(n.member)
|
||||||
node.data(data);
|
});
|
||||||
|
|
||||||
|
if (key !== structureKey) {
|
||||||
|
structureKey = key;
|
||||||
|
cy.elements().remove();
|
||||||
|
cy.add(nodes.map((n) => ({ group: "nodes", data: data(n) })));
|
||||||
|
for (const n of nodes) decorate(n);
|
||||||
|
drawLinks();
|
||||||
|
runLayout();
|
||||||
} else {
|
} else {
|
||||||
cy.add({ group: "nodes", data });
|
for (const n of nodes) {
|
||||||
relayout = true;
|
const { parent: _parent, ...rest } = data(n);
|
||||||
|
cy.getElementById(n.id).data(rest);
|
||||||
|
decorate(n);
|
||||||
}
|
}
|
||||||
cy.getElementById(member.agentId).toggleClass("aktiv", member.runs.length > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cy.nodes().filter((n) => !present.has(n.id())).remove();
|
|
||||||
|
|
||||||
if (relayout) runLayout();
|
|
||||||
|
|
||||||
renderSummary();
|
renderSummary();
|
||||||
if (selectedAgent) openPanel(selectedAgent);
|
if (selected) openPanel(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorate(n) {
|
||||||
|
const node = cy.getElementById(n.id);
|
||||||
|
node.toggleClass("einheit", Boolean(n.unit) && !n.open);
|
||||||
|
node.toggleClass("aktiv", Boolean(n.member?.runs.length) || Boolean(n.unit && !n.open && n.unit.states?.Working));
|
||||||
|
node.toggleClass("leitung", Boolean(n.member?.isLead));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Live ───
|
// ─── Live ───
|
||||||
@@ -271,7 +524,10 @@ function forActiveInstance(data) {
|
|||||||
function onActivity(data) {
|
function onActivity(data) {
|
||||||
if (!cy || !forActiveInstance(data)) return;
|
if (!cy || !forActiveInstance(data)) return;
|
||||||
|
|
||||||
if (data.kind === "link" && data.link) addLink(data.link);
|
if (data.kind === "link" && data.link) {
|
||||||
|
recordLink(data.link);
|
||||||
|
drawLinks();
|
||||||
|
}
|
||||||
|
|
||||||
// Start and end change states; ask the instance instead of guessing here.
|
// Start and end change states; ask the instance instead of guessing here.
|
||||||
clearTimeout(refreshPending);
|
clearTimeout(refreshPending);
|
||||||
@@ -282,34 +538,65 @@ async function refresh() {
|
|||||||
if (!cy) return;
|
if (!cy) return;
|
||||||
try {
|
try {
|
||||||
snapshot = await api("/team");
|
snapshot = await api("/team");
|
||||||
applySnapshot(false);
|
indexSnapshot();
|
||||||
|
applySnapshot();
|
||||||
} catch {
|
} catch {
|
||||||
// The next event or tick tries again; a failed refresh keeps the last picture.
|
// The next event or tick tries again; a failed refresh keeps the last picture.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLink(link) {
|
function recordLink(link) {
|
||||||
if (!cy) return;
|
const seenAt = new Date(link.atUtc).getTime();
|
||||||
const age = Date.now() - new Date(link.atUtc).getTime();
|
if (Date.now() - seenAt > LINK_VISIBLE_MS) return;
|
||||||
if (age > LINK_VISIBLE_MS) return;
|
const key = `${link.from}->${link.to}`;
|
||||||
if (cy.getElementById(link.from).empty() || cy.getElementById(link.to).empty()) return;
|
const known = links.get(key);
|
||||||
|
// A new contact renews the pair and counts; the snapshot may repeat one already seen.
|
||||||
// One edge per pair; a new contact renews it.
|
if (known && known.seenAt >= seenAt) return;
|
||||||
const id = `link:${link.from}->${link.to}`;
|
links.set(key, { from: link.from, to: link.to, seenAt, count: (known?.count ?? 0) + 1 });
|
||||||
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() {
|
/**
|
||||||
|
* The edges between what is drawn: links of agents inside a collapsed unit start or end at
|
||||||
|
* that unit, and several such links become one edge with a counter. A link that stays
|
||||||
|
* inside one collapsed unit is not drawn — it is the unit talking to itself.
|
||||||
|
*/
|
||||||
|
function drawLinks() {
|
||||||
if (!cy) return;
|
if (!cy) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
cy.edges().forEach((edge) => {
|
const bundles = new Map();
|
||||||
const age = now - edge.data("seenAt");
|
|
||||||
if (age > LINK_VISIBLE_MS) edge.remove();
|
for (const [key, link] of links) {
|
||||||
else edge.data("opacity", Math.max(0.15, 1 - age / LINK_VISIBLE_MS));
|
const age = now - link.seenAt;
|
||||||
});
|
if (age > LINK_VISIBLE_MS) {
|
||||||
|
links.delete(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const from = representative(link.from);
|
||||||
|
const to = representative(link.to);
|
||||||
|
if (!from || !to || from === to) continue;
|
||||||
|
if (cy.getElementById(from).empty() || cy.getElementById(to).empty()) continue;
|
||||||
|
|
||||||
|
const id = `link:${from}->${to}`;
|
||||||
|
const bundle = bundles.get(id) ?? { id, source: from, target: to, seenAt: 0, count: 0 };
|
||||||
|
bundle.seenAt = Math.max(bundle.seenAt, link.seenAt);
|
||||||
|
bundle.count += link.count;
|
||||||
|
bundles.set(id, bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.edges().filter((edge) => !bundles.has(edge.id())).remove();
|
||||||
|
for (const bundle of bundles.values()) {
|
||||||
|
const data = {
|
||||||
|
id: bundle.id,
|
||||||
|
source: bundle.source,
|
||||||
|
target: bundle.target,
|
||||||
|
opacity: Math.max(0.15, 1 - (now - bundle.seenAt) / LINK_VISIBLE_MS),
|
||||||
|
width: Math.min(8, 2 + bundle.count),
|
||||||
|
label: bundle.count > 1 ? `×${bundle.count}` : ""
|
||||||
|
};
|
||||||
|
const edge = cy.getElementById(bundle.id);
|
||||||
|
if (edge.nonempty()) edge.data(data);
|
||||||
|
else cy.add({ group: "edges", data });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Summary and legend ───
|
// ─── Summary and legend ───
|
||||||
@@ -334,32 +621,56 @@ function buildLegend() {
|
|||||||
{ klasse: "team-legende gruppe-horizontal gedaempft text-klein" },
|
{ klasse: "team-legende gruppe-horizontal gedaempft text-klein" },
|
||||||
...Object.entries(STATES).map(([state, s]) =>
|
...Object.entries(STATES).map(([state, s]) =>
|
||||||
erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })),
|
erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })),
|
||||||
|
erstelleElement("span", { text: `★ ${t("team.legendLead")}` }),
|
||||||
|
erstelleElement("span", { text: `▸ ${t("team.legendUnit")}` }),
|
||||||
erstelleElement("span", { text: `→ ${t("team.legendLink")}` })
|
erstelleElement("span", { text: `→ ${t("team.legendLink")}` })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Side panel ───
|
// ─── Side panel ───
|
||||||
|
|
||||||
function openPanel(agentId) {
|
function openPanel(target) {
|
||||||
const member = snapshot?.members.find((m) => m.agentId === agentId);
|
if (!panel || !snapshot) return;
|
||||||
if (!member || !panel) return;
|
const rows = target.unit ? unitRows(target.unit) : agentRows(target.agent);
|
||||||
selectedAgent = agentId;
|
if (!rows) return;
|
||||||
cy?.getElementById(agentId).select();
|
|
||||||
|
|
||||||
const state = STATES[member.state] ?? STATES.Idle;
|
selected = target;
|
||||||
const rows = [
|
cy?.elements().unselect();
|
||||||
erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
|
cy?.getElementById(target.unit ? UNIT_PREFIX + target.unit : target.agent).select();
|
||||||
erstelleElement("h2", { text: member.displayName || member.agentId }),
|
|
||||||
|
panel.replaceChildren(...rows);
|
||||||
|
panel.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelHead(title) {
|
||||||
|
return erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
|
||||||
|
erstelleElement("h2", { text: title }),
|
||||||
erstelleElement("button", {
|
erstelleElement("button", {
|
||||||
klasse: "schaltflaeche flach klein",
|
klasse: "schaltflaeche flach klein",
|
||||||
text: "×",
|
text: "×",
|
||||||
title: t("common.close"),
|
title: t("common.close"),
|
||||||
onclick: closePanel
|
onclick: closePanel
|
||||||
})),
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentRows(agentId) {
|
||||||
|
const member = membersById.get(agentId);
|
||||||
|
if (!member) return null;
|
||||||
|
|
||||||
|
const state = STATES[member.state] ?? STATES.Idle;
|
||||||
|
const unit = unitsById.get(member.unitId);
|
||||||
|
const rows = [
|
||||||
|
panelHead(member.displayName || member.agentId),
|
||||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
|
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
|
||||||
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
|
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (unit) {
|
||||||
|
rows.push(erstelleElement("p", { klasse: "text-klein" },
|
||||||
|
`${member.isLead ? `★ ${t("team.leads")} ` : `${t("team.memberOf")} `}`,
|
||||||
|
unitButton(unit)));
|
||||||
|
}
|
||||||
|
|
||||||
// Top layer: what is happening now.
|
// Top layer: what is happening now.
|
||||||
if (member.runs.length) {
|
if (member.runs.length) {
|
||||||
rows.push(erstelleElement("h3", { text: t("team.running") }));
|
rows.push(erstelleElement("h3", { text: t("team.running") }));
|
||||||
@@ -408,9 +719,78 @@ function openPanel(agentId) {
|
|||||||
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
|
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
|
||||||
);
|
);
|
||||||
rows.push(actions);
|
rows.push(actions);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
panel.replaceChildren(...rows);
|
function unitRows(unitId) {
|
||||||
panel.hidden = false;
|
const unit = unitsById.get(unitId);
|
||||||
|
if (!unit) return null;
|
||||||
|
|
||||||
|
const worst = STATES[unit.worst] ?? STATES.Idle;
|
||||||
|
const rows = [
|
||||||
|
panelHead(unit.name),
|
||||||
|
erstelleElement("p", { klasse: "gedaempft text-klein", text: unit.path }),
|
||||||
|
erstellePlakette(`${worst.symbol} ${unitCounts(unit)}`, worst.tone)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (unit.goal) rows.push(erstelleElement("p", { klasse: "text-klein", text: unit.goal }));
|
||||||
|
|
||||||
|
if (unit.pendingApprovals > 0) {
|
||||||
|
rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: unit.pendingApprovals })}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct members, the lead first; what is further down is one click on a sub-unit away.
|
||||||
|
rows.push(erstelleElement("h3", { text: t("team.members") }));
|
||||||
|
const members = snapshot.members
|
||||||
|
.filter((m) => m.unitId === unit.id)
|
||||||
|
.sort((a, b) => (b.isLead - a.isLead) || (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId));
|
||||||
|
if (!members.length) rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" }));
|
||||||
|
for (const member of members) {
|
||||||
|
const state = STATES[member.state] ?? STATES.Idle;
|
||||||
|
rows.push(erstelleElement("button", {
|
||||||
|
type: "button",
|
||||||
|
klasse: "flach klein team-eintrag",
|
||||||
|
text: `${state.symbol} ${member.isLead ? "★ " : ""}${member.displayName || member.agentId} — ${t(`team.state.${member.state}`)}`,
|
||||||
|
onclick: () => openPanel({ agent: member.agentId })
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = (snapshot.units ?? []).filter((u) => u.parentId === unit.id);
|
||||||
|
if (children.length) {
|
||||||
|
rows.push(erstelleElement("h3", { text: t("team.subUnits") }));
|
||||||
|
for (const child of children) rows.push(erstelleElement("div", {}, unitButton(child)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" });
|
||||||
|
const open = expanded.has(unit.id);
|
||||||
|
actions.append(
|
||||||
|
erstelleElement("button", {
|
||||||
|
type: "button",
|
||||||
|
klasse: "primaer klein",
|
||||||
|
text: open ? t("team.collapse") : t("team.expand"),
|
||||||
|
onclick: () => toggleUnit(unit.id, !open)
|
||||||
|
}),
|
||||||
|
link("#/organisation", t("team.openOrganisation"))
|
||||||
|
);
|
||||||
|
rows.push(actions);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A unit as a link in the panel, with its most urgent state. */
|
||||||
|
function unitButton(unit) {
|
||||||
|
const worst = STATES[unit.worst] ?? STATES.Idle;
|
||||||
|
return erstelleElement("button", {
|
||||||
|
type: "button",
|
||||||
|
klasse: "flach klein team-eintrag",
|
||||||
|
text: `${worst.symbol} ${unit.name} · ${unitCounts(unit)}`,
|
||||||
|
onclick: () => {
|
||||||
|
// Show it in the graph too: open everything above it.
|
||||||
|
for (const above of chain(unit.parentId)) expanded.add(above.id);
|
||||||
|
saveExpanded();
|
||||||
|
applySnapshot();
|
||||||
|
openPanel({ unit: unit.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function link(href, text, style = "flach") {
|
function link(href, text, style = "flach") {
|
||||||
@@ -423,7 +803,7 @@ function link(href, text, style = "flach") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closePanel() {
|
function closePanel() {
|
||||||
selectedAgent = null;
|
selected = null;
|
||||||
cy?.elements().unselect();
|
cy?.elements().unselect();
|
||||||
if (panel) panel.hidden = true;
|
if (panel) panel.hidden = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,8 +125,29 @@ public sealed record TaskDto(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
|
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
|
||||||
|
|
||||||
/// <summary>#711: The team overview — every agent and who reached whom recently.</summary>
|
/// <summary>#711: The team overview — every agent and unit, and who reached whom recently.</summary>
|
||||||
public sealed record TeamSnapshotDto(IReadOnlyList<TeamMemberDto> Members, IReadOnlyList<AgentLinkDto> Links, DateTime AtUtc);
|
public sealed record TeamSnapshotDto(
|
||||||
|
IReadOnlyList<TeamMemberDto> Members,
|
||||||
|
IReadOnlyList<AgentLinkDto> Links,
|
||||||
|
DateTime AtUtc,
|
||||||
|
IReadOnlyList<TeamUnitDto> Units);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #711, stage 1: A unit with the states of everyone in it and below, counted by the
|
||||||
|
/// instance. <see cref="States"/> maps a state name to a count; <see cref="Worst"/> is the
|
||||||
|
/// most urgent state inside.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TeamUnitDto(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string? ParentId,
|
||||||
|
string Path,
|
||||||
|
string? LeaderAgentId,
|
||||||
|
string? Goal,
|
||||||
|
int Agents,
|
||||||
|
IReadOnlyDictionary<string, int> States,
|
||||||
|
int PendingApprovals,
|
||||||
|
string Worst);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
|
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
|
||||||
@@ -142,7 +163,9 @@ public sealed record TeamMemberDto(
|
|||||||
string? LastRunStatus,
|
string? LastRunStatus,
|
||||||
DateTime? LastRunAtUtc,
|
DateTime? LastRunAtUtc,
|
||||||
string? LastRunResult,
|
string? LastRunResult,
|
||||||
long TypicalRunMs);
|
long TypicalRunMs,
|
||||||
|
string? UnitId,
|
||||||
|
bool IsLead);
|
||||||
|
|
||||||
/// <summary>#711: A run in progress.</summary>
|
/// <summary>#711: A run in progress.</summary>
|
||||||
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
|
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
|
||||||
|
|||||||
@@ -74,9 +74,14 @@ internal static class Mapping
|
|||||||
snapshot.Members.Select(m => new TeamMemberDto(
|
snapshot.Members.Select(m => new TeamMemberDto(
|
||||||
m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
|
m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
|
||||||
m.Runs.Select(ToDto).ToList(),
|
m.Runs.Select(ToDto).ToList(),
|
||||||
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs)).ToList(),
|
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs,
|
||||||
|
m.UnitId, m.IsLead)).ToList(),
|
||||||
snapshot.RecentLinks.Select(ToDto).ToList(),
|
snapshot.RecentLinks.Select(ToDto).ToList(),
|
||||||
snapshot.AtUtc);
|
snapshot.AtUtc,
|
||||||
|
(snapshot.Units ?? []).Select(u => new TeamUnitDto(
|
||||||
|
u.Id, u.Name, u.ParentId, u.Path, u.LeaderAgentId, u.Goal, u.Agents,
|
||||||
|
u.States.ToDictionary(s => s.Key.ToString(), s => s.Value),
|
||||||
|
u.PendingApprovals, u.Worst.ToString())).ToList());
|
||||||
|
|
||||||
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
|
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
|
||||||
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
|
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using ClawdDotNet.App.Services;
|
using ClawdDotNet.App.Services;
|
||||||
using ClawdDotNet.Core.Activity;
|
using ClawdDotNet.Core.Activity;
|
||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
using ClawdDotNet.Core.Organisation;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
|
||||||
namespace ClawdDotNet.App.Tests.Services;
|
namespace ClawdDotNet.App.Tests.Services;
|
||||||
@@ -79,4 +81,85 @@ public sealed class TeamOverviewTests
|
|||||||
public void A_cancelled_run_is_not_a_problem()
|
public void A_cancelled_run_is_not_a_problem()
|
||||||
// Cancelled means a human stopped it — that was a decision, not a fault.
|
// Cancelled means a human stopped it — that was a decision, not a fault.
|
||||||
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
|
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
|
||||||
|
|
||||||
|
// ═══════════════════ stage 1: units roll up ═══════════════════
|
||||||
|
|
||||||
|
/// <summary>marketing (lead) › amazon (lead, ads) ; accounting (bookkeeper) ; ceo at the top.</summary>
|
||||||
|
private static OrgChart Shop() => new(
|
||||||
|
[
|
||||||
|
new UnitConfig { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
|
||||||
|
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
|
||||||
|
new UnitConfig { Id = "accounting", Name = "Accounting" }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
new AgentConfig { AgentId = "ceo" },
|
||||||
|
new AgentConfig { AgentId = "marketing-lead", UnitId = "marketing" },
|
||||||
|
new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" },
|
||||||
|
new AgentConfig { AgentId = "ads", UnitId = "amazon" },
|
||||||
|
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
private static TeamMember Member(string id, string? unit, TeamState state, int approvals = 0)
|
||||||
|
=> new(id, id, "m", state, [], approvals, null, null, null, 0, unit);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_problem_deep_down_shows_on_every_container_above_it()
|
||||||
|
{
|
||||||
|
var units = TeamOverview.RollUp(Shop(),
|
||||||
|
[
|
||||||
|
Member("ceo", null, TeamState.Working),
|
||||||
|
Member("marketing-lead", "marketing", TeamState.Working),
|
||||||
|
Member("amazon-lead", "amazon", TeamState.Idle),
|
||||||
|
Member("ads", "amazon", TeamState.Problem),
|
||||||
|
Member("bookkeeper", "accounting", TeamState.Idle)
|
||||||
|
]);
|
||||||
|
|
||||||
|
var marketing = units.Single(u => u.Id == "marketing");
|
||||||
|
marketing.Agents.ShouldBe(3, "its own lead and the two in amazon below");
|
||||||
|
marketing.Worst.ShouldBe(TeamState.Problem);
|
||||||
|
marketing.States[TeamState.Working].ShouldBe(1);
|
||||||
|
marketing.States[TeamState.Problem].ShouldBe(1);
|
||||||
|
|
||||||
|
units.Single(u => u.Id == "amazon").Worst.ShouldBe(TeamState.Problem);
|
||||||
|
units.Single(u => u.Id == "accounting").Worst.ShouldBe(TeamState.Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Open_approvals_are_added_up_along_the_tree()
|
||||||
|
{
|
||||||
|
var units = TeamOverview.RollUp(Shop(),
|
||||||
|
[
|
||||||
|
Member("marketing-lead", "marketing", TeamState.WaitingForApproval, approvals: 1),
|
||||||
|
Member("ads", "amazon", TeamState.WaitingForApproval, approvals: 2)
|
||||||
|
]);
|
||||||
|
|
||||||
|
units.Single(u => u.Id == "marketing").PendingApprovals.ShouldBe(3);
|
||||||
|
units.Single(u => u.Id == "amazon").PendingApprovals.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_unit_knows_its_path_and_its_lead()
|
||||||
|
{
|
||||||
|
var amazon = TeamOverview.RollUp(Shop(), []).Single(u => u.Id == "amazon");
|
||||||
|
|
||||||
|
amazon.Path.ShouldBe("Marketing / Amazon");
|
||||||
|
amazon.LeaderAgentId.ShouldBe("amazon-lead");
|
||||||
|
amazon.Agents.ShouldBe(0);
|
||||||
|
amazon.Worst.ShouldBe(TeamState.Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stuck_is_the_most_urgent_and_idle_the_least()
|
||||||
|
{
|
||||||
|
var byUrgency = Enum.GetValues<TeamState>().OrderByDescending(TeamOverview.Urgency).ToList();
|
||||||
|
|
||||||
|
byUrgency.First().ShouldBe(TeamState.Stuck);
|
||||||
|
byUrgency.Last().ShouldBe(TeamState.Idle);
|
||||||
|
TeamOverview.Urgency(TeamState.Problem).ShouldBeGreaterThan(TeamOverview.Urgency(TeamState.WaitingForApproval));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_units_there_is_nothing_to_roll_up()
|
||||||
|
=> TeamOverview.RollUp(new OrgChart([], [new AgentConfig { AgentId = "a" }]), [Member("a", null, TeamState.Problem)])
|
||||||
|
.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user