feat(ui): surface TokenTracker status and cost in the usage monitor

This commit is contained in:
mika kuns
2026-08-24 16:15:02 +02:00
parent 66f743dd43
commit bac0d69a49
@@ -44,15 +44,57 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ModelsEmpty))] [NotifyPropertyChangedFor(nameof(ModelsEmpty))]
[NotifyPropertyChangedFor(nameof(TotalsText))]
private IReadOnlyList<ModelUsageDisplayRow> _modelRows = Array.Empty<ModelUsageDisplayRow>(); private IReadOnlyList<ModelUsageDisplayRow> _modelRows = Array.Empty<ModelUsageDisplayRow>();
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(TasksEmpty))] [NotifyPropertyChangedFor(nameof(TasksEmpty))]
private IReadOnlyList<TaskUsageDisplayRow> _taskRows = Array.Empty<TaskUsageDisplayRow>(); private IReadOnlyList<TaskUsageDisplayRow> _taskRows = Array.Empty<TaskUsageDisplayRow>();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TokenTrackerMissing))]
[NotifyPropertyChangedFor(nameof(ShowInstallButton))]
[NotifyPropertyChangedFor(nameof(NodeHintText))]
[NotifyPropertyChangedFor(nameof(TokenTrackerErrorText))]
[NotifyPropertyChangedFor(nameof(HasTokenTrackerError))]
[NotifyPropertyChangedFor(nameof(AnalyticsStampText))]
private TokenTrackerStatusDto? _tokenTracker;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TotalsText))]
private double _totalCostUsd;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowInstallButton))]
private bool _isInstalling;
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0; public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0; public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
public bool TokenTrackerMissing => TokenTracker is { Installed: false };
public bool ShowInstallButton => TokenTracker is { Installed: false, NodeOk: true } && !IsInstalling;
public string NodeHintText => TokenTracker switch
{
{ NodeOk: true } => "",
{ NodeVersion: { Length: > 0 } v } => Loc.T("modals.usageMonitor.ttNodeHintFormat", v),
_ => Loc.T("modals.usageMonitor.ttNodeMissing"),
};
public bool HasTokenTrackerError => TokenTracker?.LastError is { Length: > 0 };
public string TokenTrackerErrorText => TokenTracker?.LastError is { Length: > 0 } e
? Loc.T("modals.usageMonitor.ttErrorFormat", e)
: "";
public string AnalyticsStampText => TokenTracker?.LastFetchedUtc is { } t
? Loc.T("modals.usageMonitor.analyticsStampFormat", t.ToLocalTime().ToString("HH:mm"))
: Loc.T("modals.usageMonitor.analyticsNever");
public string TotalsText => Loc.T(
"modals.usageMonitor.analyticsTotalsFormat",
ModelRows.Sum(r => r.GrandTotal).ToString("N0"),
TotalCostUsd.ToString("0.00"));
[ObservableProperty] [ObservableProperty]
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>(); private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
@@ -154,6 +196,39 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private Task SetPreset30Days() => SetPreset(30); private Task SetPreset30Days() => SetPreset(30);
[RelayCommand]
private async Task RefreshAnalytics()
{
try
{
TokenTracker = await _worker.RefreshTokenTrackerAsync();
await LoadUsageDataAsync();
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.analyticsRefreshFailed", ex.Message));
}
}
// ErrorReported is the channel the shell hangs FlashFooterError on, so even the "install
// running" notice goes through it rather than getting its own banner.
[RelayCommand]
private async Task InstallTokenTracker()
{
IsInstalling = true;
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.installStarted"));
try
{
TokenTracker = await _worker.InstallTokenTrackerAsync();
if (TokenTracker is { Installed: true }) await RefreshAnalytics();
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.installFailed", ex.Message));
}
finally { IsInstalling = false; }
}
private Task SetPreset(int days) private Task SetPreset(int days)
{ {
ApplyPresetRange(days); ApplyPresetRange(days);
@@ -183,10 +258,14 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
var modelRows = await _worker.GetModelUsageAsync(from, to); var modelRows = await _worker.GetModelUsageAsync(from, to);
var taskRows = await _worker.GetTaskUsageAsync(from, to); var taskRows = await _worker.GetTaskUsageAsync(from, to);
ModelRows = BuildModelDisplayRows(modelRows); ModelRows = BuildModelDisplayRows(modelRows);
TotalCostUsd = modelRows.Sum(r => r.CostUsd ?? 0);
TaskRows = taskRows TaskRows = taskRows
.Select(r => new TaskUsageDisplayRow(r.TaskId, r.TaskTitle, r.ListName, r.Model, r.Runs, r.TokensIn, r.TokensOut)) .Select(r => new TaskUsageDisplayRow(
r.TaskId, r.TaskTitle, r.ListName, r.Model, r.Runs, r.TokensIn, r.TokensOut,
r.CostUsd, r.Retries))
.OrderByDescending(r => r.TotalTokens) .OrderByDescending(r => r.TotalTokens)
.ToList(); .ToList();
TokenTracker = await _worker.GetTokenTrackerStatusAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -303,9 +382,11 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
foreach (var group in rows.GroupBy(r => r.Model)) foreach (var group in rows.GroupBy(r => r.Model))
{ {
long cdIn = 0, cdOut = 0, cdCache = 0, otIn = 0, otOut = 0, otCache = 0; long cdIn = 0, cdOut = 0, cdCache = 0, otIn = 0, otOut = 0, otCache = 0;
double cost = 0;
foreach (var row in group) foreach (var row in group)
{ {
var cache = row.CacheReadTokens + row.CacheCreationTokens; var cache = row.CacheReadTokens + row.CacheCreationTokens;
cost += row.CostUsd ?? 0;
if (string.Equals(row.Scope, "ClaudeDo", StringComparison.OrdinalIgnoreCase)) if (string.Equals(row.Scope, "ClaudeDo", StringComparison.OrdinalIgnoreCase))
{ {
cdIn += row.InputTokens; cdOut += row.OutputTokens; cdCache += cache; cdIn += row.InputTokens; cdOut += row.OutputTokens; cdCache += cache;
@@ -315,7 +396,7 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
otIn += row.InputTokens; otOut += row.OutputTokens; otCache += cache; otIn += row.InputTokens; otOut += row.OutputTokens; otCache += cache;
} }
} }
built.Add(new ModelUsageDisplayRow(group.Key, cdIn, cdOut, cdCache, otIn, otOut, otCache)); built.Add(new ModelUsageDisplayRow(group.Key, cdIn, cdOut, cdCache, otIn, otOut, otCache, cost));
} }
var grandTotal = built.Sum(r => r.GrandTotal); var grandTotal = built.Sum(r => r.GrandTotal);
@@ -496,12 +577,14 @@ public sealed record ModelUsageDisplayRow(
long ClaudeDoCacheTokens, long ClaudeDoCacheTokens,
long OtherInputTokens, long OtherInputTokens,
long OtherOutputTokens, long OtherOutputTokens,
long OtherCacheTokens) long OtherCacheTokens,
double CostUsd)
{ {
public double SharePercent { get; init; } public double SharePercent { get; init; }
public long ClaudeDoTotal => ClaudeDoInputTokens + ClaudeDoOutputTokens + ClaudeDoCacheTokens; public long ClaudeDoTotal => ClaudeDoInputTokens + ClaudeDoOutputTokens + ClaudeDoCacheTokens;
public long OtherTotal => OtherInputTokens + OtherOutputTokens + OtherCacheTokens; public long OtherTotal => OtherInputTokens + OtherOutputTokens + OtherCacheTokens;
public long GrandTotal => ClaudeDoTotal + OtherTotal; public long GrandTotal => ClaudeDoTotal + OtherTotal;
public string CostText => CostUsd > 0 ? CostUsd.ToString("0.00") : "—";
} }
public sealed record TaskUsageDisplayRow( public sealed record TaskUsageDisplayRow(
@@ -511,7 +594,11 @@ public sealed record TaskUsageDisplayRow(
string? Model, string? Model,
int Runs, int Runs,
long TokensIn, long TokensIn,
long TokensOut) long TokensOut,
double? CostUsd,
int? Retries)
{ {
public long TotalTokens => TokensIn + TokensOut; public long TotalTokens => TokensIn + TokensOut;
public string CostText => CostUsd is > 0 ? CostUsd.Value.ToString("0.00") : "—";
public string RetriesText => Retries?.ToString() ?? "—";
} }