tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198630 Definitions

⎇ c22d2480b93b7802be8e8ea5a0075b8175c4fedc crates/apps/shipote-shell/src/main.rs Rust · 363 lines · 12.2 KB History Who touched what raw

shell c22d2480b · sergio ·

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}