527 lines
19 KiB
C#
527 lines
19 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;
|
|
private bool _isClosed;
|
|
|
|
public UsageMonitorModalViewModel(IWorkerClient worker) => _worker = worker;
|
|
|
|
public Action? CloseAction { get; set; }
|
|
public event Action<string>? ErrorReported;
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsStale))]
|
|
[NotifyPropertyChangedFor(nameof(LastError))]
|
|
[NotifyPropertyChangedFor(nameof(IsGateBlocked))]
|
|
[NotifyPropertyChangedFor(nameof(GateReason))]
|
|
[NotifyPropertyChangedFor(nameof(StaleStampText))]
|
|
[NotifyPropertyChangedFor(nameof(StaleBandText))]
|
|
[NotifyPropertyChangedFor(nameof(GateBandText))]
|
|
[NotifyPropertyChangedFor(nameof(IsThrottled))]
|
|
[NotifyPropertyChangedFor(nameof(ThrottleBandText))]
|
|
private UsageSnapshotDto? _snapshot;
|
|
|
|
[ObservableProperty] private DateTime? _startDate;
|
|
[ObservableProperty] private DateTime? _endDate;
|
|
[ObservableProperty] private int _selectedPresetDays = 7;
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(ModelsEmpty))]
|
|
private bool _isBusy;
|
|
|
|
[ObservableProperty] private bool _isRefreshing;
|
|
|
|
[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;
|
|
|
|
[ObservableProperty]
|
|
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
|
|
|
|
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);
|
|
|
|
public bool IsThrottled => Snapshot is { } s && !IsGateBlocked && s.EffectiveSlots < s.ConfiguredSlots;
|
|
public string ThrottleBandText => Snapshot is not { } s || !IsThrottled
|
|
? ""
|
|
: Loc.T("modals.usageMonitor.throttleFormat", s.EffectiveSlots, s.ConfiguredSlots, BucketLabel(s.ThrottleBucket));
|
|
|
|
private static string BucketLabel(string? bucket) => bucket switch
|
|
{
|
|
"five_hour" => Loc.T("usage.pill.fiveHourLabel"),
|
|
"seven_day" => Loc.T("usage.pill.sevenDayLabel"),
|
|
_ => "",
|
|
};
|
|
|
|
[RelayCommand]
|
|
private void Close()
|
|
{
|
|
_isClosed = true;
|
|
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
|
CloseAction?.Invoke();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts the load without blocking the caller, so the host can show the window right away and
|
|
/// let it fill in behind the busy spinner. The first load per worker process pays a full scan of
|
|
/// <c>~/.claude/projects</c> (hundreds of MB of transcripts) — awaiting it before showing the
|
|
/// window made the usage pill look like it swallowed the click.
|
|
/// </summary>
|
|
public void BeginLoad() => _ = LoadAsync();
|
|
|
|
public async Task LoadAsync()
|
|
{
|
|
IsBusy = true;
|
|
try
|
|
{
|
|
var snapshot = await _worker.GetUsageSnapshotAsync();
|
|
// The modal can be closed while the first-load scan is still running (see remarks
|
|
// above) — subscribing after that would leak this VM onto the long-lived WorkerClient.
|
|
if (_isClosed) return;
|
|
Snapshot = snapshot;
|
|
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
|
_worker.UsageUpdatedEvent += OnUsageUpdated;
|
|
ApplyPresetRange(SelectedPresetDays);
|
|
await LoadUsageDataAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message));
|
|
}
|
|
finally { IsBusy = false; }
|
|
}
|
|
|
|
private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;
|
|
|
|
/// <summary>
|
|
/// Manual refresh: the worker polls usage on a slow cadence (15 min idle / 5 min while a
|
|
/// task runs) to stay clear of the endpoint's 429s, so this is the way to get a number now.
|
|
/// </summary>
|
|
[RelayCommand]
|
|
private async Task RefreshAsync()
|
|
{
|
|
if (IsRefreshing) return;
|
|
IsRefreshing = true;
|
|
try
|
|
{
|
|
var snapshot = await _worker.RefreshUsageAsync();
|
|
if (snapshot is not null) Snapshot = snapshot;
|
|
await LoadUsageDataAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.refreshFailed", ex.Message));
|
|
}
|
|
finally { IsRefreshing = false; }
|
|
}
|
|
|
|
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; }
|
|
}
|
|
|
|
partial void OnSnapshotChanged(UsageSnapshotDto? value) => SyncGaugeRows();
|
|
|
|
/// <summary>
|
|
/// Folds a fresh snapshot into the existing rows instead of rebuilding them, so a poll landing
|
|
/// while the user works the markers doesn't swap the instances out from under the drag.
|
|
/// </summary>
|
|
private void SyncGaugeRows()
|
|
{
|
|
var limits = Snapshot?.Limits ?? (IReadOnlyList<UsageLimitDto>)Array.Empty<UsageLimitDto>();
|
|
var existing = GaugeRows.ToDictionary(r => r.Key);
|
|
var rows = new List<UsageGaugeRowViewModel>(limits.Count);
|
|
|
|
foreach (var limit in limits)
|
|
{
|
|
var key = GaugeKey(limit);
|
|
var bucket = GaugeBucket(limit);
|
|
var (soft, hard, gate) = StagesFor(bucket);
|
|
var label = BuildGaugeLabel(limit);
|
|
|
|
if (existing.TryGetValue(key, out var row))
|
|
{
|
|
row.Update(label, limit.Percent, limit.Severity, limit.ResetsAt, soft, hard, gate);
|
|
rows.Add(row);
|
|
}
|
|
else
|
|
{
|
|
rows.Add(new UsageGaugeRowViewModel(
|
|
key, bucket, label, limit.Percent, limit.Severity, limit.ResetsAt,
|
|
soft, hard, gate, SaveStagesAsync));
|
|
}
|
|
}
|
|
|
|
if (!rows.SequenceEqual(GaugeRows)) GaugeRows = rows;
|
|
}
|
|
|
|
// Scoped weekly buckets are plan-dependent and share no settings row, so they stay read-only.
|
|
private static string? GaugeBucket(UsageLimitDto limit) => limit.Kind switch
|
|
{
|
|
"session" => "five_hour",
|
|
"weekly_all" => "seven_day",
|
|
_ => null,
|
|
};
|
|
|
|
private static string GaugeKey(UsageLimitDto limit) =>
|
|
limit.Kind == "weekly_scoped" ? $"weekly_scoped:{limit.ScopeModelDisplayName}" : limit.Kind;
|
|
|
|
private (int? Soft, int? Hard, int? Gate) StagesFor(string? bucket) => (bucket, Snapshot) switch
|
|
{
|
|
("five_hour", { } s) => (s.ThrottleFiveHourSoftPct, s.ThrottleFiveHourHardPct, s.FiveHourThresholdPct),
|
|
("seven_day", { } s) => (s.ThrottleSevenDaySoftPct, s.ThrottleSevenDayHardPct, s.SevenDayThresholdPct),
|
|
_ => (null, null, null),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Persists one gauge's stages after a drag. Read-modify-write against the current settings, so
|
|
/// this never clobbers a field the usage monitor doesn't own.
|
|
/// </summary>
|
|
private async Task SaveStagesAsync(UsageGaugeRowViewModel row)
|
|
{
|
|
if (row.Bucket is null || row.SoftPct is not { } soft || row.HardPct is not { } hard || row.GatePct is not { } gate)
|
|
return;
|
|
|
|
try
|
|
{
|
|
var settings = await _worker.GetAppSettingsAsync();
|
|
if (settings is null)
|
|
{
|
|
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
|
|
return;
|
|
}
|
|
|
|
var updated = row.Bucket == "five_hour"
|
|
? settings with
|
|
{
|
|
UsageThrottleFiveHourSoftPct = soft,
|
|
UsageThrottleFiveHourHardPct = hard,
|
|
UsageGateFiveHourPct = gate,
|
|
}
|
|
: settings with
|
|
{
|
|
UsageThrottleSevenDaySoftPct = soft,
|
|
UsageThrottleSevenDayHardPct = hard,
|
|
UsageGateSevenDayPct = gate,
|
|
};
|
|
|
|
await _worker.UpdateAppSettingsAsync(updated);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", ex.Message));
|
|
}
|
|
}
|
|
|
|
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 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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One usage gauge. The two real buckets (5h session, 7d week) carry their three stage thresholds
|
|
/// and are adjustable by dragging; plan-dependent scoped buckets render as a plain bar.
|
|
/// </summary>
|
|
public sealed partial class UsageGaugeRowViewModel : ObservableObject
|
|
{
|
|
private readonly Func<UsageGaugeRowViewModel, Task>? _commit;
|
|
|
|
public UsageGaugeRowViewModel(
|
|
string key,
|
|
string? bucket,
|
|
string label,
|
|
double percent,
|
|
string severity,
|
|
DateTimeOffset? resetsAt,
|
|
int? softPct,
|
|
int? hardPct,
|
|
int? gatePct,
|
|
Func<UsageGaugeRowViewModel, Task>? commit = null)
|
|
{
|
|
Key = key;
|
|
Bucket = bucket;
|
|
_label = label;
|
|
_percent = percent;
|
|
_severity = severity;
|
|
_resetsAt = resetsAt;
|
|
_softPct = softPct;
|
|
_hardPct = hardPct;
|
|
_gatePct = gatePct;
|
|
_commit = commit;
|
|
}
|
|
|
|
/// <summary>Identity across snapshot updates, so a live poll updates rows instead of replacing them.</summary>
|
|
public string Key { get; }
|
|
|
|
/// <summary>Which settings bucket a drag writes to: <c>five_hour</c>, <c>seven_day</c>, or null.</summary>
|
|
public string? Bucket { get; }
|
|
|
|
[ObservableProperty] private string _label;
|
|
[ObservableProperty] private double _percent;
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsWarnSeverity))]
|
|
private string _severity;
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(ResetText))]
|
|
private DateTimeOffset? _resetsAt;
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
|
private int? _softPct;
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
|
private int? _hardPct;
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
|
private int? _gatePct;
|
|
|
|
public bool IsAdjustable => Bucket is not null && SoftPct is not null && HardPct is not null && GatePct is not null;
|
|
|
|
public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase);
|
|
|
|
public string ResetText => ResetsAt is { } r
|
|
? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r), r.ToLocalTime().ToString("HH:mm"))
|
|
: "";
|
|
|
|
/// <summary>Live values from a fresh snapshot, without replacing the row instance mid-view.</summary>
|
|
public void Update(string label, double percent, string severity, DateTimeOffset? resetsAt,
|
|
int? softPct, int? hardPct, int? gatePct)
|
|
{
|
|
Label = label;
|
|
Percent = percent;
|
|
Severity = severity;
|
|
ResetsAt = resetsAt;
|
|
SoftPct = softPct;
|
|
HardPct = hardPct;
|
|
GatePct = gatePct;
|
|
}
|
|
|
|
/// <summary>Raised by the gauge control when a drag ends — that is when the value is persisted.</summary>
|
|
[RelayCommand]
|
|
private Task Commit() => _commit?.Invoke(this) ?? Task.CompletedTask;
|
|
|
|
// One per legend input box. A typed value goes through the same clamp as a dragged one, so a box
|
|
// can't invert the order — and only the edited stage moves, never its neighbours.
|
|
[RelayCommand] private Task CommitSoft() => CommitStage(UsageThresholdDrag.Stage.Soft);
|
|
[RelayCommand] private Task CommitHard() => CommitStage(UsageThresholdDrag.Stage.Hard);
|
|
[RelayCommand] private Task CommitGate() => CommitStage(UsageThresholdDrag.Stage.Gate);
|
|
|
|
private Task CommitStage(UsageThresholdDrag.Stage stage)
|
|
{
|
|
if (!IsAdjustable) return Task.CompletedTask;
|
|
|
|
var edited = stage switch
|
|
{
|
|
UsageThresholdDrag.Stage.Soft => SoftPct!.Value,
|
|
UsageThresholdDrag.Stage.Hard => HardPct!.Value,
|
|
_ => GatePct!.Value,
|
|
};
|
|
|
|
var (soft, hard, gate) = UsageThresholdDrag.Apply(
|
|
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, edited);
|
|
SoftPct = soft;
|
|
HardPct = hard;
|
|
GatePct = gate;
|
|
|
|
return Commit();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drag math for the gauge stage markers, kept out of the control so it can be tested directly:
|
|
/// every stage stays inside 0..100 and never crosses a neighbour (soft ≤ hard ≤ gate). A neighbour
|
|
/// at 0 means "that stage is off" and therefore does not constrain anything.
|
|
/// </summary>
|
|
public static class UsageThresholdDrag
|
|
{
|
|
public enum Stage { Soft, Hard, Gate }
|
|
|
|
/// <summary>Pointer reach for grabbing a marker, as a share of the bar width.</summary>
|
|
public static Stage? Nearest(int soft, int hard, int gate, double percent, double tolerancePercent)
|
|
{
|
|
Stage? best = null;
|
|
var bestDistance = double.MaxValue;
|
|
|
|
foreach (var (stage, value) in new[] { (Stage.Soft, soft), (Stage.Hard, hard), (Stage.Gate, gate) })
|
|
{
|
|
var distance = Math.Abs(percent - value);
|
|
if (distance > tolerancePercent || distance >= bestDistance) continue;
|
|
best = stage;
|
|
bestDistance = distance;
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
public static (int Soft, int Hard, int Gate) Apply(int soft, int hard, int gate, Stage stage, double rawPercent)
|
|
{
|
|
var value = (int)Math.Round(Math.Clamp(rawPercent, 0, 100));
|
|
|
|
return stage switch
|
|
{
|
|
Stage.Soft => (ClampRange(value, 0, UpperBound(hard, gate)), hard, gate),
|
|
Stage.Hard => (soft, ClampRange(value, soft, UpperBound(gate, 100)), gate),
|
|
Stage.Gate => (soft, hard, ClampRange(value, Math.Max(soft, hard), 100)),
|
|
_ => (soft, hard, gate),
|
|
};
|
|
}
|
|
|
|
// A neighbour of 0 is switched off and must not pin the dragged marker to 0.
|
|
private static int UpperBound(int nearest, int fallback) =>
|
|
nearest > 0 ? nearest : (fallback > 0 ? fallback : 100);
|
|
|
|
// An already-inconsistent stored config (min above max) must not throw mid-drag.
|
|
private static int ClampRange(int value, int min, int max) =>
|
|
max < min ? max : Math.Clamp(value, min, max);
|
|
}
|
|
|
|
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;
|
|
}
|