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:
@@ -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
@@ -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
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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, " "))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 + "]")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user