Merge task branch for: UI: Usage-Monitor-Modal (Gauges + Modell- und Task-Analyse)
This commit is contained in:
@@ -138,6 +138,8 @@ sealed class Program
|
||||
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
||||
sc.AddTransient<WeeklyReportModalViewModel>();
|
||||
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
|
||||
sc.AddTransient<UsageMonitorModalViewModel>();
|
||||
sc.AddTransient<Func<UsageMonitorModalViewModel>>(sp => () => sp.GetRequiredService<UsageMonitorModalViewModel>());
|
||||
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
||||
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
||||
sp.GetRequiredService<IWorkerClient>(), taskId));
|
||||
|
||||
@@ -422,6 +422,39 @@
|
||||
"generate": "Erstellen",
|
||||
"regenerate": "Neu erstellen",
|
||||
"emptyStateHint": "Noch kein Bericht für diesen Zeitraum. Klicke auf „Erstellen“."
|
||||
},
|
||||
"usageMonitor": {
|
||||
"title": "USAGE MONITOR",
|
||||
"windowTitle": "Usage Monitor",
|
||||
"noGauges": "Noch keine Nutzungslimits gemeldet.",
|
||||
"staleFormat": "Werte veraltet (Stand {0})",
|
||||
"staleGateHint": "Das Gate greift in diesem Zustand nicht.",
|
||||
"gateBlockedFormat": "Queue pausiert — {0}",
|
||||
"resetIn": "Reset in {0}",
|
||||
"gaugeSession": "Session (5 Std.)",
|
||||
"gaugeWeeklyAll": "Woche (alle Modelle)",
|
||||
"gaugeWeeklyScopedFormat": "Woche ({0})",
|
||||
"period": "Zeitraum:",
|
||||
"last7Days": "7 Tage",
|
||||
"last30Days": "30 Tage",
|
||||
"fromDate": "Von",
|
||||
"toDate": "Bis",
|
||||
"modelUsage": "Modelle",
|
||||
"taskUsage": "Tasks",
|
||||
"emptyStateHint": "Noch keine Nutzungsdaten für diesen Zeitraum.",
|
||||
"columnModel": "MODELL",
|
||||
"columnIn": "IN",
|
||||
"columnOut": "OUT",
|
||||
"columnCache": "CACHE",
|
||||
"columnOtherIn": "ÜBRIGES IN",
|
||||
"columnOtherOut": "ÜBRIGES OUT",
|
||||
"columnOtherCache": "ÜBRIGES CACHE",
|
||||
"columnShare": "ANTEIL",
|
||||
"columnTask": "TASK",
|
||||
"columnList": "LISTE",
|
||||
"columnModelShort": "MODELL",
|
||||
"columnRuns": "RUNS",
|
||||
"columnTotal": "GESAMT"
|
||||
}
|
||||
},
|
||||
"installer": {
|
||||
@@ -535,6 +568,7 @@
|
||||
"restartWorker": "Worker neu starten",
|
||||
"worktrees": "Worktrees…",
|
||||
"weeklyReport": "Wochenbericht…",
|
||||
"usageMonitor": "Usage Monitor…",
|
||||
"about": "Über…",
|
||||
"addRepos": "Repos als Listen hinzufügen…"
|
||||
},
|
||||
@@ -559,6 +593,7 @@
|
||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
||||
"usageMonitor": { "loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
|
||||
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
|
||||
|
||||
@@ -422,6 +422,39 @@
|
||||
"generate": "Generate",
|
||||
"regenerate": "Regenerate",
|
||||
"emptyStateHint": "No report for this range yet. Click “Generate”."
|
||||
},
|
||||
"usageMonitor": {
|
||||
"title": "USAGE MONITOR",
|
||||
"windowTitle": "Usage Monitor",
|
||||
"noGauges": "No usage limits reported yet.",
|
||||
"staleFormat": "Values stale (as of {0})",
|
||||
"staleGateHint": "The gate does not apply while values are stale.",
|
||||
"gateBlockedFormat": "Queue paused — {0}",
|
||||
"resetIn": "Reset in {0}",
|
||||
"gaugeSession": "Session (5h)",
|
||||
"gaugeWeeklyAll": "Week (all models)",
|
||||
"gaugeWeeklyScopedFormat": "Week ({0})",
|
||||
"period": "Period:",
|
||||
"last7Days": "7 days",
|
||||
"last30Days": "30 days",
|
||||
"fromDate": "From",
|
||||
"toDate": "To",
|
||||
"modelUsage": "Models",
|
||||
"taskUsage": "Tasks",
|
||||
"emptyStateHint": "No usage data for this range yet.",
|
||||
"columnModel": "MODEL",
|
||||
"columnIn": "IN",
|
||||
"columnOut": "OUT",
|
||||
"columnCache": "CACHE",
|
||||
"columnOtherIn": "OTHER IN",
|
||||
"columnOtherOut": "OTHER OUT",
|
||||
"columnOtherCache": "OTHER CACHE",
|
||||
"columnShare": "SHARE",
|
||||
"columnTask": "TASK",
|
||||
"columnList": "LIST",
|
||||
"columnModelShort": "MODEL",
|
||||
"columnRuns": "RUNS",
|
||||
"columnTotal": "TOTAL"
|
||||
}
|
||||
},
|
||||
"installer": {
|
||||
@@ -535,6 +568,7 @@
|
||||
"restartWorker": "Restart worker",
|
||||
"worktrees": "Worktrees…",
|
||||
"weeklyReport": "Weekly Report…",
|
||||
"usageMonitor": "Usage Monitor…",
|
||||
"about": "About…",
|
||||
"addRepos": "Add repos as lists…"
|
||||
},
|
||||
@@ -559,6 +593,7 @@
|
||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
||||
"usageMonitor": { "loadFailed": "Couldn't load usage data: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
|
||||
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface IDialogService
|
||||
{
|
||||
Task ShowAboutAsync(AboutModalViewModel vm);
|
||||
Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm);
|
||||
Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm);
|
||||
Task ShowSettingsAsync(SettingsModalViewModel vm);
|
||||
Task ShowListSettingsAsync(ListSettingsModalViewModel vm);
|
||||
Task ShowRepoImportAsync(RepoImportModalViewModel vm);
|
||||
|
||||
@@ -37,6 +37,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
||||
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
||||
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
|
||||
private readonly Func<UsageMonitorModalViewModel> _usageMonitorVmFactory = () => null!;
|
||||
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
|
||||
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
|
||||
|
||||
@@ -207,6 +208,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
|
||||
Func<WeeklyReportModalViewModel> weeklyReportVmFactory,
|
||||
Func<UsageMonitorModalViewModel> usageMonitorVmFactory,
|
||||
Func<MergeModalViewModel> mergeVmFactory,
|
||||
Func<RepoImportModalViewModel> repoImportVmFactory,
|
||||
MissionControlViewModel missionControl,
|
||||
@@ -215,6 +217,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||
MissionControl = missionControl;
|
||||
UsagePill = usagePill;
|
||||
UsagePill.OpenMonitorRequested += () => _ = OpenUsageMonitor();
|
||||
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
|
||||
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
||||
MissionControl.ErrorReported += FlashFooterError;
|
||||
@@ -226,6 +229,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
_dbFactory = dbFactory;
|
||||
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
|
||||
_weeklyReportVmFactory = weeklyReportVmFactory;
|
||||
_usageMonitorVmFactory = usageMonitorVmFactory;
|
||||
_mergeVmFactory = mergeVmFactory;
|
||||
_repoImportVmFactory = repoImportVmFactory;
|
||||
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||
@@ -429,6 +433,23 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
finally { _weeklyReportOpen = false; }
|
||||
}
|
||||
|
||||
private bool _usageMonitorOpen;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenUsageMonitor()
|
||||
{
|
||||
if (Dialogs is null || _usageMonitorOpen) return;
|
||||
_usageMonitorOpen = true;
|
||||
try
|
||||
{
|
||||
var vm = _usageMonitorVmFactory();
|
||||
vm.ErrorReported += FlashFooterError;
|
||||
await vm.LoadAsync();
|
||||
await Dialogs.ShowUsageMonitorAsync(vm);
|
||||
}
|
||||
finally { _usageMonitorOpen = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
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;
|
||||
}
|
||||
@@ -66,6 +66,8 @@
|
||||
Command="{Binding RestartWorkerCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
|
||||
Command="{Binding CheckForUpdatesCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.usageMonitor}"
|
||||
Command="{Binding OpenUsageMonitorCommand}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{loc:Tr shell.menu.repositories}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
xmlns:conv="using:Avalonia.Data.Converters"
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.UsageMonitorModalView"
|
||||
x:DataType="vm:UsageMonitorModalViewModel"
|
||||
Title="{loc:Tr modals.usageMonitor.windowTitle}"
|
||||
Width="920" Height="680" MinWidth="700" MinHeight="480"
|
||||
CanResize="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1">
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="ProgressBar.usage-gauge">
|
||||
<Setter Property="Height" Value="10"/>
|
||||
<Setter Property="CornerRadius" Value="4"/>
|
||||
<Setter Property="Minimum" Value="0"/>
|
||||
<Setter Property="Maximum" Value="100"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ProgressBar.usage-gauge.warn">
|
||||
<Setter Property="Foreground" Value="{DynamicResource StatusReviewBrush}"/>
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}">
|
||||
<DockPanel>
|
||||
|
||||
<!-- Stale / gate bands -->
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="4" Margin="20,16,20,0">
|
||||
<Border IsVisible="{Binding IsStale}"
|
||||
Background="{DynamicResource ReviewTintBrush}"
|
||||
BorderBrush="{DynamicResource StatusReviewBrush}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="12,8">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Classes="meta" Text="{Binding StaleBandText}"/>
|
||||
<TextBlock Classes="meta" Text="{Binding LastError}" IsVisible="{Binding LastError, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.staleGateHint}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border IsVisible="{Binding IsGateBlocked}"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource StatusErrorBrush}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="12,8">
|
||||
<TextBlock Classes="meta" Text="{Binding GateBandText}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Gauges -->
|
||||
<ItemsControl DockPanel.Dock="Top" Margin="20,12,20,0" ItemsSource="{Binding GaugeRows}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:UsageGaugeRowViewModel">
|
||||
<Border Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="240">
|
||||
<StackPanel Spacing="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="eyebrow" Text="{Binding Label}"/>
|
||||
<TextBlock Classes="meta" Text="{Binding Percent, StringFormat={}{0:0}%}" HorizontalAlignment="Right"/>
|
||||
</StackPanel>
|
||||
<Grid Height="10">
|
||||
<ProgressBar Classes="usage-gauge" Classes.warn="{Binding IsWarnSeverity}" Value="{Binding Percent}"/>
|
||||
<Canvas IsHitTestVisible="False">
|
||||
<Rectangle Canvas.Left="{Binding ThresholdMarkerLeftPx}" Width="2" Height="10"
|
||||
Fill="{DynamicResource TextDimBrush}"
|
||||
IsVisible="{Binding ThresholdPercent, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/>
|
||||
</Canvas>
|
||||
</Grid>
|
||||
<TextBlock Classes="meta" Text="{Binding ResetText}" IsVisible="{Binding ResetText, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock DockPanel.Dock="Top" Margin="20,12,20,0" Classes="meta"
|
||||
Text="{loc:Tr modals.usageMonitor.noGauges}"
|
||||
IsVisible="{Binding !GaugeRows.Count}"/>
|
||||
|
||||
<!-- Period picker -->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="20,12,20,0">
|
||||
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.period}" VerticalAlignment="Center"/>
|
||||
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.last7Days}"
|
||||
Command="{Binding SetPreset7DaysCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.last30Days}"
|
||||
Command="{Binding SetPreset30DaysCommand}"/>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.fromDate}" VerticalAlignment="Center"/>
|
||||
<ctl:ThemedDatePicker SelectedDate="{Binding StartDate}"/>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.toDate}" VerticalAlignment="Center"/>
|
||||
<ctl:ThemedDatePicker SelectedDate="{Binding EndDate}"/>
|
||||
</StackPanel>
|
||||
|
||||
<TabControl Padding="20,12" TabStripPlacement="Top">
|
||||
|
||||
<TabItem Header="{loc:Tr modals.usageMonitor.modelUsage}">
|
||||
<ScrollViewer>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="meta" Margin="0,16"
|
||||
Text="{loc:Tr modals.usageMonitor.emptyStateHint}"
|
||||
IsVisible="{Binding ModelsEmpty}"/>
|
||||
<Grid ColumnDefinitions="*,80,80,80,80,80,80,60" Margin="12,0,12,4" IsVisible="{Binding !ModelsEmpty}">
|
||||
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnModel}"/>
|
||||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnIn}"/>
|
||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnOut}"/>
|
||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnCache}"/>
|
||||
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnOtherIn}"/>
|
||||
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnOtherOut}"/>
|
||||
<TextBlock Grid.Column="6" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnOtherCache}"/>
|
||||
<TextBlock Grid.Column="7" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnShare}"/>
|
||||
</Grid>
|
||||
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8" IsVisible="{Binding !ModelsEmpty}"/>
|
||||
<ItemsControl ItemsSource="{Binding ModelRows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ModelUsageDisplayRow">
|
||||
<Grid ColumnDefinitions="*,80,80,80,80,80,80,60" Margin="12,4">
|
||||
<TextBlock Grid.Column="0" Classes="title" Text="{Binding Model}"/>
|
||||
<TextBlock Grid.Column="1" Classes="meta" Text="{Binding ClaudeDoInputTokens}"/>
|
||||
<TextBlock Grid.Column="2" Classes="meta" Text="{Binding ClaudeDoOutputTokens}"/>
|
||||
<TextBlock Grid.Column="3" Classes="meta" Text="{Binding ClaudeDoCacheTokens}"/>
|
||||
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding OtherInputTokens}"/>
|
||||
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding OtherOutputTokens}"/>
|
||||
<TextBlock Grid.Column="6" Classes="meta" Text="{Binding OtherCacheTokens}"/>
|
||||
<TextBlock Grid.Column="7" Classes="meta" Text="{Binding SharePercent, StringFormat={}{0:0}%}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{loc:Tr modals.usageMonitor.taskUsage}">
|
||||
<ScrollViewer>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="meta" Margin="0,16"
|
||||
Text="{loc:Tr modals.usageMonitor.emptyStateHint}"
|
||||
IsVisible="{Binding TasksEmpty}"/>
|
||||
<Grid ColumnDefinitions="*,110,90,60,80,80,80" Margin="12,0,12,4" IsVisible="{Binding !TasksEmpty}">
|
||||
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnTask}"/>
|
||||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnList}"/>
|
||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnModelShort}"/>
|
||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnRuns}"/>
|
||||
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnIn}"/>
|
||||
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnOut}"/>
|
||||
<TextBlock Grid.Column="6" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnTotal}"/>
|
||||
</Grid>
|
||||
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8" IsVisible="{Binding !TasksEmpty}"/>
|
||||
<ItemsControl ItemsSource="{Binding TaskRows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TaskUsageDisplayRow">
|
||||
<Grid ColumnDefinitions="*,110,90,60,80,80,80" Margin="12,4">
|
||||
<TextBlock Grid.Column="0" Classes="title" Text="{Binding TaskTitle}"/>
|
||||
<TextBlock Grid.Column="1" Classes="meta" Text="{Binding ListName}"/>
|
||||
<TextBlock Grid.Column="2" Classes="meta" Text="{Binding Model}"/>
|
||||
<TextBlock Grid.Column="3" Classes="meta" Text="{Binding Runs}"/>
|
||||
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding TokensIn}"/>
|
||||
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding TokensOut}"/>
|
||||
<TextBlock Grid.Column="6" Classes="meta" Text="{Binding TotalTokens}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
|
||||
<Ellipse Classes="spinner" Width="20" Height="20" Margin="0,0,0,16"
|
||||
HorizontalAlignment="Center" DockPanel.Dock="Bottom"
|
||||
IsVisible="{Binding IsBusy}"/>
|
||||
|
||||
</DockPanel>
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
|
||||
public partial class UsageMonitorModalView : Window
|
||||
{
|
||||
public UsageMonitorModalView() => InitializeComponent();
|
||||
}
|
||||
@@ -58,6 +58,13 @@ public sealed class WindowDialogService : IDialogService
|
||||
await dlg.ShowDialog(_owner);
|
||||
}
|
||||
|
||||
public async Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm)
|
||||
{
|
||||
var dlg = new UsageMonitorModalView { DataContext = vm };
|
||||
vm.CloseAction = () => dlg.Close();
|
||||
await dlg.ShowDialog(_owner);
|
||||
}
|
||||
|
||||
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
|
||||
{
|
||||
var dlg = new SettingsModalView { DataContext = vm };
|
||||
|
||||
@@ -58,6 +58,7 @@ public class TasksIslandMoveToListTests : IDisposable
|
||||
|
||||
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException();
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class UsageMonitorModalViewModelTests
|
||||
{
|
||||
public UsageMonitorModalViewModelTests()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
Loc.Current = new Localizer(
|
||||
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
|
||||
}
|
||||
|
||||
private sealed class FakeWorker : StubWorkerClient
|
||||
{
|
||||
public UsageSnapshotDto? Snapshot;
|
||||
public IReadOnlyList<ModelUsageRowDto> ModelRows = Array.Empty<ModelUsageRowDto>();
|
||||
public IReadOnlyList<TaskUsageRowDto> TaskRows = Array.Empty<TaskUsageRowDto>();
|
||||
public int ModelUsageCalls;
|
||||
public int TaskUsageCalls;
|
||||
|
||||
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(Snapshot);
|
||||
|
||||
public override Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to)
|
||||
{
|
||||
ModelUsageCalls++;
|
||||
return Task.FromResult(ModelRows);
|
||||
}
|
||||
|
||||
public override Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsageAsync(DateOnly from, DateOnly to)
|
||||
{
|
||||
TaskUsageCalls++;
|
||||
return Task.FromResult(TaskRows);
|
||||
}
|
||||
}
|
||||
|
||||
private static UsageLimitDto Limit(
|
||||
string kind, double percent = 10, string severity = "normal",
|
||||
DateTimeOffset? resetsAt = null, string? scopeModelDisplayName = null, bool isActive = true)
|
||||
=> new(kind, "group", percent, severity, resetsAt, scopeModelDisplayName, isActive);
|
||||
|
||||
private static UsageSnapshotDto Snapshot(
|
||||
IReadOnlyList<UsageLimitDto>? limits = null,
|
||||
int fiveHourThresholdPct = 80,
|
||||
int sevenDayThresholdPct = 90,
|
||||
bool isGateBlocked = false,
|
||||
string? gateReason = null,
|
||||
bool isStale = false,
|
||||
string? lastError = null,
|
||||
DateTime? fetchedAtUtc = null)
|
||||
=> new(
|
||||
null, null, null, null,
|
||||
limits ?? Array.Empty<UsageLimitDto>(),
|
||||
fiveHourThresholdPct, sevenDayThresholdPct,
|
||||
isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError);
|
||||
|
||||
// ── Gauge label derivation ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeLabel_Session_UsesSessionLabel()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(Loc.T("modals.usageMonitor.gaugeSession"), vm.GaugeRows[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeLabel_WeeklyAll_UsesWeeklyAllLabel()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_all") }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(Loc.T("modals.usageMonitor.gaugeWeeklyAll"), vm.GaugeRows[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeLabel_WeeklyScoped_IncludesDisplayName()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Fable") }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(Loc.T("modals.usageMonitor.gaugeWeeklyScopedFormat", "Fable"), vm.GaugeRows[0].Label);
|
||||
Assert.Contains("Fable", vm.GaugeRows[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeLabel_UnknownKind_ShowsRawKind()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("mystery_bucket") }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal("mystery_bucket", vm.GaugeRows[0].Label);
|
||||
}
|
||||
|
||||
// ── Gauge list follows Limits ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeRows_ThreeLimits_ProducesThreeGauges()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = Snapshot(new[] { Limit("session"), Limit("weekly_all"), Limit("weekly_scoped", scopeModelDisplayName: "Opus") })
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(3, vm.GaugeRows.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeRows_NoSnapshot_EmptyNoCrash()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = null };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Empty(vm.GaugeRows);
|
||||
}
|
||||
|
||||
// ── Gate threshold marks ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeRow_Session_CarriesFiveHourThreshold()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(80, vm.GaugeRows[0].ThresholdPercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeRow_WeeklyScoped_HasNoThreshold()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Fable") }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Null(vm.GaugeRows[0].ThresholdPercent);
|
||||
}
|
||||
|
||||
// ── Stale / gate bands ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task StaleSnapshot_SetsIsStale()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(isStale: true, lastError: "timeout") };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.IsStale);
|
||||
Assert.Equal("timeout", vm.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BlockedGate_SetsIsGateBlocked_WithReason()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(isGateBlocked: true, gateReason: "5h-Limit 80% >= 80%") };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.IsGateBlocked);
|
||||
Assert.Equal("5h-Limit 80% >= 80%", vm.GateReason);
|
||||
}
|
||||
|
||||
// ── Model tab: scope split + sums ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ModelRows_SplitsClaudeDoVsOther_AndSumsTokens()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
ModelRows = new[]
|
||||
{
|
||||
new ModelUsageRowDto(new DateOnly(2026, 8, 1), "sonnet", "ClaudeDo", 100, 50, 10, 5, 1),
|
||||
new ModelUsageRowDto(new DateOnly(2026, 8, 2), "sonnet", "ClaudeDo", 20, 10, 0, 0, 1),
|
||||
new ModelUsageRowDto(new DateOnly(2026, 8, 1), "sonnet", "Other", 5, 5, 0, 0, 1),
|
||||
new ModelUsageRowDto(new DateOnly(2026, 8, 1), "opus", "Other", 1000, 1000, 0, 0, 1),
|
||||
}
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
var sonnet = vm.ModelRows.Single(r => r.Model == "sonnet");
|
||||
Assert.Equal(120, sonnet.ClaudeDoInputTokens);
|
||||
Assert.Equal(60, sonnet.ClaudeDoOutputTokens);
|
||||
Assert.Equal(15, sonnet.ClaudeDoCacheTokens);
|
||||
Assert.Equal(5, sonnet.OtherInputTokens);
|
||||
Assert.Equal(5, sonnet.OtherOutputTokens);
|
||||
Assert.Equal(205, sonnet.GrandTotal);
|
||||
|
||||
// Sorted descending by grand total — opus (2000) before sonnet (210).
|
||||
Assert.Equal("opus", vm.ModelRows[0].Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ModelRows_Empty_ReportsEmptyState()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.ModelsEmpty);
|
||||
}
|
||||
|
||||
// ── Task tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task TaskRows_SortedDescendingByTotalTokens()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
TaskRows = new[]
|
||||
{
|
||||
new TaskUsageRowDto("t1", "Small task", "l1", "List", "sonnet", 1, 10, 10),
|
||||
new TaskUsageRowDto("t2", "Big task", "l1", "List", "opus", 3, 1000, 2000),
|
||||
}
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal("t2", vm.TaskRows[0].TaskId);
|
||||
Assert.Equal(3000, vm.TaskRows[0].TotalTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TaskRows_Empty_ReportsEmptyState()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.TasksEmpty);
|
||||
}
|
||||
|
||||
// ── Period change triggers exactly one reload ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ChangingPreset_TriggersExactlyOneReload()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
var callsAfterInitialLoad = worker.ModelUsageCalls;
|
||||
|
||||
await vm.SetPreset30DaysCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(callsAfterInitialLoad + 1, worker.ModelUsageCalls);
|
||||
Assert.Equal(callsAfterInitialLoad + 1, worker.TaskUsageCalls);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user