use crossterm::{ event::{self, Event, EventStream, KeyCode}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Alignment}, style::{Color, Modifier, Style}, widgets::{Block, Borders, Paragraph, Wrap}, Terminal, }; use std::io; use tokio::sync::RwLock; use std::sync::Arc; use futures_util::StreamExt; use std::time::Duration; #[derive(Clone)] pub struct LogEntry { pub ty: String, pub msg: String, pub speed: Option, } pub struct DashboardState { pub logs: Vec, pub status: String, pub node_id: Option, pub sys_info: String, pub model_name: String, pub cur_task_id: Option, pub cur_prompt: Option, pub tasks_completed: u32, pub last_tokens_sec: f64, pub network_active_nodes: usize, pub network_total_tasks: u64, // Mallivalikko pub model_picker_open: bool, pub model_picker_items: Vec, pub model_picker_idx: usize, } impl DashboardState { pub fn new() -> Self { Self { logs: Vec::new(), status: "ACTIVE".to_string(), node_id: None, sys_info: "".to_string(), model_name: "Yhdistetään...".to_string(), cur_task_id: None, cur_prompt: None, tasks_completed: 0, last_tokens_sec: 0.0, network_active_nodes: 1, // oletetaan itsemme network_total_tasks: 0, model_picker_open: false, model_picker_items: Vec::new(), model_picker_idx: 0, } } pub fn push_log(&mut self, ty: &str, msg: String, speed: Option) { self.logs.push(LogEntry { ty: ty.to_string(), msg, speed, }); if self.logs.len() > 100 { self.logs.remove(0); } } } pub async fn run_dashboard( state: Arc>, cmd_tx: tokio::sync::mpsc::UnboundedSender, ) -> Result<(), io::Error> { enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; let mut reader = EventStream::new(); let mut interval = tokio::time::interval(Duration::from_millis(100)); loop { tokio::select! { _ = interval.tick() => { let st = state.read().await; terminal.draw(|f| ui(f, &st))?; } ev = reader.next() => { if let Some(Ok(Event::Key(key))) = ev { let picker_open = state.read().await.model_picker_open; if picker_open { // Mallivalikko auki — navigointi match key.code { KeyCode::Up | KeyCode::Char('k') => { let mut st = state.write().await; if st.model_picker_idx > 0 { st.model_picker_idx -= 1; } } KeyCode::Down | KeyCode::Char('j') => { let mut st = state.write().await; let max = st.model_picker_items.len().saturating_sub(1); if st.model_picker_idx < max { st.model_picker_idx += 1; } } KeyCode::Enter => { let mut st = state.write().await; let idx = st.model_picker_idx; if let Some(model) = st.model_picker_items.get(idx).cloned() { st.model_picker_open = false; st.push_log("System", format!("Vaihdetaan malliin: {}...", model), None); let _ = cmd_tx.send(format!("change_model:{}", model)); } } KeyCode::Esc | KeyCode::Char('m') | KeyCode::Char('M') => { state.write().await.model_picker_open = false; } _ => {} } } else { // Normaali tila match key.code { KeyCode::Char('q') | KeyCode::Esc => { disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; std::process::exit(0); } KeyCode::Char('p') | KeyCode::Char('P') => { let _ = cmd_tx.send("pause".to_string()); } KeyCode::Char('r') | KeyCode::Char('R') | KeyCode::Char('s') => { let _ = cmd_tx.send("resume".to_string()); } KeyCode::Char('m') | KeyCode::Char('M') => { let _ = cmd_tx.send("fetch_models".to_string()); } _ => {} } } } } } } } pub fn restore_terminal() { let _ = disable_raw_mode(); let _ = execute!(io::stdout(), LeaveAlternateScreen); } fn ui(f: &mut ratatui::Frame, st: &DashboardState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Header Constraint::Min(0), // Body Constraint::Length(3), // Footer / Status ].as_ref()) .split(f.area()); // --- Header --- let header_text = match st.node_id { Some(id) => format!(" Kipinä Agentic Node #{} ", id), None => " Kipinä Agentic Node (Yhdistää...) ".to_string(), }; let header = Paragraph::new(header_text) .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL).style(Style::default().fg(Color::DarkGray))); f.render_widget(header, chunks[0]); // --- Body --- let body_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(7), // Yläosan info ja tehtävä Constraint::Min(0), // Lokit / Chat alas ].as_ref()) .split(chunks[1]); let top_panels = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(40), // Vasen paneeli (Info) Constraint::Percentage(60), // Oikea paneeli (Tehtävä) ].as_ref()) .split(body_chunks[0]); // Vasen paneeli: Laitteisto, Malli & Verkosto let info_text = format!( "🚀 Malli: {}\n💻 Järjestelmä: {}\n📊 Tehdyt: {} | Nopeus: {} t/s\n🌐 Verkosto: {} solmua | {} tehtävää", st.model_name, st.sys_info, st.tasks_completed, st.last_tokens_sec, st.network_active_nodes, st.network_total_tasks ); let left_panel = Paragraph::new(info_text) .block(Block::default().title(" Laitteisto ja AI ").borders(Borders::ALL)) .style(Style::default().fg(Color::White)) .wrap(Wrap { trim: true }); f.render_widget(left_panel, top_panels[0]); // Oikea paneeli: Käynnissä oleva tehtävä let task_title = match &st.cur_task_id { Some(id) => format!(" Työn alla: {} ", id), None => " Vapaana ".to_string(), }; let task_content = st.cur_prompt.clone().unwrap_or_else(|| "Odotetaan tehtäviä Hubilta...".to_string()); let task_style = if st.cur_task_id.is_some() { Style::default().fg(Color::Magenta) } else { Style::default().fg(Color::DarkGray) }; let task_panel = Paragraph::new(task_content) .wrap(Wrap { trim: true }) .block(Block::default().title(task_title).borders(Borders::ALL).style(task_style)); f.render_widget(task_panel, top_panels[1]); // Alaosan paneeli: Tapahtumaloki koko leveydeltä let area_height = body_chunks[1].height.saturating_sub(2) as usize; let skip_count = if st.logs.len() > area_height { st.logs.len() - area_height } else { 0 }; let visible_logs: Vec = st.logs.iter().skip(skip_count).map(|log| { let ty_color = match log.ty.as_str() { "System" => Color::Yellow, "Network" => Color::Blue, "Task" => Color::Magenta, "Ping" => Color::DarkGray, _ => Color::White, }; let speed_str = if let Some(s) = log.speed { format!(" | {:.1} tok/s", s) } else { "".to_string() }; ratatui::text::Line::from(vec![ ratatui::text::Span::styled(format!("{: <8}", log.ty), Style::default().fg(ty_color).add_modifier(Modifier::BOLD)), ratatui::text::Span::raw(" | "), ratatui::text::Span::styled(log.msg.clone(), Style::default().fg(Color::White)), ratatui::text::Span::styled(speed_str, Style::default().fg(ty_color)), ]) }).collect(); let logs_panel = Paragraph::new(visible_logs) .block(Block::default().title(" Tapahtumaloki ").borders(Borders::ALL).style(Style::default().fg(Color::Cyan))); f.render_widget(logs_panel, body_chunks[1]); // --- Footer / Status --- let status_color = if st.status == "ACTIVE" { Color::Green } else { Color::Yellow }; let status_text = format!(" Tila: {} | [P] Pause [R] Työhön [M] Malli [Q] Sulje ", st.status); let footer = Paragraph::new(status_text) .style(Style::default().fg(status_color).add_modifier(Modifier::BOLD)) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); f.render_widget(footer, chunks[2]); // --- Mallivalikko-overlay --- if st.model_picker_open && !st.model_picker_items.is_empty() { let area = f.area(); let popup_h = (st.model_picker_items.len() as u16 + 4).min(area.height - 4); let popup_w = 50.min(area.width - 4); let popup = ratatui::layout::Rect::new( (area.width - popup_w) / 2, (area.height - popup_h) / 2, popup_w, popup_h, ); // Tausta f.render_widget(ratatui::widgets::Clear, popup); let items: Vec = st.model_picker_items.iter().enumerate().map(|(i, name)| { if i == st.model_picker_idx { ratatui::text::Line::from(format!(" ▸ {} ", name)) .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) } else { ratatui::text::Line::from(format!(" {} ", name)) .style(Style::default().fg(Color::White)) } }).collect(); let picker = Paragraph::new(items) .block(Block::default() .title(" Vaihda malli [↑↓] Enter=valitse Esc=peruuta ") .borders(Borders::ALL) .style(Style::default().fg(Color::Cyan))); f.render_widget(picker, popup); } }