use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Margin},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{
Block, BorderType, Borders, Cell, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation,
ScrollbarState, Table, TableState, Tabs, Wrap,
},
DefaultTerminal, Frame,
};
use serde_json::Value;
use crate::logs::{self, LogKind};
const POLL_INTERVAL: Duration = Duration::from_secs(5);
const ALL_KINDS: [LogKind; 5] = [
LogKind::Executions,
LogKind::Requests,
LogKind::Approvals,
LogKind::Alerts,
LogKind::Sessions,
];
#[derive(Debug, Clone)]
struct LogEntry {
timestamp: String,
kind: LogKind,
agent: Option<String>,
command: Option<String>,
action: Option<String>,
branch: Option<String>,
worktree: Option<String>,
env_vars: Vec<String>,
severity: Severity,
raw: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Severity {
Info,
Warning,
Critical,
}
impl Severity {
fn color(&self) -> Color {
match self {
Severity::Info => Color::White,
Severity::Warning => Color::Yellow,
Severity::Critical => Color::Red,
}
}
fn label(&self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warning => "warn",
Severity::Critical => "crit",
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum Focus {
Table,
Detail,
}
struct App {
entries: Vec<LogEntry>,
filtered: Vec<usize>,
table_state: TableState,
detail_scroll: u16,
kind_filter: Option<LogKind>,
agent_filter: Option<String>,
severity_filter: Option<Severity>,
kind_tab: usize,
focus: Focus,
last_refresh: Instant,
show_help: bool,
known_agents: Vec<String>,
agent_tab: usize,
}
impl App {
fn new() -> Self {
App {
entries: Vec::new(),
filtered: Vec::new(),
table_state: TableState::default(),
detail_scroll: 0,
kind_filter: None,
agent_filter: None,
severity_filter: None,
kind_tab: 0,
focus: Focus::Table,
last_refresh: Instant::now(),
show_help: false,
known_agents: Vec::new(),
agent_tab: 0,
}
}
fn load_logs(&mut self) {
self.entries.clear();
for &kind in &ALL_KINDS {
if let Ok(events) = logs::decrypt_events(kind) {
for v in events {
if let Some(entry) = parse_entry(kind, v) {
self.entries.push(entry);
}
}
}
}
self.entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
self.entries.reverse();
let mut agents: Vec<String> = self
.entries
.iter()
.filter_map(|e| e.agent.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
agents.insert(0, "all".to_string());
self.known_agents = agents;
self.rebuild_filter();
}
fn rebuild_filter(&mut self) {
self.filtered = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| {
if let Some(k) = self.kind_filter {
if e.kind != k {
return false;
}
}
if let Some(ref a) = self.agent_filter {
if e.agent.as_deref() != Some(a.as_str()) {
return false;
}
}
if let Some(ref s) = self.severity_filter {
if &e.severity != s {
return false;
}
}
true
})
.map(|(i, _)| i)
.collect();
if self.filtered.is_empty() {
self.table_state.select(None);
} else if self.table_state.selected().is_none() {
self.table_state.select(Some(0));
} else {
let sel = self.table_state.selected().unwrap_or(0);
if sel >= self.filtered.len() {
self.table_state.select(Some(self.filtered.len() - 1));
}
}
self.detail_scroll = 0;
}
fn selected_entry(&self) -> Option<&LogEntry> {
let idx = self.table_state.selected()?;
let entry_idx = *self.filtered.get(idx)?;
self.entries.get(entry_idx)
}
fn move_selection(&mut self, delta: i32) {
let len = self.filtered.len();
if len == 0 {
return;
}
let current = self.table_state.selected().unwrap_or(0) as i32;
let next = (current + delta).clamp(0, (len as i32) - 1) as usize;
self.table_state.select(Some(next));
self.detail_scroll = 0;
}
fn cycle_kind_tab(&mut self, forward: bool) {
let max = ALL_KINDS.len();
if forward {
self.kind_tab = (self.kind_tab + 1) % (max + 1);
} else {
self.kind_tab = (self.kind_tab + max) % (max + 1);
}
self.kind_filter = if self.kind_tab == 0 {
None
} else {
Some(ALL_KINDS[self.kind_tab - 1])
};
self.rebuild_filter();
}
fn cycle_agent_tab(&mut self, forward: bool) {
let len = self.known_agents.len();
if len == 0 {
return;
}
if forward {
self.agent_tab = (self.agent_tab + 1) % len;
} else {
self.agent_tab = (self.agent_tab + len - 1) % len;
}
let selected = &self.known_agents[self.agent_tab];
self.agent_filter = if selected == "all" {
None
} else {
Some(selected.clone())
};
self.rebuild_filter();
}
fn cycle_severity(&mut self) {
self.severity_filter = match &self.severity_filter {
None => Some(Severity::Info),
Some(Severity::Info) => Some(Severity::Warning),
Some(Severity::Warning) => Some(Severity::Critical),
Some(Severity::Critical) => None,
};
self.rebuild_filter();
}
}
fn parse_entry(kind: LogKind, raw: Value) -> Option<LogEntry> {
let payload = raw.get("payload").unwrap_or(&raw);
let timestamp = raw
.get("timestamp")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let agent = extract_str(payload, &["agent", "access", "agent"]);
let command = extract_str(payload, &["requestedCommand", "command"])
.or_else(|| extract_nested_str(payload, &["access", "command"]));
let action = extract_str(payload, &["declaredAction"])
.or_else(|| extract_nested_str(payload, &["access", "action"]));
let branch = extract_str(payload, &["branch"])
.or_else(|| extract_nested_str(payload, &["git", "branch"]))
.or_else(|| extract_nested_str(payload, &["access", "branch"]));
let worktree = extract_nested_str(payload, &["git", "worktreePath"])
.or_else(|| extract_str(payload, &["cwd"]));
let env_vars = extract_str_array(payload, "injectedEnv")
.or_else(|| extract_str_array(payload, "requestedEnv"))
.or_else(|| extract_nested_str_array(payload, &["access", "env"]))
.unwrap_or_default();
let severity = detect_severity(payload, kind);
Some(LogEntry {
timestamp,
kind,
agent,
command,
action,
branch,
worktree,
env_vars,
severity,
raw,
})
}
fn extract_str(v: &Value, keys: &[&str]) -> Option<String> {
for key in keys {
if let Some(s) = v.get(key).and_then(|v| v.as_str()) {
return Some(s.to_string());
}
}
None
}
fn extract_nested_str(v: &Value, path: &[&str]) -> Option<String> {
let mut cur = v;
for key in path {
cur = cur.get(key)?;
}
cur.as_str().map(|s| s.to_string())
}
fn extract_str_array(v: &Value, key: &str) -> Option<Vec<String>> {
let arr = v.get(key)?.as_array()?;
let result: Vec<String> = arr
.iter()
.filter_map(|e| e.as_str().map(|s| s.to_string()))
.collect();
if result.is_empty() {
None
} else {
Some(result)
}
}
fn extract_nested_str_array(v: &Value, path: &[&str]) -> Option<Vec<String>> {
let mut cur = v;
for key in path {
cur = cur.get(key)?;
}
let arr = cur.as_array()?;
let result: Vec<String> = arr
.iter()
.filter_map(|e| e.as_str().map(|s| s.to_string()))
.collect();
if result.is_empty() {
None
} else {
Some(result)
}
}
fn detect_severity(payload: &Value, kind: LogKind) -> Severity {
if kind == LogKind::Alerts {
return Severity::Warning;
}
if let Some(findings) = payload.get("policyFindings").and_then(|v| v.as_array()) {
for f in findings {
if f.get("severity").and_then(|s| s.as_str()) == Some("critical") {
return Severity::Critical;
}
}
for f in findings {
if f.get("severity").and_then(|s| s.as_str()) == Some("warning") {
return Severity::Warning;
}
}
}
if kind == LogKind::Approvals {
if let Some(decision) = payload.get("decision").and_then(|v| v.as_str()) {
if decision == "deny" {
return Severity::Warning;
}
}
}
Severity::Info
}
fn kind_label(k: LogKind) -> &'static str {
match k {
LogKind::Executions => "exec",
LogKind::Requests => "req",
LogKind::Approvals => "approve",
LogKind::Alerts => "alert",
LogKind::Sessions => "session",
}
}
fn kind_color(k: LogKind) -> Color {
match k {
LogKind::Executions => Color::Cyan,
LogKind::Requests => Color::Blue,
LogKind::Approvals => Color::Green,
LogKind::Alerts => Color::Yellow,
LogKind::Sessions => Color::Magenta,
}
}
pub fn run_dashboard() -> Result<()> {
let mut terminal = ratatui::init();
let result = run_app(&mut terminal);
ratatui::restore();
result
}
fn run_app(terminal: &mut DefaultTerminal) -> Result<()> {
let mut app = App::new();
app.load_logs();
loop {
terminal.draw(|f| draw(f, &mut app))?;
if app.last_refresh.elapsed() >= POLL_INTERVAL {
app.load_logs();
app.last_refresh = Instant::now();
}
if event::poll(Duration::from_millis(200))? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
if app.show_help {
app.show_help = false;
continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(())
}
KeyCode::Char('?') | KeyCode::Char('h') => {
app.show_help = !app.show_help;
}
KeyCode::Char('r') => {
app.load_logs();
app.last_refresh = Instant::now();
}
KeyCode::Tab => app.cycle_kind_tab(true),
KeyCode::BackTab => app.cycle_kind_tab(false),
KeyCode::Char('a') => app.cycle_agent_tab(true),
KeyCode::Char('A') => app.cycle_agent_tab(false),
KeyCode::Char('s') => app.cycle_severity(),
KeyCode::Down | KeyCode::Char('j') => {
if app.focus == Focus::Detail {
app.detail_scroll = app.detail_scroll.saturating_add(1);
} else {
app.move_selection(1);
}
}
KeyCode::Up | KeyCode::Char('k') => {
if app.focus == Focus::Detail {
app.detail_scroll = app.detail_scroll.saturating_sub(1);
} else {
app.move_selection(-1);
}
}
KeyCode::PageDown => app.move_selection(20),
KeyCode::PageUp => app.move_selection(-20),
KeyCode::Enter | KeyCode::Char('l') => {
app.focus = if app.focus == Focus::Table {
Focus::Detail
} else {
Focus::Table
};
}
KeyCode::Char(' ') | KeyCode::Char('b') => {
app.focus = Focus::Table;
}
_ => {}
}
}
}
}
}
fn draw(f: &mut Frame, app: &mut App) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(3), Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
let title = Paragraph::new(Line::from(vec![
Span::styled(
" ward ",
Style::default().fg(Color::Black).bg(Color::White).bold(),
),
Span::raw(" logs dashboard "),
Span::styled(
format!(" {} events ", app.filtered.len()),
Style::default().fg(Color::DarkGray),
),
]));
f.render_widget(title, chunks[0]);
let kind_titles: Vec<Line> = std::iter::once(Line::from("all"))
.chain(ALL_KINDS.iter().map(|&k| {
Line::from(Span::styled(
kind_label(k),
Style::default().fg(kind_color(k)),
))
}))
.collect();
let kind_tabs = Tabs::new(kind_titles)
.block(Block::default().borders(Borders::ALL).title(" kind "))
.select(app.kind_tab)
.highlight_style(Style::default().bold().bg(Color::DarkGray));
f.render_widget(kind_tabs, chunks[1]);
let filter_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(chunks[2]);
let agent_titles: Vec<Line> = app
.known_agents
.iter()
.map(|a| Line::from(a.as_str()))
.collect();
let agent_tabs = if agent_titles.is_empty() {
Tabs::new(vec![Line::from("all")])
.block(
Block::default()
.borders(Borders::ALL)
.title(" agent [a/A] "),
)
.select(0)
.highlight_style(Style::default().bold().bg(Color::DarkGray))
} else {
Tabs::new(agent_titles)
.block(
Block::default()
.borders(Borders::ALL)
.title(" agent [a/A] "),
)
.select(app.agent_tab)
.highlight_style(Style::default().bold().bg(Color::DarkGray))
};
f.render_widget(agent_tabs, filter_chunks[0]);
let sev_label = match &app.severity_filter {
None => "all".to_string(),
Some(s) => s.label().to_string(),
};
let sev_color = match &app.severity_filter {
None => Color::White,
Some(s) => s.color(),
};
let sev_widget = Paragraph::new(Line::from(vec![
Span::raw(" filter: "),
Span::styled(sev_label, Style::default().fg(sev_color).bold()),
Span::styled(" [s] cycle", Style::default().fg(Color::DarkGray)),
]))
.block(Block::default().borders(Borders::ALL).title(" severity "));
f.render_widget(sev_widget, filter_chunks[1]);
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(chunks[3]);
draw_table(f, app, body_chunks[0]);
draw_detail(f, app, body_chunks[1]);
let status = Paragraph::new(Line::from(vec![
Span::styled(" q", Style::default().fg(Color::Yellow)),
Span::raw(" quit "),
Span::styled("Tab", Style::default().fg(Color::Yellow)),
Span::raw(" kind "),
Span::styled("a", Style::default().fg(Color::Yellow)),
Span::raw(" agent "),
Span::styled("s", Style::default().fg(Color::Yellow)),
Span::raw(" severity "),
Span::styled("Enter", Style::default().fg(Color::Yellow)),
Span::raw(" detail "),
Span::styled("r", Style::default().fg(Color::Yellow)),
Span::raw(" refresh "),
Span::styled("?", Style::default().fg(Color::Yellow)),
Span::raw(" help"),
]))
.style(Style::default().bg(Color::DarkGray));
f.render_widget(status, chunks[4]);
if app.show_help {
draw_help(f, area);
}
}
fn draw_table(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let is_focused = app.focus == Focus::Table;
let block_style = if is_focused {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::DarkGray)
};
let header = Row::new(vec![
Cell::from("time").style(Style::default().bold()),
Cell::from("kind").style(Style::default().bold()),
Cell::from("sev").style(Style::default().bold()),
Cell::from("agent").style(Style::default().bold()),
Cell::from("command / action").style(Style::default().bold()),
])
.height(1)
.style(Style::default().bg(Color::DarkGray));
let rows: Vec<Row> = app
.filtered
.iter()
.map(|&idx| {
let e = &app.entries[idx];
let ts = e
.timestamp
.get(11..19)
.unwrap_or(e.timestamp.get(..8).unwrap_or(&e.timestamp));
let label = e.command.as_deref().or(e.action.as_deref()).unwrap_or("-");
let label = if label.len() > 35 {
format!("{}…", &label[..34])
} else {
label.to_string()
};
Row::new(vec![
Cell::from(ts),
Cell::from(Span::styled(
kind_label(e.kind),
Style::default().fg(kind_color(e.kind)),
)),
Cell::from(Span::styled(
e.severity.label(),
Style::default().fg(e.severity.color()),
)),
Cell::from(e.agent.as_deref().unwrap_or("-")),
Cell::from(label),
])
})
.collect();
let widths = [
Constraint::Length(8),
Constraint::Length(7),
Constraint::Length(4),
Constraint::Length(10),
Constraint::Min(0),
];
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(block_style)
.title(if is_focused {
" events [↑↓ navigate] "
} else {
" events "
}),
)
.row_highlight_style(Style::default().bg(Color::Blue).fg(Color::White).bold());
f.render_stateful_widget(table, area, &mut app.table_state);
}
fn draw_detail(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let is_focused = app.focus == Focus::Detail;
let block_style = if is_focused {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::DarkGray)
};
let entry: LogEntry = match app.selected_entry() {
Some(e) => e.clone(),
None => {
let p = Paragraph::new("select an event")
.block(
Block::default()
.borders(Borders::ALL)
.border_style(block_style)
.title(" detail "),
)
.alignment(Alignment::Center);
f.render_widget(p, area);
return;
}
};
let mut lines: Vec<Line> = Vec::new();
let ts_date = entry.timestamp.get(..10).unwrap_or("-");
let ts_time = entry.timestamp.get(11..19).unwrap_or("-");
lines.push(Line::from(vec![
Span::styled("time ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{} {}", ts_date, ts_time)),
]));
lines.push(Line::from(vec![
Span::styled("kind ", Style::default().fg(Color::DarkGray)),
Span::styled(
kind_label(entry.kind),
Style::default().fg(kind_color(entry.kind)).bold(),
),
]));
lines.push(Line::from(vec![
Span::styled("severity ", Style::default().fg(Color::DarkGray)),
Span::styled(
entry.severity.label(),
Style::default().fg(entry.severity.color()).bold(),
),
]));
lines.push(Line::from(""));
if let Some(ref a) = entry.agent {
lines.push(Line::from(vec![
Span::styled("agent ", Style::default().fg(Color::DarkGray)),
Span::raw(a.clone()),
]));
}
if let Some(ref b) = entry.branch {
lines.push(Line::from(vec![
Span::styled("branch ", Style::default().fg(Color::DarkGray)),
Span::raw(b.clone()),
]));
}
if let Some(ref w) = entry.worktree {
let wt = if w.len() > 40 {
format!("…{}", &w[w.len() - 39..])
} else {
w.clone()
};
lines.push(Line::from(vec![
Span::styled("worktree ", Style::default().fg(Color::DarkGray)),
Span::raw(wt),
]));
}
if let Some(ref cmd) = entry.command {
lines.push(Line::from(vec![
Span::styled("command ", Style::default().fg(Color::DarkGray)),
Span::raw(cmd.clone()),
]));
}
if let Some(ref act) = entry.action {
lines.push(Line::from(vec![
Span::styled("action ", Style::default().fg(Color::DarkGray)),
Span::raw(act.clone()),
]));
}
if !entry.env_vars.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"env vars",
Style::default().fg(Color::DarkGray),
)));
for var in &entry.env_vars {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(var.clone(), Style::default().fg(Color::Cyan)),
]));
}
}
let payload = entry.raw.get("payload").unwrap_or(&entry.raw);
if let Some(findings) = payload.get("policyFindings").and_then(|v| v.as_array()) {
if !findings.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"findings",
Style::default().fg(Color::DarkGray),
)));
for f in findings {
let sev = f.get("severity").and_then(|s| s.as_str()).unwrap_or("info");
let code = f.get("code").and_then(|s| s.as_str()).unwrap_or("?");
let msg = f.get("message").and_then(|s| s.as_str()).unwrap_or("");
let sev_color = match sev {
"critical" => Color::Red,
"warning" => Color::Yellow,
_ => Color::White,
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("[{}] ", sev), Style::default().fg(sev_color).bold()),
Span::styled(code, Style::default().fg(Color::DarkGray)),
Span::raw(format!(" {}", msg)),
]));
}
}
}
if let Some(commit) = payload
.get("git")
.and_then(|g| g.get("commit"))
.and_then(|s| s.as_str())
{
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("commit ", Style::default().fg(Color::DarkGray)),
Span::raw(commit.get(..12).unwrap_or(commit)),
]));
}
if let Some(scope) = payload.get("approvalScope").and_then(|s| s.as_str()) {
lines.push(Line::from(vec![
Span::styled("scope ", Style::default().fg(Color::DarkGray)),
Span::raw(scope),
]));
}
if let Some(source) = payload.get("approvalSource").and_then(|s| s.as_str()) {
lines.push(Line::from(vec![
Span::styled("source ", Style::default().fg(Color::DarkGray)),
Span::raw(source),
]));
}
if let Some(outcome) = payload.get("outcome").and_then(|o| o.as_str()) {
lines.push(Line::from(vec![
Span::styled("outcome ", Style::default().fg(Color::DarkGray)),
Span::styled(
outcome,
Style::default().fg(if outcome == "success" {
Color::Green
} else {
Color::Red
}),
),
]));
}
let total_lines = lines.len() as u16;
let visible = area.height.saturating_sub(2);
let max_scroll = total_lines.saturating_sub(visible);
let scroll = app.detail_scroll.min(max_scroll);
app.detail_scroll = scroll;
let text = Text::from(lines);
let detail = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(block_style)
.title(if is_focused {
" detail [↑↓ scroll / b back] "
} else {
" detail [Enter] "
}),
)
.scroll((scroll, 0))
.wrap(Wrap { trim: false });
f.render_widget(detail, area);
if total_lines > visible {
let mut scrollbar_state = ScrollbarState::new(total_lines as usize)
.position(scroll as usize)
.viewport_content_length(visible as usize);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
f.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
fn draw_help(f: &mut Frame, area: ratatui::layout::Rect) {
let width = 50u16.min(area.width.saturating_sub(4));
let height = 20u16.min(area.height.saturating_sub(4));
let x = (area.width.saturating_sub(width)) / 2;
let y = (area.height.saturating_sub(height)) / 2;
let popup = ratatui::layout::Rect {
x,
y,
width,
height,
};
f.render_widget(Clear, popup);
let lines = vec![
Line::from(Span::styled(
" Keyboard Shortcuts",
Style::default().bold().fg(Color::White),
)),
Line::from(""),
Line::from(vec![
Span::styled(" q / Esc ", Style::default().fg(Color::Yellow)),
Span::raw("quit"),
]),
Line::from(vec![
Span::styled(" Tab / BkTab ", Style::default().fg(Color::Yellow)),
Span::raw("cycle log kind"),
]),
Line::from(vec![
Span::styled(" a / A ", Style::default().fg(Color::Yellow)),
Span::raw("cycle agent filter"),
]),
Line::from(vec![
Span::styled(" s ", Style::default().fg(Color::Yellow)),
Span::raw("cycle severity filter"),
]),
Line::from(vec![
Span::styled(" ↑ / k ", Style::default().fg(Color::Yellow)),
Span::raw("move up"),
]),
Line::from(vec![
Span::styled(" ↓ / j ", Style::default().fg(Color::Yellow)),
Span::raw("move down"),
]),
Line::from(vec![
Span::styled(" PgUp/PgDn ", Style::default().fg(Color::Yellow)),
Span::raw("jump 20 rows"),
]),
Line::from(vec![
Span::styled(" Enter / l ", Style::default().fg(Color::Yellow)),
Span::raw("focus detail panel"),
]),
Line::from(vec![
Span::styled(" Space / b ", Style::default().fg(Color::Yellow)),
Span::raw("back to table"),
]),
Line::from(vec![
Span::styled(" r ", Style::default().fg(Color::Yellow)),
Span::raw("refresh logs"),
]),
Line::from(vec![
Span::styled(" ? / h ", Style::default().fg(Color::Yellow)),
Span::raw("toggle this help"),
]),
Line::from(""),
Line::from(Span::styled(
" Auto-refreshes every 5 seconds",
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(Span::styled(
" Press any key to close",
Style::default().fg(Color::DarkGray),
)),
];
let help = Paragraph::new(Text::from(lines))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(" help ")
.title_style(Style::default().bold()),
)
.style(Style::default().bg(Color::Black));
f.render_widget(help, popup);
}