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, FlickerPanel, GlyphRain, NebulaGauge, NeonBorder, OrbField,
Paragraph, Radar, ScrinEffect, ScrinLoader, SignalPanel, Sparkline, StatusBar, StreamPanel,
Table, WaveType, Waveform,
};
use crate::app::{App, DetailTab};
use crate::daemon::{self, Daemon, DaemonAction};
pub const SERVICES_ID: &str = "dae:services";
pub const DETAIL_PREFIX: &str = "dae:detail";
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 = if app.anomaly_count() > 0 {
AislingPalette::flare()
} else {
AislingPalette::cypherpunk()
};
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,
}
}
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!(
"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();
let rain_density = if app.anomaly_count() > 0 { 11 } else { 6 };
GlyphRain::new(app.tick)
.density(rain_density)
.glyphs("dae01srv[]{}<>")
.palette(if app.anomaly_count() > 0 {
AislingPalette::flare()
} else {
AislingPalette::phosphor()
})
.render(frame.buffer(), area);
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(palette.block("services"))
.render_with_interaction(frame, SERVICES_ID, area);
NeonBorder::new(palette.block("services"))
.tick(app.tick)
.speed(if app.anomaly_count() > 0 { 2 } else { 4 })
.palette(palette)
.render(frame.buffer(), area);
frame.mark_dirty(area);
}
fn render_detail(frame: &mut Frame<'_>, area: Rect, app: &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",
};
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(palette.block(title))
.render_with_interaction(frame, detail_id, parts[1]);
NeonBorder::new(palette.block(title))
.tick(app.tick.saturating_add(17))
.speed(5)
.palette(palette)
.render(frame.buffer(), parts[1]);
frame.mark_dirty(parts[1]);
}
fn render_selected_panel(frame: &mut Frame<'_>, area: Rect, app: &App, palette: AislingPalette) {
let Some(daemon) = app.selected_daemon() else {
frame.render_widget_timed(
"empty detail",
Paragraph::new("no daemon selected")
.palette(palette)
.block(palette.block("selection")),
area,
);
frame.mark_dirty(area);
return;
};
let parts = if area.width >= 92 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(28),
Constraint::Percentage(22),
])
.split(area)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(64), Constraint::Percentage(36)])
.split(area)
};
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());
SignalPanel::new(daemon.unit.clone())
.line(format!("state: {}", daemon.state_label()))
.line(format!("pid: {pid} restarts: {restarts}"))
.line(format!(
"mem: {} cpu: {}",
daemon::format_bytes(daemon.memory_bytes),
daemon::format_cpu(daemon.cpu_nsec)
))
.line(format!("file: {}", blank_dash(&daemon.unit_file_state)))
.line(format!("since: {}", blank_dash(&daemon.active_enter)))
.line(format!("path: {}", blank_dash(&daemon.fragment_path)))
.tick(app.tick)
.palette(palette)
.render(frame.buffer(), parts[0]);
NeonBorder::new(palette.block("selected"))
.tick(app.tick.saturating_add(31))
.speed(6)
.palette(palette)
.render(frame.buffer(), parts[0]);
frame.mark_dirty(parts[0]);
let ratio = f64::from(daemon.severity()) / 100.0;
let anomaly_text = if daemon.anomalies.is_empty() {
"ok".to_string()
} else {
daemon
.anomalies
.iter()
.map(|anomaly| format!("{}: {}", anomaly.label, anomaly.detail))
.collect::<Vec<_>>()
.join("\n")
};
let sub = 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("anomaly"))
.render(frame.buffer(), sub[0]);
frame.mark_dirty(sub[0]);
Paragraph::new(anomaly_text)
.palette(palette)
.block(palette.block("signals"))
.aisling()
.tick(app.tick)
.intensity(if daemon.anomalies.is_empty() { 2 } else { 5 })
.render(frame.buffer(), sub[1]);
frame.mark_dirty(sub[1]);
if parts.len() > 2 {
render_selected_radar(frame, parts[2], app, daemon, palette);
}
}
fn render_selected_radar(
frame: &mut Frame<'_>,
area: Rect,
app: &App,
daemon: &Daemon,
palette: AislingPalette,
) {
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
.split(area);
let block = palette.block("cgroup radar");
let inner = block.inner(parts[0]);
block.render(frame.buffer(), parts[0]);
Radar::new(8 + u64::from(daemon.severity() / 20))
.tick(app.tick)
.palette(palette)
.render(frame.buffer(), inner);
NeonBorder::new(palette.block("cgroup radar"))
.tick(app.tick)
.speed(3)
.palette(palette)
.render(frame.buffer(), parts[0]);
frame.mark_dirty(parts[0]);
let trace = format!(
"pid {} mem {} cpu {} restarts {} result {}",
daemon
.main_pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "-".to_string()),
daemon::format_bytes(daemon.memory_bytes),
daemon::format_cpu(daemon.cpu_nsec),
daemon
.restarts
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
blank_dash(&daemon.result)
);
FlickerPanel::new(trace)
.tick(app.tick)
.intensity(if daemon.severity() > 0 { 5 } else { 2 })
.palette(palette)
.block(palette.block("trace"))
.render(frame.buffer(), parts[1]);
frame.mark_dirty(parts[1]);
}
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);
OrbField::new(14)
.tick(app.tick)
.palette(palette)
.render(frame.buffer(), area);
let block = Block::new("hover actions")
.with_borders(BorderStyle::Rounded)
.with_border_color(palette.pulse)
.with_bg(palette.shadow);
block.render(frame.buffer(), area);
NeonBorder::new(block.clone())
.tick(app.tick)
.speed(2)
.palette(palette)
.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
);
let center = format!(
"tab: {} | lookback: {}h | hover rows for actions | [/ ] scroll detail | / filter",
app.detail_tab.label(),
app.cli.lookback_hours
);
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 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
}