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