diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md index 15a3af1..90283b2 100644 --- a/docs/Organisation-Konzept.md +++ b/docs/Organisation-Konzept.md @@ -1586,6 +1586,14 @@ Pixelgrafik: charmant, aber mit wenig Information. Die Verbindung aus aufklappba Organisation, Live-Zuständen und nach oben gereichten Problemen mit Direktzugriff hat keines davon. +> **Umgesetzt (#688) als Ausbau dieser Ansicht, nicht als zweite.** Jede Einheitskarte der +> Teamübersicht trägt die Lage der Abteilung samt allem darunter: Läufe und Kosten heute +> (mit Warnzeichen, wenn #699 heute eine Kostenmeldung für sie erhoben hat), offene und +> blockierte Aufgaben, offene Vorgänge aus der Einheit (#689). Die Instanzkarte summiert +> dieselben Werte über alle. Die Seitenleiste führt von dort in die Kosten je Einheit und +> die Vorgänge. **Offen:** dieselbe Ansicht in der Avalonia-Oberfläche — dort gibt es die +> Teamübersicht noch gar nicht. + --- ## 11 — Reihenfolge diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 2e1f84e..71f4d10 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -146,7 +146,9 @@ public sealed class AppHost : IAsyncDisposable /// #711: The team overview. Stateless, like . public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents, - organisation: () => Core.Organisation.OrgChart.From(Instance)); + organisation: () => Core.Organisation.OrgChart.From(Instance), + board: Board, cases: Cases, + costAlerts: CostWatch is { } watch ? ct => watch.RecentAsync(1, ct) : null); public TelegramClientManager? TelegramClient { get; private init; } public OpenRouterStatusService? Status { get; private init; } diff --git a/src/ClawdDotNet.App/Services/TeamOverview.cs b/src/ClawdDotNet.App/Services/TeamOverview.cs index 288635e..4a1fa2f 100644 --- a/src/ClawdDotNet.App/Services/TeamOverview.cs +++ b/src/ClawdDotNet.App/Services/TeamOverview.cs @@ -5,6 +5,7 @@ using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Staging; +using ClawdDotNet.Core.Tasks; namespace ClawdDotNet.App.Services; @@ -43,7 +44,12 @@ public sealed record TeamMember( string? LastRunResult, long TypicalRunMs, string? UnitId = null, - bool IsLead = false); + bool IsLead = false, + // #688: the agent's day — what a department's situation is added up from. + int RunsToday = 0, + decimal CostToday = 0, + int OpenTasks = 0, + int BlockedTasks = 0); /// /// One unit in the team overview (#711, stage 1), with what happens inside it rolled up — @@ -60,7 +66,15 @@ public sealed record TeamUnit( int Agents, IReadOnlyDictionary States, int PendingApprovals, - TeamState Worst); + TeamState Worst, + // #688: the department's situation, not the agent's — runs and cost today, tasks open and + // blocked, cases raised inside it, and the cost alert of today, if any (#699). + int RunsToday = 0, + decimal CostToday = 0, + int OpenTasks = 0, + int BlockedTasks = 0, + int OpenCases = 0, + string? CostAlert = null); /// The whole picture at one moment: every agent and unit, and who reached whom recently. public sealed record TeamSnapshot( @@ -89,7 +103,10 @@ public sealed class TeamOverview( IUsageRepository? usage, Func> agents, TimeProvider? clock = null, - Func? organisation = null) + Func? organisation = null, + TaskboardService? board = null, + Core.Cases.ICaseRepository? cases = null, + Func>>? costAlerts = null) { /// How far back the last receipt of each agent is looked for. public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3); @@ -140,6 +157,14 @@ public sealed class TeamOverview( var org = organisation?.Invoke() ?? new OrgChart([], configured); + // #688: the day of each agent, in one query each — not one per agent. + var today = DateOnly.FromDateTime(_clock.GetLocalNow().DateTime); + var usageToday = usage is null ? [] : await usage.GetDailyByAgentAsync(today, today, ct); + var openTasks = board is null ? [] : await board.ListAsync(new TaskQuery { Status = TaskItemStatus.Todo, Limit = 5_000 }, ct); + var blockedTasks = board is null ? [] : await board.ListAsync(new TaskQuery { Status = TaskItemStatus.Blocked, Limit = 5_000 }, ct); + int Count(IReadOnlyList tasks, string agentId) + => tasks.Count(t => string.Equals(TaskAssignee.AgentId(t.Assignee), agentId, StringComparison.OrdinalIgnoreCase)); + var members = new List(); foreach (var agent in configured) { @@ -155,7 +180,10 @@ public sealed class TeamOverview( members.Add(new TeamMember( agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals, last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds, - org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null)); + org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null, + usageToday.Where(u => string.Equals(u.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase)).Sum(u => u.Runs), + usageToday.Where(u => string.Equals(u.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase)).Sum(u => u.CostUsd), + Count(openTasks, agent.AgentId), Count(blockedTasks, agent.AgentId))); } // #720: files where agents keep getting in each other's way — for a human to decide @@ -165,7 +193,19 @@ public sealed class TeamOverview( .Select(h => h with { Path = Readable(h.Path, configured) }).ToList() : []; - return new TeamSnapshot(members, links, now, RollUp(org, members), hotspots); + var openCases = cases is null ? [] : await cases.ListOpenAsync(ct); + var alerts = costAlerts is null ? [] : (await costAlerts(ct)).Where(a => a.Period == today.ToString("yyyy-MM-dd")).ToList(); + var units = RollUp(org, members) + .Select(u => u with + { + OpenCases = openCases.Count(c => c.OpenedUnitId is { } id + && (string.Equals(id, u.Id, StringComparison.OrdinalIgnoreCase) + || org.Ancestors(id).Any(a => string.Equals(a.Id, u.Id, StringComparison.OrdinalIgnoreCase)))), + CostAlert = alerts.FirstOrDefault(a => string.Equals(a.Scope, u.Id, StringComparison.OrdinalIgnoreCase))?.Kind.ToString() + }) + .ToList(); + + return new TeamSnapshot(members, links, now, units, hotspots); } /// @@ -222,7 +262,8 @@ public sealed class TeamOverview( 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)); + inside.Count, states, inside.Sum(m => m.PendingApprovals), worst, + inside.Sum(m => m.RunsToday), inside.Sum(m => m.CostToday), inside.Sum(m => m.OpenTasks), inside.Sum(m => m.BlockedTasks))); } return result; } diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css index 1a5a53f..2be8ddf 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css @@ -2332,6 +2332,21 @@ td.klein { margin-top: 8px; } +/* #688: a department's situation — runs, cost, tasks, cases */ +.team-lage { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin-top: 6px; + font-size: 12px; + color: var(--gedaempft); + font-variant-numeric: tabular-nums; +} + +.team-lage .ton-warnung { + color: var(--warnung); +} + .team-chip { padding: 1px 6px; font-size: 11px; diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 1186959..0e8fad6 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -203,6 +203,13 @@ "team.members": "Mitglieder", "team.subUnits": "Untereinheiten", "team.openOrganisation": "Organisation bearbeiten", + "team.openCosts": "Kosten je Einheit", + "team.openCases": "Offene Vorgänge", + "team.situation.runsHint": "Läufe heute, in dieser Einheit und darunter", + "team.situation.costHint": "Kosten heute, in dieser Einheit und darunter", + "team.situation.tasksHint": "Offene Aufgaben", + "team.situation.blockedHint": "Blockierte Aufgaben — warten auf jemanden", + "team.situation.casesHint": "Offene Vorgänge aus dieser Einheit", "team.rootSubtitle": "Instanz · oberste Ebene", "team.structure": "Struktur", "team.searchPlaceholder": "Agenten, Einheiten, Modelle suchen…", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 12f83bb..a036a9e 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -203,6 +203,13 @@ "team.members": "Members", "team.subUnits": "Sub-units", "team.openOrganisation": "Edit organisation", + "team.openCosts": "Costs by unit", + "team.openCases": "Open cases", + "team.situation.runsHint": "Runs today, in this unit and below", + "team.situation.costHint": "Cost today, in this unit and below", + "team.situation.tasksHint": "Open tasks", + "team.situation.blockedHint": "Blocked tasks — waiting for someone", + "team.situation.casesHint": "Open cases raised in this unit", "team.rootSubtitle": "Instance · top level", "team.structure": "Structure", "team.searchPlaceholder": "Search agents, units, models…", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/team.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/team.js index 452c814..b71350f 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/team.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/team.js @@ -235,7 +235,8 @@ function indexSnapshot() { agents: snapshot.members.length, states, pendingApprovals: snapshot.members.reduce((sum, m) => sum + m.pendingApprovals, 0), - worst + worst, + situation: sumSituation(snapshot.members) }); for (const unit of snapshot.units ?? []) { @@ -250,7 +251,15 @@ function indexSnapshot() { agents: unit.agents, states: unit.states ?? {}, pendingApprovals: unit.pendingApprovals, - worst: unit.worst + worst: unit.worst, + situation: { + runsToday: unit.runsToday ?? 0, + costToday: unit.costToday ?? 0, + openTasks: unit.openTasks ?? 0, + blockedTasks: unit.blockedTasks ?? 0, + openCases: unit.openCases ?? 0, + costAlert: unit.costAlert ?? null + } }); } } @@ -727,6 +736,33 @@ function visibleMembers(card) { return { rows, hidden: all.length - rows.length }; } +/** #688: The instance's own situation — the units' come from the instance, this total is added up here. */ +function sumSituation(members) { + const sum = (key) => members.reduce((n, m) => n + (m[key] ?? 0), 0); + return { runsToday: sum("runsToday"), costToday: sum("costToday"), openTasks: sum("openTasks"), blockedTasks: sum("blockedTasks"), openCases: 0, costAlert: null }; +} + +/** + * #688: A department's situation in one line — runs and cost today, tasks open and blocked, + * cases raised in it. The way in from the top: first the unit that stands out, then the agent. + */ +function situationLine(card) { + const s = card.situation; + if (!s) return null; + const parts = [ + erstelleElement("span", { title: t("team.situation.runsHint"), text: `▶ ${s.runsToday}` }), + erstelleElement("span", { + klasse: s.costAlert ? "ton-warnung" : "", + title: s.costAlert ? t(`usage.units.kind.${s.costAlert}`) : t("team.situation.costHint"), + text: `${s.costAlert ? "⚠ " : ""}${Number(s.costToday).toFixed(2)} $` + }), + erstelleElement("span", { title: t("team.situation.tasksHint"), text: `☐ ${s.openTasks}` }) + ]; + if (s.blockedTasks) parts.push(erstelleElement("span", { klasse: "ton-warnung", title: t("team.situation.blockedHint"), text: `⛔ ${s.blockedTasks}` })); + if (s.openCases) parts.push(erstelleElement("span", { klasse: "ton-warnung", title: t("team.situation.casesHint"), text: `⚖ ${s.openCases}` })); + return erstelleElement("div", { klasse: "team-lage" }, ...parts); +} + function buildCard(card) { const worst = STATES[card.worst] ?? STATES.Idle; const dimmed = hits && !hits.onPath.has(card.id); @@ -754,7 +790,8 @@ function buildCard(card) { erstelleElement("span", { klasse: "team-marker", text: worst.symbol }), erstelleElement("span", { klasse: "team-karte-name", text: card.title })), erstelleElement("div", { klasse: "team-karte-unter", text: card.subtitle }), - erstelleElement("div", { klasse: "team-chips" }, ...chips(card)))); + erstelleElement("div", { klasse: "team-chips" }, ...chips(card)), + situationLine(card))); const { rows, hidden } = visibleMembers(card); if (rows.length || hidden) { @@ -1222,8 +1259,9 @@ function cardRows(cardId) { panelHead(card.title), erstelleElement("p", { klasse: "gedaempft text-klein", text: card.unit?.path ?? card.subtitle }), erstelleElement("div", { klasse: "team-chips" }, ...chips(card)), + situationLine(card), erstelleElement("p", { klasse: "gedaempft text-klein", text: t("team.subtreeHint") }) - ]; + ].filter(Boolean); if (card.unit?.goal) rows.push(erstelleElement("p", { klasse: "text-klein", text: card.unit.goal })); @@ -1263,6 +1301,8 @@ function cardRows(cardId) { })); } if (card.unit) actions.append(link("#/organisation", t("team.openOrganisation"))); + if (card.situation?.costAlert || card.situation?.costToday > 0) actions.append(link("#/verbrauch", t("team.openCosts"))); + if (card.situation?.openCases) actions.append(link("#/cases", t("team.openCases"))); if (actions.children.length) rows.push(actions); return rows; } diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index 7ee6ecb..d92debd 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -212,7 +212,14 @@ public sealed record TeamUnitDto( int Agents, IReadOnlyDictionary States, int PendingApprovals, - string Worst); + string Worst, + // #688: the department's situation. + int RunsToday = 0, + decimal CostToday = 0, + int OpenTasks = 0, + int BlockedTasks = 0, + int OpenCases = 0, + string? CostAlert = null); /// /// #711: One agent in the team overview. is one of Idle, Working, Stuck, @@ -230,7 +237,12 @@ public sealed record TeamMemberDto( string? LastRunResult, long TypicalRunMs, string? UnitId, - bool IsLead); + bool IsLead, + // #688: the agent's day. + int RunsToday = 0, + decimal CostToday = 0, + int OpenTasks = 0, + int BlockedTasks = 0); /// #711: A run in progress. public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc); diff --git a/src/ClawdDotNet.Web/Contracts/Mapping.cs b/src/ClawdDotNet.Web/Contracts/Mapping.cs index a9deb53..1f281f7 100644 --- a/src/ClawdDotNet.Web/Contracts/Mapping.cs +++ b/src/ClawdDotNet.Web/Contracts/Mapping.cs @@ -95,13 +95,14 @@ internal static class Mapping m.AgentId, m.DisplayName, m.Model, m.State.ToString(), m.Runs.Select(ToDto).ToList(), m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs, - m.UnitId, m.IsLead)).ToList(), + m.UnitId, m.IsLead, m.RunsToday, m.CostToday, m.OpenTasks, m.BlockedTasks)).ToList(), snapshot.RecentLinks.Select(ToDto).ToList(), 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(), + u.PendingApprovals, u.Worst.ToString(), + u.RunsToday, u.CostToday, u.OpenTasks, u.BlockedTasks, u.OpenCases, u.CostAlert)).ToList(), (snapshot.ConflictHotspots ?? []).Select(h => new ConflictHotspotDto( h.Workspace, h.Path, h.Conflicts, h.LastAtUtc, h.Agents)).ToList()); diff --git a/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs b/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs index 85b29f7..182f11d 100644 --- a/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs +++ b/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs @@ -186,4 +186,63 @@ public sealed class TeamOverviewTests TeamOverview.Readable(elsewhere, configured).ShouldBe(elsewhere); TeamOverview.Readable(elsewhere, []).ShouldBe(elsewhere); } + + // ═══════════════════ the department's situation (#688) ═══════════════════ + + [Fact] + public void A_departments_day_is_added_up_along_the_tree() + { + var units = TeamOverview.RollUp(Shop(), + [ + Member("marketing-lead", "marketing", TeamState.Idle) with { RunsToday = 2, CostToday = 0.5m, OpenTasks = 1 }, + Member("ads", "amazon", TeamState.Idle) with { RunsToday = 5, CostToday = 1.25m, OpenTasks = 3, BlockedTasks = 1 } + ]); + + var marketing = units.Single(u => u.Id == "marketing"); + marketing.RunsToday.ShouldBe(7); + marketing.CostToday.ShouldBe(1.75m); + marketing.OpenTasks.ShouldBe(4); + marketing.BlockedTasks.ShouldBe(1); + units.Single(u => u.Id == "amazon").CostToday.ShouldBe(1.25m); + } + + [Fact] + public async Task The_snapshot_carries_runs_cost_tasks_cases_and_the_cost_alert_of_today() + { + var directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "team-" + Guid.NewGuid().ToString("N")[..8]); + try + { + var storage = new Core.Storage.SqliteStorage(Path.Combine(directory, "state.db")); + var usage = new Core.Accounting.SqliteUsageRepository(storage); + var board = new Core.Tasks.TaskboardService(new Core.Tasks.SqliteTaskRepository(storage), Path.Combine(directory, "tasks")); + var cases = new Core.Cases.SqliteCaseRepository(storage); + await usage.RecordAsync(new Core.Accounting.RunUsage { AgentId = "ads", Model = "m", CostUsd = 0.4m, CostIsKnown = true, Status = "Completed", OccurredAt = DateTime.Now }, default); + await board.CreateAsync(new Core.Tasks.TaskItem { Title = "Bid for spring", Assignee = "@ads" }, default); + await board.CreateAsync(new Core.Tasks.TaskItem { Title = "Waiting", Assignee = "@ads", Status = Core.Tasks.TaskItemStatus.Blocked }, default); + await cases.SaveAsync(new Core.Cases.CaseRecord { Id = "c-1", Subject = "Field", OpenedBy = "ads", OpenedUnitId = "amazon", OpenedAt = DateTime.UtcNow }, default); + var today = DateOnly.FromDateTime(DateTime.Now).ToString("yyyy-MM-dd"); + + var overview = new TeamOverview(null, null, null, usage, () => Shop().Agents.ToList(), + organisation: Shop, board: board, cases: cases, + costAlerts: _ => Task.FromResult>( + [new Core.Accounting.RaisedCostAlert(today, "amazon", Core.Accounting.CostAlertKind.Deviation, "x", DateTime.UtcNow)])); + + var snapshot = await overview.SnapshotAsync(default); + + var ads = snapshot.Members.Single(m => m.AgentId == "ads"); + ads.RunsToday.ShouldBe(1); + ads.CostToday.ShouldBe(0.4m); + ads.OpenTasks.ShouldBe(1); + ads.BlockedTasks.ShouldBe(1); + var marketing = snapshot.Units!.Single(u => u.Id == "marketing"); + marketing.OpenCases.ShouldBe(1, "a case raised in amazon lies inside marketing"); + snapshot.Units!.Single(u => u.Id == "amazon").CostAlert.ShouldBe("Deviation"); + marketing.CostAlert.ShouldBeNull("the alert was raised on amazon, not on marketing"); + } + finally + { + Core.Storage.SqliteStorage.ReleasePooledConnections(directory); + try { Directory.Delete(directory, recursive: true); } catch { /* cleanup is secondary */ } + } + } }