1pub mod actions;
4
5use std::collections::HashSet;
6use std::io::{self, Stdout, Write};
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant};
10
11use crossterm::event::{self, Event, KeyCode, KeyEvent};
12use crossterm::execute;
13use crossterm::terminal::{
14 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
15};
16use ratatui::backend::CrosstermBackend;
17use ratatui::layout::{Constraint, Direction, Layout, Rect};
18use ratatui::style::{Color, Modifier, Style};
19use ratatui::text::{Line, Span};
20use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
21use ratatui::Terminal;
22use tokio::runtime::Runtime;
23
24use alopex_embedded::{CreateCatalogRequest, CreateNamespaceRequest};
25
26use crate::error::{CliError, Result};
27use crate::models::{Column, DataType, Row, Value};
28use crate::output::formatter::{create_formatter, Formatter};
29use crate::ui::mode::UiMode;
30use crate::{
31 batch::BatchMode,
32 cli::{
33 ColumnarCommand, DistanceMetric, HnswCommand, IndexCommand, KvCommand, LifecycleCommand,
34 OutputFormat, SqlCommand, VectorCommand,
35 },
36 client::http::HttpClient,
37};
38
39use self::actions::{
40 all_actions, execute_local_action, execute_remote_action, AdminAction, AdminCommand,
41 AdminRequest,
42};
43use super::is_tty;
44
45#[allow(dead_code)]
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AuthScope {
48 Full,
49 Restricted,
50}
51
52#[derive(Debug, Clone)]
53pub struct AuthCapabilities {
54 scope: AuthScope,
55 allowed_actions: HashSet<AdminAction>,
56}
57
58impl AuthCapabilities {
59 pub fn full() -> Self {
60 Self {
61 scope: AuthScope::Full,
62 allowed_actions: HashSet::new(),
63 }
64 }
65
66 #[allow(dead_code)]
67 pub fn restricted(allowed_actions: HashSet<AdminAction>) -> Self {
68 Self {
69 scope: AuthScope::Restricted,
70 allowed_actions,
71 }
72 }
73
74 pub fn restricted_all() -> Self {
75 Self {
76 scope: AuthScope::Restricted,
77 allowed_actions: all_actions(),
78 }
79 }
80
81 fn allows(&self, action: AdminAction) -> bool {
82 match self.scope {
83 AuthScope::Full => true,
84 AuthScope::Restricted => self.allowed_actions.contains(&action),
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
90struct AdminItem {
91 action: AdminAction,
92 title: &'static str,
93 description: &'static str,
94 enabled: bool,
95}
96
97struct AdminApp<'a> {
98 items: Vec<AdminItem>,
99 selected: usize,
100 show_help: bool,
101 connection_label: String,
102 backend: AdminBackend<'a>,
103 last_result: Option<AdminResult>,
104 target: AdminTarget,
105 params: String,
106 form_fields: Vec<AdminFormField>,
107 active_field: usize,
108 use_raw_params: bool,
109 input_mode: AdminInputMode,
110 last_action: Option<AdminAction>,
111 selection: Option<SelectionOverlay>,
112 focus: AdminFocus,
113 resources: ResourceTree,
114 preview_scroll: usize,
115}
116
117impl<'a> AdminApp<'a> {
118 fn new(
119 connection_label: impl Into<String>,
120 auth: AuthCapabilities,
121 backend: AdminBackend<'a>,
122 initial_target: Option<AdminTarget>,
123 ) -> Self {
124 let mut items = default_items();
125 for item in &mut items {
126 item.enabled = auth.allows(item.action);
127 }
128 let target = initial_target.unwrap_or(AdminTarget::Sql);
129 let selected_action = items.first().map(|item| item.action);
130 let form_fields = selected_action
131 .map(|action| build_form_fields(target, action))
132 .unwrap_or_default();
133 let resources = ResourceTree::new(&backend);
134 let last_result = resources
135 .last_error
136 .as_ref()
137 .map(|err| AdminResult::status(format!("Resource load failed: {err}")));
138 Self {
139 items,
140 selected: 0,
141 show_help: false,
142 connection_label: connection_label.into(),
143 backend,
144 last_result,
145 target,
146 params: String::new(),
147 form_fields,
148 active_field: 0,
149 use_raw_params: false,
150 input_mode: AdminInputMode::Normal,
151 last_action: selected_action,
152 selection: None,
153 focus: AdminFocus::Table,
154 resources,
155 preview_scroll: 0,
156 }
157 }
158
159 fn run(mut self) -> Result<()> {
160 if !is_tty() {
161 return Err(CliError::InvalidArgument(
162 "TUI requires a TTY. Run without --tui in batch mode.".to_string(),
163 ));
164 }
165 enable_raw_mode()?;
166 let mut stdout = io::stdout();
167 execute!(stdout, EnterAlternateScreen)?;
168
169 let backend = CrosstermBackend::new(stdout);
170 let mut terminal = Terminal::new(backend)?;
171 terminal.clear()?;
172
173 let tick_rate = Duration::from_millis(16);
174 let mut last_tick = Instant::now();
175
176 loop {
177 terminal.draw(|frame| self.draw(frame))?;
178
179 let timeout = tick_rate
180 .checked_sub(last_tick.elapsed())
181 .unwrap_or_else(|| Duration::from_secs(0));
182
183 if event::poll(timeout)? {
184 if let Event::Key(key) = event::read()? {
185 if self.handle_key(key)? {
186 break;
187 }
188 }
189 }
190
191 if last_tick.elapsed() >= tick_rate {
192 last_tick = Instant::now();
193 }
194 }
195
196 cleanup_terminal(terminal)
197 }
198
199 fn draw(&mut self, frame: &mut ratatui::Frame<'_>) {
200 let area = frame.size();
201 let chunks = Layout::default()
202 .direction(Direction::Vertical)
203 .constraints([Constraint::Min(5), Constraint::Length(3)])
204 .split(area);
205
206 let root_layout = Layout::default()
207 .direction(Direction::Horizontal)
208 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
209 .split(chunks[0]);
210
211 let right_layout = Layout::default()
212 .direction(Direction::Vertical)
213 .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
214 .split(root_layout[1]);
215
216 self.render_resources(frame, root_layout[0]);
217 self.render_input(frame, right_layout[0]);
218 self.render_preview(frame, right_layout[1]);
219 self.render_status(frame, chunks[1]);
220
221 if self.show_help {
222 render_help(frame, area);
223 }
224 if let Some(selection) = &self.selection {
225 render_selection_overlay(frame, area, selection);
226 }
227 }
228
229 fn render_action_list(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
230 let items = self
231 .items
232 .iter()
233 .map(|item| {
234 let label = if item.enabled {
235 item.title.to_string()
236 } else {
237 format!("{} (locked)", item.title)
238 };
239 ListItem::new(Line::from(Span::raw(label)))
240 })
241 .collect::<Vec<_>>();
242
243 let mut state = ListState::default();
244 state.select(Some(self.selected));
245
246 let list = List::new(items)
247 .block(
248 Block::default()
249 .borders(Borders::ALL)
250 .title("Actions")
251 .border_style(self.focus_style(AdminFocus::Detail)),
252 )
253 .highlight_style(
254 Style::default()
255 .bg(Color::Blue)
256 .fg(Color::White)
257 .add_modifier(Modifier::BOLD),
258 )
259 .highlight_symbol("> ");
260
261 frame.render_stateful_widget(list, area, &mut state);
262 }
263
264 fn render_input(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
265 let layout = Layout::default()
266 .direction(Direction::Vertical)
267 .constraints([Constraint::Length(10), Constraint::Min(6)])
268 .split(area);
269
270 self.render_action_list(frame, layout[0]);
271
272 let detail_area = layout[1];
273 let selected = self.items.get(self.selected);
274 let mut lines = Vec::new();
275 if let Some(item) = selected {
276 lines.push(Line::from(vec![Span::styled(
277 item.title,
278 Style::default().add_modifier(Modifier::BOLD),
279 )]));
280 lines.push(Line::from(""));
281 lines.push(Line::from(format!("Target: {}", self.target.label())));
282 match self.input_mode {
283 AdminInputMode::EditingField => {
284 lines.push(Line::from("Mode: editing field (Enter/Esc to finish)"));
285 }
286 AdminInputMode::EditingRaw => {
287 lines.push(Line::from("Mode: editing raw params (Enter/Esc to finish)"));
288 }
289 AdminInputMode::Normal => {}
290 }
291 if self.use_raw_params {
292 lines.push(Line::from("Input: raw parameters (press r to switch)"));
293 let line = if self.params.is_empty() {
294 "Params: <empty> (press e to edit)".to_string()
295 } else {
296 format!("Params: {}", self.params)
297 };
298 lines.push(Line::from(line));
299 if let Some(example) = self.target.example_for(item.action) {
300 lines.push(Line::from(format!("Example: {example}")));
301 }
302 } else {
303 lines.push(Line::from(
304 "Input: guided fields (Tab to move, e to edit, o to list)",
305 ));
306 for (idx, field) in self.form_fields.iter().enumerate() {
307 let marker = if idx == self.active_field { ">" } else { " " };
308 let value = if field.value.is_empty() {
309 Span::styled(
310 field.placeholder.to_string(),
311 Style::default().fg(Color::DarkGray),
312 )
313 } else {
314 Span::raw(field.value.clone())
315 };
316 let required = if field.required { " *" } else { "" };
317 let list_hint = if field.list_source.is_some() {
318 Span::styled(" (o)", Style::default().fg(Color::Blue))
319 } else {
320 Span::raw("")
321 };
322 lines.push(Line::from(vec![
323 Span::raw(format!("{marker} ")),
324 Span::styled(
325 format!("{}{}", field.label, required),
326 Style::default().add_modifier(Modifier::BOLD),
327 ),
328 Span::raw(": "),
329 value,
330 list_hint,
331 ]));
332 }
333 }
334 lines.push(Line::from(""));
335 lines.push(Line::from(item.description));
336 lines.push(Line::from(""));
337 if !item.enabled {
338 lines.push(Line::from(Span::styled(
339 "Disabled: your current authorization does not allow this action.",
340 Style::default().fg(Color::Red),
341 )));
342 } else if is_not_implemented(item.action) {
343 lines.push(Line::from(Span::styled(
344 "Status: Not implemented yet.",
345 Style::default().fg(Color::Yellow),
346 )));
347 } else {
348 lines.push(Line::from(Span::styled(
349 "Status: Ready.",
350 Style::default().fg(Color::Green),
351 )));
352 }
353 }
354 let paragraph = Paragraph::new(lines)
355 .block(
356 Block::default()
357 .borders(Borders::ALL)
358 .title("Detail")
359 .border_style(self.focus_style(AdminFocus::Detail)),
360 )
361 .wrap(Wrap { trim: true });
362 frame.render_widget(paragraph, detail_area);
363 }
364
365 fn focus_style(&self, focus: AdminFocus) -> Style {
366 if self.focus == focus {
367 Style::default().fg(Color::Green)
368 } else {
369 Style::default()
370 }
371 }
372
373 fn preview_line_count(&self) -> usize {
374 let mut lines = Vec::new();
375 if let Some(result) = &self.last_result {
376 append_result_lines(&mut lines, result);
377 } else {
378 lines.push(Line::from("No results yet."));
379 }
380 lines.len()
381 }
382
383 fn render_resources(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect) {
384 self.resources.ensure_selection_in_range();
385 let layout = if self.resources.search.is_some() {
386 Layout::default()
387 .direction(Direction::Vertical)
388 .constraints([Constraint::Length(1), Constraint::Min(3)])
389 .split(area)
390 } else {
391 Layout::default()
392 .direction(Direction::Vertical)
393 .constraints([Constraint::Min(3)])
394 .split(area)
395 };
396
397 if let Some(search) = self.resources.search.as_ref() {
398 let search_text = format!("/ {search}");
399 let style = if self.resources.search_focused {
400 Style::default().fg(Color::Yellow)
401 } else {
402 Style::default().fg(Color::Gray)
403 };
404 frame.render_widget(
405 Paragraph::new(search_text)
406 .block(
407 Block::default()
408 .borders(Borders::ALL)
409 .title("Resources")
410 .border_style(self.focus_style(AdminFocus::Table)),
411 )
412 .style(style),
413 layout[0],
414 );
415 }
416
417 let list_area = if layout.len() == 1 {
418 layout[0]
419 } else {
420 layout[1]
421 };
422 let entries = self.resources.filtered_entries();
423 let items = if entries.is_empty() {
424 vec![ListItem::new(Line::from("No resources found."))]
425 } else {
426 entries
427 .iter()
428 .map(|entry| {
429 let indent = " ".repeat(entry.depth);
430 let mut line = format!("{indent}{}", entry.label);
431 if !entry.selectable {
432 line = line.to_string();
433 }
434 let style = if entry.selectable {
435 Style::default()
436 } else {
437 Style::default().fg(Color::DarkGray)
438 };
439 ListItem::new(Line::from(Span::styled(line, style)))
440 })
441 .collect::<Vec<_>>()
442 };
443
444 let mut state = ListState::default();
445 state.select(Some(self.resources.selected));
446 let list = List::new(items)
447 .block(
448 Block::default()
449 .borders(Borders::ALL)
450 .title(if self.resources.search.is_some() {
451 ""
452 } else {
453 "Resources"
454 })
455 .border_style(self.focus_style(AdminFocus::Table)),
456 )
457 .highlight_style(
458 Style::default()
459 .bg(Color::Blue)
460 .fg(Color::White)
461 .add_modifier(Modifier::BOLD),
462 )
463 .highlight_symbol("> ");
464
465 frame.render_stateful_widget(list, list_area, &mut state);
466 }
467
468 fn render_preview(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
469 let mut lines = Vec::new();
470 if let Some(result) = &self.last_result {
471 append_result_lines(&mut lines, result);
472 } else {
473 lines.push(Line::from("No results yet."));
474 }
475
476 let height = area.height.saturating_sub(2) as usize;
477 let start = self.preview_scroll.min(lines.len());
478 let end = (start + height).min(lines.len());
479 let view = lines[start..end].to_vec();
480
481 let paragraph = Paragraph::new(view)
482 .block(
483 Block::default()
484 .borders(Borders::ALL)
485 .title("Status")
486 .border_style(self.focus_style(AdminFocus::Status)),
487 )
488 .wrap(Wrap { trim: true });
489 frame.render_widget(paragraph, area);
490 }
491
492 fn render_status(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
493 let action = self
494 .items
495 .get(self.selected)
496 .map(|item| item.title)
497 .unwrap_or("-");
498 let focus_label = match self.focus {
499 AdminFocus::Table => "Table",
500 AdminFocus::Detail => "Detail",
501 AdminFocus::Status => "Status",
502 };
503 let highlight = Style::default()
504 .fg(Color::Yellow)
505 .add_modifier(Modifier::BOLD);
506
507 let mut spans = Vec::new();
508 let push_sep = |spans: &mut Vec<Span<'_>>| {
509 spans.push(Span::raw(" | "));
510 };
511
512 spans.push(Span::raw("Connection: "));
513 spans.push(Span::styled(self.connection_label.to_string(), highlight));
514 push_sep(&mut spans);
515 spans.push(Span::raw("Focus: "));
516 spans.push(Span::styled(focus_label.to_string(), highlight));
517 push_sep(&mut spans);
518 spans.push(Span::raw("Action: "));
519 spans.push(Span::styled(action.to_string(), highlight));
520
521 let mut mode_label = None;
522 if self.show_help {
523 mode_label = Some("Help");
524 } else if self.selection.is_some() {
525 mode_label = Some("Selecting option");
526 } else if self.input_mode == AdminInputMode::EditingField {
527 mode_label = Some("Editing field");
528 } else if self.input_mode == AdminInputMode::EditingRaw {
529 mode_label = Some("Editing raw params");
530 }
531
532 if let Some(mode) = mode_label {
533 push_sep(&mut spans);
534 spans.push(Span::raw(format!("Mode: {mode}")));
535 }
536
537 let (ops_text, move_text) = if self.show_help {
538 ("?: close".to_string(), "-".to_string())
539 } else if self.selection.is_some() {
540 (
541 "Enter: choose, /: search, Esc: cancel".to_string(),
542 "j/k, g/G, Ctrl+d/u".to_string(),
543 )
544 } else if matches!(
545 self.input_mode,
546 AdminInputMode::EditingField | AdminInputMode::EditingRaw
547 ) {
548 ("Enter: done, Esc: cancel".to_string(), "-".to_string())
549 } else {
550 match self.focus {
551 AdminFocus::Table => (
552 "Enter: select, e: edit, r: raw, R: refresh, a: back, ?: help, q: quit"
553 .to_string(),
554 "j/k, g/G, Ctrl+d/u, h/l".to_string(),
555 ),
556 AdminFocus::Detail => (
557 "Enter: execute, e: edit, o: list, r: raw, a: back, ?: help, q: quit"
558 .to_string(),
559 "Up/Down, Tab, h/l".to_string(),
560 ),
561 AdminFocus::Status => (
562 "a: back, ?: help, q: quit".to_string(),
563 "j/k, g/G, Ctrl+d/u, h".to_string(),
564 ),
565 }
566 };
567
568 push_sep(&mut spans);
569 spans.push(Span::styled(format!("Ops: {ops_text}"), highlight));
570 push_sep(&mut spans);
571 if move_text == "-" {
572 spans.push(Span::raw("Move: -"));
573 } else {
574 spans.push(Span::raw(format!("Move: {move_text}")));
575 }
576
577 let paragraph = Paragraph::new(Line::from(spans))
578 .block(Block::default().borders(Borders::ALL).title("Status"))
579 .style(Style::default().fg(Color::Gray))
580 .wrap(Wrap { trim: true });
581 frame.render_widget(paragraph, area);
582 }
583
584 fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
585 if let Some(selection) = &mut self.selection {
586 if selection.search_focused {
587 match key.code {
588 KeyCode::Esc => selection.reset_search(),
589 KeyCode::Enter => selection.search_focused = false,
590 KeyCode::Backspace => selection.pop_search(),
591 KeyCode::Char(ch) => selection.push_search(ch),
592 _ => {}
593 }
594 return Ok(false);
595 }
596 match key.code {
597 KeyCode::Esc => {
598 self.selection = None;
599 }
600 KeyCode::Enter => {
601 if let Some(value) = selection.selected_value() {
602 if let Some(field) = self.form_fields.get_mut(selection.field_index) {
603 field.value = value;
604 }
605 }
606 self.selection = None;
607 }
608 KeyCode::Char('/') => {
609 selection.search_focused = true;
610 }
611 KeyCode::Up | KeyCode::Char('k') => {
612 selection.move_up();
613 }
614 KeyCode::Down | KeyCode::Char('j') => {
615 selection.move_down();
616 }
617 KeyCode::Char('g') => {
618 selection.move_top();
619 }
620 KeyCode::Char('G') => {
621 selection.move_bottom();
622 }
623 _ => {}
624 }
625 return Ok(false);
626 }
627 match self.input_mode {
628 AdminInputMode::EditingField => {
629 match key.code {
630 KeyCode::Esc | KeyCode::Enter => {
631 self.input_mode = AdminInputMode::Normal;
632 }
633 KeyCode::Backspace => {
634 if let Some(field) = self.form_fields.get_mut(self.active_field) {
635 field.value.pop();
636 }
637 }
638 KeyCode::Char(ch) => {
639 if let Some(field) = self.form_fields.get_mut(self.active_field) {
640 field.value.push(ch);
641 }
642 }
643 _ => {}
644 }
645 return Ok(false);
646 }
647 AdminInputMode::EditingRaw => {
648 match key.code {
649 KeyCode::Esc | KeyCode::Enter => {
650 self.input_mode = AdminInputMode::Normal;
651 }
652 KeyCode::Backspace => {
653 self.params.pop();
654 }
655 KeyCode::Char(ch) => {
656 self.params.push(ch);
657 }
658 _ => {}
659 }
660 return Ok(false);
661 }
662 AdminInputMode::Normal => {}
663 }
664
665 if matches!(self.focus, AdminFocus::Table)
666 && self.resources.search_focused
667 && key.code == KeyCode::Esc
668 {
669 self.resources.reset_search();
670 return Ok(false);
671 }
672
673 match key.code {
674 KeyCode::Char('q') | KeyCode::Char('a') | KeyCode::Esc => return Ok(true),
675 KeyCode::Char('?') => {
676 self.show_help = !self.show_help;
677 return Ok(false);
678 }
679 KeyCode::Char('h') | KeyCode::Left => {
680 self.focus = self.focus_left();
681 return Ok(false);
682 }
683 KeyCode::Char('l') | KeyCode::Right => {
684 self.focus = self.focus_right();
685 return Ok(false);
686 }
687 _ => {}
688 }
689
690 match self.focus {
691 AdminFocus::Table => {
692 if self.resources.search_focused {
693 match key.code {
694 KeyCode::Esc => self.resources.reset_search(),
695 KeyCode::Enter => self.resources.search_focused = false,
696 KeyCode::Backspace => self.resources.pop_search(),
697 KeyCode::Char(ch) => self.resources.push_search(ch),
698 _ => {}
699 }
700 return Ok(false);
701 }
702 match key.code {
703 KeyCode::Char('e') => {
704 self.focus = AdminFocus::Detail;
705 self.input_mode = if self.use_raw_params {
706 AdminInputMode::EditingRaw
707 } else {
708 AdminInputMode::EditingField
709 };
710 }
711 KeyCode::Char('r') => {
712 self.use_raw_params = !self.use_raw_params;
713 self.focus = AdminFocus::Detail;
714 self.input_mode = if self.use_raw_params {
715 AdminInputMode::EditingRaw
716 } else {
717 AdminInputMode::Normal
718 };
719 }
720 KeyCode::Char('/') => {
721 self.resources.search_focused = true;
722 if self.resources.search.is_none() {
723 self.resources.search = Some(String::new());
724 }
725 }
726 KeyCode::Char('R') => {
727 self.resources.reload(&self.backend);
728 if let Some(err) = self.resources.last_error.clone() {
729 self.last_result =
730 Some(AdminResult::status(format!("Resource load failed: {err}")));
731 }
732 }
733 KeyCode::Up | KeyCode::Char('k') => {
734 self.resources.move_up();
735 self.sync_target_from_resource();
736 }
737 KeyCode::Down | KeyCode::Char('j') => {
738 self.resources.move_down();
739 self.sync_target_from_resource();
740 }
741 KeyCode::Char('g') => {
742 self.resources.move_top();
743 self.sync_target_from_resource();
744 }
745 KeyCode::Char('G') => {
746 self.resources.move_bottom();
747 self.sync_target_from_resource();
748 }
749 KeyCode::Char('d') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
750 self.resources.page_down();
751 self.sync_target_from_resource();
752 }
753 KeyCode::Char('u') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
754 self.resources.page_up();
755 self.sync_target_from_resource();
756 }
757 KeyCode::Enter => {
758 self.apply_resource_selection()?;
759 }
760 _ => {}
761 }
762 }
763 AdminFocus::Detail => match key.code {
764 KeyCode::Char('e') => {
765 self.input_mode = if self.use_raw_params {
766 AdminInputMode::EditingRaw
767 } else {
768 AdminInputMode::EditingField
769 };
770 }
771 KeyCode::Char('r') => {
772 self.use_raw_params = !self.use_raw_params;
773 self.input_mode = if self.use_raw_params {
774 AdminInputMode::EditingRaw
775 } else {
776 AdminInputMode::Normal
777 };
778 }
779 KeyCode::Char('o') => {
780 self.open_selection_for_active_field()?;
781 }
782 KeyCode::Tab => {
783 if !self.use_raw_params && !self.form_fields.is_empty() {
784 self.active_field = (self.active_field + 1) % self.form_fields.len();
785 }
786 }
787 KeyCode::BackTab => {
788 if !self.use_raw_params && !self.form_fields.is_empty() {
789 if self.active_field == 0 {
790 self.active_field = self.form_fields.len() - 1;
791 } else {
792 self.active_field -= 1;
793 }
794 }
795 }
796 KeyCode::Up | KeyCode::Char('k') => {
797 if self.selected > 0 {
798 self.selected -= 1;
799 self.refresh_form_for_selection();
800 }
801 }
802 KeyCode::Down | KeyCode::Char('j') => {
803 if self.selected + 1 < self.items.len() {
804 self.selected += 1;
805 self.refresh_form_for_selection();
806 }
807 }
808 KeyCode::Enter => {
809 self.execute_selected_action()?;
810 }
811 _ => {}
812 },
813 AdminFocus::Status => match key.code {
814 KeyCode::Up | KeyCode::Char('k') => {
815 self.preview_scroll = self.preview_scroll.saturating_sub(1);
816 }
817 KeyCode::Down | KeyCode::Char('j') => {
818 let max = self.preview_line_count().saturating_sub(1);
819 self.preview_scroll = (self.preview_scroll + 1).min(max);
820 }
821 KeyCode::Char('g') => {
822 self.preview_scroll = 0;
823 }
824 KeyCode::Char('G') => {
825 self.preview_scroll = self.preview_line_count().saturating_sub(1);
826 }
827 KeyCode::Char('d') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
828 self.preview_scroll =
829 (self.preview_scroll + 5).min(self.preview_line_count().saturating_sub(1));
830 }
831 KeyCode::Char('u') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
832 self.preview_scroll = self.preview_scroll.saturating_sub(5);
833 }
834 _ => {}
835 },
836 }
837 Ok(false)
838 }
839
840 fn execute_selected_action(&mut self) -> Result<()> {
841 let Some(item) = self.items.get(self.selected) else {
842 return Ok(());
843 };
844 if !item.enabled {
845 self.last_result = Some(AdminResult::status(format!(
846 "Action '{}' is not permitted.",
847 item.title
848 )));
849 return Ok(());
850 }
851
852 let params = if self.use_raw_params {
853 parse_params(&self.params)
854 } else {
855 build_params_from_fields(&self.form_fields)
856 };
857 if let Err(message) = validate_params(item.action, self.target, ¶ms) {
858 self.last_result = Some(AdminResult::status(message));
859 return Ok(());
860 }
861 let command = match build_command_for(item.action, self.target, ¶ms) {
862 Ok(Some(command)) => command,
863 Ok(None) => {
864 self.last_result = Some(AdminResult::status(
865 "Select target/params to execute".to_string(),
866 ));
867 return Ok(());
868 }
869 Err(err) => {
870 self.last_result = Some(AdminResult::status(err.to_string()));
871 return Ok(());
872 }
873 };
874 let request = AdminRequest {
875 action: item.action,
876 command,
877 limit: self.backend.limit(),
878 quiet: self.backend.quiet(),
879 ui_mode: UiMode::Batch,
880 connection_label: self.connection_label.clone(),
881 output: self.backend.output_format(),
882 data_dir: self.backend.data_dir().map(PathBuf::from),
883 };
884
885 let (formatter, state) = CaptureFormatter::new();
886 let mut sink = io::sink();
887 let result = match &self.backend {
888 AdminBackend::Local { db, batch_mode, .. } => {
889 execute_local_action(db, batch_mode, request, &mut sink, Box::new(formatter))
890 }
891 AdminBackend::Remote {
892 client, batch_mode, ..
893 } => {
894 let runtime = Runtime::new().map_err(|err| {
895 CliError::InvalidArgument(format!("Failed to start async runtime: {err}"))
896 })?;
897 runtime.block_on(execute_remote_action(
898 client,
899 batch_mode,
900 request,
901 &mut sink,
902 Box::new(formatter),
903 ))
904 }
905 };
906
907 let capture = state.lock().expect("admin capture lock");
908 let mut result_state = AdminResult {
909 columns: capture.columns.clone(),
910 rows: capture.rows.clone(),
911 status_message: None,
912 };
913
914 if let Err(err) = result {
915 result_state.status_message = Some(err.to_string());
916 } else if result_state.columns.is_empty() && result_state.rows.is_empty() {
917 result_state.status_message = Some("OK".to_string());
918 }
919
920 self.last_result = Some(result_state);
921 self.preview_scroll = 0;
922 Ok(())
923 }
924
925 fn refresh_form_for_selection(&mut self) {
926 let action = self.items.get(self.selected).map(|item| item.action);
927 if action != self.last_action {
928 self.last_action = action;
929 self.reset_form();
930 }
931 }
932
933 fn reset_form(&mut self) {
934 if let Some(action) = self.items.get(self.selected).map(|item| item.action) {
935 self.form_fields = build_form_fields(self.target, action);
936 self.active_field = 0;
937 self.input_mode = AdminInputMode::Normal;
938 self.use_raw_params = false;
939 self.last_action = Some(action);
940 self.selection = None;
941 }
942 }
943
944 fn focus_left(&self) -> AdminFocus {
945 match self.focus {
946 AdminFocus::Table => AdminFocus::Table,
947 AdminFocus::Detail => AdminFocus::Table,
948 AdminFocus::Status => AdminFocus::Detail,
949 }
950 }
951
952 fn focus_right(&self) -> AdminFocus {
953 match self.focus {
954 AdminFocus::Table => AdminFocus::Detail,
955 AdminFocus::Detail => AdminFocus::Status,
956 AdminFocus::Status => AdminFocus::Status,
957 }
958 }
959
960 fn apply_resource_selection(&mut self) -> Result<()> {
961 let Some(entry) = self.resources.selected_entry() else {
962 return Ok(());
963 };
964 if !entry.selectable {
965 if let Some(target) = target_for_resource(&entry) {
966 self.ensure_target(target);
967 }
968 return Ok(());
969 }
970 match entry.kind {
971 ResourceKind::Section(section) => {
972 if let Some(target) = section.target() {
973 self.ensure_target(target);
974 }
975 }
976 ResourceKind::Table { name } => {
977 self.ensure_target(AdminTarget::Sql);
978 if self.set_field_value("table", &name, false).is_none() {
979 let query = format!("SELECT * FROM {name}");
980 if self.set_field_value("query", &query, false).is_none() {
981 self.last_result = Some(AdminResult::status(
982 "No matching field for table.".to_string(),
983 ));
984 }
985 }
986 }
987 ResourceKind::Column { table, name } => {
988 self.ensure_target(AdminTarget::Sql);
989 let _ = self.set_field_value("table", &table, false);
990 if self.set_field_value("columns", &name, true).is_none() {
991 self.last_result = Some(AdminResult::status(
992 "No matching field for column.".to_string(),
993 ));
994 }
995 }
996 ResourceKind::KvKey { key } => {
997 self.ensure_target(AdminTarget::Kv);
998 if self.set_field_value("key", &key, false).is_none() {
999 self.last_result = Some(AdminResult::status(
1000 "No matching field for key.".to_string(),
1001 ));
1002 }
1003 }
1004 ResourceKind::ColumnarSegment { id } => {
1005 self.ensure_target(AdminTarget::Columnar);
1006 if self.set_field_value("segment", &id, false).is_none() {
1007 self.last_result = Some(AdminResult::status(
1008 "No matching field for segment.".to_string(),
1009 ));
1010 }
1011 }
1012 ResourceKind::ColumnarColumn { segment_id, name } => {
1013 self.ensure_target(AdminTarget::Columnar);
1014 let _ = self.set_field_value("segment", &segment_id, false);
1015 if self.set_field_value("column", &name, false).is_none() {
1016 self.last_result = Some(AdminResult::status(
1017 "No matching field for column.".to_string(),
1018 ));
1019 }
1020 }
1021 ResourceKind::Info => {}
1022 }
1023 Ok(())
1024 }
1025
1026 fn sync_target_from_resource(&mut self) {
1027 let Some(entry) = self.resources.selected_entry() else {
1028 return;
1029 };
1030 if let Some(target) = target_for_resource(&entry) {
1031 self.ensure_target(target);
1032 }
1033 }
1034
1035 fn ensure_target(&mut self, target: AdminTarget) {
1036 if self.target != target {
1037 self.target = target;
1038 self.reset_form();
1039 }
1040 }
1041
1042 fn set_field_value(&mut self, key: &str, value: &str, append: bool) -> Option<()> {
1043 for (idx, field) in self.form_fields.iter_mut().enumerate() {
1044 if field.key.eq_ignore_ascii_case(key) {
1045 if append && !field.value.trim().is_empty() {
1046 field.value = format!("{},{}", field.value.trim(), value);
1047 } else {
1048 field.value = value.to_string();
1049 }
1050 self.active_field = idx;
1051 return Some(());
1052 }
1053 }
1054 None
1055 }
1056
1057 fn open_selection_for_active_field(&mut self) -> Result<()> {
1058 if self.use_raw_params {
1059 self.last_result = Some(AdminResult::status(
1060 "List selection is unavailable while using raw params.".to_string(),
1061 ));
1062 return Ok(());
1063 }
1064 let Some(field) = self.form_fields.get(self.active_field) else {
1065 return Ok(());
1066 };
1067 let Some(source) = field.list_source else {
1068 self.last_result = Some(AdminResult::status(
1069 "No list is available for this field.".to_string(),
1070 ));
1071 return Ok(());
1072 };
1073 let mut items = load_list_options(&self.backend, &self.form_fields, source)?;
1074 if items.is_empty() {
1075 items = self.list_options_from_resources(source);
1076 }
1077 items.retain(|item| !item.trim().is_empty());
1078 if items.is_empty() {
1079 self.last_result = Some(AdminResult::status(
1080 "No matching resources were found.".to_string(),
1081 ));
1082 return Ok(());
1083 }
1084 self.selection = Some(SelectionOverlay::new(
1085 format!("Select {}", field.label),
1086 items,
1087 self.active_field,
1088 ));
1089 Ok(())
1090 }
1091
1092 fn list_options_from_resources(&self, source: ListSource) -> Vec<String> {
1093 let mut items = Vec::new();
1094 match source {
1095 ListSource::KvKeys => {
1096 for entry in &self.resources.entries {
1097 if let ResourceKind::KvKey { key } = &entry.kind {
1098 items.push(key.clone());
1099 }
1100 }
1101 }
1102 ListSource::SqlTables => {
1103 for entry in &self.resources.entries {
1104 if let ResourceKind::Table { name } = &entry.kind {
1105 items.push(name.clone());
1106 }
1107 }
1108 }
1109 ListSource::SqlColumns => {
1110 let Some(table) = field_value(&self.form_fields, "table") else {
1111 return items;
1112 };
1113 for entry in &self.resources.entries {
1114 if let ResourceKind::Column {
1115 table: entry_table,
1116 name,
1117 } = &entry.kind
1118 {
1119 if entry_table == &table {
1120 items.push(name.clone());
1121 }
1122 }
1123 }
1124 }
1125 ListSource::ColumnarSegments => {
1126 for entry in &self.resources.entries {
1127 if let ResourceKind::ColumnarSegment { id } = &entry.kind {
1128 items.push(id.clone());
1129 }
1130 }
1131 }
1132 ListSource::ColumnarColumns => {
1133 let Some(segment) = field_value(&self.form_fields, "segment") else {
1134 return items;
1135 };
1136 for entry in &self.resources.entries {
1137 if let ResourceKind::ColumnarColumn { segment_id, name } = &entry.kind {
1138 if segment_id == &segment {
1139 items.push(name.clone());
1140 }
1141 }
1142 }
1143 }
1144 }
1145 items.sort();
1146 items.dedup();
1147 items
1148 }
1149}
1150
1151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1152enum AdminInputMode {
1153 Normal,
1154 EditingField,
1155 EditingRaw,
1156}
1157
1158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1159enum AdminFocus {
1160 Table,
1161 Detail,
1162 Status,
1163}
1164
1165#[derive(Debug, Clone)]
1166struct AdminFormField {
1167 key: &'static str,
1168 label: &'static str,
1169 value: String,
1170 placeholder: &'static str,
1171 required: bool,
1172 list_source: Option<ListSource>,
1173}
1174
1175#[derive(Debug, Clone, Copy)]
1176enum ListSource {
1177 KvKeys,
1178 SqlTables,
1179 SqlColumns,
1180 ColumnarSegments,
1181 ColumnarColumns,
1182}
1183
1184#[derive(Debug, Clone)]
1185struct ResourceEntry {
1186 label: String,
1187 kind: ResourceKind,
1188 depth: usize,
1189 selectable: bool,
1190}
1191
1192#[derive(Debug, Clone)]
1193enum ResourceKind {
1194 Section(ResourceSection),
1195 Table { name: String },
1196 Column { table: String, name: String },
1197 KvKey { key: String },
1198 ColumnarSegment { id: String },
1199 ColumnarColumn { segment_id: String, name: String },
1200 Info,
1201}
1202
1203struct ResourceTree {
1204 entries: Vec<ResourceEntry>,
1205 selected: usize,
1206 search: Option<String>,
1207 search_focused: bool,
1208 last_error: Option<String>,
1209}
1210
1211#[derive(Debug, Clone, Copy)]
1212enum ResourceSection {
1213 SqlTables,
1214 ColumnarSegments,
1215 KvKeys,
1216}
1217
1218impl ResourceSection {
1219 fn target(self) -> Option<AdminTarget> {
1220 match self {
1221 ResourceSection::SqlTables => Some(AdminTarget::Sql),
1222 ResourceSection::ColumnarSegments => Some(AdminTarget::Columnar),
1223 ResourceSection::KvKeys => Some(AdminTarget::Kv),
1224 }
1225 }
1226}
1227
1228fn target_for_resource(entry: &ResourceEntry) -> Option<AdminTarget> {
1229 match entry.kind {
1230 ResourceKind::Section(section) => section.target(),
1231 ResourceKind::Table { .. } | ResourceKind::Column { .. } => Some(AdminTarget::Sql),
1232 ResourceKind::KvKey { .. } => Some(AdminTarget::Kv),
1233 ResourceKind::ColumnarSegment { .. } | ResourceKind::ColumnarColumn { .. } => {
1234 Some(AdminTarget::Columnar)
1235 }
1236 ResourceKind::Info => None,
1237 }
1238}
1239
1240impl ResourceTree {
1241 fn new(backend: &AdminBackend<'_>) -> Self {
1242 let (entries, last_error) = match load_resource_entries(backend) {
1243 Ok(entries) => (entries, None),
1244 Err(err) => (Vec::new(), Some(err.to_string())),
1245 };
1246 Self {
1247 entries,
1248 selected: 0,
1249 search: None,
1250 search_focused: false,
1251 last_error,
1252 }
1253 }
1254
1255 fn reload(&mut self, backend: &AdminBackend<'_>) {
1256 match load_resource_entries(backend) {
1257 Ok(entries) => {
1258 self.entries = entries;
1259 self.selected = 0;
1260 self.last_error = None;
1261 }
1262 Err(err) => {
1263 self.entries.clear();
1264 self.selected = 0;
1265 self.last_error = Some(err.to_string());
1266 }
1267 }
1268 }
1269
1270 fn search_term(&self) -> Option<&str> {
1271 self.search
1272 .as_deref()
1273 .filter(|value| !value.trim().is_empty())
1274 }
1275
1276 fn filtered_indices(&self) -> Vec<usize> {
1277 let Some(term) = self.search_term() else {
1278 return (0..self.entries.len()).collect();
1279 };
1280 let term = term.to_lowercase();
1281 let mut include = vec![false; self.entries.len()];
1282 for (idx, entry) in self.entries.iter().enumerate() {
1283 if entry.label.to_lowercase().contains(&term) {
1284 include[idx] = true;
1285 let mut depth = entry.depth;
1286 if depth == 0 {
1287 continue;
1288 }
1289 for parent_idx in (0..idx).rev() {
1290 let parent = &self.entries[parent_idx];
1291 if parent.depth < depth {
1292 include[parent_idx] = true;
1293 depth = parent.depth;
1294 if depth == 0 {
1295 break;
1296 }
1297 }
1298 }
1299 }
1300 }
1301 include
1302 .iter()
1303 .enumerate()
1304 .filter_map(|(idx, keep)| if *keep { Some(idx) } else { None })
1305 .collect()
1306 }
1307
1308 fn filtered_entries(&self) -> Vec<ResourceEntry> {
1309 let indices = self.filtered_indices();
1310 indices
1311 .iter()
1312 .filter_map(|idx| self.entries.get(*idx))
1313 .cloned()
1314 .collect()
1315 }
1316
1317 fn selected_entry(&self) -> Option<ResourceEntry> {
1318 let indices = self.filtered_indices();
1319 let idx = indices.get(self.selected).copied()?;
1320 self.entries.get(idx).cloned()
1321 }
1322
1323 fn ensure_selection_in_range(&mut self) {
1324 let len = self.filtered_indices().len();
1325 if len == 0 {
1326 self.selected = 0;
1327 } else if self.selected >= len {
1328 self.selected = len - 1;
1329 }
1330 }
1331
1332 fn move_up(&mut self) {
1333 if self.selected > 0 {
1334 self.selected -= 1;
1335 }
1336 }
1337
1338 fn move_down(&mut self) {
1339 let len = self.filtered_indices().len();
1340 if self.selected + 1 < len {
1341 self.selected += 1;
1342 }
1343 }
1344
1345 fn move_top(&mut self) {
1346 self.selected = 0;
1347 }
1348
1349 fn move_bottom(&mut self) {
1350 let len = self.filtered_indices().len();
1351 if len > 0 {
1352 self.selected = len - 1;
1353 }
1354 }
1355
1356 fn page_down(&mut self) {
1357 let len = self.filtered_indices().len();
1358 if len == 0 {
1359 return;
1360 }
1361 self.selected = (self.selected + 5).min(len - 1);
1362 }
1363
1364 fn page_up(&mut self) {
1365 self.selected = self.selected.saturating_sub(5);
1366 }
1367
1368 fn push_search(&mut self, ch: char) {
1369 let search = self.search.get_or_insert_with(String::new);
1370 search.push(ch);
1371 self.ensure_selection_in_range();
1372 }
1373
1374 fn pop_search(&mut self) {
1375 if let Some(search) = self.search.as_mut() {
1376 if !search.is_empty() {
1377 search.pop();
1378 } else {
1379 self.reset_search();
1380 }
1381 }
1382 self.ensure_selection_in_range();
1383 }
1384
1385 fn reset_search(&mut self) {
1386 self.search = None;
1387 self.search_focused = false;
1388 self.selected = 0;
1389 }
1390}
1391
1392fn build_form_fields(target: AdminTarget, action: AdminAction) -> Vec<AdminFormField> {
1393 match (target, action) {
1394 (AdminTarget::Sql, AdminAction::Read) => vec![
1395 form_field("query", "Query", "", "SELECT * FROM table", false),
1396 form_field_with_list(
1397 "table",
1398 "Table",
1399 "",
1400 "mytable",
1401 false,
1402 Some(ListSource::SqlTables),
1403 ),
1404 form_field_with_list(
1405 "columns",
1406 "Columns",
1407 "",
1408 "col1,col2",
1409 false,
1410 Some(ListSource::SqlColumns),
1411 ),
1412 ],
1413 (AdminTarget::Sql, _) => vec![form_field(
1414 "query",
1415 "Query",
1416 "",
1417 "SELECT * FROM table",
1418 true,
1419 )],
1420 (AdminTarget::Kv, AdminAction::Read) => vec![
1421 form_field_with_list("key", "Key", "", "mykey", false, Some(ListSource::KvKeys)),
1422 form_field("prefix", "Prefix", "", "app/", false),
1423 ],
1424 (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => vec![
1425 form_field_with_list("key", "Key", "", "mykey", true, Some(ListSource::KvKeys)),
1426 form_field("value", "Value", "", "hello", true),
1427 ],
1428 (AdminTarget::Kv, AdminAction::Delete) => vec![form_field_with_list(
1429 "key",
1430 "Key",
1431 "",
1432 "mykey",
1433 true,
1434 Some(ListSource::KvKeys),
1435 )],
1436 (AdminTarget::Vector, AdminAction::Read) => vec![
1437 form_field("index", "Index", "", "myindex", true),
1438 form_field("query", "Query", "", "[0.1, 0.2]", true),
1439 form_field("k", "Top K", "10", "10", false),
1440 ],
1441 (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => vec![
1442 form_field("index", "Index", "", "myindex", true),
1443 form_field("key", "Key", "", "item1", true),
1444 form_field("vector", "Vector", "", "[0.1, 0.2]", true),
1445 ],
1446 (AdminTarget::Vector, AdminAction::Delete) => vec![
1447 form_field("index", "Index", "", "myindex", true),
1448 form_field("key", "Key", "", "item1", true),
1449 ],
1450 (AdminTarget::Hnsw, AdminAction::Read) => {
1451 vec![form_field("name", "Index", "", "myindex", true)]
1452 }
1453 (AdminTarget::Hnsw, AdminAction::Create) => vec![
1454 form_field("name", "Index", "", "myindex", true),
1455 form_field("dim", "Dimensions", "", "128", true),
1456 form_field("metric", "Metric", "cosine", "cosine", false),
1457 ],
1458 (AdminTarget::Hnsw, AdminAction::Delete) => {
1459 vec![form_field("name", "Index", "", "myindex", true)]
1460 }
1461 (AdminTarget::Columnar, AdminAction::Read) => vec![
1462 form_field("mode", "Mode", "list", "list|scan|stats|index_list", true),
1463 form_field_with_list(
1464 "segment",
1465 "Segment",
1466 "",
1467 "segment_id",
1468 false,
1469 Some(ListSource::ColumnarSegments),
1470 ),
1471 ],
1472 (AdminTarget::Columnar, AdminAction::Create) => vec![
1473 form_field("file", "File", "", "data.csv", false),
1474 form_field_with_list(
1475 "table",
1476 "Table",
1477 "",
1478 "mytable",
1479 false,
1480 Some(ListSource::SqlTables),
1481 ),
1482 form_field_with_list(
1483 "segment",
1484 "Segment",
1485 "",
1486 "segment_id",
1487 false,
1488 Some(ListSource::ColumnarSegments),
1489 ),
1490 form_field_with_list(
1491 "column",
1492 "Column",
1493 "",
1494 "column_name",
1495 false,
1496 Some(ListSource::ColumnarColumns),
1497 ),
1498 form_field("index_type", "Index Type", "", "minmax", false),
1499 ],
1500 (AdminTarget::Columnar, AdminAction::Delete) => vec![
1501 form_field_with_list(
1502 "segment",
1503 "Segment",
1504 "",
1505 "segment_id",
1506 true,
1507 Some(ListSource::ColumnarSegments),
1508 ),
1509 form_field_with_list(
1510 "column",
1511 "Column",
1512 "",
1513 "column_name",
1514 true,
1515 Some(ListSource::ColumnarColumns),
1516 ),
1517 ],
1518 _ => Vec::new(),
1519 }
1520}
1521
1522fn form_field(
1523 key: &'static str,
1524 label: &'static str,
1525 value: &str,
1526 placeholder: &'static str,
1527 required: bool,
1528) -> AdminFormField {
1529 form_field_with_list(key, label, value, placeholder, required, None)
1530}
1531
1532fn form_field_with_list(
1533 key: &'static str,
1534 label: &'static str,
1535 value: &str,
1536 placeholder: &'static str,
1537 required: bool,
1538 list_source: Option<ListSource>,
1539) -> AdminFormField {
1540 AdminFormField {
1541 key,
1542 label,
1543 value: value.to_string(),
1544 placeholder,
1545 required,
1546 list_source,
1547 }
1548}
1549
1550fn load_list_options(
1551 backend: &AdminBackend<'_>,
1552 fields: &[AdminFormField],
1553 source: ListSource,
1554) -> Result<Vec<String>> {
1555 match source {
1556 ListSource::KvKeys => {
1557 let prefix = field_value(fields, "prefix");
1558 let command = AdminCommand::Kv(KvCommand::List { prefix });
1559 let capture = capture_admin_command(backend, command, Some(50))?;
1560 Ok(extract_column_values(
1561 &capture.columns,
1562 &capture.rows,
1563 "key",
1564 ))
1565 }
1566 ListSource::ColumnarSegments => {
1567 let command = AdminCommand::Columnar(ColumnarCommand::List);
1568 let capture = capture_admin_command(backend, command, Some(50))?;
1569 Ok(extract_column_values(
1570 &capture.columns,
1571 &capture.rows,
1572 "segment_id",
1573 ))
1574 }
1575 ListSource::SqlTables => {
1576 let Some(db) = backend.local_db() else {
1577 return Err(CliError::InvalidArgument(
1578 "Table listing is only available for local admin sessions.".to_string(),
1579 ));
1580 };
1581 let mut tables = db
1582 .list_tables_simple()?
1583 .into_iter()
1584 .map(|table| table.name)
1585 .collect::<Vec<_>>();
1586 tables.sort();
1587 tables.dedup();
1588 Ok(tables)
1589 }
1590 ListSource::SqlColumns => {
1591 let table = field_value(fields, "table")
1592 .ok_or_else(|| CliError::InvalidArgument("Select a table first.".to_string()))?;
1593 let Some(db) = backend.local_db() else {
1594 return Err(CliError::InvalidArgument(
1595 "Column listing is only available for local admin sessions.".to_string(),
1596 ));
1597 };
1598 let mut columns = db
1599 .get_table_info_simple(&table)?
1600 .columns
1601 .into_iter()
1602 .map(|column| column.name)
1603 .collect::<Vec<_>>();
1604 columns.sort();
1605 columns.dedup();
1606 Ok(columns)
1607 }
1608 ListSource::ColumnarColumns => {
1609 let segment = field_value(fields, "segment")
1610 .ok_or_else(|| CliError::InvalidArgument("Select a segment first.".to_string()))?;
1611 let Some(db) = backend.local_db() else {
1612 return Err(CliError::InvalidArgument(
1613 "Column listing is only available for local admin sessions.".to_string(),
1614 ));
1615 };
1616 let mut columns = list_columnar_columns_from_segment(db, &segment)?;
1617 columns.sort();
1618 columns.dedup();
1619 Ok(columns)
1620 }
1621 }
1622}
1623
1624fn field_value(fields: &[AdminFormField], key: &str) -> Option<String> {
1625 fields
1626 .iter()
1627 .find(|field| field.key.eq_ignore_ascii_case(key))
1628 .map(|field| field.value.trim().to_string())
1629 .filter(|value| !value.is_empty())
1630}
1631
1632fn capture_admin_command(
1633 backend: &AdminBackend<'_>,
1634 command: AdminCommand,
1635 limit: Option<usize>,
1636) -> Result<CaptureState> {
1637 let request = AdminRequest {
1638 action: AdminAction::Read,
1639 command,
1640 limit,
1641 quiet: true,
1642 ui_mode: UiMode::Batch,
1643 connection_label: String::new(),
1644 output: backend.output_format(),
1645 data_dir: backend.data_dir().map(PathBuf::from),
1646 };
1647 let (formatter, state) = CaptureFormatter::new();
1648 let mut sink = io::sink();
1649 let result = match backend {
1650 AdminBackend::Local { db, batch_mode, .. } => {
1651 execute_local_action(db, batch_mode, request, &mut sink, Box::new(formatter))
1652 }
1653 AdminBackend::Remote {
1654 client, batch_mode, ..
1655 } => {
1656 let runtime = Runtime::new().map_err(|err| {
1657 CliError::InvalidArgument(format!("Failed to start async runtime: {err}"))
1658 })?;
1659 runtime.block_on(execute_remote_action(
1660 client,
1661 batch_mode,
1662 request,
1663 &mut sink,
1664 Box::new(formatter),
1665 ))
1666 }
1667 };
1668 result?;
1669 let capture = state.lock().expect("admin capture lock").clone();
1670 Ok(capture)
1671}
1672
1673fn extract_column_values(columns: &[Column], rows: &[Row], column_name: &str) -> Vec<String> {
1674 let index = columns
1675 .iter()
1676 .position(|column| column.name.eq_ignore_ascii_case(column_name))
1677 .unwrap_or(0);
1678 rows.iter()
1679 .filter_map(|row| row.columns.get(index))
1680 .map(value_to_string)
1681 .collect()
1682}
1683
1684fn list_columnar_columns_from_segment(
1685 db: &alopex_embedded::Database,
1686 segment_id: &str,
1687) -> Result<Vec<String>> {
1688 let (table_id, segment_id) = parse_segment_id(segment_id).ok_or_else(|| {
1689 CliError::InvalidArgument(
1690 "Invalid segment id. Expected format: table_id:segment_id.".to_string(),
1691 )
1692 })?;
1693 let tables = db.list_tables_simple()?;
1694 let table = tables
1695 .into_iter()
1696 .find(|table| table.table_id == table_id)
1697 .ok_or_else(|| {
1698 CliError::InvalidArgument("Unable to resolve table for segment.".to_string())
1699 })?;
1700 let batches = db.read_columnar_segment(&table.name, segment_id, None)?;
1701 let batch = batches
1702 .first()
1703 .ok_or_else(|| CliError::InvalidArgument("Columnar segment is empty.".to_string()))?;
1704 Ok(batch
1705 .schema
1706 .columns
1707 .iter()
1708 .map(|column| column.name.clone())
1709 .collect())
1710}
1711
1712fn parse_segment_id(segment_id: &str) -> Option<(u32, u64)> {
1713 let (table_id, segment_id) = segment_id.split_once(':')?;
1714 let table_id = table_id.parse::<u32>().ok()?;
1715 let segment_id = segment_id.parse::<u64>().ok()?;
1716 Some((table_id, segment_id))
1717}
1718
1719const RESOURCE_LIMIT: usize = 50;
1720const COLUMNAR_COLUMN_LIMIT: usize = 20;
1721
1722fn load_resource_entries(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
1723 let mut entries = Vec::new();
1724 entries.extend(load_sql_resources(backend)?);
1725 entries.extend(load_columnar_resources(backend)?);
1726 entries.extend(load_kv_resources(backend)?);
1727 Ok(entries)
1728}
1729
1730fn load_sql_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
1731 let mut entries = Vec::new();
1732 entries.push(ResourceEntry {
1733 label: "SQL Tables".to_string(),
1734 kind: ResourceKind::Section(ResourceSection::SqlTables),
1735 depth: 0,
1736 selectable: false,
1737 });
1738 let Some(db) = backend.local_db() else {
1739 entries.push(ResourceEntry {
1740 label: "Remote listing unavailable".to_string(),
1741 kind: ResourceKind::Info,
1742 depth: 1,
1743 selectable: false,
1744 });
1745 return Ok(entries);
1746 };
1747 let mut tables = match db.list_tables_simple() {
1748 Ok(tables) => tables,
1749 Err(alopex_embedded::Error::CatalogNotFound(_))
1750 | Err(alopex_embedded::Error::NamespaceNotFound(_, _)) => {
1751 let _ = db.create_catalog(CreateCatalogRequest::new("default"));
1752 let _ = db.create_namespace(CreateNamespaceRequest::new("default", "default"));
1753 match db.list_tables_simple() {
1754 Ok(tables) => tables,
1755 Err(err) => {
1756 entries.push(ResourceEntry {
1757 label: format!("SQL catalog unavailable: {err}"),
1758 kind: ResourceKind::Info,
1759 depth: 1,
1760 selectable: false,
1761 });
1762 return Ok(entries);
1763 }
1764 }
1765 }
1766 Err(err) => return Err(err.into()),
1767 };
1768 tables.sort_by(|a, b| a.name.cmp(&b.name));
1769 for table in tables.into_iter().take(RESOURCE_LIMIT) {
1770 entries.push(ResourceEntry {
1771 label: table.name.clone(),
1772 kind: ResourceKind::Table {
1773 name: table.name.clone(),
1774 },
1775 depth: 1,
1776 selectable: true,
1777 });
1778 for column in table.columns {
1779 entries.push(ResourceEntry {
1780 label: column.name.clone(),
1781 kind: ResourceKind::Column {
1782 table: table.name.clone(),
1783 name: column.name,
1784 },
1785 depth: 2,
1786 selectable: true,
1787 });
1788 }
1789 }
1790 Ok(entries)
1791}
1792
1793fn load_columnar_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
1794 let mut entries = Vec::new();
1795 entries.push(ResourceEntry {
1796 label: "Columnar Segments".to_string(),
1797 kind: ResourceKind::Section(ResourceSection::ColumnarSegments),
1798 depth: 0,
1799 selectable: false,
1800 });
1801 let Some(db) = backend.local_db() else {
1802 entries.push(ResourceEntry {
1803 label: "Remote listing unavailable".to_string(),
1804 kind: ResourceKind::Info,
1805 depth: 1,
1806 selectable: false,
1807 });
1808 return Ok(entries);
1809 };
1810 let mut segments = db.list_columnar_segments()?;
1811 segments.sort();
1812 let mut expanded = 0;
1813 for segment in segments.into_iter().take(RESOURCE_LIMIT) {
1814 entries.push(ResourceEntry {
1815 label: segment.clone(),
1816 kind: ResourceKind::ColumnarSegment {
1817 id: segment.clone(),
1818 },
1819 depth: 1,
1820 selectable: true,
1821 });
1822 if expanded < COLUMNAR_COLUMN_LIMIT {
1823 if let Ok(columns) = list_columnar_columns_from_segment(db, &segment) {
1824 for column in columns {
1825 entries.push(ResourceEntry {
1826 label: column.clone(),
1827 kind: ResourceKind::ColumnarColumn {
1828 segment_id: segment.clone(),
1829 name: column,
1830 },
1831 depth: 2,
1832 selectable: true,
1833 });
1834 }
1835 }
1836 expanded += 1;
1837 }
1838 }
1839 Ok(entries)
1840}
1841
1842fn load_kv_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
1843 let mut entries = Vec::new();
1844 entries.push(ResourceEntry {
1845 label: "KV Keys".to_string(),
1846 kind: ResourceKind::Section(ResourceSection::KvKeys),
1847 depth: 0,
1848 selectable: false,
1849 });
1850 let system_prefixes = [
1851 "__catalog__/",
1852 "hnsw:",
1853 "__alopex_",
1854 "__alopex:",
1855 "vector:",
1856 "columnar:",
1857 ];
1858 let command = AdminCommand::Kv(KvCommand::List { prefix: None });
1859 let capture = capture_admin_command(backend, command, Some(RESOURCE_LIMIT))?;
1860 let keys = extract_column_values(&capture.columns, &capture.rows, "key");
1861 for key in keys.into_iter().filter(|key| {
1862 !system_prefixes.iter().any(|prefix| key.starts_with(prefix))
1863 && !key.trim().is_empty()
1864 && !key.chars().any(|ch| ch.is_control())
1865 }) {
1866 entries.push(ResourceEntry {
1867 label: key.clone(),
1868 kind: ResourceKind::KvKey { key },
1869 depth: 1,
1870 selectable: true,
1871 });
1872 }
1873 Ok(entries)
1874}
1875
1876fn build_params_from_fields(
1877 fields: &[AdminFormField],
1878) -> std::collections::HashMap<String, String> {
1879 let mut params = std::collections::HashMap::new();
1880 for field in fields {
1881 if !field.value.trim().is_empty() {
1882 params.insert(field.key.to_lowercase(), field.value.trim().to_string());
1883 }
1884 }
1885 params
1886}
1887
1888fn validate_params(
1889 action: AdminAction,
1890 target: AdminTarget,
1891 params: &std::collections::HashMap<String, String>,
1892) -> std::result::Result<(), String> {
1893 match (target, action) {
1894 (AdminTarget::Sql, AdminAction::Read) => {
1895 if params.contains_key("query") {
1896 return Ok(());
1897 }
1898 if let Some(columns) = params.get("columns") {
1899 if !params.contains_key("table") {
1900 return Err("Provide table to use columns.".to_string());
1901 }
1902 if columns.trim().is_empty() {
1903 return Err("Columns cannot be empty when provided.".to_string());
1904 }
1905 }
1906 if params.contains_key("table") {
1907 Ok(())
1908 } else {
1909 Err("Provide query or table.".to_string())
1910 }
1911 }
1912 (AdminTarget::Kv, AdminAction::Read) => {
1913 if params.contains_key("key") || params.contains_key("prefix") {
1914 Ok(())
1915 } else {
1916 Err("Provide either key or prefix.".to_string())
1917 }
1918 }
1919 (AdminTarget::Columnar, AdminAction::Read) => {
1920 let mode = params.get("mode").map(|v| v.as_str()).unwrap_or("list");
1921 if matches!(mode, "scan" | "stats" | "index_list") && !params.contains_key("segment") {
1922 Err("Provide segment for scan/stats/index_list.".to_string())
1923 } else {
1924 Ok(())
1925 }
1926 }
1927 (AdminTarget::Columnar, AdminAction::Create) => {
1928 let has_ingest = params.contains_key("file") && params.contains_key("table");
1929 let has_index = params.contains_key("segment")
1930 && params.contains_key("column")
1931 && params.contains_key("index_type");
1932 if has_ingest || has_index {
1933 Ok(())
1934 } else {
1935 Err("Provide file+table or segment+column+index_type.".to_string())
1936 }
1937 }
1938 _ => {
1939 let missing = required_keys_for(target, action)
1940 .into_iter()
1941 .filter(|key| !params.contains_key(*key))
1942 .collect::<Vec<_>>();
1943 if missing.is_empty() {
1944 Ok(())
1945 } else {
1946 Err(format!("Missing: {}", missing.join(", ")))
1947 }
1948 }
1949 }
1950}
1951
1952fn required_keys_for(target: AdminTarget, action: AdminAction) -> Vec<&'static str> {
1953 match (target, action) {
1954 (AdminTarget::Sql, AdminAction::Read) => Vec::new(),
1955 (AdminTarget::Sql, _) => vec!["query"],
1956 (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => vec!["key", "value"],
1957 (AdminTarget::Kv, AdminAction::Delete) => vec!["key"],
1958 (AdminTarget::Vector, AdminAction::Read) => vec!["index", "query"],
1959 (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => {
1960 vec!["index", "key", "vector"]
1961 }
1962 (AdminTarget::Vector, AdminAction::Delete) => vec!["index", "key"],
1963 (AdminTarget::Hnsw, AdminAction::Read) => vec!["name"],
1964 (AdminTarget::Hnsw, AdminAction::Create) => vec!["name", "dim"],
1965 (AdminTarget::Hnsw, AdminAction::Delete) => vec!["name"],
1966 (AdminTarget::Columnar, AdminAction::Delete) => vec!["segment", "column"],
1967 _ => Vec::new(),
1968 }
1969}
1970
1971#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1972pub enum AdminTarget {
1973 Sql,
1974 Kv,
1975 Vector,
1976 Hnsw,
1977 Columnar,
1978}
1979
1980impl AdminTarget {
1981 fn label(self) -> &'static str {
1982 match self {
1983 AdminTarget::Sql => "SQL",
1984 AdminTarget::Kv => "KV",
1985 AdminTarget::Vector => "Vector",
1986 AdminTarget::Hnsw => "HNSW",
1987 AdminTarget::Columnar => "Columnar",
1988 }
1989 }
1990
1991 fn example_for(self, action: AdminAction) -> Option<&'static str> {
1992 match (self, action) {
1993 (AdminTarget::Sql, _) => Some("query=\"SELECT * FROM table\""),
1994 (AdminTarget::Kv, AdminAction::Read) => Some("key=mykey OR prefix=app/"),
1995 (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => {
1996 Some("key=mykey value=hello")
1997 }
1998 (AdminTarget::Kv, AdminAction::Delete) => Some("key=mykey"),
1999 (AdminTarget::Vector, AdminAction::Read) => {
2000 Some("index=myindex query=\"[0.1, 0.2]\" k=10")
2001 }
2002 (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => {
2003 Some("index=myindex key=item1 vector=\"[0.1, 0.2]\"")
2004 }
2005 (AdminTarget::Vector, AdminAction::Delete) => Some("index=myindex key=item1"),
2006 (AdminTarget::Hnsw, AdminAction::Read) => Some("name=myindex"),
2007 (AdminTarget::Hnsw, AdminAction::Create) => Some("name=myindex dim=128 metric=cosine"),
2008 (AdminTarget::Hnsw, AdminAction::Delete) => Some("name=myindex"),
2009 (AdminTarget::Columnar, AdminAction::Read) => Some("mode=list"),
2010 (AdminTarget::Columnar, AdminAction::Create) => Some("file=data.csv table=mytable"),
2011 (AdminTarget::Columnar, AdminAction::Delete) => Some("segment=seg1 column=col1"),
2012 _ => None,
2013 }
2014 }
2015}
2016
2017pub enum AdminBackend<'a> {
2018 Local {
2019 db: &'a alopex_embedded::Database,
2020 batch_mode: &'a BatchMode,
2021 output_format: OutputFormat,
2022 limit: Option<usize>,
2023 quiet: bool,
2024 data_dir: Option<PathBuf>,
2025 },
2026 Remote {
2027 client: &'a HttpClient,
2028 batch_mode: &'a BatchMode,
2029 output_format: OutputFormat,
2030 limit: Option<usize>,
2031 quiet: bool,
2032 data_dir: Option<PathBuf>,
2033 },
2034}
2035
2036impl AdminBackend<'_> {
2037 fn local_db(&self) -> Option<&alopex_embedded::Database> {
2038 match self {
2039 AdminBackend::Local { db, .. } => Some(*db),
2040 AdminBackend::Remote { .. } => None,
2041 }
2042 }
2043
2044 fn output_format(&self) -> OutputFormat {
2045 match self {
2046 AdminBackend::Local { output_format, .. } => *output_format,
2047 AdminBackend::Remote { output_format, .. } => *output_format,
2048 }
2049 }
2050
2051 fn data_dir(&self) -> Option<&Path> {
2052 match self {
2053 AdminBackend::Local { data_dir, .. } => data_dir.as_deref(),
2054 AdminBackend::Remote { data_dir, .. } => data_dir.as_deref(),
2055 }
2056 }
2057
2058 fn limit(&self) -> Option<usize> {
2059 match self {
2060 AdminBackend::Local { limit, .. } => *limit,
2061 AdminBackend::Remote { limit, .. } => *limit,
2062 }
2063 }
2064
2065 fn quiet(&self) -> bool {
2066 match self {
2067 AdminBackend::Local { quiet, .. } => *quiet,
2068 AdminBackend::Remote { quiet, .. } => *quiet,
2069 }
2070 }
2071}
2072
2073pub struct AdminContext<'a> {
2074 pub connection_label: String,
2075 pub auth: AuthCapabilities,
2076 pub backend: AdminBackend<'a>,
2077 pub initial_target: Option<AdminTarget>,
2078}
2079
2080pub fn run_admin_ui(context: AdminContext<'_>) -> Result<()> {
2081 if !is_tty() {
2082 let mut writer = io::stdout().lock();
2083 return write_non_tty_fallback(&mut writer, context.backend.output_format());
2084 }
2085
2086 let app = AdminApp::new(
2087 context.connection_label,
2088 context.auth,
2089 context.backend,
2090 context.initial_target,
2091 );
2092 app.run()
2093}
2094
2095pub fn write_non_tty_fallback<W: Write>(writer: &mut W, output_format: OutputFormat) -> Result<()> {
2096 let mut formatter = create_formatter(output_format);
2097 let columns = vec![
2098 Column::new("Status", DataType::Text),
2099 Column::new("Message", DataType::Text),
2100 ];
2101 let rows = vec![Row::new(vec![
2102 Value::Text("Error".to_string()),
2103 Value::Text("Admin UI is unavailable without a TTY.".to_string()),
2104 ])];
2105 formatter.write_header(writer, &columns)?;
2106 for row in &rows {
2107 formatter.write_row(writer, row)?;
2108 }
2109 formatter.write_footer(writer)
2110}
2111
2112fn default_items() -> Vec<AdminItem> {
2113 vec![
2114 AdminItem {
2115 action: AdminAction::Read,
2116 title: "Read / List",
2117 description: "Browse or query data across databases, tables, and indexes.",
2118 enabled: true,
2119 },
2120 AdminItem {
2121 action: AdminAction::Create,
2122 title: "Create",
2123 description: "Create new databases, tables, indexes, or data objects.",
2124 enabled: true,
2125 },
2126 AdminItem {
2127 action: AdminAction::Update,
2128 title: "Update",
2129 description: "Modify existing records, schemas, or index settings.",
2130 enabled: true,
2131 },
2132 AdminItem {
2133 action: AdminAction::Delete,
2134 title: "Delete",
2135 description: "Remove records, tables, indexes, or data sets.",
2136 enabled: true,
2137 },
2138 AdminItem {
2139 action: AdminAction::Archive,
2140 title: "Archive",
2141 description: "Move data into an archived state for long-term retention.",
2142 enabled: true,
2143 },
2144 AdminItem {
2145 action: AdminAction::Restore,
2146 title: "Restore",
2147 description: "Restore archived data into an active state.",
2148 enabled: true,
2149 },
2150 AdminItem {
2151 action: AdminAction::Backup,
2152 title: "Backup",
2153 description: "Create snapshots or backups of data and metadata.",
2154 enabled: true,
2155 },
2156 AdminItem {
2157 action: AdminAction::Export,
2158 title: "Export",
2159 description: "Export data for external systems or offline analysis.",
2160 enabled: true,
2161 },
2162 ]
2163}
2164
2165fn is_not_implemented(action: AdminAction) -> bool {
2166 let _ = action;
2167 false
2168}
2169
2170fn parse_params(input: &str) -> std::collections::HashMap<String, String> {
2171 let mut params = std::collections::HashMap::new();
2172 let mut token = String::new();
2173 let mut in_quotes: Option<char> = None;
2174 for ch in input.chars() {
2175 if let Some(quote) = in_quotes {
2176 if ch == quote {
2177 in_quotes = None;
2178 } else {
2179 token.push(ch);
2180 }
2181 continue;
2182 }
2183 match ch {
2184 '"' | '\'' => {
2185 in_quotes = Some(ch);
2186 }
2187 ' ' | '\t' | '\n' | '\r' => {
2188 push_param_token(&mut params, &token);
2189 token.clear();
2190 }
2191 _ => token.push(ch),
2192 }
2193 }
2194 push_param_token(&mut params, &token);
2195 params
2196}
2197
2198fn push_param_token(params: &mut std::collections::HashMap<String, String>, token: &str) {
2199 if let Some((key, value)) = token.split_once('=') {
2200 if !key.is_empty() && !value.is_empty() {
2201 params.insert(key.to_lowercase(), value.to_string());
2202 }
2203 }
2204}
2205
2206fn build_command_for(
2207 action: AdminAction,
2208 target: AdminTarget,
2209 params: &std::collections::HashMap<String, String>,
2210) -> Result<Option<AdminCommand>> {
2211 if matches!(
2212 action,
2213 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export
2214 ) {
2215 let command = match action {
2216 AdminAction::Archive => LifecycleCommand::Archive,
2217 AdminAction::Restore => LifecycleCommand::Restore,
2218 AdminAction::Backup => LifecycleCommand::Backup,
2219 AdminAction::Export => LifecycleCommand::Export,
2220 _ => return Ok(None),
2221 };
2222 return Ok(Some(AdminCommand::Lifecycle(command)));
2223 }
2224 match target {
2225 AdminTarget::Sql => build_sql_command(action, params),
2226 AdminTarget::Kv => build_kv_command(action, params),
2227 AdminTarget::Vector => build_vector_command(action, params),
2228 AdminTarget::Hnsw => build_hnsw_command(action, params),
2229 AdminTarget::Columnar => build_columnar_command(action, params),
2230 }
2231}
2232
2233fn build_sql_command(
2234 action: AdminAction,
2235 params: &std::collections::HashMap<String, String>,
2236) -> Result<Option<AdminCommand>> {
2237 let query = if let Some(query) = params.get("query").cloned() {
2238 Some(query)
2239 } else if action == AdminAction::Read {
2240 let table = params.get("table").cloned();
2241 let columns = params.get("columns").cloned();
2242 match table {
2243 Some(table) => {
2244 let columns = columns
2245 .filter(|value| !value.trim().is_empty())
2246 .unwrap_or_else(|| "*".to_string());
2247 Some(format!("SELECT {} FROM {}", columns, table))
2248 }
2249 None => None,
2250 }
2251 } else {
2252 None
2253 };
2254 if query.is_none() {
2255 return Ok(None);
2256 }
2257 Ok(Some(AdminCommand::Sql(SqlCommand {
2258 query,
2259 file: None,
2260 fetch_size: None,
2261 max_rows: None,
2262 deadline: None,
2263 tui: false,
2264 })))
2265}
2266
2267fn build_kv_command(
2268 action: AdminAction,
2269 params: &std::collections::HashMap<String, String>,
2270) -> Result<Option<AdminCommand>> {
2271 match action {
2272 AdminAction::Read => {
2273 if let Some(key) = params.get("key") {
2274 return Ok(Some(AdminCommand::Kv(KvCommand::Get { key: key.clone() })));
2275 }
2276 let prefix = params.get("prefix").cloned();
2277 Ok(Some(AdminCommand::Kv(KvCommand::List { prefix })))
2278 }
2279 AdminAction::Create | AdminAction::Update => {
2280 let key = params.get("key").cloned();
2281 let value = params.get("value").cloned();
2282 match (key, value) {
2283 (Some(key), Some(value)) => {
2284 Ok(Some(AdminCommand::Kv(KvCommand::Put { key, value })))
2285 }
2286 _ => Ok(None),
2287 }
2288 }
2289 AdminAction::Delete => {
2290 let key = params.get("key").cloned();
2291 match key {
2292 Some(key) => Ok(Some(AdminCommand::Kv(KvCommand::Delete { key }))),
2293 None => Ok(None),
2294 }
2295 }
2296 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2297 Ok(None)
2298 }
2299 }
2300}
2301
2302fn build_vector_command(
2303 action: AdminAction,
2304 params: &std::collections::HashMap<String, String>,
2305) -> Result<Option<AdminCommand>> {
2306 let index = params.get("index").cloned();
2307 match action {
2308 AdminAction::Read => {
2309 let query = params.get("query").cloned();
2310 let index = match index {
2311 Some(index) => index,
2312 None => return Ok(None),
2313 };
2314 let query = match query {
2315 Some(query) => query,
2316 None => return Ok(None),
2317 };
2318 let k = params
2319 .get("k")
2320 .and_then(|value| value.parse::<usize>().ok())
2321 .unwrap_or(10);
2322 Ok(Some(AdminCommand::Vector(VectorCommand::Search {
2323 index,
2324 query,
2325 k,
2326 progress: false,
2327 })))
2328 }
2329 AdminAction::Create | AdminAction::Update => {
2330 let key = params.get("key").cloned();
2331 let vector = params.get("vector").cloned();
2332 match (index, key, vector) {
2333 (Some(index), Some(key), Some(vector)) => {
2334 Ok(Some(AdminCommand::Vector(VectorCommand::Upsert {
2335 index,
2336 key,
2337 vector,
2338 })))
2339 }
2340 _ => Ok(None),
2341 }
2342 }
2343 AdminAction::Delete => {
2344 let key = params.get("key").cloned();
2345 match (index, key) {
2346 (Some(index), Some(key)) => Ok(Some(AdminCommand::Vector(VectorCommand::Delete {
2347 index,
2348 key,
2349 }))),
2350 _ => Ok(None),
2351 }
2352 }
2353 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2354 Ok(None)
2355 }
2356 }
2357}
2358
2359fn build_hnsw_command(
2360 action: AdminAction,
2361 params: &std::collections::HashMap<String, String>,
2362) -> Result<Option<AdminCommand>> {
2363 match action {
2364 AdminAction::Read => {
2365 let name = match params.get("name").cloned() {
2366 Some(name) => name,
2367 None => return Ok(None),
2368 };
2369 Ok(Some(AdminCommand::Hnsw(HnswCommand::Stats { name })))
2370 }
2371 AdminAction::Create => {
2372 let name = match params.get("name").cloned() {
2373 Some(name) => name,
2374 None => return Ok(None),
2375 };
2376 let dim = match params
2377 .get("dim")
2378 .and_then(|value| value.parse::<usize>().ok())
2379 {
2380 Some(dim) => dim,
2381 None => return Ok(None),
2382 };
2383 let metric = if let Some(value) = params.get("metric") {
2384 parse_metric(value).ok_or_else(|| {
2385 CliError::InvalidArgument(
2386 "Invalid metric. Use metric=cosine|l2|ip.".to_string(),
2387 )
2388 })?
2389 } else {
2390 DistanceMetric::Cosine
2391 };
2392 Ok(Some(AdminCommand::Hnsw(HnswCommand::Create {
2393 name,
2394 dim,
2395 metric,
2396 })))
2397 }
2398 AdminAction::Delete => {
2399 let name = match params.get("name").cloned() {
2400 Some(name) => name,
2401 None => return Ok(None),
2402 };
2403 Ok(Some(AdminCommand::Hnsw(HnswCommand::Drop { name })))
2404 }
2405 AdminAction::Update => Err(CliError::InvalidArgument(
2406 "Update is not supported for HNSW targets.".to_string(),
2407 )),
2408 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2409 Ok(None)
2410 }
2411 }
2412}
2413
2414fn build_columnar_command(
2415 action: AdminAction,
2416 params: &std::collections::HashMap<String, String>,
2417) -> Result<Option<AdminCommand>> {
2418 match action {
2419 AdminAction::Read => {
2420 let mode = params
2421 .get("mode")
2422 .map(|value| value.as_str())
2423 .unwrap_or("list");
2424 match mode {
2425 "scan" => {
2426 let segment = match params.get("segment").cloned() {
2427 Some(segment) => segment,
2428 None => return Ok(None),
2429 };
2430 Ok(Some(AdminCommand::Columnar(ColumnarCommand::Scan {
2431 segment,
2432 progress: false,
2433 })))
2434 }
2435 "stats" => {
2436 let segment = match params.get("segment").cloned() {
2437 Some(segment) => segment,
2438 None => return Ok(None),
2439 };
2440 Ok(Some(AdminCommand::Columnar(ColumnarCommand::Stats {
2441 segment,
2442 })))
2443 }
2444 "index_list" => {
2445 let segment = match params.get("segment").cloned() {
2446 Some(segment) => segment,
2447 None => return Ok(None),
2448 };
2449 Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2450 IndexCommand::List { segment },
2451 ))))
2452 }
2453 "list" => Ok(Some(AdminCommand::Columnar(ColumnarCommand::List))),
2454 _ => Err(CliError::InvalidArgument(
2455 "Unknown columnar mode. Use mode=list|scan|stats|index_list.".to_string(),
2456 )),
2457 }
2458 }
2459 AdminAction::Create => {
2460 if let (Some(file), Some(table)) =
2461 (params.get("file").cloned(), params.get("table").cloned())
2462 {
2463 return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Ingest {
2464 file: std::path::PathBuf::from(file),
2465 table,
2466 delimiter: ',',
2467 header: true,
2468 compression: "lz4".to_string(),
2469 row_group_size: None,
2470 })));
2471 }
2472 if let (Some(segment), Some(column), Some(index_type)) = (
2473 params.get("segment").cloned(),
2474 params.get("column").cloned(),
2475 params.get("index_type").cloned(),
2476 ) {
2477 return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2478 IndexCommand::Create {
2479 segment,
2480 column,
2481 index_type,
2482 },
2483 ))));
2484 }
2485 Ok(None)
2486 }
2487 AdminAction::Delete => {
2488 if let (Some(segment), Some(column)) = (
2489 params.get("segment").cloned(),
2490 params.get("column").cloned(),
2491 ) {
2492 return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2493 IndexCommand::Drop { segment, column },
2494 ))));
2495 }
2496 Ok(None)
2497 }
2498 AdminAction::Update => Err(CliError::InvalidArgument(
2499 "Update is not supported for columnar targets.".to_string(),
2500 )),
2501 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2502 Ok(None)
2503 }
2504 }
2505}
2506
2507fn parse_metric(value: &str) -> Option<DistanceMetric> {
2508 match value.to_lowercase().as_str() {
2509 "cosine" => Some(DistanceMetric::Cosine),
2510 "l2" => Some(DistanceMetric::L2),
2511 "ip" => Some(DistanceMetric::Ip),
2512 _ => None,
2513 }
2514}
2515
2516fn render_help(frame: &mut ratatui::Frame<'_>, area: Rect) {
2517 let help_width = area.width.saturating_sub(4).min(60);
2518 let help_height = area.height.saturating_sub(4).min(12);
2519 let rect = Rect::new(
2520 area.x + (area.width.saturating_sub(help_width)) / 2,
2521 area.y + (area.height.saturating_sub(help_height)) / 2,
2522 help_width,
2523 help_height,
2524 );
2525
2526 let lines = [
2527 "h/l or Left/Right: move focus",
2528 "Menu: j/k move, / search, e edit, r raw, Enter select, R refresh",
2529 "Input: Up/Down action, Tab field, e edit, o list, r raw, Enter execute",
2530 "Data: j/k scroll",
2531 "a: back",
2532 "?: toggle help",
2533 "q/Esc: quit",
2534 ]
2535 .join("\n");
2536
2537 let help = Paragraph::new(lines)
2538 .block(Block::default().borders(Borders::ALL).title("Help"))
2539 .wrap(Wrap { trim: true });
2540 frame.render_widget(help, rect);
2541}
2542
2543#[derive(Debug, Clone)]
2544struct SelectionOverlay {
2545 title: String,
2546 items: Vec<String>,
2547 selected: usize,
2548 field_index: usize,
2549 search: Option<String>,
2550 search_focused: bool,
2551}
2552
2553impl SelectionOverlay {
2554 fn new(title: String, items: Vec<String>, field_index: usize) -> Self {
2555 Self {
2556 title,
2557 items,
2558 selected: 0,
2559 field_index,
2560 search: None,
2561 search_focused: false,
2562 }
2563 }
2564
2565 fn search_term(&self) -> Option<&str> {
2566 self.search
2567 .as_deref()
2568 .filter(|value| !value.trim().is_empty())
2569 }
2570
2571 fn filtered_indices(&self) -> Vec<usize> {
2572 let Some(term) = self.search_term() else {
2573 return (0..self.items.len()).collect();
2574 };
2575 let term = term.to_lowercase();
2576 self.items
2577 .iter()
2578 .enumerate()
2579 .filter_map(|(idx, item)| {
2580 if item.to_lowercase().contains(&term) {
2581 Some(idx)
2582 } else {
2583 None
2584 }
2585 })
2586 .collect()
2587 }
2588
2589 fn selected_value(&self) -> Option<String> {
2590 let indices = self.filtered_indices();
2591 let idx = indices.get(self.selected).copied()?;
2592 self.items.get(idx).cloned()
2593 }
2594
2595 fn ensure_selection_in_range(&mut self) {
2596 let len = self.filtered_indices().len();
2597 if len == 0 {
2598 self.selected = 0;
2599 } else if self.selected >= len {
2600 self.selected = len - 1;
2601 }
2602 }
2603
2604 fn move_up(&mut self) {
2605 if self.selected > 0 {
2606 self.selected -= 1;
2607 }
2608 }
2609
2610 fn move_down(&mut self) {
2611 let len = self.filtered_indices().len();
2612 if self.selected + 1 < len {
2613 self.selected += 1;
2614 }
2615 }
2616
2617 fn move_top(&mut self) {
2618 self.selected = 0;
2619 }
2620
2621 fn move_bottom(&mut self) {
2622 let len = self.filtered_indices().len();
2623 if len > 0 {
2624 self.selected = len - 1;
2625 }
2626 }
2627
2628 fn push_search(&mut self, ch: char) {
2629 let search = self.search.get_or_insert_with(String::new);
2630 search.push(ch);
2631 self.ensure_selection_in_range();
2632 }
2633
2634 fn pop_search(&mut self) {
2635 if let Some(search) = self.search.as_mut() {
2636 if !search.is_empty() {
2637 search.pop();
2638 } else {
2639 self.reset_search();
2640 }
2641 }
2642 self.ensure_selection_in_range();
2643 }
2644
2645 fn reset_search(&mut self) {
2646 self.search = None;
2647 self.search_focused = false;
2648 self.selected = 0;
2649 }
2650}
2651
2652fn render_selection_overlay(
2653 frame: &mut ratatui::Frame<'_>,
2654 area: Rect,
2655 selection: &SelectionOverlay,
2656) {
2657 let overlay_width = area.width.saturating_sub(6).min(60);
2658 let overlay_height = area.height.saturating_sub(6).min(16);
2659 let rect = Rect::new(
2660 area.x + (area.width.saturating_sub(overlay_width)) / 2,
2661 area.y + (area.height.saturating_sub(overlay_height)) / 2,
2662 overlay_width,
2663 overlay_height,
2664 );
2665
2666 let layout = Layout::default()
2667 .direction(Direction::Vertical)
2668 .constraints([
2669 Constraint::Length(1),
2670 Constraint::Length(1),
2671 Constraint::Min(3),
2672 ])
2673 .split(rect);
2674
2675 let search = selection
2676 .search
2677 .as_ref()
2678 .map(|value| format!("/ {value}"))
2679 .unwrap_or_else(|| "/".to_string());
2680 let search_style = if selection.search_focused {
2681 Style::default().fg(Color::Yellow)
2682 } else {
2683 Style::default().fg(Color::Gray)
2684 };
2685 frame.render_widget(
2686 Paragraph::new(search)
2687 .block(
2688 Block::default()
2689 .borders(Borders::ALL)
2690 .title(selection.title.as_str()),
2691 )
2692 .style(search_style),
2693 layout[0],
2694 );
2695
2696 frame.render_widget(
2697 Paragraph::new("Enter: choose Esc: close /: search g/G: top/bottom j/k: move")
2698 .style(Style::default().fg(Color::DarkGray)),
2699 layout[1],
2700 );
2701
2702 let indices = selection.filtered_indices();
2703 let items = if indices.is_empty() {
2704 vec![ListItem::new(Line::from("No options available."))]
2705 } else {
2706 indices
2707 .iter()
2708 .filter_map(|idx| selection.items.get(*idx))
2709 .map(|item| ListItem::new(Line::from(item.clone())))
2710 .collect::<Vec<_>>()
2711 };
2712 let mut state = ListState::default();
2713 state.select(Some(selection.selected));
2714 let list = List::new(items)
2715 .block(Block::default().borders(Borders::ALL))
2716 .highlight_style(
2717 Style::default()
2718 .bg(Color::Blue)
2719 .fg(Color::White)
2720 .add_modifier(Modifier::BOLD),
2721 )
2722 .highlight_symbol("> ");
2723 frame.render_stateful_widget(list, layout[2], &mut state);
2724}
2725
2726fn cleanup_terminal(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
2727 disable_raw_mode()?;
2728 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
2729 terminal.show_cursor()?;
2730 Ok(())
2731}
2732
2733#[derive(Debug, Default, Clone)]
2734struct AdminResult {
2735 columns: Vec<Column>,
2736 rows: Vec<Row>,
2737 status_message: Option<String>,
2738}
2739
2740impl AdminResult {
2741 fn status(message: String) -> Self {
2742 Self {
2743 columns: Vec::new(),
2744 rows: Vec::new(),
2745 status_message: Some(message),
2746 }
2747 }
2748}
2749
2750#[derive(Default, Clone)]
2751struct CaptureState {
2752 columns: Vec<Column>,
2753 rows: Vec<Row>,
2754}
2755
2756struct CaptureFormatter {
2757 state: Arc<Mutex<CaptureState>>,
2758}
2759
2760impl CaptureFormatter {
2761 fn new() -> (Self, Arc<Mutex<CaptureState>>) {
2762 let state = Arc::new(Mutex::new(CaptureState::default()));
2763 (
2764 Self {
2765 state: Arc::clone(&state),
2766 },
2767 state,
2768 )
2769 }
2770}
2771
2772impl Formatter for CaptureFormatter {
2773 fn write_header(&mut self, _writer: &mut dyn std::io::Write, columns: &[Column]) -> Result<()> {
2774 let mut state = self.state.lock().expect("admin capture lock");
2775 state.columns = columns.to_vec();
2776 Ok(())
2777 }
2778
2779 fn write_row(&mut self, _writer: &mut dyn std::io::Write, row: &Row) -> Result<()> {
2780 self.state
2781 .lock()
2782 .expect("admin capture lock")
2783 .rows
2784 .push(row.clone());
2785 Ok(())
2786 }
2787
2788 fn write_footer(&mut self, _writer: &mut dyn std::io::Write) -> Result<()> {
2789 Ok(())
2790 }
2791
2792 fn supports_streaming(&self) -> bool {
2793 true
2794 }
2795}
2796
2797fn append_result_lines(lines: &mut Vec<Line<'static>>, result: &AdminResult) {
2798 if result.columns.is_empty() && result.rows.is_empty() && result.status_message.is_none() {
2799 return;
2800 }
2801 lines.push(Line::from(""));
2802 lines.push(Line::from(Span::styled(
2803 "Last Result",
2804 Style::default().add_modifier(Modifier::BOLD),
2805 )));
2806 if let Some(message) = &result.status_message {
2807 lines.push(Line::from(message.clone()));
2808 }
2809 if !result.columns.is_empty() {
2810 let header = result
2811 .columns
2812 .iter()
2813 .map(|col| col.name.clone())
2814 .collect::<Vec<_>>()
2815 .join(" | ");
2816 lines.push(Line::from(header));
2817 for row in &result.rows {
2818 let row_text = row
2819 .columns
2820 .iter()
2821 .map(value_to_string)
2822 .collect::<Vec<_>>()
2823 .join(" | ");
2824 lines.push(Line::from(row_text));
2825 }
2826 }
2827}
2828
2829fn value_to_string(value: &Value) -> String {
2830 match value {
2831 Value::Null => "NULL".to_string(),
2832 Value::Bool(b) => b.to_string(),
2833 Value::Int(i) => i.to_string(),
2834 Value::Float(f) => format!("{f:.6}"),
2835 Value::Text(text) => text.clone(),
2836 Value::Bytes(bytes) => format!("{:02x?}", bytes),
2837 Value::Vector(values) => format!(
2838 "[{}]",
2839 values
2840 .iter()
2841 .take(4)
2842 .map(|value| format!("{value:.4}"))
2843 .collect::<Vec<_>>()
2844 .join(", ")
2845 ),
2846 }
2847}
2848
2849#[cfg(test)]
2850mod tests {
2851 use super::*;
2852 use crate::batch::{BatchMode, BatchModeSource};
2853 use alopex_embedded::Database;
2854
2855 fn make_app<'a>(db: &'a Database) -> AdminApp<'a> {
2856 let batch_mode = Box::leak(Box::new(BatchMode {
2857 is_batch: true,
2858 is_tty: true,
2859 source: BatchModeSource::Explicit,
2860 }));
2861 let backend = AdminBackend::Local {
2862 db,
2863 batch_mode,
2864 output_format: OutputFormat::Table,
2865 limit: None,
2866 quiet: true,
2867 data_dir: None,
2868 };
2869 AdminApp::new(
2870 "local",
2871 AuthCapabilities::full(),
2872 backend,
2873 Some(AdminTarget::Kv),
2874 )
2875 }
2876
2877 fn field_value(app: &AdminApp<'_>, key: &str) -> Option<String> {
2878 app.form_fields
2879 .iter()
2880 .find(|field| field.key.eq_ignore_ascii_case(key))
2881 .map(|field| field.value.clone())
2882 }
2883
2884 #[test]
2885 fn resource_tree_filters_keep_parents() {
2886 let entries = vec![
2887 ResourceEntry {
2888 label: "SQL Tables".to_string(),
2889 kind: ResourceKind::Section(ResourceSection::SqlTables),
2890 depth: 0,
2891 selectable: false,
2892 },
2893 ResourceEntry {
2894 label: "users".to_string(),
2895 kind: ResourceKind::Table {
2896 name: "users".to_string(),
2897 },
2898 depth: 1,
2899 selectable: true,
2900 },
2901 ResourceEntry {
2902 label: "email".to_string(),
2903 kind: ResourceKind::Column {
2904 table: "users".to_string(),
2905 name: "email".to_string(),
2906 },
2907 depth: 2,
2908 selectable: true,
2909 },
2910 ];
2911 let tree = ResourceTree {
2912 entries,
2913 selected: 0,
2914 search: Some("email".to_string()),
2915 search_focused: false,
2916 last_error: None,
2917 };
2918 let indices = tree.filtered_indices();
2919 assert_eq!(indices, vec![0, 1, 2]);
2920 }
2921
2922 #[test]
2923 fn resource_tree_paging_clamps() {
2924 let entries = (0..12)
2925 .map(|idx| ResourceEntry {
2926 label: format!("item-{idx}"),
2927 kind: ResourceKind::Info,
2928 depth: 0,
2929 selectable: true,
2930 })
2931 .collect::<Vec<_>>();
2932 let mut tree = ResourceTree {
2933 entries,
2934 selected: 0,
2935 search: None,
2936 search_focused: false,
2937 last_error: None,
2938 };
2939 tree.page_down();
2940 assert_eq!(tree.selected, 5);
2941 tree.page_down();
2942 assert_eq!(tree.selected, 10);
2943 tree.page_down();
2944 assert_eq!(tree.selected, 11);
2945 tree.page_up();
2946 assert_eq!(tree.selected, 6);
2947 }
2948
2949 #[test]
2950 fn selection_overlay_filters_values() {
2951 let mut overlay = SelectionOverlay::new(
2952 "Select".to_string(),
2953 vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
2954 0,
2955 );
2956 overlay.search = Some("et".to_string());
2957 overlay.ensure_selection_in_range();
2958 assert_eq!(overlay.selected_value(), Some("beta".to_string()));
2959 overlay.move_down();
2960 assert_eq!(overlay.selected_value(), Some("beta".to_string()));
2961 }
2962
2963 #[test]
2964 fn focus_transitions_follow_table_detail_status() {
2965 let db = Database::open_in_memory().expect("db");
2966 let mut app = make_app(&db);
2967 app.focus = AdminFocus::Table;
2968 assert_eq!(app.focus_right(), AdminFocus::Detail);
2969 app.focus = AdminFocus::Detail;
2970 assert_eq!(app.focus_left(), AdminFocus::Table);
2971 assert_eq!(app.focus_right(), AdminFocus::Status);
2972 app.focus = AdminFocus::Status;
2973 assert_eq!(app.focus_left(), AdminFocus::Detail);
2974 assert_eq!(app.focus_right(), AdminFocus::Status);
2975 }
2976
2977 #[test]
2978 fn resource_selection_sets_sql_fields() {
2979 let db = Database::open_in_memory().expect("db");
2980 let mut app = make_app(&db);
2981 app.resources = ResourceTree {
2982 entries: vec![
2983 ResourceEntry {
2984 label: "SQL Tables".to_string(),
2985 kind: ResourceKind::Section(ResourceSection::SqlTables),
2986 depth: 0,
2987 selectable: false,
2988 },
2989 ResourceEntry {
2990 label: "users".to_string(),
2991 kind: ResourceKind::Table {
2992 name: "users".to_string(),
2993 },
2994 depth: 1,
2995 selectable: true,
2996 },
2997 ],
2998 selected: 1,
2999 search: None,
3000 search_focused: false,
3001 last_error: None,
3002 };
3003 app.apply_resource_selection().expect("select table");
3004 assert_eq!(app.target, AdminTarget::Sql);
3005 assert_eq!(field_value(&app, "table"), Some("users".to_string()));
3006 }
3007
3008 #[test]
3009 fn resource_selection_sets_kv_key() {
3010 let db = Database::open_in_memory().expect("db");
3011 let mut app = make_app(&db);
3012 app.resources = ResourceTree {
3013 entries: vec![ResourceEntry {
3014 label: "mykey".to_string(),
3015 kind: ResourceKind::KvKey {
3016 key: "mykey".to_string(),
3017 },
3018 depth: 1,
3019 selectable: true,
3020 }],
3021 selected: 0,
3022 search: None,
3023 search_focused: false,
3024 last_error: None,
3025 };
3026 app.apply_resource_selection().expect("select key");
3027 assert_eq!(app.target, AdminTarget::Kv);
3028 assert_eq!(field_value(&app, "key"), Some("mykey".to_string()));
3029 }
3030}