using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using System.Windows.Shapes; using AxCopilot.Services; namespace AxCopilot.Views; /// 에이전트 실행 통계 대시보드 창. public partial class AgentStatsDashboardWindow : Window { private int _currentDays = 1; // 현재 필터 (오늘=1) public AgentStatsDashboardWindow() { InitializeComponent(); Loaded += (_, _) => LoadStats(_currentDays); } // ─── 이벤트 핸들러 ──────────────────────────────────────────────────── private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { // 타이틀바 우측 닫기 버튼 영역에서는 DragMove 실행하지 않음 var pos = e.GetPosition(this); if (pos.X > ActualWidth - 50) return; if (e.ClickCount == 2) { WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; } else DragMove(); } private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); private void Filter_Click(object sender, MouseButtonEventArgs e) { if (sender is not Border b || b.Tag is not string tagStr) return; if (!int.TryParse(tagStr, out var days)) return; _currentDays = days; UpdateFilterButtons(b); LoadStats(days); } private void BtnClearStats_Click(object sender, MouseButtonEventArgs e) { var result = CustomMessageBox.Show( "모든 통계 데이터를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.", "통계 초기화", MessageBoxButton.YesNo, MessageBoxImage.Question); if (result != MessageBoxResult.Yes) return; AgentStatsService.Clear(); LoadStats(_currentDays); } // ─── 통계 로드/렌더링 ───────────────────────────────────────────────── private void LoadStats(int days) { var summary = AgentStatsService.Aggregate(days); RenderSummaryCards(summary); RenderDailyChart(summary, days); RenderToolFreq(summary); RenderBreakdowns(summary); UpdateStatus(summary, days); } private void RenderSummaryCards(AgentStatsService.AgentStatsSummary s) { SummaryCards.Children.Clear(); SummaryCards.Children.Add(MakeSummaryCard("\uE9D9", "총 실행", s.TotalSessions.ToString("N0"), "#A78BFA")); SummaryCards.Children.Add(MakeSummaryCard("\uE8A7", "도구 호출", s.TotalToolCalls.ToString("N0"), "#3B82F6")); SummaryCards.Children.Add(MakeSummaryCard("\uE7C8", "총 토큰", FormatTokens(s.TotalTokens), "#10B981")); SummaryCards.Children.Add(MakeSummaryCard("\uE73E", "재시도 품질", $"{s.RetryQualityRate * 100:F0}%", "#F59E0B")); } private Border MakeSummaryCard(string icon, string label, string value, string colorHex) { var bg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2A, 0x40)); var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var sub = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var col = (Color)ColorConverter.ConvertFromString(colorHex); var card = new Border { Background = bg, CornerRadius = new CornerRadius(10), Padding = new Thickness(14, 12, 14, 12), Margin = new Thickness(0, 0, 8, 0), }; var sp = new StackPanel(); sp.Children.Add(new TextBlock { Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 16, Foreground = new SolidColorBrush(col), Margin = new Thickness(0, 0, 0, 6), }); sp.Children.Add(new TextBlock { Text = value, FontSize = 20, FontWeight = FontWeights.Bold, Foreground = fg, }); sp.Children.Add(new TextBlock { Text = label, FontSize = 11, Foreground = sub, Margin = new Thickness(0, 2, 0, 0), }); card.Child = sp; return card; } private void RenderDailyChart(AgentStatsService.AgentStatsSummary s, int days) { DailyBarChart.Children.Clear(); DailyBarLabels.Children.Clear(); // 최근 N일 날짜 범위 구성 var endDate = DateTime.Today; int chartDays = days == 1 ? 1 : days == 0 ? 30 : days; var dates = Enumerable.Range(0, chartDays) .Select(i => endDate.AddDays(-(chartDays - 1 - i))) .ToList(); var maxVal = dates .Select(d => s.DailySessions.GetValueOrDefault(d.ToString("yyyy-MM-dd"), 0)) .DefaultIfEmpty(0).Max(); if (maxVal == 0) maxVal = 1; var barColor = (Color)ColorConverter.ConvertFromString("#4B5EFC"); var chartW = 748.0; // 근사값 (Canvas는 레이아웃 후 ActualWidth 확정) var barGap = 2.0; var barW = Math.Max(4, (chartW - barGap * dates.Count) / dates.Count); var chartH = 100.0; for (int i = 0; i < dates.Count; i++) { var dateKey = dates[i].ToString("yyyy-MM-dd"); var val = s.DailySessions.GetValueOrDefault(dateKey, 0); var barH = Math.Max(2, val / (double)maxVal * (chartH - 4)); var x = i * (barW + barGap); var rect = new Rectangle { Width = barW, Height = barH, Fill = new SolidColorBrush(Color.FromArgb(0xCC, barColor.R, barColor.G, barColor.B)), RadiusX = 3, RadiusY = 3, ToolTip = $"{dateKey}: {val}회", }; Canvas.SetLeft(rect, x); Canvas.SetTop(rect, chartH - barH); DailyBarChart.Children.Add(rect); // 레이블: 7일 이하면 모두 표시, 그 이상이면 월요일만 if (dates.Count <= 7 || dates[i].DayOfWeek == DayOfWeek.Monday) { var lbl = new TextBlock { Text = dates[i].ToString("MM/dd"), FontSize = 9, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, }; Canvas.SetLeft(lbl, x); DailyBarLabels.Children.Add(lbl); } } } private void RenderToolFreq(AgentStatsService.AgentStatsSummary s) { ToolFreqPanel.Children.Clear(); if (s.ToolFrequency.Count == 0) { ToolFreqPanel.Children.Add(new TextBlock { Text = "데이터 없음", FontSize = 12, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, }); return; } var maxVal = s.ToolFrequency.Values.Max(); var barColor = (Color)ColorConverter.ConvertFromString("#3B82F6"); foreach (var (tool, count) in s.ToolFrequency) { var ratio = (double)count / maxVal; var row = new Grid { Margin = new Thickness(0, 2, 0, 2) }; row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) }); row.ColumnDefinitions.Add(new ColumnDefinition()); row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(36) }); var nameTb = new TextBlock { Text = tool, FontSize = 11, FontFamily = new FontFamily("Consolas"), Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center, }; Grid.SetColumn(nameTb, 0); var barBg = new Border { Background = new SolidColorBrush(Color.FromArgb(0x20, 0x3B, 0x82, 0xF6)), CornerRadius = new CornerRadius(3), Height = 10, Margin = new Thickness(6, 0, 4, 0), VerticalAlignment = VerticalAlignment.Center, }; var barFg = new Border { Background = new SolidColorBrush(Color.FromArgb(0xCC, barColor.R, barColor.G, barColor.B)), CornerRadius = new CornerRadius(3), Height = 10, HorizontalAlignment = HorizontalAlignment.Left, }; barFg.Width = ratio * 150; // 최대 150px barBg.Child = barFg; Grid.SetColumn(barBg, 1); var countTb = new TextBlock { Text = count.ToString(), FontSize = 11, FontWeight = FontWeights.SemiBold, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, }; Grid.SetColumn(countTb, 2); row.Children.Add(nameTb); row.Children.Add(barBg); row.Children.Add(countTb); ToolFreqPanel.Children.Add(row); } } private void RenderBreakdowns(AgentStatsService.AgentStatsSummary s) { // 탭 분포 TabBreakPanel.Children.Clear(); var tabColors = new Dictionary { ["Cowork"] = "#A78BFA", ["Code"] = "#34D399", ["Chat"] = "#3B82F6", }; foreach (var (tab, count) in s.TabBreakdown.OrderByDescending(kv => kv.Value)) { var c = tabColors.GetValueOrDefault(tab, "#9CA3AF"); TabBreakPanel.Children.Add(MakeBreakRow(tab, count, c, s.TabBreakdown.Values.DefaultIfEmpty(1).Max())); } if (s.TabBreakdown.Count == 0) TabBreakPanel.Children.Add(MakeEmptyText()); // 모델 분포 ModelBreakPanel.Children.Clear(); foreach (var (model, count) in s.ModelBreakdown.OrderByDescending(kv => kv.Value)) { var shortName = model.Length > 18 ? model[..18] + "…" : model; ModelBreakPanel.Children.Add(MakeBreakRow(shortName, count, "#F59E0B", s.ModelBreakdown.Values.DefaultIfEmpty(1).Max())); } if (s.ModelBreakdown.Count == 0) ModelBreakPanel.Children.Add(MakeEmptyText()); } private UIElement MakeBreakRow(string label, int count, string colorHex, int maxVal) { var ratio = maxVal > 0 ? (double)count / maxVal : 0; var col = (Color)ColorConverter.ConvertFromString(colorHex); var row = new StackPanel { Margin = new Thickness(0, 2, 0, 2) }; var header = new Grid(); header.ColumnDefinitions.Add(new ColumnDefinition()); header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }); var lbl = new TextBlock { Text = label, FontSize = 11, Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, }; Grid.SetColumn(lbl, 0); var cnt = new TextBlock { Text = count.ToString(), FontSize = 11, FontWeight= FontWeights.SemiBold, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, }; Grid.SetColumn(cnt, 1); header.Children.Add(lbl); header.Children.Add(cnt); row.Children.Add(header); // 바 차트 var barBg = new Border { Background = new SolidColorBrush(Color.FromArgb(0x20, col.R, col.G, col.B)), CornerRadius = new CornerRadius(3), Height = 6, Margin = new Thickness(0, 3, 0, 0), }; var barFg = new Border { Background = new SolidColorBrush(Color.FromArgb(0xCC, col.R, col.G, col.B)), CornerRadius = new CornerRadius(3), Height = 6, HorizontalAlignment = HorizontalAlignment.Left, }; barFg.Width = ratio * 160; // 최대 160px barBg.Child = barFg; row.Children.Add(barBg); return row; } private TextBlock MakeEmptyText() => new() { Text = "데이터 없음", FontSize = 11, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, }; private void UpdateFilterButtons(Border active) { foreach (var btn in new[] { FilterToday, Filter7d, Filter30d, FilterAll }) { var isActive = btn == active; btn.Background = isActive ? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)) : TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x30, 0x30, 0x45)); if (btn.Child is TextBlock tb) { tb.Foreground = isActive ? Brushes.White : TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; } } } private void UpdateStatus(AgentStatsService.AgentStatsSummary s, int days) { var period = days switch { 1 => "오늘", 7 => "최근 7일", 30 => "최근 30일", _ => "전체 기간", }; var fileSize = AgentStatsService.GetFileSize(); var taskQuality = string.Join(", ", s.RetryQualityByTaskType .OrderByDescending(kv => kv.Value) .ThenBy(kv => kv.Key, StringComparer.Ordinal) .Take(3) .Select(kv => $"{kv.Key} {kv.Value * 100:F0}%")); var taskQualityText = string.IsNullOrWhiteSpace(taskQuality) ? "task 품질 데이터 없음" : $"task 품질 {taskQuality}"; StatusText.Text = $"{period} 기준 | 세션 {s.TotalSessions}회, 도구 호출 {s.TotalToolCalls}회, " + $"토큰 {FormatTokens(s.TotalTokens)} | 재시도 복구 {s.TotalRecoveredAfterFailure}, 반복차단 {s.TotalRepeatedFailureBlocked} | {taskQualityText} | 저장 파일 {FormatFileSize(fileSize)}"; } private static string FormatTokens(int t) => t >= 1_000_000 ? $"{t / 1_000_000.0:F1}M" : t >= 1_000 ? $"{t / 1_000.0:F1}K" : t.ToString(); private static string FormatFileSize(long bytes) => bytes >= 1024 * 1024 ? $"{bytes / (1024.0 * 1024):F1}MB" : bytes >= 1024 ? $"{bytes / 1024.0:F1}KB" : $"{bytes}B"; }