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 tui: false,
2545 })))
2546}
2547
2548fn build_kv_command(
2549 action: AdminAction,
2550 params: &std::collections::HashMap<String, String>,
2551) -> Result<Option<AdminCommand>> {
2552 match action {
2553 AdminAction::Read => {
2554 if let Some(key) = params.get("key") {
2555 return Ok(Some(AdminCommand::Kv(KvCommand::Get { key: key.clone() })));
2556 }
2557 let prefix = params.get("prefix").cloned();
2558 Ok(Some(AdminCommand::Kv(KvCommand::List { prefix })))
2559 }
2560 AdminAction::Create | AdminAction::Update => {
2561 let key = params.get("key").cloned();
2562 let value = params.get("value").cloned();
2563 match (key, value) {
2564 (Some(key), Some(value)) => {
2565 Ok(Some(AdminCommand::Kv(KvCommand::Put { key, value })))
2566 }
2567 _ => Ok(None),
2568 }
2569 }
2570 AdminAction::Delete => {
2571 let key = params.get("key").cloned();
2572 match key {
2573 Some(key) => Ok(Some(AdminCommand::Kv(KvCommand::Delete { key }))),
2574 None => Ok(None),
2575 }
2576 }
2577 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2578 Ok(None)
2579 }
2580 }
2581}
2582
2583fn build_vector_command(
2584 action: AdminAction,
2585 params: &std::collections::HashMap<String, String>,
2586) -> Result<Option<AdminCommand>> {
2587 let index = params.get("index").cloned();
2588 match action {
2589 AdminAction::Read => {
2590 let query = params.get("query").cloned();
2591 let index = match index {
2592 Some(index) => index,
2593 None => return Ok(None),
2594 };
2595 let query = match query {
2596 Some(query) => query,
2597 None => return Ok(None),
2598 };
2599 let k = params
2600 .get("k")
2601 .and_then(|value| value.parse::<usize>().ok())
2602 .unwrap_or(10);
2603 Ok(Some(AdminCommand::Vector(VectorCommand::Search {
2604 index,
2605 query,
2606 k,
2607 progress: false,
2608 })))
2609 }
2610 AdminAction::Create | AdminAction::Update => {
2611 let key = params.get("key").cloned();
2612 let vector = params.get("vector").cloned();
2613 match (index, key, vector) {
2614 (Some(index), Some(key), Some(vector)) => {
2615 Ok(Some(AdminCommand::Vector(VectorCommand::Upsert {
2616 index,
2617 key,
2618 vector,
2619 })))
2620 }
2621 _ => Ok(None),
2622 }
2623 }
2624 AdminAction::Delete => {
2625 let key = params.get("key").cloned();
2626 match (index, key) {
2627 (Some(index), Some(key)) => Ok(Some(AdminCommand::Vector(VectorCommand::Delete {
2628 index,
2629 key,
2630 }))),
2631 _ => Ok(None),
2632 }
2633 }
2634 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2635 Ok(None)
2636 }
2637 }
2638}
2639
2640fn build_hnsw_command(
2641 action: AdminAction,
2642 params: &std::collections::HashMap<String, String>,
2643) -> Result<Option<AdminCommand>> {
2644 match action {
2645 AdminAction::Read => {
2646 let name = match params.get("name").cloned() {
2647 Some(name) => name,
2648 None => return Ok(None),
2649 };
2650 Ok(Some(AdminCommand::Hnsw(HnswCommand::Stats { name })))
2651 }
2652 AdminAction::Create => {
2653 let name = match params.get("name").cloned() {
2654 Some(name) => name,
2655 None => return Ok(None),
2656 };
2657 let dim = match params
2658 .get("dim")
2659 .and_then(|value| value.parse::<usize>().ok())
2660 {
2661 Some(dim) => dim,
2662 None => return Ok(None),
2663 };
2664 let metric = if let Some(value) = params.get("metric") {
2665 parse_metric(value).ok_or_else(|| {
2666 CliError::InvalidArgument(
2667 "Invalid metric. Use metric=cosine|l2|ip.".to_string(),
2668 )
2669 })?
2670 } else {
2671 DistanceMetric::Cosine
2672 };
2673 Ok(Some(AdminCommand::Hnsw(HnswCommand::Create {
2674 name,
2675 dim,
2676 metric,
2677 })))
2678 }
2679 AdminAction::Delete => {
2680 let name = match params.get("name").cloned() {
2681 Some(name) => name,
2682 None => return Ok(None),
2683 };
2684 Ok(Some(AdminCommand::Hnsw(HnswCommand::Drop { name })))
2685 }
2686 AdminAction::Update => Err(CliError::InvalidArgument(
2687 "Update is not supported for HNSW targets.".to_string(),
2688 )),
2689 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2690 Ok(None)
2691 }
2692 }
2693}
2694
2695fn build_columnar_command(
2696 action: AdminAction,
2697 params: &std::collections::HashMap<String, String>,
2698) -> Result<Option<AdminCommand>> {
2699 match action {
2700 AdminAction::Read => {
2701 let mode = params
2702 .get("mode")
2703 .map(|value| value.as_str())
2704 .unwrap_or("list");
2705 match mode {
2706 "scan" => {
2707 let segment = match params.get("segment").cloned() {
2708 Some(segment) => segment,
2709 None => return Ok(None),
2710 };
2711 Ok(Some(AdminCommand::Columnar(ColumnarCommand::Scan {
2712 segment,
2713 progress: false,
2714 })))
2715 }
2716 "stats" => {
2717 let segment = match params.get("segment").cloned() {
2718 Some(segment) => segment,
2719 None => return Ok(None),
2720 };
2721 Ok(Some(AdminCommand::Columnar(ColumnarCommand::Stats {
2722 segment,
2723 })))
2724 }
2725 "index_list" => {
2726 let segment = match params.get("segment").cloned() {
2727 Some(segment) => segment,
2728 None => return Ok(None),
2729 };
2730 Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2731 IndexCommand::List { segment },
2732 ))))
2733 }
2734 "list" => Ok(Some(AdminCommand::Columnar(ColumnarCommand::List))),
2735 _ => Err(CliError::InvalidArgument(
2736 "Unknown columnar mode. Use mode=list|scan|stats|index_list.".to_string(),
2737 )),
2738 }
2739 }
2740 AdminAction::Create => {
2741 if let (Some(file), Some(table)) =
2742 (params.get("file").cloned(), params.get("table").cloned())
2743 {
2744 return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Ingest {
2745 file: std::path::PathBuf::from(file),
2746 table,
2747 delimiter: ',',
2748 header: true,
2749 compression: "lz4".to_string(),
2750 row_group_size: None,
2751 })));
2752 }
2753 if let (Some(segment), Some(column), Some(index_type)) = (
2754 params.get("segment").cloned(),
2755 params.get("column").cloned(),
2756 params.get("index_type").cloned(),
2757 ) {
2758 return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2759 IndexCommand::Create {
2760 segment,
2761 column,
2762 index_type,
2763 },
2764 ))));
2765 }
2766 Ok(None)
2767 }
2768 AdminAction::Delete => {
2769 if let (Some(segment), Some(column)) = (
2770 params.get("segment").cloned(),
2771 params.get("column").cloned(),
2772 ) {
2773 return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2774 IndexCommand::Drop { segment, column },
2775 ))));
2776 }
2777 Ok(None)
2778 }
2779 AdminAction::Update => Err(CliError::InvalidArgument(
2780 "Update is not supported for columnar targets.".to_string(),
2781 )),
2782 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2783 Ok(None)
2784 }
2785 }
2786}
2787
2788fn parse_metric(value: &str) -> Option<DistanceMetric> {
2789 match value.to_lowercase().as_str() {
2790 "cosine" => Some(DistanceMetric::Cosine),
2791 "l2" => Some(DistanceMetric::L2),
2792 "ip" => Some(DistanceMetric::Ip),
2793 _ => None,
2794 }
2795}
2796
2797fn render_help(frame: &mut ratatui::Frame<'_>, area: Rect) {
2798 let help_width = area.width.saturating_sub(4).min(60);
2799 let help_height = area.height.saturating_sub(4).min(12);
2800 let rect = Rect::new(
2801 area.x + (area.width.saturating_sub(help_width)) / 2,
2802 area.y + (area.height.saturating_sub(help_height)) / 2,
2803 help_width,
2804 help_height,
2805 );
2806
2807 let lines = [
2808 "h/l or Left/Right: move focus",
2809 "Menu: j/k move, / search, e edit, r raw, Enter select, R refresh",
2810 "Input: Up/Down action, Tab field, e edit, o list, r raw, Enter execute",
2811 "Data: j/k scroll",
2812 "a: back",
2813 "?: toggle help",
2814 "q/Esc: quit",
2815 ]
2816 .join("\n");
2817
2818 let help = Paragraph::new(lines)
2819 .block(Block::default().borders(Borders::ALL).title("Help"))
2820 .wrap(Wrap { trim: true });
2821 frame.render_widget(help, rect);
2822}
2823
2824#[derive(Debug, Clone)]
2825struct SelectionOverlay {
2826 title: String,
2827 items: Vec<String>,
2828 selected: usize,
2829 field_index: usize,
2830 search: Option<String>,
2831 search_focused: bool,
2832}
2833
2834impl SelectionOverlay {
2835 fn new(title: String, items: Vec<String>, field_index: usize) -> Self {
2836 Self {
2837 title,
2838 items,
2839 selected: 0,
2840 field_index,
2841 search: None,
2842 search_focused: false,
2843 }
2844 }
2845
2846 fn search_term(&self) -> Option<&str> {
2847 self.search
2848 .as_deref()
2849 .filter(|value| !value.trim().is_empty())
2850 }
2851
2852 fn filtered_indices(&self) -> Vec<usize> {
2853 let Some(term) = self.search_term() else {
2854 return (0..self.items.len()).collect();
2855 };
2856 let term = term.to_lowercase();
2857 self.items
2858 .iter()
2859 .enumerate()
2860 .filter_map(|(idx, item)| {
2861 if item.to_lowercase().contains(&term) {
2862 Some(idx)
2863 } else {
2864 None
2865 }
2866 })
2867 .collect()
2868 }
2869
2870 fn selected_value(&self) -> Option<String> {
2871 let indices = self.filtered_indices();
2872 let idx = indices.get(self.selected).copied()?;
2873 self.items.get(idx).cloned()
2874 }
2875
2876 fn ensure_selection_in_range(&mut self) {
2877 let len = self.filtered_indices().len();
2878 if len == 0 {
2879 self.selected = 0;
2880 } else if self.selected >= len {
2881 self.selected = len - 1;
2882 }
2883 }
2884
2885 fn move_up(&mut self) {
2886 if self.selected > 0 {
2887 self.selected -= 1;
2888 }
2889 }
2890
2891 fn move_down(&mut self) {
2892 let len = self.filtered_indices().len();
2893 if self.selected + 1 < len {
2894 self.selected += 1;
2895 }
2896 }
2897
2898 fn move_top(&mut self) {
2899 self.selected = 0;
2900 }
2901
2902 fn move_bottom(&mut self) {
2903 let len = self.filtered_indices().len();
2904 if len > 0 {
2905 self.selected = len - 1;
2906 }
2907 }
2908
2909 fn push_search(&mut self, ch: char) {
2910 let search = self.search.get_or_insert_with(String::new);
2911 search.push(ch);
2912 self.ensure_selection_in_range();
2913 }
2914
2915 fn pop_search(&mut self) {
2916 if let Some(search) = self.search.as_mut() {
2917 if !search.is_empty() {
2918 search.pop();
2919 } else {
2920 self.reset_search();
2921 }
2922 }
2923 self.ensure_selection_in_range();
2924 }
2925
2926 fn reset_search(&mut self) {
2927 self.search = None;
2928 self.search_focused = false;
2929 self.selected = 0;
2930 }
2931}
2932
2933fn render_selection_overlay(
2934 frame: &mut ratatui::Frame<'_>,
2935 area: Rect,
2936 selection: &SelectionOverlay,
2937) {
2938 let overlay_width = area.width.saturating_sub(6).min(60);
2939 let overlay_height = area.height.saturating_sub(6).min(16);
2940 let rect = Rect::new(
2941 area.x + (area.width.saturating_sub(overlay_width)) / 2,
2942 area.y + (area.height.saturating_sub(overlay_height)) / 2,
2943 overlay_width,
2944 overlay_height,
2945 );
2946
2947 let layout = Layout::default()
2948 .direction(Direction::Vertical)
2949 .constraints([
2950 Constraint::Length(1),
2951 Constraint::Length(1),
2952 Constraint::Min(3),
2953 ])
2954 .split(rect);
2955
2956 let search = selection
2957 .search
2958 .as_ref()
2959 .map(|value| format!("/ {value}"))
2960 .unwrap_or_else(|| "/".to_string());
2961 let search_style = if selection.search_focused {
2962 Style::default().fg(Color::Yellow)
2963 } else {
2964 Style::default().fg(Color::Gray)
2965 };
2966 frame.render_widget(
2967 Paragraph::new(search)
2968 .block(
2969 Block::default()
2970 .borders(Borders::ALL)
2971 .title(selection.title.as_str()),
2972 )
2973 .style(search_style),
2974 layout[0],
2975 );
2976
2977 frame.render_widget(
2978 Paragraph::new("Enter: choose Esc: close /: search g/G: top/bottom j/k: move")
2979 .style(Style::default().fg(Color::DarkGray)),
2980 layout[1],
2981 );
2982
2983 let indices = selection.filtered_indices();
2984 let items = if indices.is_empty() {
2985 vec![ListItem::new(Line::from("No options available."))]
2986 } else {
2987 indices
2988 .iter()
2989 .filter_map(|idx| selection.items.get(*idx))
2990 .map(|item| ListItem::new(Line::from(item.clone())))
2991 .collect::<Vec<_>>()
2992 };
2993 let mut state = ListState::default();
2994 state.select(Some(selection.selected));
2995 let list = List::new(items)
2996 .block(Block::default().borders(Borders::ALL))
2997 .highlight_style(
2998 Style::default()
2999 .bg(Color::Blue)
3000 .fg(Color::White)
3001 .add_modifier(Modifier::BOLD),
3002 )
3003 .highlight_symbol("> ");
3004 frame.render_stateful_widget(list, layout[2], &mut state);
3005}
3006
3007fn cleanup_terminal(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
3008 disable_raw_mode()?;
3009 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
3010 terminal.show_cursor()?;
3011 Ok(())
3012}
3013
3014#[derive(Debug, Default, Clone)]
3015struct AdminResult {
3016 columns: Vec<Column>,
3017 rows: Vec<Row>,
3018 status_message: Option<String>,
3019}
3020
3021impl AdminResult {
3022 fn status(message: String) -> Self {
3023 Self {
3024 columns: Vec::new(),
3025 rows: Vec::new(),
3026 status_message: Some(message),
3027 }
3028 }
3029}
3030
3031#[derive(Default, Clone)]
3032struct CaptureState {
3033 columns: Vec<Column>,
3034 rows: Vec<Row>,
3035}
3036
3037struct CaptureFormatter {
3038 state: Arc<Mutex<CaptureState>>,
3039}
3040
3041impl CaptureFormatter {
3042 fn shared_state() -> Arc<Mutex<CaptureState>> {
3044 Arc::new(Mutex::new(CaptureState::default()))
3045 }
3046
3047 fn factory(state: &Arc<Mutex<CaptureState>>) -> impl FnMut() -> Box<dyn Formatter> {
3052 let state = Arc::clone(state);
3053 move || {
3054 Box::new(CaptureFormatter {
3055 state: Arc::clone(&state),
3056 }) as Box<dyn Formatter>
3057 }
3058 }
3059}
3060
3061impl Formatter for CaptureFormatter {
3062 fn write_header(&mut self, _writer: &mut dyn std::io::Write, columns: &[Column]) -> Result<()> {
3063 let mut state = self.state.lock().expect("admin capture lock");
3064 state.columns = columns.to_vec();
3065 Ok(())
3066 }
3067
3068 fn write_row(&mut self, _writer: &mut dyn std::io::Write, row: &Row) -> Result<()> {
3069 self.state
3070 .lock()
3071 .expect("admin capture lock")
3072 .rows
3073 .push(row.clone());
3074 Ok(())
3075 }
3076
3077 fn write_footer(&mut self, _writer: &mut dyn std::io::Write) -> Result<()> {
3078 Ok(())
3079 }
3080
3081 fn supports_streaming(&self) -> bool {
3082 true
3083 }
3084}
3085
3086fn append_result_lines(lines: &mut Vec<Line<'static>>, result: &AdminResult) {
3087 if result.columns.is_empty() && result.rows.is_empty() && result.status_message.is_none() {
3088 return;
3089 }
3090 lines.push(Line::from(""));
3091 lines.push(Line::from(Span::styled(
3092 "Last Result",
3093 Style::default().add_modifier(Modifier::BOLD),
3094 )));
3095 if let Some(message) = &result.status_message {
3096 lines.push(Line::from(message.clone()));
3097 }
3098 if !result.columns.is_empty() {
3099 let header = result
3100 .columns
3101 .iter()
3102 .map(|col| col.name.clone())
3103 .collect::<Vec<_>>()
3104 .join(" | ");
3105 lines.push(Line::from(header));
3106 for row in &result.rows {
3107 let row_text = row
3108 .columns
3109 .iter()
3110 .map(value_to_string)
3111 .collect::<Vec<_>>()
3112 .join(" | ");
3113 lines.push(Line::from(row_text));
3114 }
3115 }
3116}
3117
3118fn value_to_string(value: &Value) -> String {
3119 match value {
3120 Value::Null => "NULL".to_string(),
3121 Value::Bool(b) => b.to_string(),
3122 Value::Int(i) => i.to_string(),
3123 Value::Float(f) => format!("{f:.6}"),
3124 Value::Text(text) => text.clone(),
3125 Value::Bytes(bytes) => format!("{:02x?}", bytes),
3126 Value::Vector(values) => format!(
3127 "[{}]",
3128 values
3129 .iter()
3130 .take(4)
3131 .map(|value| format!("{value:.4}"))
3132 .collect::<Vec<_>>()
3133 .join(", ")
3134 ),
3135 }
3136}
3137
3138#[cfg(test)]
3139mod tests {
3140 use super::*;
3141 use crate::batch::{BatchMode, BatchModeSource};
3142 use alopex_embedded::Database;
3143
3144 fn make_app<'a>(db: &'a Database) -> AdminApp<'a> {
3145 let batch_mode = Box::leak(Box::new(BatchMode {
3146 is_batch: true,
3147 is_tty: true,
3148 source: BatchModeSource::Explicit,
3149 }));
3150 let backend = AdminBackend::Local {
3151 db,
3152 batch_mode,
3153 output_format: OutputFormat::Table,
3154 limit: None,
3155 quiet: true,
3156 data_dir: None,
3157 };
3158 AdminApp::new(
3159 "local",
3160 AuthCapabilities::full(),
3161 backend,
3162 Some(AdminTarget::Kv),
3163 )
3164 }
3165
3166 fn field_value(app: &AdminApp<'_>, key: &str) -> Option<String> {
3167 app.form_fields
3168 .iter()
3169 .find(|field| field.key.eq_ignore_ascii_case(key))
3170 .map(|field| field.value.clone())
3171 }
3172
3173 #[test]
3174 fn resource_tree_filters_keep_parents() {
3175 let entries = vec![
3176 ResourceEntry {
3177 label: "SQL Tables".to_string(),
3178 kind: ResourceKind::Section(ResourceSection::SqlTables),
3179 depth: 0,
3180 selectable: false,
3181 },
3182 ResourceEntry {
3183 label: "users".to_string(),
3184 kind: ResourceKind::Table {
3185 name: "users".to_string(),
3186 },
3187 depth: 1,
3188 selectable: true,
3189 },
3190 ResourceEntry {
3191 label: "email".to_string(),
3192 kind: ResourceKind::Column {
3193 table: "users".to_string(),
3194 name: "email".to_string(),
3195 },
3196 depth: 2,
3197 selectable: true,
3198 },
3199 ];
3200 let tree = ResourceTree {
3201 entries,
3202 selected: 0,
3203 search: Some("email".to_string()),
3204 search_focused: false,
3205 last_error: None,
3206 last_status: None,
3207 };
3208 let indices = tree.filtered_indices();
3209 assert_eq!(indices, vec![0, 1, 2]);
3210 }
3211
3212 #[test]
3213 fn resource_tree_paging_clamps() {
3214 let entries = (0..12)
3215 .map(|idx| ResourceEntry {
3216 label: format!("item-{idx}"),
3217 kind: ResourceKind::Info,
3218 depth: 0,
3219 selectable: true,
3220 })
3221 .collect::<Vec<_>>();
3222 let mut tree = ResourceTree {
3223 entries,
3224 selected: 0,
3225 search: None,
3226 search_focused: false,
3227 last_error: None,
3228 last_status: None,
3229 };
3230 tree.page_down();
3231 assert_eq!(tree.selected, 5);
3232 tree.page_down();
3233 assert_eq!(tree.selected, 10);
3234 tree.page_down();
3235 assert_eq!(tree.selected, 11);
3236 tree.page_up();
3237 assert_eq!(tree.selected, 6);
3238 }
3239
3240 #[test]
3241 fn selection_overlay_filters_values() {
3242 let mut overlay = SelectionOverlay::new(
3243 "Select".to_string(),
3244 vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
3245 0,
3246 );
3247 overlay.search = Some("et".to_string());
3248 overlay.ensure_selection_in_range();
3249 assert_eq!(overlay.selected_value(), Some("beta".to_string()));
3250 overlay.move_down();
3251 assert_eq!(overlay.selected_value(), Some("beta".to_string()));
3252 }
3253
3254 #[test]
3255 fn focus_transitions_follow_table_detail_status() {
3256 let db = Database::open_in_memory().expect("db");
3257 let mut app = make_app(&db);
3258 app.focus = AdminFocus::Table;
3259 assert_eq!(app.focus_right(), AdminFocus::Detail);
3260 app.focus = AdminFocus::Detail;
3261 assert_eq!(app.focus_left(), AdminFocus::Table);
3262 assert_eq!(app.focus_right(), AdminFocus::Status);
3263 app.focus = AdminFocus::Status;
3264 assert_eq!(app.focus_left(), AdminFocus::Detail);
3265 assert_eq!(app.focus_right(), AdminFocus::Status);
3266 }
3267
3268 #[test]
3269 fn resource_selection_sets_sql_fields() {
3270 let db = Database::open_in_memory().expect("db");
3271 let mut app = make_app(&db);
3272 app.resources = ResourceTree {
3273 entries: vec![
3274 ResourceEntry {
3275 label: "SQL Tables".to_string(),
3276 kind: ResourceKind::Section(ResourceSection::SqlTables),
3277 depth: 0,
3278 selectable: false,
3279 },
3280 ResourceEntry {
3281 label: "users".to_string(),
3282 kind: ResourceKind::Table {
3283 name: "users".to_string(),
3284 },
3285 depth: 1,
3286 selectable: true,
3287 },
3288 ],
3289 selected: 1,
3290 search: None,
3291 search_focused: false,
3292 last_error: None,
3293 last_status: None,
3294 };
3295 app.apply_resource_selection().expect("select table");
3296 assert_eq!(app.target, AdminTarget::Sql);
3297 assert_eq!(field_value(&app, "table"), Some("users".to_string()));
3298 }
3299
3300 #[test]
3301 fn resource_selection_sets_kv_key() {
3302 let db = Database::open_in_memory().expect("db");
3303 let mut app = make_app(&db);
3304 app.resources = ResourceTree {
3305 entries: vec![ResourceEntry {
3306 label: "mykey".to_string(),
3307 kind: ResourceKind::KvKey {
3308 key: "mykey".to_string(),
3309 },
3310 depth: 1,
3311 selectable: true,
3312 }],
3313 selected: 0,
3314 search: None,
3315 search_focused: false,
3316 last_error: None,
3317 last_status: None,
3318 };
3319 app.apply_resource_selection().expect("select key");
3320 assert_eq!(app.target, AdminTarget::Kv);
3321 assert_eq!(field_value(&app, "key"), Some("mykey".to_string()));
3322 }
3323}