399 lines
15 KiB
C#
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";
|
|
}
|