pub mod dashboard;
pub mod memory_client;
pub mod search_client;
use std::time::{Duration, Instant};
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use dashboard::{DashboardState, PanelStatus};
use memory_client::MemoryClient;
use search_client::SearchClient;
const REFRESH_INTERVAL: Duration = Duration::from_millis(2000);
const INPUT_POLL: Duration = Duration::from_millis(50);
const OFFLINE_RETRY: Duration = Duration::from_millis(5000);
pub async fn run() -> anyhow::Result<()> {
let search_url = search_client::resolve_search_url();
let memory_url = memory_client::resolve_memory_url();
run_with_urls(search_url, memory_url).await
}
pub async fn run_with_urls(search_url: String, memory_url: String) -> anyhow::Result<()> {
let mut search = SearchClient::new(search_url.clone());
let mut memory = MemoryClient::new(memory_url.clone());
let mut state = DashboardState::new(search_url, memory_url);
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_loop(&mut terminal, &mut state, &mut search, &mut memory).await;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
async fn poll_search(state: &mut DashboardState, client: &mut SearchClient) {
if !state.search.status.is_online() {
let resolved = search_client::resolve_search_url();
if resolved != client.base_url() {
client.set_base_url(resolved.clone());
state.search.base_url = resolved;
}
}
match client.fetch_all().await {
Ok(data) => state.search.status = PanelStatus::Online(data),
Err(e) => {
state.search.status = PanelStatus::Offline {
last_error: e.to_string(),
}
}
}
}
async fn poll_memory(state: &mut DashboardState, client: &mut MemoryClient) {
if !state.memory.status.is_online() {
let resolved = memory_client::resolve_memory_url();
if resolved != client.base_url() {
client.set_base_url(resolved.clone());
state.memory.base_url = resolved;
}
}
match client.fetch_all().await {
Ok(data) => state.memory.status = PanelStatus::Online(data),
Err(e) => {
state.memory.status = PanelStatus::Offline {
last_error: e.to_string(),
}
}
}
}
fn panel_due(online: bool, elapsed: Duration) -> bool {
let cadence = if online {
REFRESH_INTERVAL
} else {
OFFLINE_RETRY
};
elapsed >= cadence
}
async fn run_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
state: &mut DashboardState,
search: &mut SearchClient,
memory: &mut MemoryClient,
) -> anyhow::Result<()> {
poll_search(state, search).await;
poll_memory(state, memory).await;
let mut last_search_poll = Instant::now();
let mut last_memory_poll = Instant::now();
loop {
terminal.draw(|f| dashboard::render(f, state))?;
let key = if event::poll(INPUT_POLL)? {
match event::read()? {
Event::Key(key) => Some(key),
_ => None,
}
} else {
None
};
if let Some(key) = key {
use crossterm::event::{KeyEventKind, KeyModifiers};
if key.kind != KeyEventKind::Release {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Ok(());
}
if state.show_help {
if matches!(key.code, KeyCode::Char('?') | KeyCode::Esc) {
state.show_help = false;
} else if key.code == KeyCode::Char('q') {
return Ok(());
}
} else {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Char('?') => state.show_help = true,
KeyCode::Tab => state.toggle_focus(),
KeyCode::Char('r') => {
trigger_reindex(state, search).await;
poll_search(state, search).await;
last_search_poll = Instant::now();
}
_ => {}
}
}
}
}
if panel_due(state.search.status.is_online(), last_search_poll.elapsed()) {
poll_search(state, search).await;
last_search_poll = Instant::now();
}
if panel_due(state.memory.status.is_online(), last_memory_poll.elapsed()) {
poll_memory(state, memory).await;
last_memory_poll = Instant::now();
}
}
}
async fn trigger_reindex(state: &mut DashboardState, client: &SearchClient) {
let Some(id) = state.reindex_target() else {
state.last_action = Some("reindex: focus the SEARCH panel first".to_string());
return;
};
match client.reindex(&id).await {
Ok(()) => state.last_action = Some(format!("reindex queued for '{id}'")),
Err(e) => {
tracing::warn!("reindex of {id} failed: {e}");
state.last_action = Some(format!("reindex of '{id}' failed: {e}"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn panel_due_uses_online_cadence() {
assert!(!panel_due(true, Duration::from_millis(1999)));
assert!(panel_due(true, Duration::from_millis(2000)));
}
#[test]
fn panel_due_uses_offline_cadence() {
assert!(!panel_due(false, Duration::from_millis(2000)));
assert!(panel_due(false, Duration::from_millis(5000)));
}
#[tokio::test]
async fn trigger_reindex_without_focus_records_note() {
let mut state = DashboardState::new("http://127.0.0.1:7878", "http://127.0.0.1:7070");
state.toggle_focus(); let client = SearchClient::new("http://127.0.0.1:7878");
trigger_reindex(&mut state, &client).await;
assert!(
state
.last_action
.as_deref()
.unwrap_or_default()
.contains("focus the SEARCH panel"),
"expected a focus hint, got {:?}",
state.last_action
);
}
#[tokio::test]
async fn poll_memory_resolves_to_a_terminal_status() {
let mut state = DashboardState::new("http://127.0.0.1:1", "http://127.0.0.1:2");
let mut client = MemoryClient::new("http://127.0.0.1:2");
poll_memory(&mut state, &mut client).await;
match &state.memory.status {
PanelStatus::Offline { last_error } => assert!(!last_error.is_empty()),
PanelStatus::Online(_) => {}
PanelStatus::Connecting => panic!("poll must leave Connecting behind"),
}
}
}