main
rs 294 lines 9.73 KB
Raw
1 use chrono::Utc;
2 use ratatui::{
3 layout::{Alignment, Constraint, Layout, Rect},
4 style::{Color, Modifier, Style, Stylize},
5 text::{Line, Span},
6 widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap},
7 Frame,
8 };
9
10 use crate::process::ProcessStatus;
11 use crate::tui::app::{App, LogSource};
12
13 // ── Entry point ───────────────────────────────────────────────────────────────
14
15 /// Render one full frame: title bar → process table + log pane → status bar.
16 pub fn render(frame: &mut Frame, app: &mut App) {
17 let area = frame.area();
18
19 let chunks = Layout::vertical([
20 Constraint::Length(3), // title / header
21 Constraint::Fill(1), // process table + log pane
22 Constraint::Length(1), // status / help bar
23 ])
24 .split(area);
25
26 render_title(frame, chunks[0], app);
27 render_body(frame, chunks[1], app);
28 render_statusbar(frame, chunks[2], app);
29 }
30
31 // ── Title bar ─────────────────────────────────────────────────────────────────
32
33 fn render_title(frame: &mut Frame, area: Rect, app: &App) {
34 let online = app
35 .processes
36 .iter()
37 .filter(|p| p.status == ProcessStatus::Running)
38 .count();
39 let stopped = app
40 .processes
41 .iter()
42 .filter(|p| p.status == ProcessStatus::Stopped)
43 .count();
44 let errored = app
45 .processes
46 .iter()
47 .filter(|p| p.status == ProcessStatus::Errored)
48 .count();
49
50 let title_line = Line::from(vec![
51 Span::styled(" PROSES ", Style::default().bold().fg(Color::White)),
52 Span::styled(" ", Style::default()),
53 Span::styled(
54 format!("● {online} online"),
55 Style::default().fg(Color::Green).bold(),
56 ),
57 Span::styled(" · ", Style::default().fg(Color::DarkGray)),
58 Span::styled(
59 format!("○ {stopped} stopped"),
60 if stopped > 0 {
61 Style::default().fg(Color::Gray)
62 } else {
63 Style::default().fg(Color::DarkGray)
64 },
65 ),
66 Span::styled(" · ", Style::default().fg(Color::DarkGray)),
67 Span::styled(
68 format!("✕ {errored} errored"),
69 if errored > 0 {
70 Style::default().fg(Color::Red).bold()
71 } else {
72 Style::default().fg(Color::DarkGray)
73 },
74 ),
75 ]);
76
77 let block = Block::default()
78 .borders(Borders::ALL)
79 .border_style(Style::default().fg(Color::DarkGray))
80 .title(title_line)
81 .title_alignment(Alignment::Left)
82 .title_bottom(
83 Line::from(Span::styled(
84 " proses v0.1.0 ",
85 Style::default().fg(Color::DarkGray),
86 ))
87 .right_aligned(),
88 );
89
90 frame.render_widget(block, area);
91 }
92
93 // ── Body: process table + log pane ───────────────────────────────────────────
94
95 fn render_body(frame: &mut Frame, area: Rect, app: &mut App) {
96 let chunks = Layout::vertical([Constraint::Percentage(50), Constraint::Fill(1)]).split(area);
97
98 render_process_table(frame, chunks[0], app);
99 render_log_pane(frame, chunks[1], app);
100 }
101
102 fn render_process_table(frame: &mut Frame, area: Rect, app: &mut App) {
103 let header_style = Style::default().bold().fg(Color::Cyan);
104 let selected_style = Style::default().add_modifier(Modifier::REVERSED);
105
106 let header = Row::new(vec![
107 Cell::from(" ID"),
108 Cell::from("Name"),
109 Cell::from("Status"),
110 Cell::from("PID"),
111 Cell::from("↺"),
112 Cell::from("Uptime"),
113 Cell::from("Command"),
114 ])
115 .style(header_style)
116 .bottom_margin(0);
117
118 let rows: Vec<Row> = app
119 .processes
120 .iter()
121 .map(|p| {
122 let (symbol, status_style) = match p.status {
123 ProcessStatus::Running => ("● online ", Style::default().fg(Color::Green).bold()),
124 ProcessStatus::Stopped => ("○ stopped", Style::default().fg(Color::DarkGray)),
125 ProcessStatus::Errored => ("✕ errored", Style::default().fg(Color::Red).bold()),
126 };
127
128 let uptime = if p.status == ProcessStatus::Running && p.pid > 0 {
129 format_uptime(p.started_at)
130 } else {
131 "–".to_string()
132 };
133
134 let pid_str = if p.pid > 0 {
135 p.pid.to_string()
136 } else {
137 "–".to_string()
138 };
139
140 Row::new(vec![
141 Cell::from(format!(" {}", p.id)),
142 Cell::from(p.name.clone()),
143 Cell::from(Span::styled(symbol, status_style)),
144 Cell::from(pid_str),
145 Cell::from(p.restarts.to_string()),
146 Cell::from(uptime),
147 Cell::from(truncate(&p.command, 48)),
148 ])
149 })
150 .collect();
151
152 let widths = [
153 Constraint::Length(5), // ID
154 Constraint::Length(18), // Name
155 Constraint::Length(11), // Status
156 Constraint::Length(8), // PID
157 Constraint::Length(4), // ↺
158 Constraint::Length(10), // Uptime
159 Constraint::Fill(1), // Command
160 ];
161
162 let table = Table::new(rows, widths)
163 .header(header)
164 .block(
165 Block::default()
166 .borders(Borders::ALL)
167 .border_style(Style::default().fg(Color::DarkGray))
168 .title(Span::styled(
169 " Processes ",
170 Style::default().fg(Color::White).bold(),
171 )),
172 )
173 .row_highlight_style(selected_style)
174 .highlight_symbol("▶ ");
175
176 frame.render_stateful_widget(table, area, &mut app.table_state);
177 }
178
179 fn render_log_pane(frame: &mut Frame, area: Rect, app: &App) {
180 let proc_name = app
181 .selected_process()
182 .map(|p| p.name.as_str())
183 .unwrap_or("none");
184
185 let src_label = match app.log_source {
186 LogSource::Stdout => "stdout",
187 LogSource::Stderr => "stderr",
188 };
189
190 let title = format!(" Logs: {proc_name} [{src_label}] ");
191
192 let content: Vec<Line> = if app.log_lines.is_empty() {
193 vec![Line::from(Span::styled(
194 " (no output yet)",
195 Style::default().fg(Color::DarkGray),
196 ))]
197 } else {
198 app.log_lines
199 .iter()
200 .map(|l| Line::from(Span::raw(format!(" {l}"))))
201 .collect()
202 };
203
204 // Auto-scroll: always show the most recent lines at the bottom.
205 let total_lines = content.len() as u16;
206 let inner_height = area.height.saturating_sub(2); // subtract borders
207 let scroll_offset = total_lines.saturating_sub(inner_height);
208
209 let para = Paragraph::new(content)
210 .block(
211 Block::default()
212 .borders(Borders::ALL)
213 .border_style(Style::default().fg(Color::DarkGray))
214 .title(Span::styled(
215 title,
216 Style::default().fg(Color::White).bold(),
217 )),
218 )
219 .wrap(Wrap { trim: false })
220 .scroll((scroll_offset, 0));
221
222 frame.render_widget(para, area);
223 }
224
225 // ── Status / help bar ─────────────────────────────────────────────────────────
226
227 fn render_statusbar(frame: &mut Frame, area: Rect, app: &App) {
228 let line = if app.confirm_delete {
229 Line::from(vec![Span::styled(
230 " ⚠ Press d again to confirm deletion · Esc to cancel",
231 Style::default().fg(Color::Yellow).bold(),
232 )])
233 } else if let Some((msg, is_error)) = app.status_text() {
234 let color = if is_error { Color::Red } else { Color::Green };
235 Line::from(Span::styled(format!(" {msg}"), Style::default().fg(color)))
236 } else {
237 help_line()
238 };
239
240 frame.render_widget(Paragraph::new(line), area);
241 }
242
243 fn help_line<'a>() -> Line<'a> {
244 let dim = Style::default().fg(Color::DarkGray);
245 let key = Style::default().fg(Color::White);
246
247 Line::from(vec![
248 Span::styled(" ", dim),
249 Span::styled("↑↓", key),
250 Span::styled(" navigate", dim),
251 Span::styled(" r", key),
252 Span::styled(" restart", dim),
253 Span::styled(" s", key),
254 Span::styled(" stop/start", dim),
255 Span::styled(" d", key),
256 Span::styled(" delete", dim),
257 Span::styled(" e", key),
258 Span::styled(" stderr/stdout", dim),
259 Span::styled(" q", key),
260 Span::styled(" quit", dim),
261 ])
262 }
263
264 // ── Utilities ─────────────────────────────────────────────────────────────────
265
266 fn truncate(s: &str, max: usize) -> String {
267 let chars: Vec<char> = s.chars().collect();
268 if chars.len() <= max {
269 s.to_string()
270 } else {
271 let truncated: String = chars[..max - 1].iter().collect();
272 format!("{truncated}…")
273 }
274 }
275
276 fn format_uptime(started_at: chrono::DateTime<Utc>) -> String {
277 let secs = (Utc::now() - started_at).num_seconds().max(0) as u64;
278
279 if secs < 60 {
280 format!("{secs}s")
281 } else if secs < 3600 {
282 let m = secs / 60;
283 let s = secs % 60;
284 format!("{m}m {s}s")
285 } else if secs < 86_400 {
286 let h = secs / 3600;
287 let m = (secs % 3600) / 60;
288 format!("{h}h {m}m")
289 } else {
290 let d = secs / 86_400;
291 let h = (secs % 86_400) / 3600;
292 format!("{d}d {h}h")
293 }
294 }