Skip to content
Open
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
138 changes: 138 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/tedilabs/ota/internal/tui/logs"
"github.com/tedilabs/ota/internal/tui/overlay"
"github.com/tedilabs/ota/internal/tui/policies"
"github.com/tedilabs/ota/internal/tui/ratelimitgauge"
"github.com/tedilabs/ota/internal/tui/rules"
"github.com/tedilabs/ota/internal/tui/admins"
"github.com/tedilabs/ota/internal/tui/apitokens"
Expand Down Expand Up @@ -214,6 +215,11 @@ const (
// "are you sure?" guardrail stays consistent with `:deactivate`,
// `:delete`, etc.
OverlayStatusPicker
// OverlayRateLimit — MW-11. `:ratelimit` opens a full-screen
// gauge + 60-second sparkline modal so operators can watch
// per-category rate-limit consumption while the app makes calls.
// A tea.Tick refreshes the model every second while it's up.
OverlayRateLimit
)

// Actioner is implemented by screens that publish a list of
Expand Down Expand Up @@ -442,6 +448,16 @@ type Model struct {
// nil when m.deps.APIRecorder is nil (recorder unavailable).
apiRecorderModel *overlay.APIRecorderModel

// rateLimitGauge owns the `:ratelimit` gauge + 60s sparkline
// history per category (MW-11). Retained across overlay open/
// close cycles so the sparkline doesn't reset every time the
// operator peeks. Fed by rateLimitTickMsg while OverlayRateLimit
// is active; the tick self-schedules until the overlay closes.
rateLimitGauge ratelimitgauge.Model
// rateLimitGaugeTickGen invalidates in-flight ticks when the
// overlay closes so a stale tea.Tick doesn't reopen the modal.
rateLimitGaugeTickGen int

// principalLogin is the authenticated Okta user (issue #124). Empty
// until the /api/v1/users/me probe completes; once populated it
// renders next to the profile label in the chrome ContextBar.
Expand Down Expand Up @@ -528,6 +544,7 @@ func New(deps Deps) Model {
overlay: OverlayNone,
navStack: []Screen{deps.InitialScreen},
screens: map[Screen]tea.Model{},
rateLimitGauge: ratelimitgauge.NewModel(),
}
if mdl, _ := m.buildScreen(m.active); mdl != nil {
m.screens[m.active] = mdl
Expand Down Expand Up @@ -654,6 +671,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.apiRecorderModel = &ar
m.overlay = OverlayAPIRecorder
return m, nil
case openRateLimitMsg:
// MW-11 — open the `:ratelimit` gauge. Feed one sample from
// the port immediately so the modal isn't blank on open, then
// start the 1s tick chain. Advance-only ticks keep the
// sparkline scrolling even when the transport is idle.
now := m.now()
snaps := m.rateLimitSnapshots()
m.rateLimitGauge = m.rateLimitGauge.Ingest(snaps, now)
m.overlay = OverlayRateLimit
m.rateLimitGaugeTickGen++
return m, rateLimitGaugeTickCmd(m.rateLimitGaugeTickGen)
case rateLimitTickMsg:
if msg.gen != m.rateLimitGaugeTickGen || m.overlay != OverlayRateLimit {
return m, nil
}
now := m.now()
snaps := m.rateLimitSnapshots()
m.rateLimitGauge = m.rateLimitGauge.
AdvanceTo(now).
Ingest(snaps, now)
return m, rateLimitGaugeTickCmd(m.rateLimitGaugeTickGen)
case RateLimitObservedMsg:
// If a monitor / adapter fans out a per-response snapshot as
// a tea.Msg, feed it through the same Ingest path so the
// gauge picks it up between ticks.
m.rateLimitGauge = m.rateLimitGauge.Ingest(
[]domain.RateLimitSnapshot{msg.Snapshot}, m.now())
return m, nil
case openActionMenuMsg:
// Issue #175: build the action picker from the active
// screen's Actioner. The `a` key handler already gated on
Expand Down Expand Up @@ -1318,6 +1363,9 @@ func (m Model) composeBody() string {
sized := m.apiRecorderModel.WithSize(contentWidth, bodyHeight+2)
return m.composeModalOverDimmedBody(sized.View())
}
if m.overlay == OverlayRateLimit {
return m.composeModalOverDimmedBody(m.renderRateLimitGaugeModal(activeTokens()))
}
if m.overlay == OverlayActionConfirm {
// Issue #199 v0.2.4 — destructive-op confirmation popup floats
// over the dimmed body so the operator still sees their list /
Expand Down Expand Up @@ -1596,6 +1644,33 @@ func (m Model) renderActionConfirmModal(tk shared.Tokens) string {
})
}

// renderRateLimitGaugeModal draws the MW-11 gauge overlay — a
// full-height modal that hosts the ratelimitgauge.Model's rendered
// gauges + sparklines. Sized to the chrome content rectangle so the
// sparkline stretches out; collapses gracefully on 80x24 via the
// gauge model's height-budget rule.
func (m Model) renderRateLimitGaugeModal(tk shared.Tokens) string {
contentWidth := clampWidth(m.width) - 3
bodyHeight := clampBodyLines(m.height)
modalWidth := contentWidth - 4
if modalWidth < 40 {
modalWidth = 40
}
inner := modalWidth - 4
if inner < 20 {
inner = 20
}
sized := m.rateLimitGauge.WithSize(inner, bodyHeight-4)
return shared.MountModal(shared.ModalIn{
Title: "Rate limit",
Body: sized.View(),
Footer: "auto-refreshes 1s · Esc / q to close",
Tone: shared.ModalToneAccent,
Width: modalWidth,
Tokens: tk,
})
}

// centerInBody horizontally centers a multi-line block inside the
// chrome's content width. Each line gets enough leading spaces to push
// the block to the visual center; lines wider than contentWidth are
Expand Down Expand Up @@ -1661,6 +1736,17 @@ func (m Model) profileLabel() string {
return m.deps.Profile
}

// rateLimitSnapshots returns the current per-category snapshots from
// the injected port, or nil when unwired. The gauge overlay and the
// [RL: ...] badge classifier both consume this so the semantics stay
// aligned.
func (m Model) rateLimitSnapshots() []domain.RateLimitSnapshot {
if m.deps.RateLimit == nil {
return nil
}
return m.deps.RateLimit.Snapshots()
}

// rateLimitState classifies the [RL: ...] badge from the injected port.
// nil port → ok (chrome stays informative even before wiring is complete).
func (m Model) rateLimitState() shared.RateLimitState {
Expand Down Expand Up @@ -2653,6 +2739,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.overlay == OverlayAPIRecorder {
return m.handleAPIRecorderKey(msg)
}
if m.overlay == OverlayRateLimit {
return m.handleRateLimitGaugeKey(msg)
}

// 2026-05-04 nav stack: Esc walks the navigation history.
//
Expand Down Expand Up @@ -2821,6 +2910,9 @@ func (m Model) handlePaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
mode = shared.ToggleTZ()
}
return m, toastCmdInfo("Timezone: " + mode.String() + " (" + shared.TZLabel() + ")")
case paletteCmdRateLimit:
// MW-11 — `:ratelimit` opens the gauge overlay.
return m, openRateLimitCmd()
case paletteCmdUnmask:
if child, ok := m.screens[m.active]; ok {
updated, c := child.Update(UnmaskFieldMsg{Field: arg})
Expand Down Expand Up @@ -2934,6 +3026,7 @@ func paletteCommandPool() []string {
// MW-1 — `:xray` opens the user-scoped dependency tree.
"xray",
"apilog",
"ratelimit",
"help", "quit",
}
}
Expand Down Expand Up @@ -3196,6 +3289,27 @@ func (m Model) handleAPIRecorderKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}

// handleRateLimitGaugeKey routes keys for the MW-11 gauge overlay.
// Esc closes; the tick chain self-invalidates via rateLimitGaugeTickGen.
// Every other key is dropped so the gauge stays read-only.
func (m Model) handleRateLimitGaugeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
m.overlay = OverlayNone
m.rateLimitGaugeTickGen++
return m, nil
case tea.KeyRunes:
// `q` also closes so operators fall back on the global quit
// gesture without triggering the quit confirm underneath.
if s := string(msg.Runes); s == "q" {
m.overlay = OverlayNone
m.rateLimitGaugeTickGen++
return m, nil
}
}
return m, nil
}

// --- Palette resolver ----------------------------------------------------

type paletteCmdKind int
Expand Down Expand Up @@ -3234,6 +3348,8 @@ const (
// Arg may carry "local" / "utc" to force a specific mode instead
// of toggling.
paletteCmdTimezone
// paletteCmdRateLimit opens the `:ratelimit` gauge modal (MW-11).
paletteCmdRateLimit
)

// UnmaskFieldMsg / MaskAllMsg are re-exported from the shared msgs
Expand Down Expand Up @@ -3287,6 +3403,8 @@ func resolvePaletteCommand(raw string) (kind paletteCmdKind, screen Screen, arg
return paletteCmdTimezone, 0, "local", true
case "tz utc", "timezone utc":
return paletteCmdTimezone, 0, "utc", true
case "ratelimit", "rate-limit", "rate_limit", "rl":
return paletteCmdRateLimit, 0, "", true
}
// Direct policy-type routes (issue #165). The verb arg field
// carries the canonical PolicyType so the App Shell can build a
Expand Down Expand Up @@ -3437,11 +3555,31 @@ type openCmdPaletteMsg struct{}
type openHelpMsg struct{}
type openActionMenuMsg struct{}
type openAPIRecorderMsg struct{}
type openRateLimitMsg struct{}

// rateLimitTickMsg drives the `:ratelimit` gauge's 1-second refresh
// while OverlayRateLimit is open. gen is compared against
// m.rateLimitGaugeTickGen so a stale tick from a previous open cycle
// (closed and reopened) is silently discarded.
type rateLimitTickMsg struct{ gen int }

func openAPIRecorderCmd() tea.Cmd {
return func() tea.Msg { return openAPIRecorderMsg{} }
}

func openRateLimitCmd() tea.Cmd {
return func() tea.Msg { return openRateLimitMsg{} }
}

// rateLimitGaugeTickCmd schedules the next 1-second refresh for the
// `:ratelimit` gauge modal. gen is stamped onto the msg so a stale
// tick from a closed overlay drops instead of self-reopening.
func rateLimitGaugeTickCmd(gen int) tea.Cmd {
return tea.Tick(time.Second, func(time.Time) tea.Msg {
return rateLimitTickMsg{gen: gen}
})
}

// openActionMenuCmd opens the resource-specific action picker
// (issue #175). Routed through a Cmd so `a` flows the same way as
// `:` / `?` — the App Shell sees an openActionMenuMsg and instantiates
Expand Down
80 changes: 80 additions & 0 deletions internal/app/ratelimit_gauge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package app_test

// MW-11 — `:ratelimit` opens a real-time gauge overlay backed by
// ratelimitgauge.Model. The palette route + a per-second tea.Tick
// keep the sparkline scrolling while the app makes calls.

import (
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/tedilabs/ota/internal/app"
"github.com/tedilabs/ota/internal/domain"
)

// openRateLimitOverlay drives the `:ratelimit` palette path and
// returns the App Shell with the gauge modal open.
func openRateLimitOverlay(t *testing.T, m app.Model) app.Model {
t.Helper()

updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(":")})
model := updated.(app.Model)
if cmd != nil {
if msg := cmd(); msg != nil {
updated, _ = model.Update(msg)
model = updated.(app.Model)
}
}
for _, r := range "ratelimit" {
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
model = updated.(app.Model)
}
updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(app.Model)
require.NotNil(t, cmd, "Enter must produce a Cmd for :ratelimit")
msg := cmd()
require.NotNil(t, msg, "openRateLimitCmd must produce a msg")
updated, _ = model.Update(msg)
return updated.(app.Model)
}

// Test_Palette_RateLimit_OpensGaugeOverlay drives `:ratelimit` through
// the palette and asserts the resulting Overlay is the gauge modal.
func Test_Palette_RateLimit_OpensGaugeOverlay(t *testing.T) {
t.Parallel()

port := stubRateLimitPort{snaps: []domain.RateLimitSnapshot{{
Category: "management",
Remaining: 60,
Limit: 600,
}}}
m := app.New(app.Deps{InitialScreen: app.ScreenUsers, RateLimit: port})
model := openRateLimitOverlay(t, m)

assert.Equal(t, app.OverlayRateLimit, model.Overlay(),
":ratelimit palette command must open OverlayRateLimit")

view := model.View()
assert.Contains(t, view, "management",
"gauge modal must render the category name from the RateLimitPort")
assert.Contains(t, view, "540 / 600",
"gauge modal must render used/limit numbers from the snapshot")
}

// Test_RateLimitGauge_EscClosesOverlay locks in the standard overlay
// close gesture.
func Test_RateLimitGauge_EscClosesOverlay(t *testing.T) {
t.Parallel()

m := app.New(app.Deps{InitialScreen: app.ScreenUsers})
model := openRateLimitOverlay(t, m)
require.Equal(t, app.OverlayRateLimit, model.Overlay(),
"precondition: overlay is open before Esc")

updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
assert.Equal(t, app.OverlayNone, updated.(app.Model).Overlay(),
"Esc must close the ratelimit gauge overlay")
}
Loading