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.
242 lines
8.7 KiB
C#
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;
|
|
}
|