Files
AX-Copilot-Codex/src/AxCopilot/Views/AgentStatsDashboardWindow.xaml.cs

399 lines
15 KiB
C#

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;
/// <summary>에이전트 실행 통계 대시보드 창.</summary>
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<string, string>
{
["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";
}