From 8103006e26631d30446be96f60100368c872a61e Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 13:57:21 +0200 Subject: [PATCH] feat(ui): add usage monitor modal with gauges and model/task usage analysis Adds a Usage Monitor modal (Worker menu + wired to the footer/Mission-Control usage pill's Open command): dynamic gauges built from UsageSnapshotDto.Limits with gate-threshold marks, a stale/blocked-gate band, and Models/Tasks tabs backed by GetModelUsageAsync/GetTaskUsageAsync over a 7d/30d/custom range. --- src/ClaudeDo.App/Program.cs | 2 + src/ClaudeDo.Localization/locales/de.json | 35 +++ src/ClaudeDo.Localization/locales/en.json | 35 +++ src/ClaudeDo.Ui/Services/IDialogService.cs | 1 + .../ViewModels/IslandsShellViewModel.cs | 21 ++ .../Modals/UsageMonitorModalViewModel.cs | 236 ++++++++++++++++ src/ClaudeDo.Ui/Views/MainWindow.axaml | 2 + .../Views/Modals/UsageMonitorModalView.axaml | 186 ++++++++++++ .../Modals/UsageMonitorModalView.axaml.cs | 8 + src/ClaudeDo.Ui/Views/WindowDialogService.cs | 7 + .../ViewModels/TasksIslandMoveToListTests.cs | 1 + .../UsageMonitorModalViewModelTests.cs | 266 ++++++++++++++++++ 12 files changed, 800 insertions(+) create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs create mode 100644 src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml create mode 100644 src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/UsageMonitorModalViewModelTests.cs diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 7037ff49..cb3e95aa 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -138,6 +138,8 @@ sealed class Program sc.AddTransient>(sp => () => sp.GetRequiredService()); sc.AddTransient(); sc.AddTransient>(sp => () => sp.GetRequiredService()); + sc.AddTransient(); + sc.AddTransient>(sp => () => sp.GetRequiredService()); sc.AddSingleton>(sp => taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel( sp.GetRequiredService(), taskId)); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index eed661a8..7a8bfdec 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -422,6 +422,39 @@ "generate": "Erstellen", "regenerate": "Neu erstellen", "emptyStateHint": "Noch kein Bericht für diesen Zeitraum. Klicke auf „Erstellen“." + }, + "usageMonitor": { + "title": "USAGE MONITOR", + "windowTitle": "Usage Monitor", + "noGauges": "Noch keine Nutzungslimits gemeldet.", + "staleFormat": "Werte veraltet (Stand {0})", + "staleGateHint": "Das Gate greift in diesem Zustand nicht.", + "gateBlockedFormat": "Queue pausiert — {0}", + "resetIn": "Reset in {0}", + "gaugeSession": "Session (5 Std.)", + "gaugeWeeklyAll": "Woche (alle Modelle)", + "gaugeWeeklyScopedFormat": "Woche ({0})", + "period": "Zeitraum:", + "last7Days": "7 Tage", + "last30Days": "30 Tage", + "fromDate": "Von", + "toDate": "Bis", + "modelUsage": "Modelle", + "taskUsage": "Tasks", + "emptyStateHint": "Noch keine Nutzungsdaten für diesen Zeitraum.", + "columnModel": "MODELL", + "columnIn": "IN", + "columnOut": "OUT", + "columnCache": "CACHE", + "columnOtherIn": "ÜBRIGES IN", + "columnOtherOut": "ÜBRIGES OUT", + "columnOtherCache": "ÜBRIGES CACHE", + "columnShare": "ANTEIL", + "columnTask": "TASK", + "columnList": "LISTE", + "columnModelShort": "MODELL", + "columnRuns": "RUNS", + "columnTotal": "GESAMT" } }, "installer": { @@ -535,6 +568,7 @@ "restartWorker": "Worker neu starten", "worktrees": "Worktrees…", "weeklyReport": "Wochenbericht…", + "usageMonitor": "Usage Monitor…", "about": "Über…", "addRepos": "Repos als Listen hinzufügen…" }, @@ -559,6 +593,7 @@ "settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" }, "onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" }, "weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" }, + "usageMonitor": { "loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" }, "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index b1feeccf..3df5d58c 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -422,6 +422,39 @@ "generate": "Generate", "regenerate": "Regenerate", "emptyStateHint": "No report for this range yet. Click “Generate”." + }, + "usageMonitor": { + "title": "USAGE MONITOR", + "windowTitle": "Usage Monitor", + "noGauges": "No usage limits reported yet.", + "staleFormat": "Values stale (as of {0})", + "staleGateHint": "The gate does not apply while values are stale.", + "gateBlockedFormat": "Queue paused — {0}", + "resetIn": "Reset in {0}", + "gaugeSession": "Session (5h)", + "gaugeWeeklyAll": "Week (all models)", + "gaugeWeeklyScopedFormat": "Week ({0})", + "period": "Period:", + "last7Days": "7 days", + "last30Days": "30 days", + "fromDate": "From", + "toDate": "To", + "modelUsage": "Models", + "taskUsage": "Tasks", + "emptyStateHint": "No usage data for this range yet.", + "columnModel": "MODEL", + "columnIn": "IN", + "columnOut": "OUT", + "columnCache": "CACHE", + "columnOtherIn": "OTHER IN", + "columnOtherOut": "OTHER OUT", + "columnOtherCache": "OTHER CACHE", + "columnShare": "SHARE", + "columnTask": "TASK", + "columnList": "LIST", + "columnModelShort": "MODEL", + "columnRuns": "RUNS", + "columnTotal": "TOTAL" } }, "installer": { @@ -535,6 +568,7 @@ "restartWorker": "Restart worker", "worktrees": "Worktrees…", "weeklyReport": "Weekly Report…", + "usageMonitor": "Usage Monitor…", "about": "About…", "addRepos": "Add repos as lists…" }, @@ -559,6 +593,7 @@ "settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" }, "onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" }, "weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" }, + "usageMonitor": { "loadFailed": "Couldn't load usage data: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" }, "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" }, diff --git a/src/ClaudeDo.Ui/Services/IDialogService.cs b/src/ClaudeDo.Ui/Services/IDialogService.cs index cc222018..725197af 100644 --- a/src/ClaudeDo.Ui/Services/IDialogService.cs +++ b/src/ClaudeDo.Ui/Services/IDialogService.cs @@ -16,6 +16,7 @@ public interface IDialogService { Task ShowAboutAsync(AboutModalViewModel vm); Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm); + Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm); Task ShowSettingsAsync(SettingsModalViewModel vm); Task ShowListSettingsAsync(ListSettingsModalViewModel vm); Task ShowRepoImportAsync(RepoImportModalViewModel vm); diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 070c7d50..ef34bfc5 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -37,6 +37,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable private readonly IDbContextFactory? _dbFactory; private readonly Func _worktreesOverviewVmFactory = () => null!; private readonly Func _weeklyReportVmFactory = () => null!; + private readonly Func _usageMonitorVmFactory = () => null!; private readonly Func _mergeVmFactory = () => null!; private readonly Func? _repoImportVmFactory; @@ -207,6 +208,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable IDbContextFactory dbFactory, Func worktreesOverviewVmFactory, Func weeklyReportVmFactory, + Func usageMonitorVmFactory, Func mergeVmFactory, Func repoImportVmFactory, MissionControlViewModel missionControl, @@ -215,6 +217,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable Lists = lists; Tasks = tasks; Details = details; Worker = worker; MissionControl = missionControl; UsagePill = usagePill; + UsagePill.OpenMonitorRequested += () => _ = OpenUsageMonitor(); MissionControl.OpenInApp = id => _ = RevealTaskAsync(id); MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null); MissionControl.ErrorReported += FlashFooterError; @@ -226,6 +229,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable _dbFactory = dbFactory; _worktreesOverviewVmFactory = worktreesOverviewVmFactory; _weeklyReportVmFactory = weeklyReportVmFactory; + _usageMonitorVmFactory = usageMonitorVmFactory; _mergeVmFactory = mergeVmFactory; _repoImportVmFactory = repoImportVmFactory; Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); @@ -429,6 +433,23 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable finally { _weeklyReportOpen = false; } } + private bool _usageMonitorOpen; + + [RelayCommand] + private async Task OpenUsageMonitor() + { + if (Dialogs is null || _usageMonitorOpen) return; + _usageMonitorOpen = true; + try + { + var vm = _usageMonitorVmFactory(); + vm.ErrorReported += FlashFooterError; + await vm.LoadAsync(); + await Dialogs.ShowUsageMonitorAsync(vm); + } + finally { _usageMonitorOpen = false; } + } + [RelayCommand] private async Task CheckForUpdatesAsync() { diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs new file mode 100644 index 00000000..785f0511 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals; + +public sealed partial class UsageMonitorModalViewModel : ViewModelBase +{ + private readonly IWorkerClient _worker; + private bool _applyingRange; + + public UsageMonitorModalViewModel(IWorkerClient worker) => _worker = worker; + + public Action? CloseAction { get; set; } + public event Action? ErrorReported; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(GaugeRows))] + [NotifyPropertyChangedFor(nameof(IsStale))] + [NotifyPropertyChangedFor(nameof(LastError))] + [NotifyPropertyChangedFor(nameof(IsGateBlocked))] + [NotifyPropertyChangedFor(nameof(GateReason))] + [NotifyPropertyChangedFor(nameof(StaleStampText))] + [NotifyPropertyChangedFor(nameof(StaleBandText))] + [NotifyPropertyChangedFor(nameof(GateBandText))] + private UsageSnapshotDto? _snapshot; + + [ObservableProperty] private DateTime? _startDate; + [ObservableProperty] private DateTime? _endDate; + [ObservableProperty] private int _selectedPresetDays = 7; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ModelsEmpty))] + private bool _isBusy; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ModelsEmpty))] + private IReadOnlyList _modelRows = Array.Empty(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(TasksEmpty))] + private IReadOnlyList _taskRows = Array.Empty(); + + public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0; + public bool TasksEmpty => !IsBusy && TaskRows.Count == 0; + + public IReadOnlyList GaugeRows => + Snapshot is null ? Array.Empty() : Snapshot.Limits.Select(BuildGaugeRow).ToList(); + + public bool IsStale => Snapshot?.IsStale == true; + public string? LastError => Snapshot?.LastError; + public bool IsGateBlocked => Snapshot?.IsGateBlocked == true; + public string? GateReason => Snapshot?.GateReason; + public string StaleStampText => Snapshot?.FetchedAtUtc is { } t ? t.ToLocalTime().ToString("HH:mm") : "?"; + public string StaleBandText => Loc.T("modals.usageMonitor.staleFormat", StaleStampText); + public string GateBandText => GateReason is null ? "" : Loc.T("modals.usageMonitor.gateBlockedFormat", GateReason); + + [RelayCommand] + private void Close() + { + _worker.UsageUpdatedEvent -= OnUsageUpdated; + CloseAction?.Invoke(); + } + + public async Task LoadAsync() + { + Snapshot = await _worker.GetUsageSnapshotAsync(); + _worker.UsageUpdatedEvent -= OnUsageUpdated; + _worker.UsageUpdatedEvent += OnUsageUpdated; + ApplyPresetRange(SelectedPresetDays); + await LoadUsageDataAsync(); + } + + private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot; + + private void ApplyPresetRange(int days) + { + var today = DateOnly.FromDateTime(DateTime.Today); + _applyingRange = true; + SelectedPresetDays = days; + StartDate = today.AddDays(-(days - 1)).ToDateTime(TimeOnly.MinValue); + EndDate = today.ToDateTime(TimeOnly.MinValue); + _applyingRange = false; + } + + [RelayCommand] + private Task SetPreset(int days) + { + ApplyPresetRange(days); + return LoadUsageDataAsync(); + } + + partial void OnStartDateChanged(DateTime? value) + { + if (!_applyingRange) _ = LoadUsageDataAsync(); + } + + partial void OnEndDateChanged(DateTime? value) + { + if (!_applyingRange) _ = LoadUsageDataAsync(); + } + + private bool RangeValid => StartDate is not null && EndDate is not null && StartDate <= EndDate; + + private async Task LoadUsageDataAsync() + { + if (!RangeValid) return; + IsBusy = true; + try + { + var from = DateOnly.FromDateTime(StartDate!.Value); + var to = DateOnly.FromDateTime(EndDate!.Value); + var modelRows = await _worker.GetModelUsageAsync(from, to); + var taskRows = await _worker.GetTaskUsageAsync(from, to); + ModelRows = BuildModelDisplayRows(modelRows); + TaskRows = taskRows + .Select(r => new TaskUsageDisplayRow(r.TaskId, r.TaskTitle, r.ListName, r.Model, r.Runs, r.TokensIn, r.TokensOut)) + .OrderByDescending(r => r.TotalTokens) + .ToList(); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message)); + } + finally { IsBusy = false; } + } + + private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch + { + "session" => Loc.T("modals.usageMonitor.gaugeSession"), + "weekly_all" => Loc.T("modals.usageMonitor.gaugeWeeklyAll"), + "weekly_scoped" when !string.IsNullOrWhiteSpace(limit.ScopeModelDisplayName) + => Loc.T("modals.usageMonitor.gaugeWeeklyScopedFormat", limit.ScopeModelDisplayName!), + _ => limit.Kind, + }; + + private UsageGaugeRowViewModel BuildGaugeRow(UsageLimitDto limit) + { + int? threshold = limit.Kind switch + { + "session" => Snapshot?.FiveHourThresholdPct, + "weekly_all" => Snapshot?.SevenDayThresholdPct, + _ => null, + }; + return new UsageGaugeRowViewModel(BuildGaugeLabel(limit), limit.Percent, limit.Severity, limit.ResetsAt, threshold); + } + + private static IReadOnlyList BuildModelDisplayRows(IReadOnlyList rows) + { + var built = new List(); + foreach (var group in rows.GroupBy(r => r.Model)) + { + long cdIn = 0, cdOut = 0, cdCache = 0, otIn = 0, otOut = 0, otCache = 0; + foreach (var row in group) + { + var cache = row.CacheReadTokens + row.CacheCreationTokens; + if (string.Equals(row.Scope, "ClaudeDo", StringComparison.OrdinalIgnoreCase)) + { + cdIn += row.InputTokens; cdOut += row.OutputTokens; cdCache += cache; + } + else + { + otIn += row.InputTokens; otOut += row.OutputTokens; otCache += cache; + } + } + built.Add(new ModelUsageDisplayRow(group.Key, cdIn, cdOut, cdCache, otIn, otOut, otCache)); + } + + var grandTotal = built.Sum(r => r.GrandTotal); + return built + .Select(r => r with { SharePercent = grandTotal > 0 ? r.GrandTotal * 100.0 / grandTotal : 0 }) + .OrderByDescending(r => r.GrandTotal) + .ToList(); + } +} + +public sealed record UsageGaugeRowViewModel( + string Label, + double Percent, + string Severity, + DateTimeOffset? ResetsAt, + int? ThresholdPercent) +{ + public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase); + + public string ResetText => ResetsAt is { } r ? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r)) : ""; + + // Matches the gauge card's inner track width in the view (240 card width - 12*2 padding). + private const double GaugeTrackWidthPx = 216; + + public double ThresholdMarkerLeftPx => + ThresholdPercent is { } t ? GaugeTrackWidthPx * Math.Clamp(t, 0, 100) / 100.0 : 0; + + private static string FormatRemaining(DateTimeOffset resetsAt) + { + var remaining = resetsAt - DateTimeOffset.UtcNow; + if (remaining < TimeSpan.Zero) remaining = TimeSpan.Zero; + var hours = (int)remaining.TotalHours; + var minutes = remaining.Minutes; + return hours > 0 + ? Loc.T("usage.pill.durationHoursMinutes", hours, minutes) + : Loc.T("usage.pill.durationMinutes", minutes); + } +} + +public sealed record ModelUsageDisplayRow( + string Model, + long ClaudeDoInputTokens, + long ClaudeDoOutputTokens, + long ClaudeDoCacheTokens, + long OtherInputTokens, + long OtherOutputTokens, + long OtherCacheTokens) +{ + public double SharePercent { get; init; } + public long ClaudeDoTotal => ClaudeDoInputTokens + ClaudeDoOutputTokens + ClaudeDoCacheTokens; + public long OtherTotal => OtherInputTokens + OtherOutputTokens + OtherCacheTokens; + public long GrandTotal => ClaudeDoTotal + OtherTotal; +} + +public sealed record TaskUsageDisplayRow( + string TaskId, + string TaskTitle, + string ListName, + string? Model, + int Runs, + long TokensIn, + long TokensOut) +{ + public long TotalTokens => TokensIn + TokensOut; +} diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml index b59d01fd..cfb59514 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml @@ -66,6 +66,8 @@ Command="{Binding RestartWorkerCommand}"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +