use scrin::core::buffer::Cell;
use scrin::effects::{EffectKind, LoaderKind};
use scrin::interaction::{HitRegion, MouseCursor, WidgetAction, WidgetRole};
use scrin::layout::{Constraint, Direction, Layout};
use scrin::widgets::{
Clear, Widget,
block::{Block, BorderStyle},
};
use scrin::{Color, Frame, Rect};
use scrin_widgets::{
AislingExt, AislingPalette, NebulaGauge, OrbField, Paragraph, ScrinEffect, ScrinLoader,
SignalPanel, Sparkline, StatusBar, StreamPanel, Table, WaveType, Waveform,
};
use crate::app::{ActivePane, App, DetailTab, ThemeMode};
use crate::daemon::{self, Daemon, DaemonAction};
pub const SERVICES_ID: &str = "dae:services";
pub const DETAIL_PREFIX: &str = "dae:detail";
pub const INSPECTOR_ID: &str = "dae:pane:inspector";
const JOURNAL_ID: &str = "dae:detail:journal";
const STATUS_ID: &str = "dae:detail:status";
const ACTION_PREFIX: &str = "dae:action:";
pub fn render(frame: &mut Frame<'_>, app: &mut App) {
let palette = palette_for(app);
let area = frame.area();
frame.render_widget_timed("clear", Clear::with_bg(palette.shadow), area);
frame.mark_dirty(area);
if area.width < 64 || area.height < 18 {
render_tiny(frame, area, app, palette);
return;
}
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7),
Constraint::Min(10),
Constraint::Length(1),
])
.split(area);
render_header(frame, root[0], app, palette);
let body = if root[1].width < 110 {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(54), Constraint::Percentage(46)])
.split(root[1])
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(62), Constraint::Percentage(38)])
.split(root[1])
};
render_service_table(frame, body[0], app, palette);
render_detail(frame, body[1], app, palette);
render_hover_overlay(frame, area, app, palette);
render_status(frame, root[2], app, palette);
}
pub fn service_row_from_id(id: &str) -> Option<usize> {
id.strip_prefix("dae:services:row:")?.parse().ok()
}
pub fn action_from_id(id: &str) -> Option<DaemonAction> {
match id.strip_prefix(ACTION_PREFIX)? {
"start" => Some(DaemonAction::Start),
"stop" => Some(DaemonAction::Stop),
"restart" => Some(DaemonAction::Restart),
"reload" => Some(DaemonAction::Reload),
"enable" => Some(DaemonAction::Enable),
"disable" => Some(DaemonAction::Disable),
"term" => Some(DaemonAction::KillTerm),
"kill" => Some(DaemonAction::KillKill),
_ => None,
}
}
pub fn pane_from_id(id: &str) -> Option<ActivePane> {
if id == SERVICES_ID || id.starts_with("dae:services:") {
Some(ActivePane::Services)
} else if id == INSPECTOR_ID {
Some(ActivePane::Inspector)
} else if id.starts_with(DETAIL_PREFIX) {
Some(ActivePane::Detail)
} else {
None
}
}
fn palette_for(app: &App) -> AislingPalette {
match app.theme_mode {
ThemeMode::Auto if app.anomaly_count() > 0 => AislingPalette::flare(),
ThemeMode::Auto => AislingPalette::cypherpunk(),
ThemeMode::Cypher => AislingPalette::cypherpunk(),
ThemeMode::Dream => AislingPalette::dream(),
ThemeMode::Phosphor => AislingPalette::phosphor(),
ThemeMode::Flare => AislingPalette::flare(),
}
}
fn pane_block<'a>(
app: &App,
pane: ActivePane,
title: &'a str,
palette: AislingPalette,
) -> Block<'a> {
let block = Block::new(title)
.with_borders(BorderStyle::Plain)
.with_border_color(if app.active_pane == pane {
palette.pulse
} else {
palette.low
})
.with_bg(palette.shadow);
if app.active_pane == pane {
block.with_title_right("active")
} else {
block
}
}
fn register_pane(frame: &mut Frame<'_>, id: &'static str, area: Rect, pane: ActivePane) {
frame.register_hit_region(
HitRegion::new(id, area)
.with_role(WidgetRole::Pane)
.with_label(pane.label())
.with_action(WidgetAction::Focus)
.with_cursor(MouseCursor::Pointer)
.with_z_index(-10),
);
}
fn render_scrollbar(
frame: &mut Frame<'_>,
area: Rect,
total_rows: usize,
viewport_rows: usize,
top_offset: usize,
palette: AislingPalette,
active: bool,
) {
if area.width == 0 || area.height < 2 || total_rows <= viewport_rows.max(1) {
return;
}
let track_height = usize::from(area.height);
let max_offset = total_rows.saturating_sub(viewport_rows.max(1)).max(1);
let thumb_len = ((viewport_rows.max(1) as f64 / total_rows.max(1) as f64) * track_height as f64)
.round()
.clamp(1.0, track_height as f64) as usize;
let travel = track_height.saturating_sub(thumb_len);
let thumb_pos = if travel == 0 {
0
} else {
((top_offset.min(max_offset) as f64 / max_offset as f64) * travel as f64)
.round()
.min(travel as f64) as usize
};
let x = area.right().saturating_sub(1);
let thumb = if active { palette.pulse } else { palette.mid };
for row in 0..track_height {
let y = area.y.saturating_add(row as u16);
let in_thumb = row >= thumb_pos && row < thumb_pos.saturating_add(thumb_len);
let cell = if in_thumb {
Cell::new('#', thumb, Some(palette.shadow)).with_bold(active)
} else {
Cell::new('|', palette.low, Some(palette.shadow))
};
frame.buffer().set(usize::from(x), usize::from(y), cell);
}
}
fn render_tiny(frame: &mut Frame<'_>, area: Rect, app: &App, palette: AislingPalette) {
let text = format!(
"dae needs at least 64x18. current: {}x{}\nservices: {} anomalies: {}\nq quits",
area.width,
area.height,
app.daemons.len(),
app.anomaly_count()
);
frame.render_widget_timed(
"tiny",
Paragraph::new(text)
.palette(palette)
.aisling()
.tick(app.tick)
.intensity(3),
area,
);
frame.mark_dirty(area);
}
fn render_header(frame: &mut Frame<'_>, area: Rect, app: &App, palette: AislingPalette) {
let parts = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(29),
Constraint::Percentage(27),
Constraint::Percentage(22),
Constraint::Percentage(22),
])
.split(area);
let effects = EffectKind::all();
let effect = effects[((app.tick / 96) as usize) % effects.len()];
ScrinEffect::new(effect, "dae // scrin aisling scrin-widgets")
.tick(app.tick)
.duration(64)
.seed(42)
.palette(palette)
.block(palette.block(effect.name()))
.render_with_interaction(frame, "dae:header:effect", parts[0]);
let selected = app
.selected_daemon()
.map(|daemon| daemon.unit.as_str())
.unwrap_or("none");
SignalPanel::new("daemon lens")
.line(format!(
"{} services / {} running",
app.daemons.len(),
app.running_count()
))
.line(format!(
"{} anomalies / {} aisling fx",
app.anomaly_count(),
effects.len()
))
.line(format!("selected: {selected}"))
.line(format!(
"sort: {} / theme: {}{}",
app.sort_mode.label(),
app.theme_mode.label(),
if app.anomalies_only {
" / anomalies"
} else {
""
}
))
.line(format!(
"lookback: {}h / filter: {}",
app.cli.lookback_hours,
if app.filter.is_empty() {
"all"
} else {
app.filter.as_str()
}
))
.tick(app.tick)
.palette(palette)
.render(frame.buffer(), parts[1]);
frame.mark_dirty(parts[1]);
let pulse = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(52), Constraint::Percentage(48)])
.split(parts[2]);
Sparkline::new(fleet_sparkline(app))
.max_value(100)
.palette(palette)
.block(palette.block("fleet pulse"))
.render(frame.buffer(), pulse[0]);
frame.mark_dirty(pulse[0]);
let wave_type = match app.anomaly_count() % 4 {
0 => WaveType::Sine,
1 => WaveType::Triangle,
2 => WaveType::Sawtooth,
_ => WaveType::Square,
};
Waveform::new(2.0 + app.anomaly_count() as f64 * 0.12, 0.78)
.tick(app.tick)
.wave_type(wave_type)
.palette(palette)
.block(palette.block("signal"))
.render(frame.buffer(), pulse[1]);
frame.mark_dirty(pulse[1]);
let progress = wave_ratio(app.tick, 160, 0) as f32;
let loaders = LoaderKind::all();
let loader = loaders[((app.tick / 72) as usize) % loaders.len()];
OrbField::new(10)
.tick(app.tick)
.palette(palette)
.render(frame.buffer(), parts[3]);
ScrinLoader::new(loader, progress)
.tick(app.tick)
.label(loader.name())
.unit("units")
.fraction(true)
.palette(palette)
.block(palette.block("scan"))
.render_with_interaction(frame, "dae:header:loader", parts[3]);
}
fn render_service_table(frame: &mut Frame<'_>, area: Rect, app: &mut App, palette: AislingPalette) {
let table_rows = area.height.saturating_sub(4).max(1);
app.table_rows_hint = table_rows;
app.ensure_selected_visible();
fill(frame, area, palette.shadow);
frame.mark_dirty(area);
let rows = app
.visible_indices()
.into_iter()
.filter_map(|idx| app.daemons.get(idx))
.map(service_row)
.collect::<Vec<_>>();
let unit_w = (area.width.saturating_mul(34) / 100).clamp(18, 44);
let state_w = (area.width.saturating_mul(18) / 100).clamp(12, 22);
let pid_w = 7;
let mem_w = 10;
let rst_w = 5;
let used = unit_w
.saturating_add(state_w)
.saturating_add(pid_w)
.saturating_add(mem_w)
.saturating_add(rst_w);
let anom_w = area.width.saturating_sub(used).max(12);
Table::new(["unit", "state", "pid", "mem", "rst", "signal"])
.rows(rows)
.widths(vec![unit_w, state_w, pid_w, mem_w, rst_w, anom_w])
.selected(Some(app.selected))
.scroll_offset(app.table_scroll)
.tick(app.tick)
.palette(palette)
.block(pane_block(app, ActivePane::Services, "services", palette))
.render_with_interaction(frame, SERVICES_ID, area);
register_pane(frame, SERVICES_ID, area, ActivePane::Services);
render_scrollbar(
frame,
area.inner(Rect::new(1, 2, 1, 1)),
app.visible_count(),
usize::from(app.table_rows_hint.max(1)),
usize::from(app.table_scroll),
palette,
app.active_pane == ActivePane::Services,
);
frame.mark_dirty(area);
}
fn render_detail(frame: &mut Frame<'_>, area: Rect, app: &mut App, palette: AislingPalette) {
let selected_height = if area.height >= 24 { 12 } else { 10 };
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(selected_height), Constraint::Min(8)])
.split(area);
render_selected_panel(frame, parts[0], app, palette);
let detail_id = match app.detail_tab {
DetailTab::Journal => JOURNAL_ID,
DetailTab::Status => STATUS_ID,
};
let title = match app.detail_tab {
DetailTab::Journal => "journal",
DetailTab::Status => "status",
};
app.detail_rows_hint = parts[1].height.saturating_sub(2).max(1);
StreamPanel::new()
.lines(app.detail_lines().iter().cloned())
.show_line_numbers(true)
.follow_tail(false)
.scroll_offset(app.detail_scroll)
.tick(app.tick)
.palette(palette)
.block(pane_block(app, ActivePane::Detail, title, palette))
.render_with_interaction(frame, detail_id, parts[1]);
let total = app.detail_lines().len();
let viewport = usize::from(app.detail_rows_hint.max(1));
let max_top = total.saturating_sub(viewport);
let top_offset = max_top.saturating_sub(usize::from(app.detail_scroll));
render_scrollbar(
frame,
parts[1].inner(Rect::new(1, 1, 1, 1)),
total,
viewport,
top_offset,
palette,
app.active_pane == ActivePane::Detail,
);
frame.mark_dirty(parts[1]);
}
fn render_selected_panel(
frame: &mut Frame<'_>,
area: Rect,
app: &mut App,
palette: AislingPalette,
) {
let Some(daemon) = app.selected_daemon().cloned() else {
frame.render_widget_timed(
"empty detail",
Paragraph::new("no daemon selected")
.palette(palette)
.block(pane_block(app, ActivePane::Inspector, "inspector", palette)),
area,
);
frame.mark_dirty(area);
return;
};
let text = inspector_text(&daemon);
let total = text.lines().count();
app.inspector_total_hint = total;
app.inspector_rows_hint = area.height.saturating_sub(2).max(1);
let ratio = f64::from(daemon.severity()) / 100.0;
if area.width >= 72 && area.height >= 9 {
let parts = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(68), Constraint::Percentage(32)])
.split(area);
Paragraph::new(text)
.scroll_offset(app.inspector_scroll)
.palette(palette)
.block(pane_block(app, ActivePane::Inspector, "inspector", palette))
.render(frame.buffer(), parts[0]);
register_pane(frame, INSPECTOR_ID, parts[0], ActivePane::Inspector);
render_scrollbar(
frame,
parts[0].inner(Rect::new(1, 1, 1, 1)),
total,
usize::from(app.inspector_rows_hint.max(1)),
usize::from(app.inspector_scroll),
palette,
app.active_pane == ActivePane::Inspector,
);
frame.mark_dirty(parts[0]);
let side = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(4), Constraint::Min(3)])
.split(parts[1]);
NebulaGauge::new(ratio)
.tick(app.tick)
.label(format!("risk {:>3}%", daemon.severity()))
.palette(palette)
.block(palette.block("risk"))
.render(frame.buffer(), side[0]);
frame.mark_dirty(side[0]);
SignalPanel::new("controls")
.line("Tab focus pane")
.line("Wheel/Pg scroll")
.line("v detail tab")
.line("r/x/s manage")
.tick(app.tick)
.palette(palette)
.render(frame.buffer(), side[1]);
frame.mark_dirty(side[1]);
} else {
Paragraph::new(text)
.scroll_offset(app.inspector_scroll)
.palette(palette)
.block(pane_block(app, ActivePane::Inspector, "inspector", palette))
.render(frame.buffer(), area);
register_pane(frame, INSPECTOR_ID, area, ActivePane::Inspector);
frame.mark_dirty(area);
}
}
fn render_hover_overlay(frame: &mut Frame<'_>, root: Rect, app: &App, palette: AislingPalette) {
let Some(row) = app.hovered_row else {
return;
};
let Some(daemon) = app.visible_daemon_at(row) else {
return;
};
let Some((mx, my)) = app.mouse_position else {
return;
};
let width = root.width.min(54).max(40);
let height = 12.min(root.height.saturating_sub(2)).max(9);
let x = if mx.saturating_add(width).saturating_add(2) >= root.right() {
root.right().saturating_sub(width).saturating_sub(1)
} else {
mx.saturating_add(2)
};
let y = if my.saturating_add(height).saturating_add(1) >= root.bottom() {
root.bottom().saturating_sub(height).saturating_sub(1)
} else {
my.saturating_add(1)
};
let area = Rect::new(x, y, width, height);
fill(frame, area, palette.shadow);
let block = Block::new("hover actions")
.with_borders(BorderStyle::Rounded)
.with_border_color(palette.pulse)
.with_bg(palette.shadow);
block.render(frame.buffer(), area);
let inner = block.inner(area);
draw_line(
frame,
inner.x,
inner.y,
inner.width,
&daemon.unit,
palette.high,
Some(palette.shadow),
);
draw_line(
frame,
inner.x,
inner.y.saturating_add(1),
inner.width,
&format!(
"{} | pid {} | {}",
daemon.state_label(),
daemon
.main_pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "-".to_string()),
daemon.anomaly_summary()
),
palette.mid,
Some(palette.shadow),
);
let button_y = inner.y.saturating_add(3);
render_action_button(
frame,
Rect::new(inner.x, button_y, 11, 1),
"restart",
"restart",
palette,
);
render_action_button(
frame,
Rect::new(inner.x.saturating_add(12), button_y, 9, 1),
"start",
"start",
palette,
);
render_action_button(
frame,
Rect::new(inner.x.saturating_add(22), button_y, 8, 1),
"stop",
"stop",
palette,
);
let button_y_2 = button_y.saturating_add(2);
render_action_button(
frame,
Rect::new(inner.x, button_y_2, 8, 1),
"term",
"term",
palette,
);
render_action_button(
frame,
Rect::new(inner.x.saturating_add(9), button_y_2, 8, 1),
"kill",
"kill",
palette,
);
render_action_button(
frame,
Rect::new(inner.x.saturating_add(18), button_y_2, 9, 1),
"reload",
"reload",
palette,
);
let button_y_3 = button_y_2.saturating_add(2);
render_action_button(
frame,
Rect::new(inner.x, button_y_3, 10, 1),
"enable",
"enable",
palette,
);
render_action_button(
frame,
Rect::new(inner.x.saturating_add(11), button_y_3, 11, 1),
"disable",
"disable",
palette,
);
draw_line(
frame,
inner.x,
inner.bottom().saturating_sub(1),
inner.width,
"click a button, or use r/x/s/t/K/l/e/d",
palette.low,
Some(palette.shadow),
);
frame.mark_dirty(area);
}
fn render_action_button(
frame: &mut Frame<'_>,
area: Rect,
label: &str,
action_id: &str,
palette: AislingPalette,
) {
if area.is_empty() {
return;
}
let bg = match action_id {
"kill" | "term" | "stop" | "disable" => palette.pulse,
"restart" | "reload" => palette.low,
_ => palette.mid,
};
fill(frame, area, bg);
let text = format!("[{label}]");
draw_line(
frame,
area.x,
area.y,
area.width,
&text,
palette.shadow,
Some(bg),
);
frame.register_hit_region(
HitRegion::new(format!("{ACTION_PREFIX}{action_id}"), area)
.with_role(WidgetRole::Button)
.with_label(label)
.with_action(WidgetAction::Custom(action_id.to_string()))
.with_cursor(MouseCursor::Pointer)
.with_z_index(30),
);
}
fn render_status(frame: &mut Frame<'_>, area: Rect, app: &App, palette: AislingPalette) {
let filter = if app.filter.is_empty() {
"filter: all".to_string()
} else {
format!("filter: {}", app.filter)
};
let left = format!(
"dae | {} visible / {} total | {}{}",
app.visible_count(),
app.daemons.len(),
filter,
if app.anomalies_only {
" | anomalies-only"
} else {
""
}
);
let center = format!(
"pane {} | tab {} | sort {} | theme {} | wheel/Pg scroll",
app.active_pane.label(),
app.detail_tab.label(),
app.sort_mode.label(),
app.theme_mode.label()
);
let right = clip(&app.message, usize::from(area.width / 3));
frame.render_widget_timed(
"status",
StatusBar::new()
.left(left)
.center(center)
.right(right)
.palette(palette),
area,
);
frame.mark_dirty(area);
}
fn service_row(daemon: &Daemon) -> Vec<String> {
vec![
daemon.unit.clone(),
daemon.state_label(),
daemon
.main_pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "-".to_string()),
daemon::format_bytes(daemon.memory_bytes),
daemon
.restarts
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
daemon.anomaly_summary(),
]
}
fn inspector_text(daemon: &Daemon) -> String {
let pid = daemon
.main_pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "-".to_string());
let restarts = daemon
.restarts
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string());
let exec_status = daemon
.exec_status
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string());
let mut lines = vec![
daemon.unit.clone(),
daemon.description.clone(),
String::new(),
format!("state: {}", daemon.state_label()),
format!(
"load: {} result: {}",
daemon.load,
blank_dash(&daemon.result)
),
format!("pid: {pid} restarts: {restarts} exec: {exec_status}"),
format!(
"memory: {} cpu: {}",
daemon::format_bytes(daemon.memory_bytes),
daemon::format_cpu(daemon.cpu_nsec)
),
format!("unit file: {}", blank_dash(&daemon.unit_file_state)),
format!("active since: {}", blank_dash(&daemon.active_enter)),
format!("fragment: {}", blank_dash(&daemon.fragment_path)),
String::new(),
"anomalies:".to_string(),
];
if daemon.anomalies.is_empty() {
lines.push("ok".to_string());
} else {
for anomaly in &daemon.anomalies {
lines.push(format!(
"{} [{}]: {}",
anomaly.label, anomaly.severity, anomaly.detail
));
}
}
lines.extend([
String::new(),
"controls:".to_string(),
"Tab focus pane, v journal/status, wheel/PgUp/PgDn scroll active pane".to_string(),
"s start, x stop, r restart, l reload, t TERM, K KILL, e enable, d disable".to_string(),
]);
lines.join("\n")
}
fn fleet_sparkline(app: &App) -> Vec<u16> {
let mut data = app
.daemons
.iter()
.map(|daemon| {
let severity = u16::from(daemon.severity());
let restart = daemon.restarts.unwrap_or(0).min(20) as u16 * 3;
let memory = daemon
.memory_bytes
.map(|bytes| (bytes / (64 * 1024 * 1024)).min(35) as u16)
.unwrap_or(0);
severity
.saturating_add(restart)
.saturating_add(memory)
.min(100)
})
.collect::<Vec<_>>();
if data.is_empty() {
data.push(0);
}
while data.len() < 32 {
let idx = data.len() as u64;
data.push(((app.tick + idx * 7) % 37) as u16);
}
data.truncate(96);
data
}
fn wave_ratio(tick: u64, period: u64, offset: u64) -> f64 {
let phase = ((tick + offset) % period) as f64 / period as f64;
1.0 - (phase - 0.5).abs() * 2.0
}
fn blank_dash(value: &str) -> &str {
if value.is_empty() { "-" } else { value }
}
fn fill(frame: &mut Frame<'_>, area: Rect, color: Color) {
let buffer = frame.buffer();
for y in area.y..area.bottom() {
for x in area.x..area.right() {
buffer.set(
usize::from(x),
usize::from(y),
Cell::new(' ', color, Some(color)),
);
}
}
}
fn draw_line(
frame: &mut Frame<'_>,
x: u16,
y: u16,
width: u16,
text: &str,
fg: Color,
bg: Option<Color>,
) {
frame.buffer().set_str(
usize::from(x),
usize::from(y),
&clip(text, usize::from(width)),
fg,
bg,
);
}
fn clip(text: &str, max: usize) -> String {
if max == 0 {
return String::new();
}
let mut out = String::new();
for ch in text.chars().take(max) {
out.push(ch);
}
out
}