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]
[NotifyPropertyChangedFor(nameof(ModelsEmpty))]
[NotifyPropertyChangedFor(nameof(TotalsText))]
private IReadOnlyList<ModelUsageDisplayRow> _modelRows = Array.Empty<ModelUsageDisplayRow>();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TasksEmpty))]
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 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]
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
@@ -154,6 +196,39 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
[RelayCommand]
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)
{
ApplyPresetRange(days);
@@ -183,10 +258,14 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
var modelRows = await _worker.GetModelUsageAsync(from, to);
var taskRows = await _worker.GetTaskUsageAsync(from, to);
ModelRows = BuildModelDisplayRows(modelRows);
TotalCostUsd = modelRows.Sum(r => r.CostUsd ?? 0);
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)
.ToList();
TokenTracker = await _worker.GetTokenTrackerStatusAsync();
}
catch (Exception ex)
{
@@ -303,9 +382,11 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
foreach (var group in rows.GroupBy(r => r.Model))
{
long cdIn = 0, cdOut = 0, cdCache = 0, otIn = 0, otOut = 0, otCache = 0;
double cost = 0;
foreach (var row in group)
{
var cache = row.CacheReadTokens + row.CacheCreationTokens;
cost += row.CostUsd ?? 0;
if (string.Equals(row.Scope, "ClaudeDo", StringComparison.OrdinalIgnoreCase))
{
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;
}
}
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);
@@ -496,12 +577,14 @@ public sealed record ModelUsageDisplayRow(
long ClaudeDoCacheTokens,
long OtherInputTokens,
long OtherOutputTokens,
long OtherCacheTokens)
long OtherCacheTokens,
double CostUsd)
{
public double SharePercent { get; init; }
public long ClaudeDoTotal => ClaudeDoInputTokens + ClaudeDoOutputTokens + ClaudeDoCacheTokens;
public long OtherTotal => OtherInputTokens + OtherOutputTokens + OtherCacheTokens;
public long GrandTotal => ClaudeDoTotal + OtherTotal;
public string CostText => CostUsd > 0 ? CostUsd.ToString("0.00") : "—";
}
public sealed record TaskUsageDisplayRow(
@@ -511,7 +594,11 @@ public sealed record TaskUsageDisplayRow(
string? Model,
int Runs,
long TokensIn,
long TokensOut)
long TokensOut,
double? CostUsd,
int? Retries)
{
public long TotalTokens => TokensIn + TokensOut;
public string CostText => CostUsd is > 0 ? CostUsd.Value.ToString("0.00") : "—";
public string RetriesText => Retries?.ToString() ?? "—";
}