feat: surface backend runtime workers in tui
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
use std::error::Error;
|
||||
use std::io;
|
||||
|
||||
use client::{
|
||||
BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary, list_backend_workers,
|
||||
};
|
||||
use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind};
|
||||
use ratatui::Frame;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
|
||||
|
||||
use crate::console;
|
||||
|
||||
pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box<dyn Error>> {
|
||||
let response = list_backend_workers(&target).await.map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"failed to list Backend runtime workers from {}: {error}",
|
||||
target.base_url
|
||||
))
|
||||
})?;
|
||||
if response.items.is_empty() {
|
||||
return Err(Box::new(io::Error::other(format!(
|
||||
"Backend returned no runtime workers for workspace {}",
|
||||
response.workspace_id
|
||||
))));
|
||||
}
|
||||
|
||||
let selected = pick_worker(target.clone(), response.items)?;
|
||||
let attach_target =
|
||||
BackendRuntimeTarget::new(target.base_url, selected.runtime_id, selected.worker_id);
|
||||
console::run_backend_runtime(attach_target).await
|
||||
}
|
||||
|
||||
fn pick_worker(
|
||||
target: BackendRuntimeListTarget,
|
||||
mut workers: Vec<BackendWorkerSummary>,
|
||||
) -> Result<BackendWorkerSummary, Box<dyn Error>> {
|
||||
workers.sort_by(|a, b| {
|
||||
a.runtime_id
|
||||
.cmp(&b.runtime_id)
|
||||
.then_with(|| a.label.cmp(&b.label))
|
||||
.then_with(|| a.worker_id.cmp(&b.worker_id))
|
||||
});
|
||||
let mut state = BackendWorkerPickerState::new(target, workers);
|
||||
let mut terminal = ratatui::Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
||||
terminal.clear()?;
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| draw(frame, &mut state))?;
|
||||
match event::read()? {
|
||||
CrosstermEvent::Key(KeyEvent {
|
||||
code,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) => match code {
|
||||
KeyCode::Up | KeyCode::Char('k') => state.previous(),
|
||||
KeyCode::Down | KeyCode::Char('j') => state.next(),
|
||||
KeyCode::Enter => return Ok(state.selected_worker().clone()),
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
return Err(Box::new(io::Error::other(
|
||||
"Backend worker picker cancelled",
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BackendWorkerPickerState {
|
||||
target: BackendRuntimeListTarget,
|
||||
workers: Vec<BackendWorkerSummary>,
|
||||
selected: usize,
|
||||
}
|
||||
|
||||
impl BackendWorkerPickerState {
|
||||
fn new(target: BackendRuntimeListTarget, workers: Vec<BackendWorkerSummary>) -> Self {
|
||||
Self {
|
||||
target,
|
||||
workers,
|
||||
selected: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn next(&mut self) {
|
||||
if self.workers.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.selected = (self.selected + 1).min(self.workers.len() - 1);
|
||||
}
|
||||
|
||||
fn previous(&mut self) {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
}
|
||||
|
||||
fn selected_worker(&self) -> &BackendWorkerSummary {
|
||||
&self.workers[self.selected]
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(frame: &mut Frame<'_>, state: &mut BackendWorkerPickerState) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(4),
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(5),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
draw_header(frame, chunks[0], state);
|
||||
draw_list(frame, chunks[1], state);
|
||||
draw_details(frame, chunks[2], state.selected_worker());
|
||||
}
|
||||
|
||||
fn draw_header(frame: &mut Frame<'_>, area: Rect, state: &BackendWorkerPickerState) {
|
||||
let workspace = state
|
||||
.target
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.unwrap_or("unscoped backend");
|
||||
let runtime = state.target.runtime_id.as_deref().unwrap_or("all runtimes");
|
||||
let text = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
"Backend runtime workers",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" {}", state.target.base_url)),
|
||||
]),
|
||||
Line::from(format!("workspace: {workspace} runtime: {runtime}")),
|
||||
Line::from("↑/↓ or k/j select Enter attach q/Esc cancel"),
|
||||
];
|
||||
frame.render_widget(
|
||||
Paragraph::new(text).block(Block::default().borders(Borders::ALL)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_list(frame: &mut Frame<'_>, area: Rect, state: &mut BackendWorkerPickerState) {
|
||||
let items: Vec<_> = state
|
||||
.workers
|
||||
.iter()
|
||||
.map(|worker| ListItem::new(worker_row(worker)))
|
||||
.collect();
|
||||
let mut list_state = ListState::default().with_selected(Some(state.selected));
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title("Workers"))
|
||||
.highlight_symbol("▶ ")
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
}
|
||||
|
||||
fn worker_row(worker: &BackendWorkerSummary) -> Line<'static> {
|
||||
let label = if worker.label.is_empty() {
|
||||
worker.worker_id.as_str()
|
||||
} else {
|
||||
worker.label.as_str()
|
||||
};
|
||||
let profile = worker.profile.as_deref().unwrap_or("-");
|
||||
let wd = working_directory_text(worker);
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{}:{}", worker.runtime_id, worker.worker_id),
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
Span::raw(format!(" {label}")),
|
||||
Span::raw(format!(" profile:{profile}")),
|
||||
Span::raw(format!(" state:{}", worker.state)),
|
||||
Span::raw(format!(" wd:{wd}")),
|
||||
])
|
||||
}
|
||||
|
||||
fn draw_details(frame: &mut Frame<'_>, area: Rect, worker: &BackendWorkerSummary) {
|
||||
let profile = worker.profile.as_deref().unwrap_or("-");
|
||||
let role = worker.role.as_deref().unwrap_or("-");
|
||||
let text = vec![
|
||||
Line::from(format!(
|
||||
"runtime={} worker={} host={}",
|
||||
worker.runtime_id, worker.worker_id, worker.host_id
|
||||
)),
|
||||
Line::from(format!(
|
||||
"label={} role={} profile={} state={}",
|
||||
worker.label, role, profile, worker.state
|
||||
)),
|
||||
Line::from(format!(
|
||||
"working_directory={}",
|
||||
working_directory_text(worker)
|
||||
)),
|
||||
];
|
||||
frame.render_widget(
|
||||
Paragraph::new(text)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Selected worker"),
|
||||
)
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
||||
let Some(wd) = worker.working_directory.as_ref() else {
|
||||
return "-".to_string();
|
||||
};
|
||||
let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown");
|
||||
format!(
|
||||
"{}:{} {} {}",
|
||||
wd.repository_id, wd.working_directory_id, wd.status, cleanliness
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use client::{
|
||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||
BackendWorkerWorkspaceSummary,
|
||||
};
|
||||
|
||||
fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary {
|
||||
BackendWorkerSummary {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
host_id: "host".to_string(),
|
||||
label: "label".to_string(),
|
||||
role: None,
|
||||
profile: profile.map(str::to_string),
|
||||
workspace: BackendWorkerWorkspaceSummary {
|
||||
visibility: "workspace".to_string(),
|
||||
identity: "ws".to_string(),
|
||||
},
|
||||
state: "running".to_string(),
|
||||
last_seen_at: None,
|
||||
pinned: false,
|
||||
retention_state: String::new(),
|
||||
implementation: BackendWorkerImplementationSummary {
|
||||
kind: "embedded".to_string(),
|
||||
display_hint: "embedded".to_string(),
|
||||
},
|
||||
capabilities: BackendWorkerCapabilitySummary {
|
||||
can_stop: true,
|
||||
can_spawn_followup: false,
|
||||
},
|
||||
working_directory: None,
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_row_contains_backend_authority_fields() {
|
||||
let row = worker_row(&worker("runtime-a", "worker-b", Some("default")));
|
||||
let text = row
|
||||
.spans
|
||||
.into_iter()
|
||||
.map(|span| span.content)
|
||||
.collect::<String>();
|
||||
assert!(text.contains("runtime-a:worker-b"));
|
||||
assert!(text.contains("profile:default"));
|
||||
assert!(text.contains("state:running"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod app;
|
||||
mod backend_worker_picker;
|
||||
mod block;
|
||||
mod cache;
|
||||
mod command;
|
||||
@@ -33,7 +34,7 @@ use crossterm::execute;
|
||||
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
|
||||
use session_store::SegmentId;
|
||||
|
||||
use client::{BackendRuntimeTarget, WorkerRuntimeCommand};
|
||||
use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchOptions {
|
||||
@@ -55,6 +56,9 @@ pub enum LaunchMode {
|
||||
worker_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
},
|
||||
/// `yoi workers` / `yoi --backend <url>`: list Backend-authoritative runtime workers,
|
||||
/// then attach to the selected Worker through the Backend Runtime API.
|
||||
BackendRuntimePicker { target: BackendRuntimeListTarget },
|
||||
/// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: connect through the
|
||||
/// Workspace Backend Runtime API and observe the Backend-proxied event stream.
|
||||
BackendRuntime { target: BackendRuntimeTarget },
|
||||
@@ -106,6 +110,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
worker_name,
|
||||
socket_override,
|
||||
} => console::run_worker_name(worker_name, socket_override, runtime_command).await,
|
||||
LaunchMode::BackendRuntimePicker { target } => backend_worker_picker::run(target).await,
|
||||
LaunchMode::BackendRuntime { target } => console::run_backend_runtime(target).await,
|
||||
LaunchMode::Resume { all } => {
|
||||
console::run_resume(runtime_command, workspace_root.clone(), all).await
|
||||
|
||||
Reference in New Issue
Block a user