681 lines
22 KiB
Rust
681 lines
22 KiB
Rust
use std::io;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use crossterm::{
|
|
event::{
|
|
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
|
|
},
|
|
execute,
|
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
|
};
|
|
use fuzzy_matcher::FuzzyMatcher;
|
|
use fuzzy_matcher::skim::SkimMatcherV2;
|
|
use nocontrol::{ControlPlane, Operator, Specification, manifests::ManifestState};
|
|
use ratatui::{
|
|
Frame, Terminal,
|
|
backend::{Backend, CrosstermBackend},
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span, Text},
|
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
|
};
|
|
use tokio::sync::Mutex;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum InputMode {
|
|
Normal,
|
|
Search,
|
|
}
|
|
|
|
struct App<TOperator: Operator> {
|
|
control_plane: ControlPlane<TOperator>,
|
|
manifests: Vec<ManifestState<TOperator::Specifications>>,
|
|
filtered_indices: Vec<usize>,
|
|
list_state: ListState,
|
|
|
|
input_mode: InputMode,
|
|
command_input: String,
|
|
search_query: String,
|
|
|
|
command_history: Vec<String>,
|
|
history_index: Option<usize>,
|
|
|
|
messages: Vec<String>,
|
|
should_quit: bool,
|
|
|
|
fuzzy_matcher: SkimMatcherV2,
|
|
}
|
|
|
|
impl<TOperator: Operator> App<TOperator> {
|
|
fn new(control_plane: ControlPlane<TOperator>) -> Self {
|
|
let mut list_state = ListState::default();
|
|
list_state.select(Some(0));
|
|
|
|
Self {
|
|
control_plane,
|
|
manifests: Vec::new(),
|
|
filtered_indices: Vec::new(),
|
|
list_state,
|
|
|
|
input_mode: InputMode::Normal,
|
|
command_input: String::new(),
|
|
search_query: String::new(),
|
|
|
|
command_history: Vec::new(),
|
|
history_index: None,
|
|
|
|
messages: vec![
|
|
"Welcome to nocontrol TUI".to_string(),
|
|
"Press / to search, type commands, ↑↓ for history/navigation".to_string(),
|
|
],
|
|
should_quit: false,
|
|
|
|
fuzzy_matcher: SkimMatcherV2::default(),
|
|
}
|
|
}
|
|
|
|
async fn refresh_manifests(&mut self) {
|
|
if let Ok(manifests) = self.control_plane.get_manifests().await {
|
|
self.manifests = manifests;
|
|
self.update_filtered_list();
|
|
}
|
|
}
|
|
|
|
fn update_filtered_list(&mut self) {
|
|
if self.input_mode == InputMode::Search && !self.search_query.is_empty() {
|
|
// Fuzzy search through manifest names and specs
|
|
let mut scored_indices: Vec<(usize, i64)> = self
|
|
.manifests
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(idx, manifest)| {
|
|
let search_text = format!(
|
|
"{} {:?} {:?}",
|
|
manifest.manifest.name,
|
|
manifest.status.status,
|
|
manifest.manifest.spec.kind()
|
|
);
|
|
|
|
self.fuzzy_matcher
|
|
.fuzzy_match(&search_text, &self.search_query)
|
|
.map(|score| (idx, score))
|
|
})
|
|
.collect();
|
|
|
|
// Sort by score (highest first)
|
|
scored_indices.sort_by(|a, b| b.1.cmp(&a.1));
|
|
|
|
self.filtered_indices = scored_indices.into_iter().map(|(idx, _)| idx).collect();
|
|
} else {
|
|
// No filtering, show all manifests
|
|
self.filtered_indices = (0..self.manifests.len()).collect();
|
|
}
|
|
|
|
// Reset selection to first item if list changed
|
|
if !self.filtered_indices.is_empty() {
|
|
self.list_state.select(Some(0));
|
|
} else {
|
|
self.list_state.select(None);
|
|
}
|
|
}
|
|
|
|
fn get_selected_manifest(&self) -> Option<&ManifestState<TOperator::Specifications>> {
|
|
self.list_state
|
|
.selected()
|
|
.and_then(|selected_idx| self.filtered_indices.get(selected_idx))
|
|
.and_then(|&manifest_idx| self.manifests.get(manifest_idx))
|
|
}
|
|
|
|
fn next(&mut self) {
|
|
if self.filtered_indices.is_empty() {
|
|
return;
|
|
}
|
|
let i = match self.list_state.selected() {
|
|
Some(i) => {
|
|
if i >= self.filtered_indices.len() - 1 {
|
|
0
|
|
} else {
|
|
i + 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
self.list_state.select(Some(i));
|
|
}
|
|
|
|
fn previous(&mut self) {
|
|
if self.filtered_indices.is_empty() {
|
|
return;
|
|
}
|
|
let i = match self.list_state.selected() {
|
|
Some(i) => {
|
|
if i == 0 {
|
|
self.filtered_indices.len() - 1
|
|
} else {
|
|
i - 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
self.list_state.select(Some(i));
|
|
}
|
|
|
|
fn history_previous(&mut self) {
|
|
if self.command_history.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let new_index = match self.history_index {
|
|
None => Some(self.command_history.len() - 1),
|
|
Some(0) => Some(0), // Stay at oldest
|
|
Some(i) => Some(i - 1),
|
|
};
|
|
|
|
if let Some(idx) = new_index {
|
|
self.history_index = Some(idx);
|
|
self.command_input = self.command_history[idx].clone();
|
|
}
|
|
}
|
|
|
|
fn history_next(&mut self) {
|
|
if self.command_history.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let new_index = match self.history_index {
|
|
None => None,
|
|
Some(i) if i >= self.command_history.len() - 1 => {
|
|
// Clear input when going past newest
|
|
self.command_input.clear();
|
|
None
|
|
}
|
|
Some(i) => Some(i + 1),
|
|
};
|
|
|
|
self.history_index = new_index;
|
|
if let Some(idx) = new_index {
|
|
self.command_input = self.command_history[idx].clone();
|
|
}
|
|
}
|
|
|
|
fn toggle_search_mode(&mut self) {
|
|
self.input_mode = match self.input_mode {
|
|
InputMode::Normal => {
|
|
self.search_query.clear();
|
|
InputMode::Search
|
|
}
|
|
InputMode::Search => {
|
|
self.search_query.clear();
|
|
self.update_filtered_list();
|
|
InputMode::Normal
|
|
}
|
|
};
|
|
}
|
|
|
|
fn update_search(&mut self) {
|
|
if self.input_mode == InputMode::Search {
|
|
self.search_query = self.command_input.clone();
|
|
self.update_filtered_list();
|
|
}
|
|
}
|
|
|
|
async fn execute_command(&mut self) {
|
|
let cmd = self.command_input.trim().to_string();
|
|
if cmd.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Add to history
|
|
if self.command_history.last() != Some(&cmd) {
|
|
self.command_history.push(cmd.clone());
|
|
}
|
|
self.history_index = None;
|
|
|
|
self.messages.push(format!("> {}", cmd));
|
|
|
|
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
|
match parts.as_slice() {
|
|
["get"] | ["list"] => {
|
|
self.refresh_manifests().await;
|
|
self.messages.push(format!(
|
|
"Found {} manifest(s), showing {}",
|
|
self.manifests.len(),
|
|
self.filtered_indices.len()
|
|
));
|
|
}
|
|
["describe"] => {
|
|
if let Some(manifest) = self.get_selected_manifest() {
|
|
let json = serde_json::to_string_pretty(manifest).unwrap_or_default();
|
|
self.messages.push(json);
|
|
} else {
|
|
self.messages.push("No manifest selected".to_string());
|
|
}
|
|
}
|
|
["quit"] | ["exit"] | ["q"] => {
|
|
self.should_quit = true;
|
|
}
|
|
["clear"] => {
|
|
self.messages.clear();
|
|
self.messages.push("Screen cleared".to_string());
|
|
}
|
|
["history"] => {
|
|
self.messages.push("Command history:".to_string());
|
|
for (i, cmd) in self.command_history.iter().enumerate() {
|
|
self.messages.push(format!(" {}: {}", i + 1, cmd));
|
|
}
|
|
}
|
|
["help"] => {
|
|
self.messages.push("Commands:".to_string());
|
|
self.messages
|
|
.push(" get, list - Refresh manifest list".to_string());
|
|
self.messages
|
|
.push(" describe - Show selected manifest details".to_string());
|
|
self.messages
|
|
.push(" history - Show command history".to_string());
|
|
self.messages
|
|
.push(" clear - Clear output messages".to_string());
|
|
self.messages
|
|
.push(" quit, exit, q - Exit application".to_string());
|
|
self.messages
|
|
.push(" help - Show this help".to_string());
|
|
self.messages.push("".to_string());
|
|
self.messages.push("Keys:".to_string());
|
|
self.messages
|
|
.push(" / - Toggle search mode".to_string());
|
|
self.messages
|
|
.push(" ↑/↓ - Navigate list OR cycle command history".to_string());
|
|
self.messages
|
|
.push(" Enter - Execute command (or exit search)".to_string());
|
|
self.messages
|
|
.push(" Esc - Clear input / exit search".to_string());
|
|
self.messages
|
|
.push(" q - Quick quit (when input empty)".to_string());
|
|
}
|
|
_ => {
|
|
self.messages.push(format!(
|
|
"Unknown command: {}. Type 'help' for commands.",
|
|
cmd
|
|
));
|
|
}
|
|
}
|
|
|
|
self.command_input.clear();
|
|
|
|
// Keep only last 100 messages
|
|
if self.messages.len() > 100 {
|
|
self.messages.drain(0..self.messages.len() - 100);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_app<B: Backend, TOperator: Operator + Send + Sync + 'static>(
|
|
terminal: &mut Terminal<B>,
|
|
app: Arc<Mutex<App<TOperator>>>,
|
|
) -> anyhow::Result<()>
|
|
where
|
|
TOperator::Specifications: Send + Sync,
|
|
{
|
|
// Spawn refresh task
|
|
let app_clone = app.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
app_clone.lock().await.refresh_manifests().await;
|
|
}
|
|
});
|
|
|
|
loop {
|
|
// Draw UI
|
|
{
|
|
let mut app = app.lock().await;
|
|
terminal.draw(|f| ui(f, &mut app))?;
|
|
|
|
if app.should_quit {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Handle input with timeout
|
|
if event::poll(Duration::from_millis(100))?
|
|
&& let Event::Key(key) = event::read()?
|
|
&& key.kind == KeyEventKind::Press
|
|
{
|
|
let mut app = app.lock().await;
|
|
|
|
match (key.code, key.modifiers, app.input_mode) {
|
|
// Quick quit with 'q' when input is empty
|
|
(KeyCode::Char('q'), KeyModifiers::NONE, InputMode::Normal)
|
|
if app.command_input.is_empty() =>
|
|
{
|
|
app.should_quit = true;
|
|
}
|
|
|
|
// Toggle search mode with '/'
|
|
(KeyCode::Char('/'), KeyModifiers::NONE, _) => {
|
|
app.toggle_search_mode();
|
|
}
|
|
|
|
// Character input
|
|
(KeyCode::Char(c), _, _) => {
|
|
app.command_input.push(c);
|
|
app.update_search();
|
|
}
|
|
|
|
// Backspace
|
|
(KeyCode::Backspace, _, _) => {
|
|
app.command_input.pop();
|
|
app.update_search();
|
|
}
|
|
|
|
// Enter key
|
|
(KeyCode::Enter, _, InputMode::Search) => {
|
|
// Exit search mode but keep filter
|
|
app.input_mode = InputMode::Normal;
|
|
app.command_input.clear();
|
|
}
|
|
(KeyCode::Enter, _, InputMode::Normal) => {
|
|
app.execute_command().await;
|
|
}
|
|
|
|
// Arrow keys
|
|
(KeyCode::Up, _, InputMode::Normal) if !app.command_input.is_empty() => {
|
|
// Navigate history when typing a command
|
|
app.history_previous();
|
|
}
|
|
(KeyCode::Down, _, InputMode::Normal) if !app.command_input.is_empty() => {
|
|
// Navigate history when typing a command
|
|
app.history_next();
|
|
}
|
|
(KeyCode::Up, _, _) => {
|
|
// Navigate manifest list
|
|
app.previous();
|
|
}
|
|
(KeyCode::Down, _, _) => {
|
|
// Navigate manifest list
|
|
app.next();
|
|
}
|
|
|
|
// Escape key
|
|
(KeyCode::Esc, _, InputMode::Search) => {
|
|
app.toggle_search_mode();
|
|
app.command_input.clear();
|
|
}
|
|
(KeyCode::Esc, _, InputMode::Normal) => {
|
|
app.command_input.clear();
|
|
app.history_index = None;
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ui<TOperator: Operator>(f: &mut Frame, app: &mut App<TOperator>) {
|
|
// Create layout
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3), // Title
|
|
Constraint::Min(10), // Main content
|
|
Constraint::Length(10), // Messages
|
|
Constraint::Length(3), // Command input
|
|
])
|
|
.split(f.area());
|
|
|
|
// Title
|
|
let mode_text = match app.input_mode {
|
|
InputMode::Normal => "NORMAL",
|
|
InputMode::Search => "SEARCH",
|
|
};
|
|
let title = Paragraph::new(vec![Line::from(vec![
|
|
Span::styled(
|
|
"NoControl - Kubernetes-like Control Plane",
|
|
Style::default()
|
|
.fg(Color::Cyan)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::raw(" "),
|
|
Span::styled(
|
|
format!("[{}]", mode_text),
|
|
Style::default()
|
|
.fg(if app.input_mode == InputMode::Search {
|
|
Color::Yellow
|
|
} else {
|
|
Color::Green
|
|
})
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
])])
|
|
.block(Block::default().borders(Borders::ALL));
|
|
f.render_widget(title, chunks[0]);
|
|
|
|
// Main content area - split into list and details
|
|
let main_chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
|
|
.split(chunks[1]);
|
|
|
|
// Manifest list
|
|
render_manifest_list(f, app, main_chunks[0]);
|
|
|
|
// Manifest details
|
|
render_manifest_details(f, app, main_chunks[1]);
|
|
|
|
// Messages area
|
|
render_messages(f, app, chunks[2]);
|
|
|
|
// Command input
|
|
render_command_input(f, app, chunks[3]);
|
|
}
|
|
|
|
fn render_manifest_list<TOperator: Operator>(f: &mut Frame, app: &mut App<TOperator>, area: Rect) {
|
|
// Collect filtered manifests data before borrowing list_state
|
|
let filtered_data: Vec<_> = app
|
|
.filtered_indices
|
|
.iter()
|
|
.filter_map(|&idx| app.manifests.get(idx))
|
|
.map(|m| (m.manifest.name.clone(), m.status.status.clone()))
|
|
.collect();
|
|
|
|
let items: Vec<ListItem> = filtered_data
|
|
.iter()
|
|
.map(|(name, status)| {
|
|
let status_color = match status {
|
|
nocontrol::manifests::ManifestStatusState::Running => Color::Green,
|
|
nocontrol::manifests::ManifestStatusState::Started => Color::Yellow,
|
|
nocontrol::manifests::ManifestStatusState::Pending => Color::Gray,
|
|
nocontrol::manifests::ManifestStatusState::Stopping => Color::Magenta,
|
|
nocontrol::manifests::ManifestStatusState::Deleting => Color::Red,
|
|
nocontrol::manifests::ManifestStatusState::Errored => Color::Red,
|
|
};
|
|
|
|
let status_text = format!("{:?}", status);
|
|
let content = Line::from(vec![
|
|
Span::styled("● ", Style::default().fg(status_color)),
|
|
Span::raw(name),
|
|
Span::raw(" "),
|
|
Span::styled(
|
|
format!("[{}]", status_text),
|
|
Style::default().fg(status_color),
|
|
),
|
|
]);
|
|
ListItem::new(content)
|
|
})
|
|
.collect();
|
|
|
|
let title = if app.input_mode == InputMode::Search && !app.search_query.is_empty() {
|
|
format!(
|
|
" Manifests ({}/{}) - Filtered ",
|
|
filtered_data.len(),
|
|
app.manifests.len()
|
|
)
|
|
} else {
|
|
format!(" Manifests ({}) ", app.manifests.len())
|
|
};
|
|
|
|
let list = List::new(items)
|
|
.block(Block::default().title(title).borders(Borders::ALL))
|
|
.highlight_style(
|
|
Style::default()
|
|
.bg(Color::DarkGray)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.highlight_symbol(">> ");
|
|
|
|
f.render_stateful_widget(list, area, &mut app.list_state);
|
|
}
|
|
|
|
fn render_manifest_details<TOperator: Operator>(f: &mut Frame, app: &App<TOperator>, area: Rect) {
|
|
let content = if let Some(manifest) = app.get_selected_manifest() {
|
|
let mut lines = vec![
|
|
Line::from(vec![
|
|
Span::styled("Name: ", Style::default().add_modifier(Modifier::BOLD)),
|
|
Span::raw(&manifest.manifest.name),
|
|
]),
|
|
Line::from(vec![
|
|
Span::styled("Kind: ", Style::default().add_modifier(Modifier::BOLD)),
|
|
Span::raw(manifest.manifest.spec.kind()),
|
|
]),
|
|
Line::from(vec![
|
|
Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)),
|
|
Span::raw(format!("{:?}", manifest.status.status)),
|
|
]),
|
|
Line::from(vec![
|
|
Span::styled(
|
|
"Generation: ",
|
|
Style::default().add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::raw(format!("{}", manifest.generation)),
|
|
]),
|
|
Line::from(vec![
|
|
Span::styled("Created: ", Style::default().add_modifier(Modifier::BOLD)),
|
|
Span::raw(format!("{}", manifest.created)),
|
|
]),
|
|
Line::from(vec![
|
|
Span::styled("Updated: ", Style::default().add_modifier(Modifier::BOLD)),
|
|
Span::raw(format!("{}", manifest.updated)),
|
|
]),
|
|
];
|
|
|
|
if !manifest.status.events.is_empty() {
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from(Span::styled(
|
|
"Events:",
|
|
Style::default().add_modifier(Modifier::BOLD),
|
|
)));
|
|
for event in manifest.status.events.iter().rev().take(5) {
|
|
lines.push(Line::from(format!(" • {}", event.message)));
|
|
}
|
|
}
|
|
|
|
if !manifest.status.changes.is_empty() {
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from(Span::styled(
|
|
"Changes:",
|
|
Style::default().add_modifier(Modifier::BOLD),
|
|
)));
|
|
for change in manifest.status.changes.iter().rev().take(5) {
|
|
lines.push(Line::from(format!(
|
|
" • {:?} at {}",
|
|
change.event, change.created
|
|
)));
|
|
}
|
|
}
|
|
|
|
Text::from(lines)
|
|
} else {
|
|
Text::from("No manifest selected")
|
|
};
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
.block(Block::default().title(" Details ").borders(Borders::ALL))
|
|
.wrap(Wrap { trim: true });
|
|
|
|
f.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn render_messages<TOperator: Operator>(f: &mut Frame, app: &App<TOperator>, area: Rect) {
|
|
let messages: Vec<Line> = app
|
|
.messages
|
|
.iter()
|
|
.rev()
|
|
.take(8)
|
|
.rev()
|
|
.map(|m| Line::from(m.clone()))
|
|
.collect();
|
|
|
|
let paragraph = Paragraph::new(messages)
|
|
.block(Block::default().title(" Output ").borders(Borders::ALL))
|
|
.wrap(Wrap { trim: false });
|
|
|
|
f.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn render_command_input<TOperator: Operator>(f: &mut Frame, app: &App<TOperator>, area: Rect) {
|
|
let (title, input_text, style) = match app.input_mode {
|
|
InputMode::Normal => {
|
|
let hist_info = if let Some(idx) = app.history_index {
|
|
format!(" [History {}/{}]", idx + 1, app.command_history.len())
|
|
} else {
|
|
String::new()
|
|
};
|
|
(
|
|
format!(
|
|
" Command{} (/ to search, ↑↓ for history, 'help' for commands) ",
|
|
hist_info
|
|
),
|
|
app.command_input.as_str(),
|
|
Style::default().fg(Color::Yellow),
|
|
)
|
|
}
|
|
InputMode::Search => (
|
|
" Search (fuzzy) - Enter to apply, Esc to cancel ".to_string(),
|
|
app.command_input.as_str(),
|
|
Style::default()
|
|
.fg(Color::Cyan)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
};
|
|
|
|
let input = Paragraph::new(input_text)
|
|
.style(style)
|
|
.block(Block::default().title(title).borders(Borders::ALL));
|
|
|
|
f.render_widget(input, area);
|
|
}
|
|
|
|
/// Run the TUI with the given control plane
|
|
pub async fn run<TOperator>(control_plane: ControlPlane<TOperator>) -> anyhow::Result<()>
|
|
where
|
|
TOperator: Operator + Send + Sync + 'static,
|
|
TOperator::Specifications: Send + Sync,
|
|
{
|
|
// Setup terminal
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
// Create app state
|
|
let app = Arc::new(Mutex::new(App::new(control_plane)));
|
|
|
|
// Run TUI
|
|
let res = run_app(&mut terminal, app).await;
|
|
|
|
// Restore terminal
|
|
disable_raw_mode()?;
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
LeaveAlternateScreen,
|
|
DisableMouseCapture
|
|
)?;
|
|
terminal.show_cursor()?;
|
|
|
|
res
|
|
}
|