feat: add reconciliation loop

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-01-06 22:06:54 +01:00
parent 2d59c7fd69
commit f73b8c7796
13 changed files with 790 additions and 221 deletions

View File

@@ -3,20 +3,22 @@ use std::sync::Arc;
use std::time::Duration;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers},
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use nocontrol::{manifests::ManifestState, ControlPlane, Operator, Specification};
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},
Frame, Terminal,
};
use tokio::sync::Mutex;
@@ -265,22 +267,36 @@ impl<TOperator: Operator> App<TOperator> {
}
["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(" 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(" / - 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.messages.push(format!(
"Unknown command: {}. Type 'help' for commands.",
cmd
));
}
}
@@ -321,76 +337,76 @@ where
}
// Handle input with timeout
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
let mut app = app.lock().await;
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;
}
_ => {}
}
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;
}
_ => {}
}
}
}
@@ -403,10 +419,10 @@ fn ui<TOperator: Operator>(f: &mut Frame, app: &mut App<TOperator>) {
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
Constraint::Length(3), // Title
Constraint::Min(10), // Main content
Constraint::Length(10), // Messages
Constraint::Length(3), // Command input
])
.split(f.area());
@@ -415,21 +431,25 @@ fn ui<TOperator: Operator>(f: &mut Frame, app: &mut App<TOperator>) {
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),
),
]),
])
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]);
@@ -454,7 +474,8 @@ fn ui<TOperator: Operator>(f: &mut Frame, app: &mut App<TOperator>) {
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
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()))
@@ -469,14 +490,12 @@ fn render_manifest_list<TOperator: Operator>(f: &mut Frame, app: &mut App<TOpera
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::styled("", Style::default().fg(status_color)),
Span::raw(name),
Span::raw(" "),
Span::styled(
@@ -526,7 +545,10 @@ fn render_manifest_details<TOperator: Operator>(f: &mut Frame, app: &App<TOperat
Span::raw(format!("{:?}", manifest.status.status)),
]),
Line::from(vec![
Span::styled("Generation: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
"Generation: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!("{}", manifest.generation)),
]),
Line::from(vec![
@@ -541,7 +563,10 @@ fn render_manifest_details<TOperator: Operator>(f: &mut Frame, app: &App<TOperat
if !manifest.status.events.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Events:", Style::default().add_modifier(Modifier::BOLD))));
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)));
}
@@ -549,7 +574,10 @@ fn render_manifest_details<TOperator: Operator>(f: &mut Frame, app: &App<TOperat
if !manifest.status.changes.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Changes:", Style::default().add_modifier(Modifier::BOLD))));
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 {}",
@@ -596,7 +624,10 @@ fn render_command_input<TOperator: Operator>(f: &mut Frame, app: &App<TOperator>
String::new()
};
(
format!(" Command{} (/ to search, ↑↓ for history, 'help' for commands) ", hist_info),
format!(
" Command{} (/ to search, ↑↓ for history, 'help' for commands) ",
hist_info
),
app.command_input.as_str(),
Style::default().fg(Color::Yellow),
)
@@ -604,7 +635,9 @@ fn render_command_input<TOperator: Operator>(f: &mut Frame, app: &App<TOperator>
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),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
};