From 3db48c3bae378db4eca999a17999b8d0f2e5500d Mon Sep 17 00:00:00 2001 From: Godopu Date: Thu, 7 May 2026 01:32:15 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=B1=EB=8A=A5=20=EB=B9=84=EA=B5=90?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=B2=A0=EB=93=9C=20=EB=8D=B0?= =?UTF-8?q?=EB=AA=A8=20UI=20=EA=B5=AC=ED=98=84=20(src/,=20Bubble=20Tea=20+?= =?UTF-8?q?=20Lipgloss)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 산출물 — 실제 측정 없이 데모 모드로 동작하는 Terminal UI. 시연 시 최종 목표 화면을 가시화하고, 파라미터 조정에 반응하는 그럴듯한 추세를 시뮬레이션한다. 비교 대상 시스템: - REST + HTTP/2 (TCP+TLS) + JSON - gRPC + HTTP/2 (TCP+TLS) + Protobuf - gRPC + HTTP/3 (QUIC+TLS1.3) + Protobuf ★ 본 연구 제안 조절 가능한 파라미터: - 워크로드 시나리오 (Small-Many 1KB×10000 / Large-Few 1MB×50) - 링크 지연 (0~500ms), 패킷 손실 (0~5%) - 대역폭 (1~1000Mbps), 디바이스 수 (1~100) 화면 구성: - 메인 메뉴 / 설정 / 실시간 진행 / 결과 비교 / 정보 5개 화면 - 진행률 막대, latency sparkline, 비교 차트(P50/P95/P99/RPS/연결시간) 구현: - src/cmd/benchcli/main.go 진입점 - src/internal/ui/app.go Bubble Tea Model + 화면 dispatcher - src/internal/ui/types.go Config / Result / RunState 정의 - src/internal/ui/styles.go Lipgloss 스타일·색상 - src/internal/ui/components.go progressBar / sparkline / slider - src/internal/ui/simulator.go mock 시뮬레이터 (Phase 5에서 실측으로 교체) - src/internal/ui/screen_*.go 각 화면 (menu/config/running/results/about) 의존성: bubbletea v1.2.4, lipgloss v1.0.0 빌드: `cd src && go run ./cmd/benchcli` Co-Authored-By: Claude Opus 4.7 --- src/cmd/benchcli/main.go | 18 ++ src/go.mod | 26 +++ src/go.sum | 37 ++++ src/internal/ui/app.go | 138 +++++++++++++++ src/internal/ui/components.go | 130 ++++++++++++++ src/internal/ui/screen_about.go | 85 +++++++++ src/internal/ui/screen_config.go | 238 +++++++++++++++++++++++++ src/internal/ui/screen_menu.go | 76 ++++++++ src/internal/ui/screen_results.go | 257 +++++++++++++++++++++++++++ src/internal/ui/screen_running.go | 111 ++++++++++++ src/internal/ui/simulator.go | 283 ++++++++++++++++++++++++++++++ src/internal/ui/styles.go | 71 ++++++++ src/internal/ui/types.go | 103 +++++++++++ 13 files changed, 1573 insertions(+) create mode 100644 src/cmd/benchcli/main.go create mode 100644 src/go.mod create mode 100644 src/go.sum create mode 100644 src/internal/ui/app.go create mode 100644 src/internal/ui/components.go create mode 100644 src/internal/ui/screen_about.go create mode 100644 src/internal/ui/screen_config.go create mode 100644 src/internal/ui/screen_menu.go create mode 100644 src/internal/ui/screen_results.go create mode 100644 src/internal/ui/screen_running.go create mode 100644 src/internal/ui/simulator.go create mode 100644 src/internal/ui/styles.go create mode 100644 src/internal/ui/types.go diff --git a/src/cmd/benchcli/main.go b/src/cmd/benchcli/main.go new file mode 100644 index 0000000..fa07a2b --- /dev/null +++ b/src/cmd/benchcli/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "aiot-grpc-bench/internal/ui" +) + +func main() { + p := tea.NewProgram(ui.NewApp(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "ui error: %v\n", err) + os.Exit(1) + } +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..1418b39 --- /dev/null +++ b/src/go.mod @@ -0,0 +1,26 @@ +module aiot-grpc-bench + +go 1.22 + +require ( + github.com/charmbracelet/bubbletea v1.2.4 + github.com/charmbracelet/lipgloss v1.0.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..6ef777c --- /dev/null +++ b/src/go.sum @@ -0,0 +1,37 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= +github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= +github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/src/internal/ui/app.go b/src/internal/ui/app.go new file mode 100644 index 0000000..75d2afa --- /dev/null +++ b/src/internal/ui/app.go @@ -0,0 +1,138 @@ +package ui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type screen int + +const ( + screenMenu screen = iota + screenConfig + screenRunning + screenResults + screenAbout +) + +// App 전체 UI Model +type App struct { + screen screen + config Config + run *RunState + + width int + height int + + // 화면별 커서 상태 + menuIdx int + configIdx int + resultsTab int +} + +func NewApp() App { + return App{ + screen: screenMenu, + config: DefaultConfig(), + } +} + +func (a App) Init() tea.Cmd { + return nil +} + +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.WindowSizeMsg: + a.width = m.Width + a.height = m.Height + return a, nil + + case tea.KeyMsg: + // 글로벌 단축키 + if m.String() == "ctrl+c" { + return a, tea.Quit + } + switch a.screen { + case screenMenu: + return a.updateMenu(m) + case screenConfig: + return a.updateConfig(m) + case screenRunning: + return a.updateRunning(m) + case screenResults: + return a.updateResults(m) + case screenAbout: + return a.updateAbout(m) + } + + case tickMsg: + if a.screen == screenRunning && a.run != nil { + cont := step(a.run, a.config) + if !cont { + return a, func() tea.Msg { return doneMsg{} } + } + return a, tickCmd() + } + + case doneMsg: + a.screen = screenResults + return a, nil + } + + return a, nil +} + +func (a App) View() string { + if a.width == 0 { + return "초기화 중..." + } + + var content string + switch a.screen { + case screenMenu: + content = a.viewMenu() + case screenConfig: + content = a.viewConfig() + case screenRunning: + content = a.viewRunning() + case screenResults: + content = a.viewResults() + case screenAbout: + content = a.viewAbout() + } + + return lipgloss.JoinVertical(lipgloss.Left, a.viewHeader(), content) +} + +func (a App) viewHeader() string { + title := titleStyle.Render("⚡ AIoT gRPC 성능 비교 테스트베드") + subtitle := subtitleStyle.Render("HTTP/2 REST vs HTTP/2 gRPC vs HTTP/3 gRPC") + + var stage string + switch a.screen { + case screenMenu: + stage = "메인 메뉴" + case screenConfig: + stage = "1/3 · 시나리오 & 네트워크 설정" + case screenRunning: + stage = "2/3 · 실험 진행 중" + case screenResults: + stage = "3/3 · 결과 분석" + case screenAbout: + stage = "테스트베드 정보" + } + + headerLine := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", subtitle) + stageLine := helpStyle.Render("[ " + stage + " ]") + + width := a.width + if width < 10 { + width = 80 + } + divider := lipgloss.NewStyle().Foreground(colorMuted).Render(strings.Repeat("─", width)) + + return lipgloss.JoinVertical(lipgloss.Left, headerLine, stageLine, divider) +} diff --git a/src/internal/ui/components.go b/src/internal/ui/components.go new file mode 100644 index 0000000..1d48bf5 --- /dev/null +++ b/src/internal/ui/components.go @@ -0,0 +1,130 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// progressBar 0~1 범위의 진행도를 width 칸 막대로 렌더링 +func progressBar(progress float64, width int, color lipgloss.Color) string { + if progress < 0 { + progress = 0 + } + if progress > 1 { + progress = 1 + } + filled := int(progress * float64(width)) + bar := strings.Repeat("▓", filled) + strings.Repeat("░", width-filled) + return "[" + lipgloss.NewStyle().Foreground(color).Render(bar) + "]" +} + +// horizontalBar 값을 0~max 범위에서 width 칸 막대로 렌더링 +func horizontalBar(v, max float64, width int, color lipgloss.Color) string { + if max <= 0 { + max = 1 + } + fill := int(v / max * float64(width)) + if fill > width { + fill = width + } + if fill < 0 { + fill = 0 + } + bar := strings.Repeat("█", fill) + strings.Repeat("·", width-fill) + return "[" + lipgloss.NewStyle().Foreground(color).Render(bar) + "]" +} + +// slider 정수값을 [min,max] 범위 안에서 슬라이더로 표시 +func slider(value, min, max, width int) string { + if max == min { + return "" + } + pos := (value - min) * width / (max - min) + if pos < 0 { + pos = 0 + } + if pos > width { + pos = width + } + bar := strings.Repeat("─", pos) + "●" + strings.Repeat("─", width-pos) + return helpStyle.Render("[" + bar + "]") +} + +// sparkline 시계열을 한 줄 미니 차트로 렌더링 +func sparkline(data []float64, width int, color lipgloss.Color) string { + if len(data) == 0 || width <= 0 { + return strings.Repeat(" ", width) + } + chars := []rune("▁▂▃▄▅▆▇█") + + // 최근 width개의 샘플만 사용 + start := len(data) - width + if start < 0 { + start = 0 + } + samples := data[start:] + + minV, maxV := samples[0], samples[0] + for _, v := range samples { + if v < minV { + minV = v + } + if v > maxV { + maxV = v + } + } + if maxV == minV { + maxV = minV + 1 + } + + var sb strings.Builder + for _, v := range samples { + idx := int((v - minV) / (maxV - minV) * float64(len(chars)-1)) + if idx < 0 { + idx = 0 + } + if idx >= len(chars) { + idx = len(chars) - 1 + } + sb.WriteRune(chars[idx]) + } + + rendered := lipgloss.NewStyle().Foreground(color).Render(sb.String()) + pad := width - len([]rune(sb.String())) + if pad < 0 { + pad = 0 + } + return strings.Repeat(" ", pad) + rendered +} + +// formatMs latency 포맷 (0이면 dash) +func formatMs(v float64) string { + if v == 0 { + return "—" + } + return fmt.Sprintf("%.1fms", v) +} + +// formatBytes 바이트 단위 포맷 +func formatBytes(b int64) string { + switch { + case b < 1024: + return fmt.Sprintf("%d B", b) + case b < 1024*1024: + return fmt.Sprintf("%.1f KB", float64(b)/1024) + case b < 1024*1024*1024: + return fmt.Sprintf("%.1f MB", float64(b)/1024/1024) + } + return fmt.Sprintf("%.2f GB", float64(b)/1024/1024/1024) +} + +// helpKeys 단축키 도움말을 한 줄로 렌더링 +func helpKeys(pairs ...[2]string) string { + parts := make([]string, 0, len(pairs)) + for _, p := range pairs { + parts = append(parts, keyStyle.Render(p[0])+" "+p[1]) + } + return helpStyle.Render(strings.Join(parts, " ")) +} diff --git a/src/internal/ui/screen_about.go b/src/internal/ui/screen_about.go new file mode 100644 index 0000000..9428b45 --- /dev/null +++ b/src/internal/ui/screen_about.go @@ -0,0 +1,85 @@ +package ui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (a App) updateAbout(m tea.KeyMsg) (App, tea.Cmd) { + switch m.String() { + case "esc", "q", "enter", " ": + a.screen = screenMenu + } + return a, nil +} + +func (a App) viewAbout() string { + rows := []string{} + + rows = append(rows, sectionHeader("🎯 연구 목표")) + rows = append(rows, " AI Agent 및 AIoT 환경에서 고성능·경량 통신을 위한") + rows = append(rows, " gRPC-QUIC 통신 모듈과 엣지 게이트웨이의 효과를 정량 검증") + rows = append(rows, "") + + rows = append(rows, sectionHeader("🔬 비교 대상 (3 시스템)")) + systems := []struct { + id, label string + }{ + {"rest-h2", "REST + HTTP/2 (TCP+TLS) + JSON · 베이스라인"}, + {"grpc-h2", "gRPC + HTTP/2 (TCP+TLS) + Protobuf · 선행 연구 확인"}, + {"grpc-h3", "gRPC + HTTP/3 (QUIC+TLS1.3) + Protobuf · ★ 본 연구 제안"}, + } + for _, s := range systems { + clr := systemColor(s.id) + bullet := lipgloss.NewStyle().Foreground(clr).Render("●") + text := lipgloss.NewStyle().Foreground(clr).Render(s.label) + rows = append(rows, " "+bullet+" "+text) + } + rows = append(rows, "") + + rows = append(rows, sectionHeader("📦 워크로드 시나리오")) + rows = append(rows, " ① "+selectedItemStyle.Render("Small-Many")+" — 1KB 메시지 × 10,000회 / 디바이스") + rows = append(rows, " "+helpStyle.Render("AI Agent RPC 패턴: 격리된 에이전트 간 빈번한 단발성 호출")) + rows = append(rows, " ② "+selectedItemStyle.Render("Large-Few")+" — 1MB 메시지 × 50회 / 디바이스") + rows = append(rows, " "+helpStyle.Render("IoT 이미지 전송: 해충 탐지 ROI, 센서 클립 등")) + rows = append(rows, "") + + rows = append(rows, sectionHeader("🌐 네트워크 제어")) + rows = append(rows, " Phase 1 · "+selectedItemStyle.Render("Linux tc (netem qdisc)")+ + " 단일 NIC 구간에 지연·손실·대역폭 제어") + rows = append(rows, " "+helpStyle.Render("tc qdisc add dev eth0 root netem delay 50ms loss 1%")) + rows = append(rows, " Phase 2 · "+selectedItemStyle.Render("Mininet")+ + " SDN 스위치 기반 다중 홉 토폴로지") + rows = append(rows, " "+helpStyle.Render("OpenFlow 기반 라우팅, 다중 디바이스 동시 시뮬레이션")) + rows = append(rows, "") + + rows = append(rows, sectionHeader("📊 측정 지표 (KPI)")) + rows = append(rows, " • Latency P50 / P95 / P99") + rows = append(rows, " • Throughput (RPS)") + rows = append(rows, " • 데이터 전송량 (Payload Size)") + rows = append(rows, " • 성공률 (패킷 손실 환경 내구성)") + rows = append(rows, " • 연결 수립 시간 (Connection Overhead)") + rows = append(rows, " • 0-RTT Resumption · HoL Blocking 내성 (P2 단계)") + rows = append(rows, "") + + rows = append(rows, sectionHeader("🛠 조절 가능한 파라미터")) + rows = append(rows, " • 링크 지연: 0 ~ 500 ms (편도)") + rows = append(rows, " • 패킷 손실율: 0 ~ 5 %") + rows = append(rows, " • 대역폭: 1 ~ 1000 Mbps (1, 5, 10, 25, 50, 100, 250, 500, 1000)") + rows = append(rows, " • 디바이스 수: 1 ~ 100") + rows = append(rows, "") + + rows = append(rows, sectionHeader("⚠ 안내")) + rows = append(rows, helpStyle.Render(" 본 UI는 데모 모드로 동작합니다. 실제 측정 결과를 표시하지 않으며,")) + rows = append(rows, helpStyle.Render(" 파라미터에 반응하는 그럴듯한 추세만을 시뮬레이션합니다.")) + rows = append(rows, helpStyle.Render(" 실제 벤치마크 연동은 cmd/server / cmd/benchmark-runner 구현 후 수행됩니다.")) + + box := boxStyle.Render(strings.Join(rows, "\n")) + help := helpKeys( + [2]string{"Esc/Enter", "메뉴로 돌아가기"}, + ) + + return lipgloss.JoinVertical(lipgloss.Left, "", box, "", help) +} diff --git a/src/internal/ui/screen_config.go b/src/internal/ui/screen_config.go new file mode 100644 index 0000000..4fb56f3 --- /dev/null +++ b/src/internal/ui/screen_config.go @@ -0,0 +1,238 @@ +package ui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var bandwidthSteps = []int{1, 5, 10, 25, 50, 100, 250, 500, 1000} + +// 파라미터 인덱스 +const ( + pIdxScenario = 0 + pIdxDelay = 1 + pIdxLoss = 2 + pIdxBandwidth = 3 + pIdxDevices = 4 + pIdxSystemBase = 5 +) + +func (a App) updateConfig(m tea.KeyMsg) (App, tea.Cmd) { + paramCount := pIdxSystemBase + len(a.config.Systems) + + switch m.String() { + case "up", "k": + if a.configIdx > 0 { + a.configIdx-- + } + case "down", "j": + if a.configIdx < paramCount-1 { + a.configIdx++ + } + case "left", "h": + a.adjustParam(-1) + case "right", "l": + a.adjustParam(+1) + case " ": + a.toggleSystem() + case "enter": + if !a.config.HasSelectedSystem() { + return a, nil + } + rs := startRun(a.config) + a.run = &rs + a.screen = screenRunning + return a, tickCmd() + case "esc", "q": + a.screen = screenMenu + case "1": + a.config.Scenario = ScenarioSmallMany + case "2": + a.config.Scenario = ScenarioLargeFew + } + return a, nil +} + +func (a *App) adjustParam(delta int) { + switch a.configIdx { + case pIdxScenario: + // 토글 + if a.config.Scenario == ScenarioSmallMany { + a.config.Scenario = ScenarioLargeFew + } else { + a.config.Scenario = ScenarioSmallMany + } + case pIdxDelay: + a.config.LinkDelayMs += delta * 10 + if a.config.LinkDelayMs < 0 { + a.config.LinkDelayMs = 0 + } + if a.config.LinkDelayMs > 500 { + a.config.LinkDelayMs = 500 + } + case pIdxLoss: + a.config.PacketLossPct += float64(delta) * 0.5 + if a.config.PacketLossPct < 0 { + a.config.PacketLossPct = 0 + } + if a.config.PacketLossPct > 5 { + a.config.PacketLossPct = 5 + } + case pIdxBandwidth: + if delta > 0 { + a.config.BandwidthMbps = nextBandwidth(a.config.BandwidthMbps) + } else { + a.config.BandwidthMbps = prevBandwidth(a.config.BandwidthMbps) + } + case pIdxDevices: + stepSize := 1 + if a.config.DeviceCount >= 10 { + stepSize = 5 + } + if a.config.DeviceCount >= 50 { + stepSize = 10 + } + a.config.DeviceCount += delta * stepSize + if a.config.DeviceCount < 1 { + a.config.DeviceCount = 1 + } + if a.config.DeviceCount > 100 { + a.config.DeviceCount = 100 + } + } +} + +func (a *App) toggleSystem() { + idx := a.configIdx - pIdxSystemBase + if idx < 0 || idx >= len(a.config.Systems) { + return + } + a.config.Systems[idx].Selected = !a.config.Systems[idx].Selected +} + +func nextBandwidth(cur int) int { + for _, b := range bandwidthSteps { + if b > cur { + return b + } + } + return cur +} + +func prevBandwidth(cur int) int { + prev := bandwidthSteps[0] + for _, b := range bandwidthSteps { + if b >= cur { + return prev + } + prev = b + } + return prev +} + +func (a App) viewConfig() string { + rows := []string{} + + // 시나리오 + rows = append(rows, sectionHeader("📦 워크로드 시나리오")) + rows = append(rows, a.paramRow(pIdxScenario, + "시나리오", + a.config.ScenarioLabel(), + fmt.Sprintf("디바이스당 %dKB × %d 메시지 (총 목표 %d개/시스템)", + a.config.MessageSizeKB(), + a.config.MessageCount(), + a.config.MessageCount()*a.config.DeviceCount, + ), + )) + + // 네트워크 + rows = append(rows, "") + rows = append(rows, sectionHeader("🌐 네트워크 조건 (Phase 1: tc netem · Phase 2: mininet 예정)")) + rows = append(rows, a.paramRow(pIdxDelay, + "링크 지연 (편도)", + fmt.Sprintf("%d ms", a.config.LinkDelayMs), + slider(a.config.LinkDelayMs, 0, 500, 30), + )) + rows = append(rows, a.paramRow(pIdxLoss, + "패킷 손실율", + fmt.Sprintf("%.1f %%", a.config.PacketLossPct), + slider(int(a.config.PacketLossPct*10), 0, 50, 30), + )) + rows = append(rows, a.paramRow(pIdxBandwidth, + "대역폭", + fmt.Sprintf("%d Mbps", a.config.BandwidthMbps), + bandwidthGauge(a.config.BandwidthMbps), + )) + rows = append(rows, a.paramRow(pIdxDevices, + "디바이스 수", + fmt.Sprintf("%d", a.config.DeviceCount), + slider(a.config.DeviceCount, 1, 100, 30), + )) + + // 시스템 선택 + rows = append(rows, "") + rows = append(rows, sectionHeader("🔬 비교 대상 시스템 (Space로 토글)")) + for i, sys := range a.config.Systems { + idx := pIdxSystemBase + i + check := "○" + if sys.Selected { + check = "●" + } + prefix := " " + labelStyle := inactiveItemStyle + if a.configIdx == idx { + prefix = "▶ " + labelStyle = selectedItemStyle + } + coloredLabel := lipgloss.NewStyle().Foreground(systemColor(sys.ID)).Bold(true).Render(sys.Label) + rows = append(rows, prefix+labelStyle.Render(check)+" "+coloredLabel) + } + + box := boxStyle.Render(strings.Join(rows, "\n")) + + help := helpKeys( + [2]string{"↑/↓", "항목"}, + [2]string{"←/→", "값 조정"}, + [2]string{"Space", "시스템 토글"}, + [2]string{"Enter", "실험 시작"}, + [2]string{"Esc", "메뉴"}, + ) + + if !a.config.HasSelectedSystem() { + warn := errorStyle.Render("⚠ 최소 하나의 시스템을 선택하세요.") + help = lipgloss.JoinVertical(lipgloss.Left, warn, help) + } + + return lipgloss.JoinVertical(lipgloss.Left, "", box, "", help) +} + +func (a App) paramRow(idx int, label, value, suffix string) string { + prefix := " " + labelStyle := inactiveItemStyle + if a.configIdx == idx { + prefix = "▶ " + labelStyle = selectedItemStyle + } + return fmt.Sprintf("%s%-22s %s %s", + prefix, + labelStyle.Render(label), + valueStyle.Render(value), + suffix, + ) +} + +func bandwidthGauge(v int) string { + width := 30 + pos := 0 + for i, b := range bandwidthSteps { + if v == b { + pos = i * width / (len(bandwidthSteps) - 1) + break + } + } + bar := strings.Repeat("─", pos) + "●" + strings.Repeat("─", width-pos) + return helpStyle.Render("[" + bar + "]") +} diff --git a/src/internal/ui/screen_menu.go b/src/internal/ui/screen_menu.go new file mode 100644 index 0000000..d1df0f5 --- /dev/null +++ b/src/internal/ui/screen_menu.go @@ -0,0 +1,76 @@ +package ui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var menuItems = []struct { + label, desc string +}{ + {"새 실험 시작", "시나리오·네트워크 조건·시스템을 선택하여 비교 실험 수행"}, + {"테스트베드 정보", "비교 대상, 시나리오 정의, 측정 지표, 향후 계획"}, + {"종료", "프로그램 종료 (Ctrl+C로도 가능)"}, +} + +func (a App) updateMenu(m tea.KeyMsg) (App, tea.Cmd) { + switch m.String() { + case "up", "k": + if a.menuIdx > 0 { + a.menuIdx-- + } + case "down", "j": + if a.menuIdx < len(menuItems)-1 { + a.menuIdx++ + } + case "enter", " ": + switch a.menuIdx { + case 0: + a.screen = screenConfig + case 1: + a.screen = screenAbout + case 2: + return a, tea.Quit + } + case "q": + return a, tea.Quit + } + return a, nil +} + +func (a App) viewMenu() string { + lines := []string{} + + intro := []string{ + "본 프로그램은 AIoT 환경에서의 통신 프로토콜 성능을", + "동일한 워크로드 하에 정량 비교하기 위한 테스트베드입니다.", + "", + helpStyle.Render("※ 현재 빌드는 데모 모드입니다 — 측정값은 시뮬레이션이며,"), + helpStyle.Render(" 실제 tc/mininet 연동 및 gRPC/REST 서버는 추후 구현 예정입니다."), + } + lines = append(lines, strings.Join(intro, "\n")) + lines = append(lines, "") + + for i, item := range menuItems { + prefix := " " + labelStyle := inactiveItemStyle + if i == a.menuIdx { + prefix = "▶ " + labelStyle = selectedItemStyle + } + row := prefix + labelStyle.Render(item.label) + "\n " + helpStyle.Render(item.desc) + lines = append(lines, row) + } + + box := boxStyle.Render(strings.Join(lines, "\n")) + + help := helpKeys( + [2]string{"↑/↓", "이동"}, + [2]string{"Enter", "선택"}, + [2]string{"q", "종료"}, + ) + + return lipgloss.JoinVertical(lipgloss.Left, "", box, "", help) +} diff --git a/src/internal/ui/screen_results.go b/src/internal/ui/screen_results.go new file mode 100644 index 0000000..16036af --- /dev/null +++ b/src/internal/ui/screen_results.go @@ -0,0 +1,257 @@ +package ui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (a App) updateResults(m tea.KeyMsg) (App, tea.Cmd) { + switch m.String() { + case "esc": + a.screen = screenMenu + case "r": + // 같은 조건으로 재실행 + rs := startRun(a.config) + a.run = &rs + a.screen = screenRunning + return a, tickCmd() + case "n": + // 새 실험 (설정 화면으로) + a.screen = screenConfig + case "q": + return a, tea.Quit + } + return a, nil +} + +func (a App) viewResults() string { + if a.run == nil { + return errorStyle.Render("결과가 없습니다.") + } + + rows := []string{} + + // 실험 헤더 + header := fmt.Sprintf("📊 결과 — %s", a.config.ScenarioLabel()) + if a.run.Aborted { + header += " " + errorStyle.Render("(중단됨)") + } + rows = append(rows, sectionHeader(header)) + rows = append(rows, helpStyle.Render(fmt.Sprintf( + "지연=%dms · 손실=%.1f%% · 대역폭=%dMbps · 디바이스=%d · 메시지=%dKB×%d/디바이스", + a.config.LinkDelayMs, + a.config.PacketLossPct, + a.config.BandwidthMbps, + a.config.DeviceCount, + a.config.MessageSizeKB(), + a.config.MessageCount(), + ))) + rows = append(rows, "") + + // 비교 테이블 + rows = append(rows, sectionHeader("성능 비교 표")) + rows = append(rows, helpStyle.Render(fmt.Sprintf( + " %-32s %9s %9s %9s %10s %10s %12s", + "시스템", "P50(ms)", "P95(ms)", "P99(ms)", "RPS", "성공률", "총 데이터", + ))) + rows = append(rows, helpStyle.Render(strings.Repeat("─", 100))) + + bestRPS, bestRPSID := 0.0, "" + bestP99, bestP99ID := 1e9, "" + for _, sys := range a.config.Systems { + if !sys.Selected { + continue + } + r := a.run.Results[sys.ID] + if r == nil { + continue + } + if r.ThroughputRPS > bestRPS { + bestRPS = r.ThroughputRPS + bestRPSID = sys.ID + } + if r.P99 > 0 && r.P99 < bestP99 { + bestP99 = r.P99 + bestP99ID = sys.ID + } + } + + for _, sys := range a.config.Systems { + if !sys.Selected { + continue + } + r := a.run.Results[sys.ID] + if r == nil { + continue + } + + successRate := 0.0 + if r.Total > 0 { + successRate = float64(r.Success) / float64(r.Total) * 100 + } + + clr := systemColor(sys.ID) + labelStyled := lipgloss.NewStyle().Foreground(clr).Bold(true).Render(sys.Label) + + marker := "" + if sys.ID == bestRPSID { + marker += "★" + } + if sys.ID == bestP99ID { + marker += "⚡" + } + + // 라벨이 색상 escape 코드로 인해 visible width가 다르므로 자체 padding + labelPad := padVisible(sys.Label, 32) + rows = append(rows, fmt.Sprintf( + " %s %9.1f %9.1f %9.1f %10.0f %9.1f%% %12s %s", + lipgloss.NewStyle().Foreground(clr).Bold(true).Render(labelPad), + r.P50, r.P95, r.P99, + r.ThroughputRPS, + successRate, + formatBytes(r.BytesSent), + marker, + )) + _ = labelStyled + } + rows = append(rows, "") + rows = append(rows, helpStyle.Render(" ★ 최고 처리량 ⚡ 최저 P99 latency")) + rows = append(rows, "") + + // Latency 비교 차트 + rows = append(rows, sectionHeader("Latency 비교 (낮을수록 좋음)")) + maxLat := 0.0 + for _, sys := range a.config.Systems { + if !sys.Selected { + continue + } + r := a.run.Results[sys.ID] + if r == nil { + continue + } + if r.P99 > maxLat { + maxLat = r.P99 + } + } + if maxLat == 0 { + maxLat = 1 + } + + for _, sys := range a.config.Systems { + if !sys.Selected { + continue + } + r := a.run.Results[sys.ID] + if r == nil { + continue + } + clr := systemColor(sys.ID) + rows = append(rows, lipgloss.NewStyle().Foreground(clr).Bold(true).Render(sys.Label)) + rows = append(rows, " P50 "+horizontalBar(r.P50, maxLat, 50, clr)+fmt.Sprintf(" %.1f ms", r.P50)) + rows = append(rows, " P95 "+horizontalBar(r.P95, maxLat, 50, clr)+fmt.Sprintf(" %.1f ms", r.P95)) + rows = append(rows, " P99 "+horizontalBar(r.P99, maxLat, 50, clr)+fmt.Sprintf(" %.1f ms", r.P99)) + } + rows = append(rows, "") + + // 처리량 비교 + rows = append(rows, sectionHeader("처리량 비교 (높을수록 좋음)")) + maxRPS := 0.0 + for _, sys := range a.config.Systems { + if !sys.Selected { + continue + } + r := a.run.Results[sys.ID] + if r == nil { + continue + } + if r.ThroughputRPS > maxRPS { + maxRPS = r.ThroughputRPS + } + } + if maxRPS == 0 { + maxRPS = 1 + } + for _, sys := range a.config.Systems { + if !sys.Selected { + continue + } + r := a.run.Results[sys.ID] + if r == nil { + continue + } + clr := systemColor(sys.ID) + rows = append(rows, fmt.Sprintf( + " %-32s %s %.0f RPS", + lipgloss.NewStyle().Foreground(clr).Bold(true).Render(padVisible(sys.Label, 32)), + horizontalBar(r.ThroughputRPS, maxRPS, 50, clr), + r.ThroughputRPS, + )) + } + rows = append(rows, "") + + // 연결 수립 시간 + rows = append(rows, sectionHeader("연결 수립 시간 (낮을수록 좋음 — QUIC 0/1-RTT 효과)")) + maxConn := 0.0 + for _, sys := range a.config.Systems { + if !sys.Selected { + continue + } + r := a.run.Results[sys.ID] + if r == nil { + continue + } + if r.ConnectionTime > maxConn { + maxConn = r.ConnectionTime + } + } + if maxConn == 0 { + maxConn = 1 + } + for _, sys := range a.config.Systems { + if !sys.Selected { + continue + } + r := a.run.Results[sys.ID] + if r == nil { + continue + } + clr := systemColor(sys.ID) + rows = append(rows, fmt.Sprintf( + " %-32s %s %.1f ms", + lipgloss.NewStyle().Foreground(clr).Bold(true).Render(padVisible(sys.Label, 32)), + horizontalBar(r.ConnectionTime, maxConn, 50, clr), + r.ConnectionTime, + )) + } + + rows = append(rows, "") + rows = append(rows, helpStyle.Render(strings.Repeat("─", 70))) + rows = append(rows, helpStyle.Render( + "💡 본 결과는 데모 시뮬레이션이며 실제 측정값이 아닙니다.", + )) + rows = append(rows, helpStyle.Render( + " 실제 벤치마크는 향후 cmd/server / cmd/benchmark-runner 와 tc 연동 후 수행됩니다.", + )) + + box := boxStyle.Render(strings.Join(rows, "\n")) + + help := helpKeys( + [2]string{"r", "같은 조건 재실행"}, + [2]string{"n", "새 실험"}, + [2]string{"Esc", "메뉴"}, + [2]string{"q", "종료"}, + ) + + return lipgloss.JoinVertical(lipgloss.Left, "", box, "", help) +} + +// padVisible visible 길이 기준으로 우측 공백 패딩 (한글 등 wide character 무시 단순 구현) +func padVisible(s string, width int) string { + if len(s) >= width { + return s + } + return s + strings.Repeat(" ", width-len(s)) +} diff --git a/src/internal/ui/screen_running.go b/src/internal/ui/screen_running.go new file mode 100644 index 0000000..d02de96 --- /dev/null +++ b/src/internal/ui/screen_running.go @@ -0,0 +1,111 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (a App) updateRunning(m tea.KeyMsg) (App, tea.Cmd) { + switch m.String() { + case "esc", "q": + if a.run != nil { + a.run.Aborted = true + a.run.Running = false + a.run.EndTime = time.Now() + } + a.screen = screenResults + } + return a, nil +} + +func (a App) viewRunning() string { + if a.run == nil { + return errorStyle.Render("실행 상태가 없습니다.") + } + + rows := []string{} + + // 실험 조건 요약 + rows = append(rows, sectionHeader("🚀 실험 진행 중")) + rows = append(rows, helpStyle.Render(fmt.Sprintf( + "%s · 디바이스 %d · 지연 %dms · 손실 %.1f%% · 대역폭 %dMbps", + a.config.ScenarioLabel(), + a.config.DeviceCount, + a.config.LinkDelayMs, + a.config.PacketLossPct, + a.config.BandwidthMbps, + ))) + rows = append(rows, "") + + // 진행 상태 + elapsed := time.Since(a.run.StartTime).Truncate(100 * time.Millisecond) + rows = append(rows, fmt.Sprintf( + "경과: %s 목표: %s 메시지/시스템", + valueStyle.Render(elapsed.String()), + valueStyle.Render(fmt.Sprintf("%d", a.run.Target)), + )) + rows = append(rows, "") + + // 시스템별 진행 + 메트릭 + for _, sys := range a.config.Systems { + if !sys.Selected { + continue + } + r := a.run.Results[sys.ID] + if r == nil { + continue + } + + clr := systemColor(sys.ID) + labelStyle := lipgloss.NewStyle().Foreground(clr).Bold(true) + + progress := 0.0 + if a.run.Target > 0 { + progress = float64(r.Total) / float64(a.run.Target) + } + + rows = append(rows, labelStyle.Render(sys.Label)) + rows = append(rows, fmt.Sprintf( + " %s %5.1f%% (%d / %d)", + progressBar(progress, 50, clr), + progress*100, + r.Total, + a.run.Target, + )) + rows = append(rows, fmt.Sprintf( + " P50=%s P95=%s P99=%s RPS=%s 성공=%d 실패=%d 데이터=%s", + valueStyle.Render(formatMs(r.P50)), + valueStyle.Render(formatMs(r.P95)), + valueStyle.Render(formatMs(r.P99)), + valueStyle.Render(fmt.Sprintf("%.0f", r.ThroughputRPS)), + r.Success, + r.Failed, + valueStyle.Render(formatBytes(r.BytesSent)), + )) + rows = append(rows, " "+helpStyle.Render("latency:")+" "+sparkline(r.Latencies, 60, clr)) + rows = append(rows, "") + } + + // 활성 시스템이 없으면 + if len(a.run.Results) == 0 { + rows = append(rows, errorStyle.Render("선택된 시스템이 없습니다.")) + } + + rows = append(rows, helpStyle.Render(strings.Repeat("─", 70))) + rows = append(rows, helpStyle.Render( + "💡 본 화면의 진행도와 메트릭은 데모 시뮬레이션입니다 (tc/실서버 미연동).", + )) + + box := boxStyle.Render(strings.Join(rows, "\n")) + + help := helpKeys( + [2]string{"Esc", "중단 후 결과 보기"}, + [2]string{"Ctrl+C", "프로그램 종료"}, + ) + + return lipgloss.JoinVertical(lipgloss.Left, "", box, "", help) +} diff --git a/src/internal/ui/simulator.go b/src/internal/ui/simulator.go new file mode 100644 index 0000000..752adec --- /dev/null +++ b/src/internal/ui/simulator.go @@ -0,0 +1,283 @@ +package ui + +import ( + "math/rand" + "sort" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// 본 파일은 데모 목적의 mock 시뮬레이터다. +// 실제 측정값이 아니며, 파라미터에 반응하여 그럴듯한 추세만 만들어낸다. +// +// 시뮬레이션 가정: +// - HTTP/3(QUIC)은 패킷 손실 환경에서 HoL Blocking 해소로 처리량/latency 안정성 우위 +// - gRPC(Protobuf)는 REST(JSON) 대비 페이로드 30% 절감 +// - QUIC은 0/1-RTT 핸드셰이크로 연결 수립 비용이 TCP+TLS 대비 낮음 +// - 큰 메시지 시나리오는 대역폭의 영향이 지배적 + +type tickMsg time.Time +type doneMsg struct{} + +const tickInterval = 120 * time.Millisecond + +func tickCmd() tea.Cmd { + return tea.Tick(tickInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// startRun Config에 따라 RunState를 초기화한다. +func startRun(cfg Config) RunState { + rs := RunState{ + Running: true, + StartTime: time.Now(), + Target: cfg.MessageCount() * cfg.DeviceCount, + Results: make(map[string]*Result), + } + for _, s := range cfg.Systems { + if s.Selected { + rs.Results[s.ID] = &Result{SystemID: s.ID} + } + } + return rs +} + +// step 한 tick 동안 각 시스템의 진행도를 갱신한다. +// 반환값: 아직 진행 중이면 true. +func step(rs *RunState, cfg Config) bool { + if !rs.Running { + return false + } + + allDone := true + for _, sys := range cfg.Systems { + if !sys.Selected { + continue + } + r := rs.Results[sys.ID] + if r == nil { + continue + } + if r.Total >= rs.Target { + continue + } + allDone = false + + inc := simSpeed(sys.ID, cfg) + if r.Total+inc > rs.Target { + inc = rs.Target - r.Total + } + r.Total += inc + + // 성공/실패 (시스템별 패킷 손실 영향 차등) + lossFactor := cfg.PacketLossPct + switch sys.ID { + case "rest-h2": + lossFactor *= 1.5 + case "grpc-h2": + lossFactor *= 1.2 + case "grpc-h3": + lossFactor *= 0.5 // HoL Blocking 해소로 재시도 부담 감소 + } + for i := 0; i < inc; i++ { + if rand.Float64()*100 < lossFactor { + r.Failed++ + } else { + r.Success++ + } + } + + // 데이터 전송량 (Protobuf는 JSON 대비 ~30% 절감) + bytesPerMsg := int64(cfg.MessageSizeKB()) * 1024 + if sys.ID == "grpc-h2" || sys.ID == "grpc-h3" { + bytesPerMsg = int64(float64(bytesPerMsg) * 0.7) + } + r.BytesSent += int64(inc) * bytesPerMsg + + // latency 샘플 (최근 500개만 유지) + samples := inc + if samples > 12 { + samples = 12 // tick당 시각화 부담 방지 + } + for i := 0; i < samples; i++ { + r.Latencies = append(r.Latencies, mockLatency(sys.ID, cfg)) + } + if len(r.Latencies) > 500 { + r.Latencies = r.Latencies[len(r.Latencies)-500:] + } + + updatePercentiles(r) + + elapsed := time.Since(rs.StartTime).Seconds() + if elapsed > 0 { + r.ThroughputRPS = float64(r.Success) / elapsed + } + + // 첫 측정 시 연결 수립 시간 기록 + if r.ConnectionTime == 0 { + r.ConnectionTime = mockConnectionTime(sys.ID, cfg) + } + } + + if allDone { + rs.Running = false + rs.EndTime = time.Now() + } + return rs.Running +} + +// simSpeed tick당 시스템 진행 속도 (메시지 개수) +func simSpeed(id string, cfg Config) int { + base := 30 + switch id { + case "rest-h2": + base = 18 + case "grpc-h2": + base = 32 + case "grpc-h3": + base = 42 + } + + // 패킷 손실 영향 (HoL Blocking) + if cfg.PacketLossPct > 0 { + var penalty float64 + switch id { + case "rest-h2": + penalty = clampF(cfg.PacketLossPct/100.0*10, 0, 0.85) + case "grpc-h2": + penalty = clampF(cfg.PacketLossPct/100.0*7, 0, 0.7) + case "grpc-h3": + penalty = clampF(cfg.PacketLossPct/100.0*2.5, 0, 0.4) + } + base = int(float64(base) * (1.0 - penalty)) + } + + // 지연 영향 + if cfg.LinkDelayMs > 50 { + factor := 1.0 - float64(cfg.LinkDelayMs-50)/1000.0 + if factor < 0.2 { + factor = 0.2 + } + base = int(float64(base) * factor) + } + + // 디바이스 수에 비례하여 진행 속도 증가 (병렬성) + parFactor := 1.0 + float64(cfg.DeviceCount-1)*0.04 + if parFactor > 4.0 { + parFactor = 4.0 + } + base = int(float64(base) * parFactor) + + // 큰 메시지 시나리오는 대역폭이 지배적 + if cfg.Scenario == ScenarioLargeFew { + base = base / 4 + if cfg.BandwidthMbps < 50 { + base = base / 2 + } + } + + if base < 1 { + base = 1 + } + return base +} + +// mockLatency 단일 RPC의 latency (ms) 샘플 생성 +func mockLatency(id string, cfg Config) float64 { + rtt := float64(cfg.LinkDelayMs) * 2.0 + base := rtt + + // 시스템별 stack 오버헤드 + switch id { + case "rest-h2": + base += 12 + rand.Float64()*8 + case "grpc-h2": + base += 4 + rand.Float64()*4 + case "grpc-h3": + base += 1.5 + rand.Float64()*2.5 + } + + // 큰 메시지: 대역폭 지배적 + if cfg.Scenario == ScenarioLargeFew { + bwBytesPerSec := float64(cfg.BandwidthMbps) * 125000.0 // Mbps → bytes/s + msgBytes := float64(cfg.MessageSizeKB()) * 1024 + addMs := msgBytes / bwBytesPerSec * 1000 + switch id { + case "rest-h2": + base += addMs * 1.4 // JSON 인코딩 + 텍스트 오버헤드 + case "grpc-h2": + base += addMs * 1.0 + case "grpc-h3": + base += addMs * 0.95 + } + } + + // 패킷 손실 영향 (재전송) + if cfg.PacketLossPct > 0 { + var mul float64 + switch id { + case "rest-h2": + mul = 1.0 + cfg.PacketLossPct*0.5 + rand.Float64()*cfg.PacketLossPct*0.6 + case "grpc-h2": + mul = 1.0 + cfg.PacketLossPct*0.35 + rand.Float64()*cfg.PacketLossPct*0.4 + case "grpc-h3": + mul = 1.0 + cfg.PacketLossPct*0.1 + rand.Float64()*cfg.PacketLossPct*0.15 + } + base *= mul + } + + // 약간의 노이즈 + base += rand.NormFloat64() * (rtt*0.05 + 1.0) + if base < 0.5 { + base = 0.5 + } + return base +} + +// mockConnectionTime 연결 수립 비용 (TCP/QUIC 핸드셰이크) +func mockConnectionTime(id string, cfg Config) float64 { + rtt := float64(cfg.LinkDelayMs) * 2.0 + switch id { + case "rest-h2": + // TCP(1.5 RTT) + TLS(1 RTT) ≈ 2.5 RTT 근사 + return rtt*1.5 + 20 + rand.Float64()*5 + case "grpc-h2": + return rtt*1.5 + 18 + rand.Float64()*5 + case "grpc-h3": + // QUIC 1-RTT (또는 0-RTT 재연결) + return rtt*0.5 + 8 + rand.Float64()*3 + } + return rtt +} + +func updatePercentiles(r *Result) { + if len(r.Latencies) == 0 { + return + } + sorted := make([]float64, len(r.Latencies)) + copy(sorted, r.Latencies) + sort.Float64s(sorted) + n := len(sorted) + at := func(pct int) float64 { + idx := n * pct / 100 + if idx >= n { + idx = n - 1 + } + return sorted[idx] + } + r.P50 = at(50) + r.P95 = at(95) + r.P99 = at(99) +} + +func clampF(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/src/internal/ui/styles.go b/src/internal/ui/styles.go new file mode 100644 index 0000000..427cfe1 --- /dev/null +++ b/src/internal/ui/styles.go @@ -0,0 +1,71 @@ +package ui + +import "github.com/charmbracelet/lipgloss" + +var ( + colorPrimary = lipgloss.Color("#A78BFA") // 보라 + colorAccent = lipgloss.Color("#34D399") // 초록 + colorWarning = lipgloss.Color("#FBBF24") // 노랑 + colorDanger = lipgloss.Color("#F87171") // 빨강 + colorMuted = lipgloss.Color("#94A3B8") // 회색 + colorFg = lipgloss.Color("#E2E8F0") // 텍스트 + + // 시스템별 식별 색상 + colorRestH2 = lipgloss.Color("#FB7185") // rose + colorGrpcH2 = lipgloss.Color("#22D3EE") // cyan + colorGrpcH3 = lipgloss.Color("#FACC15") // amber + + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorPrimary) + + subtitleStyle = lipgloss.NewStyle(). + Foreground(colorMuted). + Italic(true) + + boxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorPrimary). + Padding(1, 2) + + selectedItemStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorPrimary) + + inactiveItemStyle = lipgloss.NewStyle(). + Foreground(colorFg) + + helpStyle = lipgloss.NewStyle(). + Foreground(colorMuted) + + keyStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorWarning) + + valueStyle = lipgloss.NewStyle(). + Foreground(colorAccent) + + errorStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorDanger) + + sectionStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorPrimary) +) + +func systemColor(id string) lipgloss.Color { + switch id { + case "rest-h2": + return colorRestH2 + case "grpc-h2": + return colorGrpcH2 + case "grpc-h3": + return colorGrpcH3 + } + return colorFg +} + +func sectionHeader(s string) string { + return sectionStyle.Render(s) +} diff --git a/src/internal/ui/types.go b/src/internal/ui/types.go new file mode 100644 index 0000000..b783855 --- /dev/null +++ b/src/internal/ui/types.go @@ -0,0 +1,103 @@ +package ui + +import "time" + +// Scenario 워크로드 시나리오 +type Scenario string + +const ( + ScenarioSmallMany Scenario = "small-many" // 1KB × 10000회 (AI Agent RPC) + ScenarioLargeFew Scenario = "large-few" // 1MB × 50회 (IoT 이미지 전송) +) + +// System 비교 대상 시스템 +type System struct { + ID string + Label string + Selected bool +} + +// Config 사용자가 조정하는 실험 파라미터 +type Config struct { + Scenario Scenario + LinkDelayMs int // 편도 지연 (RTT/2), tc netem delay + PacketLossPct float64 // 패킷 손실율, tc netem loss + BandwidthMbps int // 대역폭, tc tbf rate + DeviceCount int // 동시 디바이스 수 + Systems []System +} + +// DefaultConfig 합리적 기본값 +func DefaultConfig() Config { + return Config{ + Scenario: ScenarioSmallMany, + LinkDelayMs: 50, + PacketLossPct: 0.0, + BandwidthMbps: 100, + DeviceCount: 10, + Systems: []System{ + {ID: "rest-h2", Label: "REST + HTTP/2 (TCP) + JSON", Selected: true}, + {ID: "grpc-h2", Label: "gRPC + HTTP/2 (TCP) + Protobuf", Selected: true}, + {ID: "grpc-h3", Label: "gRPC + HTTP/3 (QUIC) + Protobuf", Selected: true}, + }, + } +} + +// MessageSizeKB 시나리오별 메시지 크기 +func (c Config) MessageSizeKB() int { + if c.Scenario == ScenarioSmallMany { + return 1 + } + return 1024 +} + +// MessageCount 시나리오별 디바이스당 메시지 개수 +func (c Config) MessageCount() int { + if c.Scenario == ScenarioSmallMany { + return 10000 + } + return 50 +} + +// ScenarioLabel 표시용 시나리오 이름 +func (c Config) ScenarioLabel() string { + if c.Scenario == ScenarioSmallMany { + return "Small-Many · 1KB × 10000회 (AI Agent RPC)" + } + return "Large-Few · 1MB × 50회 (IoT 이미지 전송)" +} + +// HasSelectedSystem 최소 한 개 시스템 선택 여부 +func (c Config) HasSelectedSystem() bool { + for _, s := range c.Systems { + if s.Selected { + return true + } + } + return false +} + +// Result 시스템별 누적 측정값 +type Result struct { + SystemID string + Total int // 시도된 메시지 수 + Success int + Failed int + P50 float64 // ms + P95 float64 + P99 float64 + ThroughputRPS float64 + BytesSent int64 + ConnectionTime float64 // ms + Latencies []float64 +} + +// RunState 진행 중인 실험 상태 +type RunState struct { + Running bool + Aborted bool + StartTime time.Time + EndTime time.Time + Target int // 시스템당 목표 메시지 수 + Results map[string]*Result +}