feat(ui): surface TokenTracker status and cost in the usage monitor
This commit is contained in:
@@ -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() ?? "—";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user