diff --git a/CLAUDE.md b/CLAUDE.md index cbd4116..bc7946f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ docker build -f ci/Dockerfile -t golang-build:latest . - **`cmd/admin/`** — TUI deployment inspector. Demonstrates the dynamic Picker pattern (Picker fed by an AsyncTask result). Has a `Backend` interface with both a live (pgxpool + valkey) and a fake implementation. - **`cmd/devup/`** — TUI wrapper around `docker compose`. Uses `internal/proc.Runner` for subprocess execution (`proc.OS` shells out, `proc.Fake` for tests). - **`cmd/migrate/`** — TUI for schema migrations. Has a `Migrator` interface with `LiveMigrator` (golang-migrate against the real DB) and `FakeMigrator` (in-memory). Composes existing phases without adding new framework muscle. +- **`cmd/inspect/`** — TUI demo wiring `internal/eventbus` into `LiveView`. The `Inspector` subscribes to an `eventbus.Async[Event]`, accumulates per-level/-kind counters and a ring buffer of recent events, exposes itself as a `tui.LiveSampler`. Synthetic producers (steady/bursty/noisy) drive the bus. - **`internal/handlers/`** — Route handlers. Handlers return `http.HandlerFunc` closures. - **`internal/middleware/`** — HTTP middleware (logging, panic recovery, content-type detection). Each middleware is a `func(http.Handler) http.Handler` applied via `router.Use()`. - **`internal/tui/`** — Reusable TUI scaffolding built on bubbletea/lipgloss. Exports a `Phase` interface and reusable phases (`Picker`, `Confirm`, `TextInput`, `AsyncTask`, `LiveView`, `Done`); callers assemble them into a root `tea.Model`. See `cmd/onboard/model.go`, `cmd/loadgen/model.go`, and `cmd/scaffold/model.go` for examples. diff --git a/Makefile b/Makefile index fb9bf93..6be1db5 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,9 @@ build-devup: build-migrate-tui: go build -ldflags "$(LDFLAGS)" -o migrate-tui ./cmd/migrate/ +build-inspect: + go build -ldflags "$(LDFLAGS)" -o inspect ./cmd/inspect/ + test: go test ./... -count=1 @@ -58,6 +61,6 @@ validate-workflows: bash scripts/validate-workflows.sh clean: - rm -f $(BIN) onboard loadgen scaffold admin devup migrate-tui junit-report.xml gosec-report.xml + rm -f $(BIN) onboard loadgen scaffold admin devup migrate-tui inspect junit-report.xml gosec-report.xml all: build vet test diff --git a/README.md b/README.md index 8b4198a..02f7da0 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Experimental Go build tooling, references, and CI pipeline. - `cmd/admin` — TUI for inspecting a deployment: list users, run healthchecks. Falls back to a fake backend when DATABASE_URL is unset. - `cmd/devup` — TUI wrapper around `docker compose`: pick service, pick action, view output. Demonstrates subprocess management via `internal/proc`. - `cmd/migrate` — TUI for inspecting and applying schema migrations. Composes `AsyncTask`/`Picker`/`Confirm`/`Done` against a `Migrator` interface; falls back to a `FakeMigrator` with `--fake` for demos / CI without a database. +- `cmd/inspect` — TUI demo that wires `internal/eventbus` into the `LiveView` phase: pick a scenario, watch synthetic events flow with live counters, level breakdown, and a rolling tail of the most recent events. ## TUI scaffolding diff --git a/cmd/inspect/event.go b/cmd/inspect/event.go new file mode 100644 index 0000000..7fc7e20 --- /dev/null +++ b/cmd/inspect/event.go @@ -0,0 +1,144 @@ +package main + +import ( + "sync" + "time" + + "github.com/kaeawc/golang-build/internal/eventbus" +) + +// Event is a single observed item. Kind/Level/Message keep the data +// model close to a structured log line; the TUI doesn't care about the +// shape, only how to render it. +type Event struct { + At time.Time + Kind string + Level string // "info", "warn", "error" + Message string +} + +// Snapshot is what the LiveView samples each tick. +type Snapshot struct { + Total int64 + ByLevel map[string]int64 + ByKind map[string]int64 + Recent []Event + Subscribers int + Dropped int64 + Elapsed time.Duration + Done bool +} + +// Inspector wraps an eventbus.Async with a ring buffer of recent events +// and per-level/-kind counters. Tests use this directly without spinning +// up a TUI. +type Inspector struct { + bus *eventbus.Async[Event] + off eventbus.Unsubscribe + cap int + startedAt time.Time + doneAt time.Time + done bool + + mu sync.Mutex + total int64 + byLevel map[string]int64 + byKind map[string]int64 + recent []Event +} + +// NewInspector returns an Inspector wired to bus, retaining the most +// recent ringCap events for display. +func NewInspector(bus *eventbus.Async[Event], ringCap int) *Inspector { + if ringCap < 1 { + ringCap = 10 + } + i := &Inspector{ + bus: bus, + cap: ringCap, + byLevel: map[string]int64{}, + byKind: map[string]int64{}, + recent: make([]Event, 0, ringCap), + } + i.off = bus.Subscribe(i.handle) + return i +} + +// Start records the moment when the producer begins (for rate calc). +func (i *Inspector) Start() { + i.mu.Lock() + defer i.mu.Unlock() + i.startedAt = time.Now() +} + +// Stop unsubscribes from the bus and freezes Elapsed. +func (i *Inspector) Stop() { + i.mu.Lock() + if !i.done { + i.done = true + i.doneAt = time.Now() + } + i.mu.Unlock() + if i.off != nil { + i.off() + } +} + +func (i *Inspector) handle(e Event) { + i.mu.Lock() + defer i.mu.Unlock() + i.total++ + i.byLevel[e.Level]++ + i.byKind[e.Kind]++ + i.recent = append(i.recent, e) + if len(i.recent) > i.cap { + // Drop oldest. Cheaper than a real ring for cap ~ 10-50. + i.recent = i.recent[len(i.recent)-i.cap:] + } +} + +// Sample is the LiveSampler-compatible accessor. The second return is +// always false; the model ends the inspection by an explicit user +// action (quit) rather than the sampler reporting done. +func (i *Inspector) Sample() (any, bool) { + return i.snapshot(), i.isDone() +} + +func (i *Inspector) snapshot() Snapshot { + i.mu.Lock() + defer i.mu.Unlock() + byLevel := make(map[string]int64, len(i.byLevel)) + for k, v := range i.byLevel { + byLevel[k] = v + } + byKind := make(map[string]int64, len(i.byKind)) + for k, v := range i.byKind { + byKind[k] = v + } + recent := make([]Event, len(i.recent)) + copy(recent, i.recent) + end := time.Now() + if i.done { + end = i.doneAt + } + elapsed := time.Duration(0) + if !i.startedAt.IsZero() { + elapsed = end.Sub(i.startedAt) + } + return Snapshot{ + Total: i.total, + ByLevel: byLevel, + ByKind: byKind, + Recent: recent, + Subscribers: i.bus.Subscribers(), + Dropped: i.bus.Stats().Dropped, + Elapsed: elapsed, + Done: i.done, + } +} + +func (i *Inspector) isDone() bool { + i.mu.Lock() + defer i.mu.Unlock() + return i.done +} diff --git a/cmd/inspect/inspect_test.go b/cmd/inspect/inspect_test.go new file mode 100644 index 0000000..9b64a56 --- /dev/null +++ b/cmd/inspect/inspect_test.go @@ -0,0 +1,285 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/kaeawc/golang-build/internal/eventbus" + "github.com/kaeawc/golang-build/internal/tui" +) + +func TestInspectorAccumulatesAndRingBuffersEvents(t *testing.T) { + bus := eventbus.NewAsync[Event](eventbus.AsyncConfig{BufferSize: 64, DropWhenFull: true}) + defer bus.Close() + insp := NewInspector(bus, 5) + defer insp.Stop() + insp.Start() + + for i := 0; i < 10; i++ { + level := "info" + if i%3 == 0 { + level = "warn" + } + bus.Publish(Event{ + At: time.Now(), + Kind: "request", + Level: level, + Message: "n", + }) + } + + // Async delivery: poll until total reaches 10 or fail. + deadline := time.Now().Add(time.Second) + var snap Snapshot + for time.Now().Before(deadline) { + s, _ := insp.Sample() + snap = s.(Snapshot) + if snap.Total == 10 { + break + } + time.Sleep(10 * time.Millisecond) + } + if snap.Total != 10 { + t.Fatalf("expected 10 events, got %d", snap.Total) + } + if len(snap.Recent) != 5 { + t.Errorf("ring should retain last 5, got %d", len(snap.Recent)) + } + if snap.ByLevel["info"]+snap.ByLevel["warn"] != 10 { + t.Errorf("level totals don't add up: %v", snap.ByLevel) + } + if snap.ByKind["request"] != 10 { + t.Errorf("kind total wrong: %v", snap.ByKind) + } +} + +func TestInspectorStopFreezesElapsed(t *testing.T) { + bus := eventbus.NewAsync[Event](eventbus.AsyncConfig{BufferSize: 4}) + defer bus.Close() + insp := NewInspector(bus, 3) + insp.Start() + + time.Sleep(20 * time.Millisecond) + insp.Stop() + + a, done := insp.Sample() + if !done { + t.Errorf("Sample should report done=true after Stop") + } + first := a.(Snapshot).Elapsed + + time.Sleep(20 * time.Millisecond) + b, _ := insp.Sample() + second := b.(Snapshot).Elapsed + if second != first { + t.Errorf("Elapsed should be frozen after Stop, got %v then %v", first, second) + } +} + +func TestInspectorReturnsCopiedMaps(t *testing.T) { + bus := eventbus.NewAsync[Event](eventbus.AsyncConfig{BufferSize: 4}) + defer bus.Close() + insp := NewInspector(bus, 3) + defer insp.Stop() + insp.Start() + bus.Publish(Event{Kind: "x", Level: "info"}) + // Wait for delivery. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + s, _ := insp.Sample() + if s.(Snapshot).Total > 0 { + break + } + time.Sleep(5 * time.Millisecond) + } + a, _ := insp.Sample() + a.(Snapshot).ByLevel["info"] = 999 + b, _ := insp.Sample() + if b.(Snapshot).ByLevel["info"] == 999 { + t.Errorf("Snapshot leaked map; mutation visible in next snapshot") + } +} + +func TestScenarioByNameLookup(t *testing.T) { + for _, want := range []string{"steady", "bursty", "noisy"} { + if scenarioByName(want) == nil { + t.Errorf("scenarioByName(%q) returned nil", want) + } + } + if scenarioByName("nope") != nil { + t.Errorf("scenarioByName(nope) should be nil") + } +} + +func TestRunSteadyPublishesUntilCanceled(t *testing.T) { + bus := eventbus.NewAsync[Event](eventbus.AsyncConfig{BufferSize: 256, DropWhenFull: true}) + defer bus.Close() + insp := NewInspector(bus, 50) + defer insp.Stop() + insp.Start() + + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancel() + runSteady(ctx, bus) + + deadline := time.Now().Add(500 * time.Millisecond) + var total int64 + for time.Now().Before(deadline) { + s, _ := insp.Sample() + total = s.(Snapshot).Total + if total >= 2 { + break + } + time.Sleep(10 * time.Millisecond) + } + if total < 2 { + t.Errorf("expected at least 2 steady events in 250ms, got %d", total) + } +} + +func TestRunNoisyEmitsMixedLevels(t *testing.T) { + bus := eventbus.NewAsync[Event](eventbus.AsyncConfig{BufferSize: 256, DropWhenFull: true}) + defer bus.Close() + insp := NewInspector(bus, 100) + defer insp.Stop() + insp.Start() + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + runNoisy(ctx, bus) + + // Drain. + deadline := time.Now().Add(time.Second) + var snap Snapshot + for time.Now().Before(deadline) { + s, _ := insp.Sample() + snap = s.(Snapshot) + if snap.Total >= 5 { + break + } + time.Sleep(10 * time.Millisecond) + } + if len(snap.ByLevel) < 2 { + t.Errorf("expected mixed levels, got %v", snap.ByLevel) + } +} + +// ---------- model flow ----------------------------------------------------- + +func TestModelFlowEndToEnd(t *testing.T) { + m := newModel() + if cmd := m.Init(); cmd != nil { + _ = cmd + } + + // Pick the first scenario (steady). + m = step(t, m, tui.PickerDoneMsg{Tag: tagPickScenario, Index: 0}) + if m.scenario.Name != "steady" { + t.Errorf("scenario = %s, want steady", m.scenario.Name) + } + if _, ok := m.phase.(tui.LiveView); !ok { + t.Fatalf("expected LiveView phase, got %T", m.phase) + } + + // Stop the goroutine immediately so the test doesn't hang. + m.cancel() + + // Simulate the dashboard finishing (e.g. via quit). + final := Snapshot{Total: 5, Done: true} + m = step(t, m, tui.LiveDoneMsg{Tag: tagDashboard, Final: final}) + if _, ok := m.phase.(tui.Done); !ok { + t.Errorf("expected Done phase, got %T", m.phase) + } + if m.final.Total != 5 { + t.Errorf("final not captured: %+v", m.final) + } +} + +func TestModelGlobalQuitShutsDownProducer(t *testing.T) { + m := newModel() + m = step(t, m, tui.PickerDoneMsg{Tag: tagPickScenario, Index: 1}) // bursty + + bus := m.bus + if bus == nil { + t.Fatal("expected bus to be set") + } + + // Send 'q'. Must shut down the producer + bus. + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + if cmd == nil { + t.Fatalf("expected quit cmd") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Errorf("expected QuitMsg") + } + // After quit, m.cancel should have been called and m.bus cleared. + // We can't easily assert from outside since shutdown mutates the + // receiver's struct copy, but we can verify cancel doesn't panic + // when called again. +} + +// ---------- headless ------------------------------------------------------- + +func TestRunHeadlessHappyPath(t *testing.T) { + var buf bytes.Buffer + if code := runHeadless(&buf, "steady", 200*time.Millisecond); code != 0 { + t.Fatalf("exit %d: %s", code, buf.String()) + } + out := buf.String() + if !strings.Contains(out, "events:") { + t.Errorf("missing summary in output: %s", out) + } + if !strings.Contains(out, "stopped") { + t.Errorf("missing 'stopped' in output: %s", out) + } +} + +func TestRunHeadlessRejectsBadScenario(t *testing.T) { + if code := runHeadless(&bytes.Buffer{}, "nope", time.Second); code == 0 { + t.Errorf("expected non-zero for bad scenario") + } +} + +func TestRunHeadlessRejectsZeroDuration(t *testing.T) { + if code := runHeadless(&bytes.Buffer{}, "steady", 0); code == 0 { + t.Errorf("expected non-zero for zero duration") + } +} + +func TestRunHeadlessRequiresScenario(t *testing.T) { + if code := runHeadless(&bytes.Buffer{}, "", time.Second); code == 0 { + t.Errorf("expected non-zero when scenario unset") + } +} + +func TestRenderSnapshotMentionsKeyMetrics(t *testing.T) { + s := Snapshot{ + Total: 42, + Subscribers: 1, + Dropped: 3, + Elapsed: 2 * time.Second, + ByLevel: map[string]int64{"info": 30, "warn": 10, "error": 2}, + ByKind: map[string]int64{"request": 42}, + Recent: []Event{ + {At: time.Now(), Kind: "request", Level: "info", Message: "hello"}, + {At: time.Now(), Kind: "request", Level: "error", Message: "boom"}, + }, + } + out := renderSnapshot(s) + for _, want := range []string{"42", "info", "warn", "error", "request", "hello", "boom"} { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Errorf("renderSnapshot missing %q in output", want) + } + } +} + +func step(t *testing.T, m model, msg tea.Msg) model { + t.Helper() + next, _ := m.Update(msg) + return next.(model) +} diff --git a/cmd/inspect/main.go b/cmd/inspect/main.go new file mode 100644 index 0000000..44c3cb0 --- /dev/null +++ b/cmd/inspect/main.go @@ -0,0 +1,91 @@ +// Command inspect is a TUI demonstration of internal/eventbus piped +// into the LiveView phase. A synthetic producer publishes events; the +// dashboard renders live counters, per-level/-kind breakdowns, and a +// rolling tail of the most recent events. +// +// Interactive (default): +// +// inspect +// +// Headless: +// +// inspect --scenario steady --duration 2s --yes +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "time" + + "github.com/kaeawc/golang-build/internal/eventbus" + "github.com/kaeawc/golang-build/internal/tui" +) + +func main() { os.Exit(run(os.Args[1:])) } + +func run(args []string) int { + fs := flag.NewFlagSet("inspect", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + scenario := fs.String("scenario", "", "Scenario name (steady|bursty|noisy)") + dur := fs.Duration("duration", 2*time.Second, "Run duration (headless only)") + yes := fs.Bool("yes", false, "Skip the TUI") + fs.BoolVar(yes, "y", false, "Alias for --yes") + if err := fs.Parse(args); err != nil { + return 2 + } + + if *yes { + return runHeadless(os.Stdout, *scenario, *dur) + } + return runInteractive() +} + +func runInteractive() int { + final, err := tui.Run(newModel()) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + if fm, ok := final.(model); ok && fm.err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", fm.err) + return 1 + } + return 0 +} + +func runHeadless(out io.Writer, name string, dur time.Duration) int { + if name == "" { + fmt.Fprintln(os.Stderr, "error: --yes requires --scenario") + return 2 + } + scenario := scenarioByName(name) + if scenario == nil { + fmt.Fprintf(os.Stderr, "error: unknown scenario %q (steady|bursty|noisy)\n", name) + return 2 + } + if dur <= 0 { + fmt.Fprintln(os.Stderr, "error: duration must be positive") + return 2 + } + + bus := eventbus.NewAsync[Event](eventbus.AsyncConfig{ + BufferSize: 64, + DropWhenFull: true, + }) + insp := NewInspector(bus, 10) + insp.Start() + + ctx, cancel := context.WithTimeout(context.Background(), dur) + defer cancel() + scenario.Run(ctx, bus) + + insp.Stop() + bus.Close() + + snap, _ := insp.Sample() + fmt.Fprintln(out, renderFinal(snap.(Snapshot))) + return 0 +} diff --git a/cmd/inspect/model.go b/cmd/inspect/model.go new file mode 100644 index 0000000..0a85f57 --- /dev/null +++ b/cmd/inspect/model.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/kaeawc/golang-build/internal/eventbus" + "github.com/kaeawc/golang-build/internal/tui" +) + +const ( + tagPickScenario = "scenario" + tagDashboard = "dashboard" +) + +type model struct { + scenario Scenario + bus *eventbus.Async[Event] + inspector *Inspector + cancel context.CancelFunc + final Snapshot + phase tui.Phase + err error +} + +func newModel() model { + m := model{} + m.phase = m.scenarioPicker() + return m +} + +func (m model) Init() tea.Cmd { return tui.PhaseInit(m.phase) } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if cmd := tui.HandleGlobal(msg); cmd != nil { + // Stop the producer + inspector before quitting so the goroutines + // don't outlive the program. + m.shutdown() + return m, cmd + } + switch msg := msg.(type) { + case tui.PickerDoneMsg: + return m.onPicker(msg) + case tui.LiveDoneMsg: + return m.onDashboardDone(msg) + } + next, cmd := m.phase.Update(msg) + m.phase = next + return m, cmd +} + +func (m model) View() string { + if m.err != nil { + return tui.ErrorStyle.Render("error: ") + m.err.Error() + "\n" + } + return m.phase.View() +} + +func (m *model) shutdown() { + if m.cancel != nil { + m.cancel() + m.cancel = nil + } + if m.inspector != nil { + m.inspector.Stop() + } + if m.bus != nil { + m.bus.Close() + m.bus = nil + } +} + +// ---------- transitions ---------------------------------------------------- + +func (m model) onPicker(msg tui.PickerDoneMsg) (tea.Model, tea.Cmd) { + if msg.Tag != tagPickScenario { + return m, nil + } + m.scenario = Scenarios[msg.Index] + m.bus = eventbus.NewAsync[Event](eventbus.AsyncConfig{ + BufferSize: 64, + DropWhenFull: true, + }) + m.inspector = NewInspector(m.bus, 10) + m.inspector.Start() + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + go m.scenario.Run(ctx, m.bus) + + title := fmt.Sprintf("inspect — %s (%s)", m.scenario.Name, m.scenario.Description) + next := tui.NewLiveView(tagDashboard, title, m.inspector, + 200*time.Millisecond, func(s any) string { + return renderSnapshot(s.(Snapshot)) + }) + m.phase = next + return m, tui.PhaseInit(next) +} + +func (m model) onDashboardDone(msg tui.LiveDoneMsg) (tea.Model, tea.Cmd) { + m.shutdown() + if snap, ok := msg.Final.(Snapshot); ok { + m.final = snap + } + m.phase = tui.NewDone("inspect — done", renderFinal(m.final)) + return m, nil +} + +// ---------- phase factories ------------------------------------------------ + +func (m model) scenarioPicker() tui.Phase { + headers := []string{"scenario", "description"} + items := make([]tui.PickerItem, len(Scenarios)) + for i, s := range Scenarios { + items[i] = tui.PickerItem{Label: s.Name, Columns: []string{s.Description}} + } + return tui.NewPicker(tagPickScenario, "inspect — pick a scenario", headers, items) +} diff --git a/cmd/inspect/producer.go b/cmd/inspect/producer.go new file mode 100644 index 0000000..dd64dea --- /dev/null +++ b/cmd/inspect/producer.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "fmt" + "math/rand/v2" + "time" + + "github.com/kaeawc/golang-build/internal/eventbus" +) + +// Scenario describes a synthetic event source. Different scenarios are +// useful for exercising different bus behaviors: steady throughput, +// bursty bursts that may cause drops, error-heavy mixes for stat +// rendering. +type Scenario struct { + Name string + Description string + // Run publishes events to bus until ctx is cancelled. Implementations + // must respect cancellation promptly. + Run func(ctx context.Context, bus *eventbus.Async[Event]) +} + +// Scenarios is the canonical ordered list shown in the picker. +var Scenarios = []Scenario{ + { + Name: "steady", + Description: "10 events/sec, info-level only", + Run: runSteady, + }, + { + Name: "bursty", + Description: "bursts of 50 events every 2s", + Run: runBursty, + }, + { + Name: "noisy", + Description: "20/sec mixed info/warn/error", + Run: runNoisy, + }, +} + +// scenarioByName returns the scenario with the given name, or nil if +// not found. +func scenarioByName(name string) *Scenario { + for i := range Scenarios { + if Scenarios[i].Name == name { + return &Scenarios[i] + } + } + return nil +} + +func runSteady(ctx context.Context, bus *eventbus.Async[Event]) { + t := time.NewTicker(100 * time.Millisecond) + defer t.Stop() + n := 0 + for { + select { + case <-ctx.Done(): + return + case <-t.C: + n++ + bus.Publish(Event{ + At: time.Now(), + Kind: "request", + Level: "info", + Message: fmt.Sprintf("tick %d", n), + }) + } + } +} + +func runBursty(ctx context.Context, bus *eventbus.Async[Event]) { + t := time.NewTicker(2 * time.Second) + defer t.Stop() + burst := 0 + emit := func() { + burst++ + for j := 0; j < 50; j++ { + select { + case <-ctx.Done(): + return + default: + } + bus.Publish(Event{ + At: time.Now(), + Kind: "batch", + Level: "info", + Message: fmt.Sprintf("burst %d item %d", burst, j), + }) + } + } + emit() // initial burst so the user sees something quickly + for { + select { + case <-ctx.Done(): + return + case <-t.C: + emit() + } + } +} + +func runNoisy(ctx context.Context, bus *eventbus.Async[Event]) { + // Deterministic enough for snapshot tests; uses math/rand/v2 (no + // crypto requirements here). + // #nosec G404 -- synthetic event stream for demo, not security-sensitive + r := rand.New(rand.NewPCG(1, 2)) + t := time.NewTicker(50 * time.Millisecond) + defer t.Stop() + n := 0 + kinds := []string{"request", "db", "cache", "auth"} + levels := []string{"info", "info", "info", "info", "warn", "warn", "error"} + for { + select { + case <-ctx.Done(): + return + case <-t.C: + n++ + bus.Publish(Event{ + At: time.Now(), + Kind: kinds[r.IntN(len(kinds))], + Level: levels[r.IntN(len(levels))], + Message: fmt.Sprintf("noisy %d", n), + }) + } + } +} diff --git a/cmd/inspect/render.go b/cmd/inspect/render.go new file mode 100644 index 0000000..66cf6fb --- /dev/null +++ b/cmd/inspect/render.go @@ -0,0 +1,117 @@ +package main + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/kaeawc/golang-build/internal/tui" +) + +// renderSnapshot is the LiveView render fn passed to the dashboard. +func renderSnapshot(s Snapshot) string { + var b strings.Builder + b.WriteString(renderHeader(s)) + b.WriteString(renderLevelTable(s.ByLevel)) + b.WriteString(renderKindTable(s.ByKind)) + b.WriteString(renderRecent(s.Recent)) + if !s.Done { + b.WriteString("\n") + b.WriteString(tui.DimStyle.Render("watching... q to stop")) + } + return b.String() +} + +func renderHeader(s Snapshot) string { + rate := 0.0 + if s.Elapsed.Seconds() > 0 { + rate = float64(s.Total) / s.Elapsed.Seconds() + } + return fmt.Sprintf( + "%s %d %s %.1f/s %s %s\n%s %d %s %d\n\n", + tui.DimStyle.Render("events:"), s.Total, + tui.DimStyle.Render("rate:"), rate, + tui.DimStyle.Render("elapsed:"), fmtDuration(s.Elapsed), + tui.DimStyle.Render("subs:"), s.Subscribers, + tui.DimStyle.Render("dropped:"), s.Dropped, + ) +} + +func renderLevelTable(byLevel map[string]int64) string { + rows := [][]string{} + for _, lv := range []string{"info", "warn", "error"} { + if c := byLevel[lv]; c > 0 { + rows = append(rows, []string{lv, fmt.Sprintf("%d", c)}) + } + } + if len(rows) == 0 { + return "" + } + return tui.RenderTable([]string{"level", "count"}, rows, -1) + "\n" +} + +func renderKindTable(byKind map[string]int64) string { + if len(byKind) == 0 { + return "" + } + kinds := make([]string, 0, len(byKind)) + for k := range byKind { + kinds = append(kinds, k) + } + sort.Strings(kinds) + rows := make([][]string, 0, len(kinds)) + for _, k := range kinds { + rows = append(rows, []string{k, fmt.Sprintf("%d", byKind[k])}) + } + return tui.RenderTable([]string{"kind", "count"}, rows, -1) + "\n" +} + +func renderRecent(events []Event) string { + if len(events) == 0 { + return "" + } + var b strings.Builder + b.WriteString(tui.DimStyle.Render("recent:") + "\n") + for _, e := range events { + b.WriteString(renderEventLine(e) + "\n") + } + return b.String() +} + +// renderFinal is the body of the Done phase. +func renderFinal(s Snapshot) string { + return renderSnapshot(s) + "\n" + tui.AccentStyle.Render("✓ stopped") +} + +func renderEventLine(e Event) string { + stamp := e.At.Format("15:04:05.000") + level := levelBadge(e.Level) + return fmt.Sprintf(" %s %s %s %s", + tui.DimStyle.Render(stamp), + level, + tui.DimStyle.Render("["+e.Kind+"]"), + e.Message, + ) +} + +func levelBadge(level string) string { + switch level { + case "error": + return tui.ErrorStyle.Render("ERR ") + case "warn": + return tui.WarningStyle.Render("WARN") + default: + return tui.AccentStyle.Render("INFO") + } +} + +func fmtDuration(d time.Duration) string { + if d <= 0 { + return "—" + } + if d < time.Second { + return fmt.Sprintf("%.0fms", float64(d.Nanoseconds())/1e6) + } + return fmt.Sprintf("%.1fs", d.Seconds()) +}