diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/tui/ansi_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/tui/ansi_spec.cr new file mode 100644 index 00000000..e63f3108 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/tui/ansi_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/tui/renderer_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/tui/renderer_spec.cr new file mode 100644 index 00000000..4e65a57c --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/tui/renderer_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/tui/state_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/tui/state_spec.cr new file mode 100644 index 00000000..715a3545 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/tui/state_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/ansi.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/ansi.cr new file mode 100644 index 00000000..fa71dc58 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/ansi.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/renderer.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/renderer.cr new file mode 100644 index 00000000..cc6bc628 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/renderer.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/state.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/state.cr new file mode 100644 index 00000000..ff459e3c --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/state.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/tui.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/tui.cr new file mode 100644 index 00000000..253d218a --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/tui.cr @@ -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