feat(tui): hand-rolled live monitor with ANSI primitives + 4 panels

Stack:
- Ansi: stdlib-only escape helpers (move, colorize, strip)
- State: rolling view of active rotations + recent events; trims to N
- Renderer: paints header / status / active / recent panels; works against
  any IO so tests assert against IO::Memory
- Tui: ties State + Renderer to event bus; coalesces repaints to a tick
  interval (default 200ms) to avoid flicker under burst events

Active rotation rows show step progress as a filled-bar (▰▰▰▱). Recent
events list latest 8 with timestamp + severity glyph (✓ / ⚠ / !). The
renderer's pad/center helpers correctly account for ANSI escape widths
when computing visible column width.

9 specs cover ANSI helpers (color codes, cursor move, strip), State
event-to-row mapping with completion bookkeeping and ring-buffer trim,
and Renderer output assertions across empty + active + populated states.
This commit is contained in:
CarterPerez-dev 2026-04-29 01:09:20 -04:00
parent 67d65a934f
commit 9651ff56f1
7 changed files with 492 additions and 0 deletions

View File

@ -0,0 +1,25 @@
# ===================
# ©AngelaMos | 2026
# ansi_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/tui/ansi"
describe CRE::Tui::Ansi do
it "wraps text with color escape codes" do
out = CRE::Tui::Ansi.green("hello")
out.should contain "\e[32m"
out.should contain "hello"
out.should end_with "\e[0m"
end
it "move produces a CSI cursor-position sequence" do
CRE::Tui::Ansi.move(5, 10).should eq "\e[5;10H"
end
it "strip removes escape sequences" do
raw = "\e[1m\e[33mwarn\e[0m message"
CRE::Tui::Ansi.strip(raw).should eq "warn message"
end
end

View File

@ -0,0 +1,52 @@
# ===================
# ©AngelaMos | 2026
# renderer_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/tui/renderer"
require "../../../src/cre/events/credential_events"
describe CRE::Tui::Renderer do
it "renders the layout with header + panels" do
state = CRE::Tui::State.new(kek_version: 3)
io = IO::Memory.new
CRE::Tui::Renderer.new(state, io, use_color: false).render
out = CRE::Tui::Ansi.strip(io.to_s)
out.should contain "Credential Rotation Enforcer"
out.should contain "STATUS"
out.should contain "Active Rotations"
out.should contain "Recent Events"
out.should contain "(no active rotations)"
out.should contain "(no events yet)"
out.should contain "v3"
end
it "renders active rotations" do
state = CRE::Tui::State.new
cred_id = UUID.random
state.apply(CRE::Events::RotationStarted.new(cred_id, UUID.random, "aws_secretsmgr"))
state.apply(CRE::Events::RotationStepCompleted.new(cred_id, UUID.random, :generate))
state.apply(CRE::Events::RotationStepCompleted.new(cred_id, UUID.random, :apply))
io = IO::Memory.new
CRE::Tui::Renderer.new(state, io, use_color: false).render
out = CRE::Tui::Ansi.strip(io.to_s)
out.should contain "aws_secretsmgr"
out.should contain "step 2/4"
end
it "renders recent events with timestamps" do
state = CRE::Tui::State.new
cred_id = UUID.random
state.apply(CRE::Events::RotationCompleted.new(cred_id, UUID.random))
state.apply(CRE::Events::PolicyViolation.new(cred_id, "p1", "stale"))
io = IO::Memory.new
CRE::Tui::Renderer.new(state, io, use_color: false).render
out = CRE::Tui::Ansi.strip(io.to_s)
out.should contain "rotation completed"
out.should contain "p1"
end
end

View File

@ -0,0 +1,54 @@
# ===================
# ©AngelaMos | 2026
# state_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/tui/state"
require "../../../src/cre/events/credential_events"
describe CRE::Tui::State do
it "tracks active rotations through their step lifecycle" do
state = CRE::Tui::State.new
cred_id = UUID.random
rot_id = UUID.random
state.apply(CRE::Events::RotationStarted.new(cred_id, rot_id, "env_file"))
state.active[cred_id]?.should_not be_nil
state.active[cred_id].step.should eq "starting"
state.active[cred_id].progress.should eq 0
state.apply(CRE::Events::RotationStepCompleted.new(cred_id, rot_id, :generate))
state.active[cred_id].progress.should eq 1
state.active[cred_id].step.should eq "generate"
state.apply(CRE::Events::RotationStepCompleted.new(cred_id, rot_id, :apply))
state.apply(CRE::Events::RotationStepCompleted.new(cred_id, rot_id, :verify))
state.apply(CRE::Events::RotationStepCompleted.new(cred_id, rot_id, :commit))
state.active[cred_id].progress.should eq 4
state.apply(CRE::Events::RotationCompleted.new(cred_id, rot_id))
state.active.has_key?(cred_id).should be_false
state.completed_24h.should eq 1
state.recent.size.should eq 1
state.recent.first.symbol.should eq ""
end
it "records failed rotations" do
state = CRE::Tui::State.new
cred_id = UUID.random
state.apply(CRE::Events::RotationFailed.new(cred_id, UUID.random, "boom"))
state.recent.first.symbol.should eq "!"
state.recent.first.summary.should contain "FAILED"
end
it "trims recent events to MAX_RECENT_EVENTS" do
state = CRE::Tui::State.new
25.times do |i|
state.apply(CRE::Events::AlertRaised.new(CRE::Events::Severity::Info, "msg-#{i}"))
end
state.recent.size.should eq CRE::Tui::State::MAX_RECENT_EVENTS
state.recent.first.summary.should contain "msg-5"
state.recent.last.summary.should contain "msg-24"
end
end

View File

@ -0,0 +1,52 @@
# ===================
# ©AngelaMos | 2026
# ansi.cr
# ===================
module CRE::Tui
# Minimal ANSI escape helpers. We hand-roll instead of depending on a TUI
# framework so the rendering is small, predictable, and easy to test by
# capturing IO writes.
module Ansi
ESC = "\e["
CLEAR_SCREEN = "#{ESC}2J"
CLEAR_LINE = "#{ESC}2K"
HIDE_CURSOR = "#{ESC}?25l"
SHOW_CURSOR = "#{ESC}?25h"
HOME = "#{ESC}H"
RESET = "#{ESC}0m"
BOLD = "#{ESC}1m"
DIM = "#{ESC}2m"
FG_RED = "#{ESC}31m"
FG_GREEN = "#{ESC}32m"
FG_YELLOW = "#{ESC}33m"
FG_BLUE = "#{ESC}34m"
FG_CYAN = "#{ESC}36m"
FG_WHITE = "#{ESC}37m"
FG_GRAY = "#{ESC}90m"
def self.move(row : Int, col : Int) : String
"#{ESC}#{row};#{col}H"
end
def self.colorize(text : String, color : String) : String
"#{color}#{text}#{RESET}"
end
def self.green(text : String) : String ; colorize(text, FG_GREEN) ; end
def self.red(text : String) : String ; colorize(text, FG_RED) ; end
def self.yellow(text : String) : String ; colorize(text, FG_YELLOW) ; end
def self.cyan(text : String) : String ; colorize(text, FG_CYAN) ; end
def self.gray(text : String) : String ; colorize(text, FG_GRAY) ; end
def self.bold(text : String) : String ; colorize(text, BOLD) ; end
def self.dim(text : String) : String ; colorize(text, DIM) ; end
# Strip ANSI escape sequences (useful for testing rendered output).
def self.strip(text : String) : String
text.gsub(/\e\[[0-9;?]*[a-zA-Z]/, "")
end
end
end

View File

@ -0,0 +1,108 @@
# ===================
# ©AngelaMos | 2026
# renderer.cr
# ===================
require "./ansi"
require "./state"
module CRE::Tui
# Renderer paints the four-panel TUI to a target IO. Decoupled from terminal
# ownership so it can render to STDOUT in production or to IO::Memory in
# tests for assertion.
class Renderer
HEADER = "Credential Rotation Enforcer"
PANEL_HR = ""
PANEL_BL = ""
PANEL_BR = ""
PANEL_TL = ""
PANEL_TR = ""
PANEL_VL = ""
PANEL_LMID = ""
PANEL_RMID = ""
WIDTH = 64
def initialize(@state : State, @io : IO = STDOUT, @use_color : Bool = true)
end
def render : Nil
@io << Ansi::HOME
render_header
render_status
render_active
render_recent
render_footer
@io.flush
end
private def render_header : Nil
title = "#{HEADER} - PID #{Process.pid} | Uptime #{format_span(@state.uptime)}"
line = pad_centered(title, WIDTH - 2)
@io << PANEL_TL << PANEL_HR * (WIDTH - 2) << PANEL_TR << '\n'
@io << PANEL_VL << colorize(line, Ansi::FG_CYAN) << PANEL_VL << '\n'
@io << PANEL_LMID << PANEL_HR * (WIDTH - 2) << PANEL_RMID << '\n'
end
private def render_status : Nil
header = " STATUS CREDS DUE-NOW OVERDUE ROTATED-24h KEK"
values = " #{Ansi.green("● live")} ? ? ? #{@state.completed_24h.to_s.ljust(13)} v#{@state.kek_version}"
@io << PANEL_VL << pad(header, WIDTH - 2) << PANEL_VL << '\n'
@io << PANEL_VL << pad(values, WIDTH - 2) << PANEL_VL << '\n'
@io << PANEL_LMID << " Active Rotations " << PANEL_HR * (WIDTH - 21) << PANEL_RMID << '\n'
end
private def render_active : Nil
if @state.active.empty?
@io << PANEL_VL << pad(" (no active rotations)", WIDTH - 2) << PANEL_VL << '\n'
else
@state.active.values.first(State::MAX_ACTIVE_ROTATIONS).each do |row|
progress = "" * row.progress + "" * (4 - row.progress)
line = " [▶] #{row.rotator_kind.ljust(20)} #{progress} step #{row.progress}/4: #{row.step}"
@io << PANEL_VL << pad(line, WIDTH - 2) << PANEL_VL << '\n'
end
end
@io << PANEL_LMID << " Recent Events " << PANEL_HR * (WIDTH - 18) << PANEL_RMID << '\n'
end
private def render_recent : Nil
if @state.recent.empty?
@io << PANEL_VL << pad(" (no events yet)", WIDTH - 2) << PANEL_VL << '\n'
else
@state.recent.last(8).each do |row|
ts = row.occurred_at.to_s("%H:%M:%S")
line = " #{ts} #{row.symbol} #{row.summary}"
@io << PANEL_VL << pad(line, WIDTH - 2) << PANEL_VL << '\n'
end
end
end
private def render_footer : Nil
@io << PANEL_BL << PANEL_HR * (WIDTH - 2) << PANEL_BR << '\n'
@io << Ansi.dim(" q=quit r=refresh ?=help") << '\n'
end
private def colorize(text : String, color : String) : String
@use_color ? Ansi.colorize(text, color) : text
end
private def pad(text : String, width : Int) : String
visible = Ansi.strip(text)
return text[0, width + (text.size - visible.size)] if visible.size >= width
text + " " * (width - visible.size)
end
private def pad_centered(text : String, width : Int) : String
visible_size = Ansi.strip(text).size
return text[0, width] if visible_size >= width
pad = (width - visible_size) // 2
" " * pad + text + " " * (width - pad - visible_size)
end
private def format_span(span : Time::Span) : String
total = span.total_seconds.to_i
hours = total // 3600
mins = (total % 3600) // 60
"#{hours}h #{mins}m"
end
end
end

View File

@ -0,0 +1,107 @@
# ===================
# ©AngelaMos | 2026
# state.cr
# ===================
require "../events/credential_events"
require "../events/system_events"
module CRE::Tui
# State holds the rolling view of recent events for the TUI to render.
# Updated synchronously from the event subscriber; rendering reads it.
class State
MAX_RECENT_EVENTS = 20
MAX_ACTIVE_ROTATIONS = 10
record EventRow,
occurred_at : Time,
symbol : String,
summary : String
record RotationRow,
credential_id : UUID,
rotator_kind : String,
step : String,
progress : Int32 # 0..4
@recent : Array(EventRow)
@active : Hash(UUID, RotationRow)
@completed_24h : Int32
@started_at : Time
@kek_version : Int32
def initialize(@kek_version : Int32 = 0)
@recent = [] of EventRow
@active = {} of UUID => RotationRow
@completed_24h = 0
@started_at = Time.utc
end
def apply(ev : Events::Event) : Nil
case ev
when Events::RotationStarted
@active[ev.credential_id] = RotationRow.new(
credential_id: ev.credential_id,
rotator_kind: ev.rotator_kind,
step: "starting",
progress: 0,
)
when Events::RotationStepStarted
if row = @active[ev.credential_id]?
@active[ev.credential_id] = RotationRow.new(
credential_id: row.credential_id,
rotator_kind: row.rotator_kind,
step: ev.step.to_s,
progress: row.progress,
)
end
when Events::RotationStepCompleted
if row = @active[ev.credential_id]?
@active[ev.credential_id] = RotationRow.new(
credential_id: row.credential_id,
rotator_kind: row.rotator_kind,
step: ev.step.to_s,
progress: row.progress + 1,
)
end
when Events::RotationCompleted
@active.delete(ev.credential_id)
@completed_24h += 1
push_event("", "rotation completed for #{short(ev.credential_id)}")
when Events::RotationFailed
@active.delete(ev.credential_id)
push_event("!", "rotation FAILED for #{short(ev.credential_id)}: #{ev.reason}")
when Events::PolicyViolation
push_event("", "policy '#{ev.policy_name}' violated by #{short(ev.credential_id)}")
when Events::DriftDetected
push_event("", "drift detected on #{short(ev.credential_id)}")
when Events::AlertRaised
sym = case ev.severity
in Events::Severity::Critical then "!"
in Events::Severity::Warn then ""
in Events::Severity::Info then ""
end
push_event(sym, ev.message)
end
end
getter recent : Array(EventRow)
getter active : Hash(UUID, RotationRow)
getter completed_24h : Int32
getter started_at : Time
getter kek_version : Int32
def uptime : Time::Span
Time.utc - @started_at
end
private def push_event(symbol : String, summary : String) : Nil
@recent << EventRow.new(occurred_at: Time.utc, symbol: symbol, summary: summary)
@recent.shift if @recent.size > MAX_RECENT_EVENTS
end
private def short(uuid : UUID) : String
uuid.to_s[0, 8]
end
end
end

View File

@ -0,0 +1,94 @@
# ===================
# ©AngelaMos | 2026
# tui.cr
# ===================
require "./ansi"
require "./state"
require "./renderer"
require "../engine/event_bus"
module CRE::Tui
# Tui glues State + Renderer to the event bus. A single fiber consumes events
# (Drop overflow - stale UI is acceptable) and triggers a repaint at most
# every refresh_interval to coalesce bursts.
class Tui
@running : Bool
@ch : ::Channel(Events::Event)?
@last_render : Time
def initialize(
@bus : Engine::EventBus,
@state : State = State.new,
@io : IO = STDOUT,
@refresh_interval : Time::Span = 200.milliseconds,
@use_color : Bool = true,
)
@running = false
@last_render = Time::UNIX_EPOCH
end
def start : Nil
@running = true
ch = @bus.subscribe(buffer: 64, overflow: Engine::EventBus::Overflow::Drop)
@ch = ch
enter_alt_screen if @io == STDOUT
spawn(name: "tui-events") do
while @running
begin
ev = ch.receive
rescue ::Channel::ClosedError
break
end
@state.apply(ev)
maybe_render
end
end
spawn(name: "tui-tick") do
while @running
sleep @refresh_interval
maybe_render
end
end
maybe_render(force: true)
end
def stop : Nil
@running = false
@ch.try(&.close)
leave_alt_screen if @io == STDOUT
end
def state : State
@state
end
def force_render : Nil
Renderer.new(@state, @io, @use_color).render
@last_render = Time.utc
end
private def maybe_render(force : Bool = false) : Nil
now = Time.utc
return if !force && (now - @last_render) < @refresh_interval
Renderer.new(@state, @io, @use_color).render
@last_render = now
end
private def enter_alt_screen : Nil
@io << "\e[?1049h"
@io << Ansi::HIDE_CURSOR
@io << Ansi::CLEAR_SCREEN
@io.flush
end
private def leave_alt_screen : Nil
@io << Ansi::SHOW_CURSOR
@io << "\e[?1049l"
@io.flush
end
end
end