⎇ c22d2480b93b7802be8e8ea5a0075b8175c4fedc
crates/apps/shipote-shell/src/main.rs
History
Who touched what
raw
1//! `shipote-shell` — GUI dashboard del daemon shipote.
2//!
3//! Probe-style: conecta al socket del daemon cada 2s, pide
4//! capabilities + workspace-list y los muestra en cards.
5//! Si el daemon no está corriendo, marca DOWN.
6
7use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window};
8use shipote_protocol::{
9 default_socket_path, read_frame, write_frame, CommandInfo, Request, Response, WorkspaceSummary,
10};
11use std::path::PathBuf;
12use std::time::Duration;
13use tokio::net::UnixStream;
14use yahweh_launcher::launch_app;
15use yahweh_theme::Theme;
16use yahweh_widget_app_header::app_header;
17use yahweh_widget_banner::{banner_themed, Banner};
18use yahweh_widget_stat_card::stat_card;
19
20const POLL_INTERVAL: Duration = Duration::from_secs(2);
21
22#[derive(Clone, Debug)]
23enum DaemonState {
24 Pending,
25 Down { reason: String },
26 Up,
27}
28
29#[derive(Clone, Debug, Default)]
30struct CapsSummary {
31 kernel_version: (u32, u32, u32),
32 user_ns: String,
33 cgroup_v2: String,
34 cgroup_delegated: bool,
35 has_cap_sys_admin: bool,
36}
37
38struct Shell {
39 socket_path: PathBuf,
40 state: DaemonState,
41 workspaces: Vec<WorkspaceSummary>,
42 /// Comandos por workspace, indexados por workspace id.toString().
43 commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
44 saved_pipelines: Vec<String>,
45 caps: Option<CapsSummary>,
46 last_probe_ms: u64,
47}
48
49fn main() {
50 launch_app("Shipote — Shell", (820., 560.), Shell::new);
51}
52
53impl Shell {
54 fn new(cx: &mut Context<Self>) -> Self {
55 let socket_path = default_socket_path();
56 let socket_for_loop = socket_path.clone();
57 cx.spawn(async move |this, cx| {
58 let timer = cx.background_executor().clone();
59 let bg = cx.background_executor().clone();
60 loop {
61 let path = socket_for_loop.clone();
62 let started = std::time::Instant::now();
63 let result = bg
64 .spawn(async move { probe_blocking(&path) })
65 .await;
66 let elapsed = started.elapsed().as_millis() as u64;
67 let _ = this.update(cx, |me, cx| {
68 match result {
69 Ok(snap) => {
70 me.state = DaemonState::Up;
71 me.workspaces = snap.workspaces;
72 me.commands = snap.commands;
73 me.saved_pipelines = snap.saved_pipelines;
74 me.caps = Some(snap.caps);
75 }
76 Err(reason) => {
77 me.state = DaemonState::Down { reason };
78 me.workspaces.clear();
79 me.commands.clear();
80 me.saved_pipelines.clear();
81 me.caps = None;
82 }
83 }
84 me.last_probe_ms = elapsed;
85 cx.notify();
86 });
87 timer.timer(POLL_INTERVAL).await;
88 }
89 })
90 .detach();
91
92 Self {
93 socket_path,
94 state: DaemonState::Pending,
95 workspaces: Vec::new(),
96 commands: std::collections::BTreeMap::new(),
97 saved_pipelines: Vec::new(),
98 caps: None,
99 last_probe_ms: 0,
100 }
101 }
102}
103
104#[derive(Debug)]
105struct Snapshot {
106 workspaces: Vec<WorkspaceSummary>,
107 commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
108 saved_pipelines: Vec<String>,
109 caps: CapsSummary,
110}
111
112fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
113 // Mini tokio runtime efímero por probe — no compartimos runtime con
114 // GPUI. Costo aceptable cada 2s: setup ≈ <1 ms.
115 let rt = tokio::runtime::Builder::new_current_thread()
116 .enable_io()
117 .enable_time()
118 .build()
119 .map_err(|e| format!("rt: {e}"))?;
120 rt.block_on(async {
121 let mut stream = UnixStream::connect(path)
122 .await
123 .map_err(|e| format!("connect: {e}"))?;
124 write_frame(&mut stream, &Request::WorkspaceList)
125 .await
126 .map_err(|e| format!("write list: {e}"))?;
127 let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read list: {e}"))?;
128 let workspaces = match resp {
129 Response::WorkspaceList { items } => items,
130 other => return Err(format!("unexpected list resp: {other:?}")),
131 };
132
133 // Commands por workspace.
134 let mut commands_map = std::collections::BTreeMap::new();
135 for w in &workspaces {
136 write_frame(&mut stream, &Request::CommandList { workspace: w.id })
137 .await
138 .map_err(|e| format!("write commands: {e}"))?;
139 let resp: Response = read_frame(&mut stream)
140 .await
141 .map_err(|e| format!("read commands: {e}"))?;
142 if let Response::CommandList { items } = resp {
143 if !items.is_empty() {
144 commands_map.insert(w.id.to_string(), items);
145 }
146 }
147 }
148
149 // Saved pipelines.
150 write_frame(&mut stream, &Request::PipelineSavedList)
151 .await
152 .map_err(|e| format!("write saved: {e}"))?;
153 let resp: Response = read_frame(&mut stream)
154 .await
155 .map_err(|e| format!("read saved: {e}"))?;
156 let saved_pipelines = match resp {
157 Response::PipelineSavedList { names } => names,
158 _ => Vec::new(),
159 };
160
161 write_frame(&mut stream, &Request::Capabilities)
162 .await
163 .map_err(|e| format!("write caps: {e}"))?;
164 let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read caps: {e}"))?;
165 let caps = match resp {
166 Response::Capabilities {
167 kernel_version,
168 user_ns,
169 cgroup_v2,
170 cgroup_delegated,
171 has_cap_sys_admin,
172 } => CapsSummary {
173 kernel_version,
174 user_ns,
175 cgroup_v2,
176 cgroup_delegated,
177 has_cap_sys_admin,
178 },
179 other => return Err(format!("unexpected caps resp: {other:?}")),
180 };
181 Ok(Snapshot {
182 workspaces,
183 commands: commands_map,
184 saved_pipelines,
185 caps,
186 })
187 })
188}
189
190impl Render for Shell {
191 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
192 let theme = Theme::global(cx).clone();
193 let bg = theme.bg_app.clone();
194 let text = theme.fg_text;
195 let text_dim = theme.fg_muted;
196
197 let accent_up = gpui::rgb(0xa3be8c);
198 let accent_down = gpui::rgb(0xbf616a);
199 let accent_pending = gpui::rgb(0x6a7280);
200 let accent_info = gpui::rgb(0x88c0d0);
201
202 let header_text = format!(
203 "Daemon: {} · reload {} ms",
204 self.socket_path.display(),
205 self.last_probe_ms
206 );
207 let header = app_header(cx, header_text);
208
209 let status_banner = match &self.state {
210 DaemonState::Pending => None,
211 DaemonState::Down { reason } => Some(banner_themed(
212 cx,
213 Banner::Error,
214 SharedString::from(format!("Daemon DOWN — {reason}")),
215 )),
216 DaemonState::Up => Some(banner_themed(
217 cx,
218 Banner::Success,
219 SharedString::from("Daemon UP"),
220 )),
221 };
222
223 let (status_value, status_descr, status_accent) = match &self.state {
224 DaemonState::Pending => ("PENDING".to_string(), "primer probe…".to_string(), accent_pending),
225 DaemonState::Down { reason } => ("DOWN".to_string(), reason.clone(), accent_down),
226 DaemonState::Up => ("UP".to_string(), "shipote-daemon respondiendo".to_string(), accent_up),
227 };
228
229 let caps_items: Vec<String> = self
230 .caps
231 .as_ref()
232 .map(|c| {
233 vec![
234 format!(
235 "kernel: {}.{}.{}",
236 c.kernel_version.0, c.kernel_version.1, c.kernel_version.2
237 ),
238 format!("user_ns: {}", c.user_ns),
239 format!("cgroup_v2: {}", c.cgroup_v2),
240 format!("cgroup_delegated: {}", c.cgroup_delegated),
241 format!("cap_sys_admin: {}", c.has_cap_sys_admin),
242 ]
243 })
244 .unwrap_or_default();
245 let caps_value = if self.caps.is_some() { "OK".to_string() } else { "—".to_string() };
246
247 let ws_items: Vec<String> = self
248 .workspaces
249 .iter()
250 .map(|w| format!("{} {:<20} cmds={} uptime={}ms", w.id, w.label, w.commands, w.uptime_ms))
251 .collect();
252 let ws_count = self.workspaces.len().to_string();
253 let ws_descr = if self.workspaces.is_empty() {
254 "no hay workspaces vivos".to_string()
255 } else {
256 "id · label · cmds · uptime".to_string()
257 };
258
259 // Comandos: aplanamos por workspace, tomamos los más recientes (orden ULID ya temporal).
260 let mut cmd_items: Vec<String> = Vec::new();
261 let mut cmd_total = 0usize;
262 for (ws_id, cmds) in &self.commands {
263 cmd_total += cmds.len();
264 for c in cmds.iter().rev().take(8) {
265 let alive = if c.alive { "▶" } else { "✓" };
266 let exit = c
267 .exit_status
268 .map(|s| format!(" exit={s}"))
269 .unwrap_or_default();
270 cmd_items.push(format!(
271 "{} {} {:<20} pid={} logs={}B{}",
272 alive,
273 &ws_id[..6.min(ws_id.len())],
274 c.label,
275 c.pid,
276 c.log_bytes,
277 exit
278 ));
279 }
280 }
281 let cmd_count = cmd_total.to_string();
282 let cmd_descr = if cmd_total == 0 {
283 "no hay comandos lanzados".to_string()
284 } else {
285 "▶=alive ✓=exited · ws_prefix · label · pid · logs".to_string()
286 };
287
288 // Saved pipelines.
289 let saved_count = self.saved_pipelines.len().to_string();
290 let saved_items: Vec<String> = self.saved_pipelines.clone();
291 let saved_descr = if saved_items.is_empty() {
292 "shipote pipeline save <name> <file> para persistir".to_string()
293 } else {
294 "definiciones reusables vía run-saved".to_string()
295 };
296
297 let body = div()
298 .flex()
299 .flex_col()
300 .gap(px(8.))
301 .px(px(16.))
302 .py(px(16.))
303 .child(stat_card(
304 cx,
305 "Estado",
306 status_value,
307 &status_descr,
308 status_accent,
309 text,
310 text_dim,
311 &[],
312 ))
313 .child(stat_card(
314 cx,
315 "Capabilities",
316 caps_value,
317 "kernel + namespaces + cgroup delegation",
318 accent_info,
319 text,
320 text_dim,
321 &caps_items,
322 ))
323 .child(stat_card(
324 cx,
325 "Workspaces",
326 ws_count,
327 &ws_descr,
328 accent_info,
329 text,
330 text_dim,
331 &ws_items,
332 ))
333 .child(stat_card(
334 cx,
335 "Comandos",
336 cmd_count,
337 &cmd_descr,
338 accent_info,
339 text,
340 text_dim,
341 &cmd_items,
342 ))
343 .child(stat_card(
344 cx,
345 "Saved pipelines",
346 saved_count,
347 &saved_descr,
348 accent_info,
349 text,
350 text_dim,
351 &saved_items,
352 ));
353
354 div()
355 .flex()
356 .flex_col()
357 .size_full()
358 .bg(bg)
359 .child(header)
360 .when_some(status_banner, |d, b| d.child(b))
361 .child(body)
362 }
363}