3db48c3bae
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>
258 lines
6.1 KiB
Go
258 lines
6.1 KiB
Go
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))
|
||
}
|