Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs
T
mika kuns 57326f1d3b fix(ui): avoid int CommandParameter cast crash in usage monitor presets
RelayCommand<int> casts its object parameter directly rather than
converting it, so a string CommandParameter ("7"/"30") would throw
InvalidCastException at runtime. Split into two parameterless commands.
2026-08-05 13:58:40 +02:00

242 lines
8.7 KiB
C#

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 SetPreset7Days() => SetPreset(7);
[RelayCommand]
private Task SetPreset30Days() => SetPreset(30);
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;
}