Files
ClaudeDo/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml
T
mika kuns 3003cfa561 feat(usage): open the TokenTracker dashboard from the usage monitor
The modal deliberately shows only a slice of the analytics; this hands off
to TokenTracker's own local dashboard for the rest. The worker starts
`tokentracker serve` on demand and returns the URL, the UI opens the browser.

Three things the spawn has to get right: port 7680 is not free on Windows
(Delivery Optimization holds [::]:7680) and serve does not fall back, so we
scan 7680-7689 with a dual-stack bind probe; --no-open because the CLI would
open the browser before the server answers; and the child is a cmd.exe shim,
so shutdown kills the process tree. --no-sync keeps our no-cloud-sync rule.
2026-08-24 16:51:00 +02:00

280 lines
18 KiB
XML

<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>
<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>
<Border IsVisible="{Binding IsThrottled}"
Background="{DynamicResource ReviewTintBrush}"
BorderBrush="{DynamicResource StatusReviewBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,8">
<TextBlock Classes="meta" Text="{Binding ThrottleBandText}"/>
</Border>
</StackPanel>
<!-- Refresh: the worker polls on a slow cadence to avoid the endpoint's 429s -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8"
Margin="20,12,20,0" VerticalAlignment="Center">
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.refresh}"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsRefreshing}"/>
<Ellipse Classes="spinner" Width="14" Height="14" VerticalAlignment="Center"
IsVisible="{Binding IsRefreshing}"/>
<TextBlock Classes="meta" VerticalAlignment="Center"
Text="{loc:Tr modals.usageMonitor.refreshHint}"/>
</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="270">
<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>
<ctl:UsageGaugeBar Height="16"
Percent="{Binding Percent}"
IsWarn="{Binding IsWarnSeverity}"
SoftPct="{Binding SoftPct, Mode=TwoWay}"
HardPct="{Binding HardPct, Mode=TwoWay}"
GatePct="{Binding GatePct, Mode=TwoWay}"
CommitCommand="{Binding CommitCommand}"
TrackBrush="{DynamicResource LineBrush}"
FillBrush="{DynamicResource AccentBrush}"
WarnFillBrush="{DynamicResource StatusReviewBrush}"
SoftMarkerBrush="{DynamicResource TextDimBrush}"
HardMarkerBrush="{DynamicResource StatusReviewBrush}"
GateMarkerBrush="{DynamicResource StatusErrorBrush}"
ToolTip.Tip="{loc:Tr modals.usageMonitor.dragHint}"/>
<!-- Legend doubles as the numeric editor: swatch colours match the bar's markers,
and each box commits on Enter / focus loss (handlers in the code-behind). -->
<Grid ColumnDefinitions="10,*,62" RowDefinitions="Auto,Auto,Auto"
IsVisible="{Binding IsAdjustable}" Margin="0,2,0,0">
<Rectangle Grid.Row="0" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource TextDimBrush}"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendSoft}"/>
<NumericUpDown Grid.Row="0" Grid.Column="2" Tag="soft"
Value="{Binding SoftPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
<Rectangle Grid.Row="1" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource StatusReviewBrush}"/>
<TextBlock Grid.Row="1" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendHard}"/>
<NumericUpDown Grid.Row="1" Grid.Column="2" Tag="hard"
Value="{Binding HardPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
<Rectangle Grid.Row="2" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource StatusErrorBrush}"/>
<TextBlock Grid.Row="2" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendGate}"/>
<NumericUpDown Grid.Row="2" Grid.Column="2" Tag="gate"
Value="{Binding GatePct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
</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>
<!-- TokenTracker hint: shown instead of the analytics header while the CLI is missing -->
<Border DockPanel.Dock="Top" Margin="20,12,20,0"
IsVisible="{Binding TokenTrackerMissing}"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource StatusReviewBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,10">
<StackPanel Spacing="6">
<TextBlock Classes="title" Text="{loc:Tr modals.usageMonitor.ttMissingTitle}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr modals.usageMonitor.ttMissingBody}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
IsVisible="{Binding !ShowInstallButton}"
Text="{Binding NodeHintText}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
IsVisible="{Binding HasTokenTrackerError}"
Text="{Binding TokenTrackerErrorText}"/>
<Button Classes="btn" HorizontalAlignment="Left"
IsVisible="{Binding ShowInstallButton}"
Content="{loc:Tr modals.usageMonitor.ttInstall}"
Command="{Binding InstallTokenTrackerCommand}"/>
<Ellipse Classes="spinner" Width="14" Height="14" HorizontalAlignment="Left"
IsVisible="{Binding IsInstalling}"/>
</StackPanel>
</Border>
<!-- Analytics header: totals, freshness stamp, manual refresh -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="12"
Margin="20,12,20,0" VerticalAlignment="Center"
IsVisible="{Binding !TokenTrackerMissing}">
<TextBlock Classes="title" VerticalAlignment="Center" Text="{Binding TotalsText}"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding AnalyticsStampText}"/>
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.analyticsRefresh}"
Command="{Binding RefreshAnalyticsCommand}"/>
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.openDashboard}"
ToolTip.Tip="{loc:Tr modals.usageMonitor.openDashboardTip}"
IsEnabled="{Binding !IsOpeningDashboard}"
Command="{Binding OpenDashboardCommand}"/>
<Ellipse Classes="spinner" Width="14" Height="14" VerticalAlignment="Center"
IsVisible="{Binding IsOpeningDashboard}"/>
</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,80" 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}"/>
<TextBlock Grid.Column="8" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnCost}"/>
</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,80" 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}%}"/>
<TextBlock Grid.Column="8" Classes="meta" Text="{Binding CostText}"/>
</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,80,70" 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}"/>
<TextBlock Grid.Column="7" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnCost}"/>
<TextBlock Grid.Column="8" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnRetries}"/>
</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,80,70" 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}"/>
<TextBlock Grid.Column="7" Classes="meta" Text="{Binding CostText}"/>
<TextBlock Grid.Column="8" Classes="meta" Text="{Binding RetriesText}"/>
</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>