[Wip] CLI & Daemon
This commit is contained in:
parent
3bc5cc959a
commit
6a535099bb
44 changed files with 4417 additions and 303 deletions
282
iota-cli/src/screens/overview.rs
Normal file
282
iota-cli/src/screens/overview.rs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
use crate::{
|
||||
controls::button::{ActionButton, ButtonIntent, render_button},
|
||||
interaction_result::InteractionResult,
|
||||
ipc_client::{DaemonStatus, IpcConnectionState},
|
||||
render_context::RenderContext,
|
||||
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
|
||||
};
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
};
|
||||
use std::{
|
||||
any::Any,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::sync::watch;
|
||||
|
||||
pub struct OverviewScreen {
|
||||
connection_rx: watch::Receiver<IpcConnectionState>,
|
||||
daemon_rx: watch::Receiver<DaemonStatus>,
|
||||
_focus: Focus,
|
||||
scroll_offset: usize,
|
||||
content_height: AtomicUsize,
|
||||
viewport_height: AtomicUsize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Back,
|
||||
}
|
||||
|
||||
impl OverviewScreen {
|
||||
pub fn new(
|
||||
connection_rx: watch::Receiver<IpcConnectionState>,
|
||||
daemon_rx: watch::Receiver<DaemonStatus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
connection_rx,
|
||||
daemon_rx,
|
||||
_focus: Focus::Back,
|
||||
scroll_offset: 0,
|
||||
content_height: AtomicUsize::new(0),
|
||||
viewport_height: AtomicUsize::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_lines(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Line<'static>> {
|
||||
let conn = self.connection_rx.borrow().clone();
|
||||
let daemon = self.daemon_rx.borrow().clone();
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Connection",
|
||||
theme.text.heading,
|
||||
)));
|
||||
lines.push(Line::from(format!(" State: {}", connection_label(&conn))));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Daemon",
|
||||
theme.text.heading,
|
||||
)));
|
||||
lines.push(Line::from(format!(
|
||||
" Version: {}",
|
||||
version_or_unknown(&daemon.version)
|
||||
)));
|
||||
lines.push(Line::from(format!(
|
||||
" Instance: {}",
|
||||
truncate_id(&daemon.instance_id)
|
||||
)));
|
||||
|
||||
let phase = daemon
|
||||
.startup_phase
|
||||
.map(|p| format!("{:?}", p))
|
||||
.unwrap_or_else(|| "Unknown".into());
|
||||
lines.push(Line::from(format!(" Phase: {}", phase)));
|
||||
|
||||
let lifecycle = daemon
|
||||
.lifecycle
|
||||
.map(|l| format!("{:?}", l))
|
||||
.unwrap_or_else(|| "Unknown".into());
|
||||
lines.push(Line::from(format!(" Lifecycle: {}", lifecycle)));
|
||||
|
||||
let health = match daemon.health {
|
||||
iota_ipc::HealthStatus::Healthy => "[OK] Healthy",
|
||||
iota_ipc::HealthStatus::Degraded => "[WARN] Degraded",
|
||||
iota_ipc::HealthStatus::Failed => "[FAIL] Failed",
|
||||
};
|
||||
lines.push(Line::from(format!(" Health: {health}")));
|
||||
|
||||
if let Some(ref reason) = daemon.degraded_reason {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Degraded: {reason}"),
|
||||
theme.status.warning,
|
||||
)));
|
||||
}
|
||||
|
||||
let mode = daemon
|
||||
.deployment_mode
|
||||
.map(|m| format!("{:?}", m))
|
||||
.unwrap_or_else(|| "Unknown".into());
|
||||
lines.push(Line::from(format!(" Deployment: {mode}")));
|
||||
|
||||
let supervisor = daemon
|
||||
.supervisor
|
||||
.map(|s| format!("{:?}", s))
|
||||
.unwrap_or_else(|| "Unknown".into());
|
||||
lines.push(Line::from(format!(" Supervisor: {supervisor}")));
|
||||
|
||||
if !daemon.components.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Components",
|
||||
theme.text.heading,
|
||||
)));
|
||||
for (id, health) in &daemon.components {
|
||||
let status_str = match health.status {
|
||||
iota_ipc::HealthStatus::Healthy => "[OK] healthy",
|
||||
iota_ipc::HealthStatus::Degraded => "[WARN] degraded",
|
||||
iota_ipc::HealthStatus::Failed => "[FAIL] failed",
|
||||
};
|
||||
let suffix = health
|
||||
.message
|
||||
.as_deref()
|
||||
.map(|m| format!(" ({m})"))
|
||||
.unwrap_or_default();
|
||||
lines.push(Line::from(format!(" {:?}: {}{}", id, status_str, suffix)));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Press Esc or B to return to the dashboard",
|
||||
theme.text.muted,
|
||||
)));
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
fn connection_label(conn: &IpcConnectionState) -> String {
|
||||
match conn {
|
||||
IpcConnectionState::Connected => "Connected".into(),
|
||||
IpcConnectionState::Connecting => "Connecting...".into(),
|
||||
IpcConnectionState::Reconnecting { attempt } => {
|
||||
format!("Reconnecting (attempt {attempt})...")
|
||||
}
|
||||
IpcConnectionState::Incompatible { message } => {
|
||||
format!("Incompatible: {message}")
|
||||
}
|
||||
IpcConnectionState::Failed { message } => format!("Failed: {message}"),
|
||||
IpcConnectionState::Disconnected => "Disconnected".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn version_or_unknown(v: &str) -> String {
|
||||
if v.is_empty() {
|
||||
"Unknown".into()
|
||||
} else {
|
||||
v.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_id(id: &str) -> String {
|
||||
if id.len() > 8 {
|
||||
format!("{}…", &id[..8])
|
||||
} else {
|
||||
id.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Screen for OverviewScreen {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
||||
let block = Block::default()
|
||||
.title(" Overview ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(context.theme.borders.normal)
|
||||
.title_style(context.theme.borders.title);
|
||||
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme) } else { let inner = block.inner(rect); f.render_widget(block, rect); inner };
|
||||
let rows = ratatui::layout::Layout::vertical([
|
||||
ratatui::layout::Constraint::Min(1),
|
||||
ratatui::layout::Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let lines = self.build_lines(context.theme);
|
||||
self.content_height.store(lines.len(), Ordering::Relaxed);
|
||||
self.viewport_height
|
||||
.store(rows[0].height as usize, Ordering::Relaxed);
|
||||
let par = Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: true })
|
||||
.scroll((self.scroll_offset as u16, 0));
|
||||
f.render_widget(par, rows[0]);
|
||||
render_button(
|
||||
f,
|
||||
rows[1],
|
||||
ActionButton {
|
||||
label: "Back",
|
||||
intent: ButtonIntent::Cancel,
|
||||
focused: self._focus == Focus::Back,
|
||||
enabled: true,
|
||||
},
|
||||
context.theme,
|
||||
);
|
||||
_hits.register(rows[1], AppAction::Back);
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
||||
let UiEvent::Key(event) = event else {
|
||||
return InteractionResult::Unhandled;
|
||||
};
|
||||
match event.code {
|
||||
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let max = self
|
||||
.content_height
|
||||
.load(Ordering::Relaxed)
|
||||
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(1).min(max);
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
|
||||
let max = self
|
||||
.content_height
|
||||
.load(Ordering::Relaxed)
|
||||
.saturating_sub(page);
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(page).min(max);
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(page);
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.scroll_offset = 0;
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.scroll_offset = self
|
||||
.content_height
|
||||
.load(Ordering::Relaxed)
|
||||
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
|
||||
InteractionResult::Handled
|
||||
}
|
||||
_ => InteractionResult::Unhandled,
|
||||
}
|
||||
}
|
||||
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
|
||||
if action == AppAction::Back {
|
||||
InteractionResult::CloseScreen
|
||||
} else {
|
||||
InteractionResult::Unhandled
|
||||
}
|
||||
}
|
||||
fn key_hints(&self) -> Vec<KeyHint> {
|
||||
vec![
|
||||
KeyHint { keys: "Up/Down", action: "Scroll" },
|
||||
KeyHint { keys: "PgUp/PgDn", action: "Page" },
|
||||
KeyHint { keys: "Esc/B", action: "Back" },
|
||||
KeyHint { keys: "F6", action: "Header" },
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue