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.
This commit is contained in:
@@ -58,6 +58,7 @@ public class TasksIslandMoveToListTests : IDisposable
|
||||
|
||||
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException();
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class UsageMonitorModalViewModelTests
|
||||
{
|
||||
public UsageMonitorModalViewModelTests()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
Loc.Current = new Localizer(
|
||||
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
|
||||
}
|
||||
|
||||
private sealed class FakeWorker : StubWorkerClient
|
||||
{
|
||||
public UsageSnapshotDto? Snapshot;
|
||||
public IReadOnlyList<ModelUsageRowDto> ModelRows = Array.Empty<ModelUsageRowDto>();
|
||||
public IReadOnlyList<TaskUsageRowDto> TaskRows = Array.Empty<TaskUsageRowDto>();
|
||||
public int ModelUsageCalls;
|
||||
public int TaskUsageCalls;
|
||||
|
||||
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(Snapshot);
|
||||
|
||||
public override Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to)
|
||||
{
|
||||
ModelUsageCalls++;
|
||||
return Task.FromResult(ModelRows);
|
||||
}
|
||||
|
||||
public override Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsageAsync(DateOnly from, DateOnly to)
|
||||
{
|
||||
TaskUsageCalls++;
|
||||
return Task.FromResult(TaskRows);
|
||||
}
|
||||
}
|
||||
|
||||
private static UsageLimitDto Limit(
|
||||
string kind, double percent = 10, string severity = "normal",
|
||||
DateTimeOffset? resetsAt = null, string? scopeModelDisplayName = null, bool isActive = true)
|
||||
=> new(kind, "group", percent, severity, resetsAt, scopeModelDisplayName, isActive);
|
||||
|
||||
private static UsageSnapshotDto Snapshot(
|
||||
IReadOnlyList<UsageLimitDto>? limits = null,
|
||||
int fiveHourThresholdPct = 80,
|
||||
int sevenDayThresholdPct = 90,
|
||||
bool isGateBlocked = false,
|
||||
string? gateReason = null,
|
||||
bool isStale = false,
|
||||
string? lastError = null,
|
||||
DateTime? fetchedAtUtc = null)
|
||||
=> new(
|
||||
null, null, null, null,
|
||||
limits ?? Array.Empty<UsageLimitDto>(),
|
||||
fiveHourThresholdPct, sevenDayThresholdPct,
|
||||
isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError);
|
||||
|
||||
// ── Gauge label derivation ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeLabel_Session_UsesSessionLabel()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(Loc.T("modals.usageMonitor.gaugeSession"), vm.GaugeRows[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeLabel_WeeklyAll_UsesWeeklyAllLabel()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_all") }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(Loc.T("modals.usageMonitor.gaugeWeeklyAll"), vm.GaugeRows[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeLabel_WeeklyScoped_IncludesDisplayName()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Fable") }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(Loc.T("modals.usageMonitor.gaugeWeeklyScopedFormat", "Fable"), vm.GaugeRows[0].Label);
|
||||
Assert.Contains("Fable", vm.GaugeRows[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeLabel_UnknownKind_ShowsRawKind()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("mystery_bucket") }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal("mystery_bucket", vm.GaugeRows[0].Label);
|
||||
}
|
||||
|
||||
// ── Gauge list follows Limits ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeRows_ThreeLimits_ProducesThreeGauges()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = Snapshot(new[] { Limit("session"), Limit("weekly_all"), Limit("weekly_scoped", scopeModelDisplayName: "Opus") })
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(3, vm.GaugeRows.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeRows_NoSnapshot_EmptyNoCrash()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = null };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Empty(vm.GaugeRows);
|
||||
}
|
||||
|
||||
// ── Gate threshold marks ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeRow_Session_CarriesFiveHourThreshold()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(80, vm.GaugeRows[0].ThresholdPercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeRow_WeeklyScoped_HasNoThreshold()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Fable") }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Null(vm.GaugeRows[0].ThresholdPercent);
|
||||
}
|
||||
|
||||
// ── Stale / gate bands ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task StaleSnapshot_SetsIsStale()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(isStale: true, lastError: "timeout") };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.IsStale);
|
||||
Assert.Equal("timeout", vm.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BlockedGate_SetsIsGateBlocked_WithReason()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(isGateBlocked: true, gateReason: "5h-Limit 80% >= 80%") };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.IsGateBlocked);
|
||||
Assert.Equal("5h-Limit 80% >= 80%", vm.GateReason);
|
||||
}
|
||||
|
||||
// ── Model tab: scope split + sums ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ModelRows_SplitsClaudeDoVsOther_AndSumsTokens()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
ModelRows = new[]
|
||||
{
|
||||
new ModelUsageRowDto(new DateOnly(2026, 8, 1), "sonnet", "ClaudeDo", 100, 50, 10, 5, 1),
|
||||
new ModelUsageRowDto(new DateOnly(2026, 8, 2), "sonnet", "ClaudeDo", 20, 10, 0, 0, 1),
|
||||
new ModelUsageRowDto(new DateOnly(2026, 8, 1), "sonnet", "Other", 5, 5, 0, 0, 1),
|
||||
new ModelUsageRowDto(new DateOnly(2026, 8, 1), "opus", "Other", 1000, 1000, 0, 0, 1),
|
||||
}
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
var sonnet = vm.ModelRows.Single(r => r.Model == "sonnet");
|
||||
Assert.Equal(120, sonnet.ClaudeDoInputTokens);
|
||||
Assert.Equal(60, sonnet.ClaudeDoOutputTokens);
|
||||
Assert.Equal(15, sonnet.ClaudeDoCacheTokens);
|
||||
Assert.Equal(5, sonnet.OtherInputTokens);
|
||||
Assert.Equal(5, sonnet.OtherOutputTokens);
|
||||
Assert.Equal(205, sonnet.GrandTotal);
|
||||
|
||||
// Sorted descending by grand total — opus (2000) before sonnet (210).
|
||||
Assert.Equal("opus", vm.ModelRows[0].Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ModelRows_Empty_ReportsEmptyState()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.ModelsEmpty);
|
||||
}
|
||||
|
||||
// ── Task tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task TaskRows_SortedDescendingByTotalTokens()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
TaskRows = new[]
|
||||
{
|
||||
new TaskUsageRowDto("t1", "Small task", "l1", "List", "sonnet", 1, 10, 10),
|
||||
new TaskUsageRowDto("t2", "Big task", "l1", "List", "opus", 3, 1000, 2000),
|
||||
}
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal("t2", vm.TaskRows[0].TaskId);
|
||||
Assert.Equal(3000, vm.TaskRows[0].TotalTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TaskRows_Empty_ReportsEmptyState()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.TasksEmpty);
|
||||
}
|
||||
|
||||
// ── Period change triggers exactly one reload ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ChangingPreset_TriggersExactlyOneReload()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
var callsAfterInitialLoad = worker.ModelUsageCalls;
|
||||
|
||||
await vm.SetPresetCommand.ExecuteAsync(30);
|
||||
|
||||
Assert.Equal(callsAfterInitialLoad + 1, worker.ModelUsageCalls);
|
||||
Assert.Equal(callsAfterInitialLoad + 1, worker.TaskUsageCalls);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user