feat(ui): add usage monitor modal with gauges and model/task usage analysis

Adds a Usage Monitor modal (Worker menu + wired to the footer/Mission-Control
usage pill's Open command): dynamic gauges built from UsageSnapshotDto.Limits
with gate-threshold marks, a stale/blocked-gate band, and Models/Tasks tabs
backed by GetModelUsageAsync/GetTaskUsageAsync over a 7d/30d/custom range.
This commit is contained in:
mika kuns
2026-08-05 13:57:21 +02:00
parent 5115cfc288
commit 8103006e26
12 changed files with 800 additions and 0 deletions
+2
View File
@@ -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 SetPresetCommand}" CommandParameter="7"/>
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.last30Days}"
Command="{Binding SetPresetCommand}" CommandParameter="30"/>
<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 };