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:
parent
67d65a934f
commit
9651ff56f1
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue