Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
144 changes: 144 additions & 0 deletions cmd/inspect/event.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading