shell
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "shipote-shell"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "GUI de shipote: vista de Workspaces+comandos+capabilities. Conecta al daemon vía shipote-protocol."
|
||||
|
||||
[[bin]]
|
||||
name = "shipote-shell"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
shipote-card = { path = "../../modules/shipote/shipote-card" }
|
||||
shipote-protocol = { path = "../../modules/shipote/shipote-protocol" }
|
||||
yahweh-theme = { path = "../../modules/ui_engine/libs/theme" }
|
||||
yahweh-launcher = { path = "../../modules/ui_engine/libs/launcher" }
|
||||
yahweh-widget-banner = { path = "../../modules/ui_engine/widgets/banner" }
|
||||
yahweh-widget-stat-card = { path = "../../modules/ui_engine/widgets/stat-card" }
|
||||
yahweh-widget-app-header = { path = "../../modules/ui_engine/widgets/app-header" }
|
||||
tokio = { workspace = true }
|
||||
gpui = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
@@ -0,0 +1,363 @@
|
||||
//! `shipote-shell` — GUI dashboard del daemon shipote.
|
||||
//!
|
||||
//! Probe-style: conecta al socket del daemon cada 2s, pide
|
||||
//! capabilities + workspace-list y los muestra en cards.
|
||||
//! Si el daemon no está corriendo, marca DOWN.
|
||||
|
||||
use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window};
|
||||
use shipote_protocol::{
|
||||
default_socket_path, read_frame, write_frame, CommandInfo, Request, Response, WorkspaceSummary,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::net::UnixStream;
|
||||
use yahweh_launcher::launch_app;
|
||||
use yahweh_theme::Theme;
|
||||
use yahweh_widget_app_header::app_header;
|
||||
use yahweh_widget_banner::{banner_themed, Banner};
|
||||
use yahweh_widget_stat_card::stat_card;
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum DaemonState {
|
||||
Pending,
|
||||
Down { reason: String },
|
||||
Up,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct CapsSummary {
|
||||
kernel_version: (u32, u32, u32),
|
||||
user_ns: String,
|
||||
cgroup_v2: String,
|
||||
cgroup_delegated: bool,
|
||||
has_cap_sys_admin: bool,
|
||||
}
|
||||
|
||||
struct Shell {
|
||||
socket_path: PathBuf,
|
||||
state: DaemonState,
|
||||
workspaces: Vec<WorkspaceSummary>,
|
||||
/// Comandos por workspace, indexados por workspace id.toString().
|
||||
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
|
||||
saved_pipelines: Vec<String>,
|
||||
caps: Option<CapsSummary>,
|
||||
last_probe_ms: u64,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
launch_app("Shipote — Shell", (820., 560.), Shell::new);
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let socket_path = default_socket_path();
|
||||
let socket_for_loop = socket_path.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let timer = cx.background_executor().clone();
|
||||
let bg = cx.background_executor().clone();
|
||||
loop {
|
||||
let path = socket_for_loop.clone();
|
||||
let started = std::time::Instant::now();
|
||||
let result = bg
|
||||
.spawn(async move { probe_blocking(&path) })
|
||||
.await;
|
||||
let elapsed = started.elapsed().as_millis() as u64;
|
||||
let _ = this.update(cx, |me, cx| {
|
||||
match result {
|
||||
Ok(snap) => {
|
||||
me.state = DaemonState::Up;
|
||||
me.workspaces = snap.workspaces;
|
||||
me.commands = snap.commands;
|
||||
me.saved_pipelines = snap.saved_pipelines;
|
||||
me.caps = Some(snap.caps);
|
||||
}
|
||||
Err(reason) => {
|
||||
me.state = DaemonState::Down { reason };
|
||||
me.workspaces.clear();
|
||||
me.commands.clear();
|
||||
me.saved_pipelines.clear();
|
||||
me.caps = None;
|
||||
}
|
||||
}
|
||||
me.last_probe_ms = elapsed;
|
||||
cx.notify();
|
||||
});
|
||||
timer.timer(POLL_INTERVAL).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
socket_path,
|
||||
state: DaemonState::Pending,
|
||||
workspaces: Vec::new(),
|
||||
commands: std::collections::BTreeMap::new(),
|
||||
saved_pipelines: Vec::new(),
|
||||
caps: None,
|
||||
last_probe_ms: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Snapshot {
|
||||
workspaces: Vec<WorkspaceSummary>,
|
||||
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
|
||||
saved_pipelines: Vec<String>,
|
||||
caps: CapsSummary,
|
||||
}
|
||||
|
||||
fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
||||
// Mini tokio runtime efímero por probe — no compartimos runtime con
|
||||
// GPUI. Costo aceptable cada 2s: setup ≈ <1 ms.
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|e| format!("rt: {e}"))?;
|
||||
rt.block_on(async {
|
||||
let mut stream = UnixStream::connect(path)
|
||||
.await
|
||||
.map_err(|e| format!("connect: {e}"))?;
|
||||
write_frame(&mut stream, &Request::WorkspaceList)
|
||||
.await
|
||||
.map_err(|e| format!("write list: {e}"))?;
|
||||
let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read list: {e}"))?;
|
||||
let workspaces = match resp {
|
||||
Response::WorkspaceList { items } => items,
|
||||
other => return Err(format!("unexpected list resp: {other:?}")),
|
||||
};
|
||||
|
||||
// Commands por workspace.
|
||||
let mut commands_map = std::collections::BTreeMap::new();
|
||||
for w in &workspaces {
|
||||
write_frame(&mut stream, &Request::CommandList { workspace: w.id })
|
||||
.await
|
||||
.map_err(|e| format!("write commands: {e}"))?;
|
||||
let resp: Response = read_frame(&mut stream)
|
||||
.await
|
||||
.map_err(|e| format!("read commands: {e}"))?;
|
||||
if let Response::CommandList { items } = resp {
|
||||
if !items.is_empty() {
|
||||
commands_map.insert(w.id.to_string(), items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Saved pipelines.
|
||||
write_frame(&mut stream, &Request::PipelineSavedList)
|
||||
.await
|
||||
.map_err(|e| format!("write saved: {e}"))?;
|
||||
let resp: Response = read_frame(&mut stream)
|
||||
.await
|
||||
.map_err(|e| format!("read saved: {e}"))?;
|
||||
let saved_pipelines = match resp {
|
||||
Response::PipelineSavedList { names } => names,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
write_frame(&mut stream, &Request::Capabilities)
|
||||
.await
|
||||
.map_err(|e| format!("write caps: {e}"))?;
|
||||
let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read caps: {e}"))?;
|
||||
let caps = match resp {
|
||||
Response::Capabilities {
|
||||
kernel_version,
|
||||
user_ns,
|
||||
cgroup_v2,
|
||||
cgroup_delegated,
|
||||
has_cap_sys_admin,
|
||||
} => CapsSummary {
|
||||
kernel_version,
|
||||
user_ns,
|
||||
cgroup_v2,
|
||||
cgroup_delegated,
|
||||
has_cap_sys_admin,
|
||||
},
|
||||
other => return Err(format!("unexpected caps resp: {other:?}")),
|
||||
};
|
||||
Ok(Snapshot {
|
||||
workspaces,
|
||||
commands: commands_map,
|
||||
saved_pipelines,
|
||||
caps,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
impl Render for Shell {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
let bg = theme.bg_app.clone();
|
||||
let text = theme.fg_text;
|
||||
let text_dim = theme.fg_muted;
|
||||
|
||||
let accent_up = gpui::rgb(0xa3be8c);
|
||||
let accent_down = gpui::rgb(0xbf616a);
|
||||
let accent_pending = gpui::rgb(0x6a7280);
|
||||
let accent_info = gpui::rgb(0x88c0d0);
|
||||
|
||||
let header_text = format!(
|
||||
"Daemon: {} · reload {} ms",
|
||||
self.socket_path.display(),
|
||||
self.last_probe_ms
|
||||
);
|
||||
let header = app_header(cx, header_text);
|
||||
|
||||
let status_banner = match &self.state {
|
||||
DaemonState::Pending => None,
|
||||
DaemonState::Down { reason } => Some(banner_themed(
|
||||
cx,
|
||||
Banner::Error,
|
||||
SharedString::from(format!("Daemon DOWN — {reason}")),
|
||||
)),
|
||||
DaemonState::Up => Some(banner_themed(
|
||||
cx,
|
||||
Banner::Success,
|
||||
SharedString::from("Daemon UP"),
|
||||
)),
|
||||
};
|
||||
|
||||
let (status_value, status_descr, status_accent) = match &self.state {
|
||||
DaemonState::Pending => ("PENDING".to_string(), "primer probe…".to_string(), accent_pending),
|
||||
DaemonState::Down { reason } => ("DOWN".to_string(), reason.clone(), accent_down),
|
||||
DaemonState::Up => ("UP".to_string(), "shipote-daemon respondiendo".to_string(), accent_up),
|
||||
};
|
||||
|
||||
let caps_items: Vec<String> = self
|
||||
.caps
|
||||
.as_ref()
|
||||
.map(|c| {
|
||||
vec![
|
||||
format!(
|
||||
"kernel: {}.{}.{}",
|
||||
c.kernel_version.0, c.kernel_version.1, c.kernel_version.2
|
||||
),
|
||||
format!("user_ns: {}", c.user_ns),
|
||||
format!("cgroup_v2: {}", c.cgroup_v2),
|
||||
format!("cgroup_delegated: {}", c.cgroup_delegated),
|
||||
format!("cap_sys_admin: {}", c.has_cap_sys_admin),
|
||||
]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let caps_value = if self.caps.is_some() { "OK".to_string() } else { "—".to_string() };
|
||||
|
||||
let ws_items: Vec<String> = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|w| format!("{} {:<20} cmds={} uptime={}ms", w.id, w.label, w.commands, w.uptime_ms))
|
||||
.collect();
|
||||
let ws_count = self.workspaces.len().to_string();
|
||||
let ws_descr = if self.workspaces.is_empty() {
|
||||
"no hay workspaces vivos".to_string()
|
||||
} else {
|
||||
"id · label · cmds · uptime".to_string()
|
||||
};
|
||||
|
||||
// Comandos: aplanamos por workspace, tomamos los más recientes (orden ULID ya temporal).
|
||||
let mut cmd_items: Vec<String> = Vec::new();
|
||||
let mut cmd_total = 0usize;
|
||||
for (ws_id, cmds) in &self.commands {
|
||||
cmd_total += cmds.len();
|
||||
for c in cmds.iter().rev().take(8) {
|
||||
let alive = if c.alive { "▶" } else { "✓" };
|
||||
let exit = c
|
||||
.exit_status
|
||||
.map(|s| format!(" exit={s}"))
|
||||
.unwrap_or_default();
|
||||
cmd_items.push(format!(
|
||||
"{} {} {:<20} pid={} logs={}B{}",
|
||||
alive,
|
||||
&ws_id[..6.min(ws_id.len())],
|
||||
c.label,
|
||||
c.pid,
|
||||
c.log_bytes,
|
||||
exit
|
||||
));
|
||||
}
|
||||
}
|
||||
let cmd_count = cmd_total.to_string();
|
||||
let cmd_descr = if cmd_total == 0 {
|
||||
"no hay comandos lanzados".to_string()
|
||||
} else {
|
||||
"▶=alive ✓=exited · ws_prefix · label · pid · logs".to_string()
|
||||
};
|
||||
|
||||
// Saved pipelines.
|
||||
let saved_count = self.saved_pipelines.len().to_string();
|
||||
let saved_items: Vec<String> = self.saved_pipelines.clone();
|
||||
let saved_descr = if saved_items.is_empty() {
|
||||
"shipote pipeline save <name> <file> para persistir".to_string()
|
||||
} else {
|
||||
"definiciones reusables vía run-saved".to_string()
|
||||
};
|
||||
|
||||
let body = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(8.))
|
||||
.px(px(16.))
|
||||
.py(px(16.))
|
||||
.child(stat_card(
|
||||
cx,
|
||||
"Estado",
|
||||
status_value,
|
||||
&status_descr,
|
||||
status_accent,
|
||||
text,
|
||||
text_dim,
|
||||
&[],
|
||||
))
|
||||
.child(stat_card(
|
||||
cx,
|
||||
"Capabilities",
|
||||
caps_value,
|
||||
"kernel + namespaces + cgroup delegation",
|
||||
accent_info,
|
||||
text,
|
||||
text_dim,
|
||||
&caps_items,
|
||||
))
|
||||
.child(stat_card(
|
||||
cx,
|
||||
"Workspaces",
|
||||
ws_count,
|
||||
&ws_descr,
|
||||
accent_info,
|
||||
text,
|
||||
text_dim,
|
||||
&ws_items,
|
||||
))
|
||||
.child(stat_card(
|
||||
cx,
|
||||
"Comandos",
|
||||
cmd_count,
|
||||
&cmd_descr,
|
||||
accent_info,
|
||||
text,
|
||||
text_dim,
|
||||
&cmd_items,
|
||||
))
|
||||
.child(stat_card(
|
||||
cx,
|
||||
"Saved pipelines",
|
||||
saved_count,
|
||||
&saved_descr,
|
||||
accent_info,
|
||||
text,
|
||||
text_dim,
|
||||
&saved_items,
|
||||
));
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
.bg(bg)
|
||||
.child(header)
|
||||
.when_some(status_banner, |d, b| d.child(b))
|
||||
.child(body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user