feat: 성능 비교 테스트베드 데모 UI 구현 (src/, Bubble Tea + Lipgloss)

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 01:32:15 +00:00
parent d2c63e7c19
commit 3db48c3bae
13 changed files with 1573 additions and 0 deletions
+18
View File
@@ -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)
}
}
+26
View File
@@ -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
)
+37
View File
@@ -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=
+138
View File
@@ -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)
}
+130
View File
@@ -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, " "))
}
+85
View File
@@ -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)
}
+238
View File
@@ -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 + "]")
}
+76
View File
@@ -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)
}
+257
View File
@@ -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))
}
+111
View File
@@ -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)
}
+283
View File
@@ -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
}
+71
View File
@@ -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)
}
+103
View File
@@ -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
}