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:
@@ -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<string>? 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<ModelUsageDisplayRow> _modelRows = Array.Empty<ModelUsageDisplayRow>();
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(TasksEmpty))]
|
||||
private IReadOnlyList<TaskUsageDisplayRow> _taskRows = Array.Empty<TaskUsageDisplayRow>();
|
||||
|
||||
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
|
||||
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
|
||||
|
||||
public IReadOnlyList<UsageGaugeRowViewModel> GaugeRows =>
|
||||
Snapshot is null ? Array.Empty<UsageGaugeRowViewModel>() : 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<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows)
|
||||
{
|
||||
var built = new List<ModelUsageDisplayRow>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user