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 (formatter, state) = CaptureFormatter::new();
909 let mut sink = io::sink();
910 let result = match &self.backend {
911 AdminBackend::Local { db, batch_mode, .. } => {
912 execute_local_action(db, batch_mode, request, &mut sink, Box::new(formatter))
913 }
914 AdminBackend::Remote {
915 client, batch_mode, ..
916 } => block_on_with_runtime(execute_remote_action(
917 client,
918 batch_mode,
919 request,
920 &mut sink,
921 Box::new(formatter),
922 ))?,
923 };
924
925 let capture = state.lock().expect("admin capture lock");
926 let mut result_state = AdminResult {
927 columns: capture.columns.clone(),
928 rows: capture.rows.clone(),
929 status_message: None,
930 };
931
932 if let Err(err) = result {
933 result_state.status_message = Some(err.to_string());
934 } else if result_state.columns.is_empty() && result_state.rows.is_empty() {
935 result_state.status_message = Some("OK".to_string());
936 }
937
938 self.last_result = Some(result_state);
939 self.preview_scroll = 0;
940 Ok(())
941 }
942
943 fn refresh_form_for_selection(&mut self) {
944 let action = self.items.get(self.selected).map(|item| item.action);
945 if action != self.last_action {
946 self.last_action = action;
947 self.reset_form();
948 }
949 }
950
951 fn reset_form(&mut self) {
952 if let Some(action) = self.items.get(self.selected).map(|item| item.action) {
953 self.form_fields = build_form_fields(self.target, action);
954 self.active_field = 0;
955 self.input_mode = AdminInputMode::Normal;
956 self.use_raw_params = false;
957 self.last_action = Some(action);
958 self.selection = None;
959 }
960 }
961
962 fn focus_left(&self) -> AdminFocus {
963 match self.focus {
964 AdminFocus::Table => AdminFocus::Table,
965 AdminFocus::Detail => AdminFocus::Table,
966 AdminFocus::Status => AdminFocus::Detail,
967 }
968 }
969
970 fn focus_right(&self) -> AdminFocus {
971 match self.focus {
972 AdminFocus::Table => AdminFocus::Detail,
973 AdminFocus::Detail => AdminFocus::Status,
974 AdminFocus::Status => AdminFocus::Status,
975 }
976 }
977
978 fn apply_resource_selection(&mut self) -> Result<()> {
979 let Some(entry) = self.resources.selected_entry() else {
980 return Ok(());
981 };
982 if !entry.selectable {
983 if let Some(target) = target_for_resource(&entry) {
984 self.ensure_target(target);
985 }
986 return Ok(());
987 }
988 match entry.kind {
989 ResourceKind::Section(section) => {
990 if let Some(target) = section.target() {
991 self.ensure_target(target);
992 }
993 }
994 ResourceKind::Table { name } => {
995 self.ensure_target(AdminTarget::Sql);
996 if self.set_field_value("table", &name, false).is_none() {
997 let query = format!("SELECT * FROM {name}");
998 if self.set_field_value("query", &query, false).is_none() {
999 self.last_result = Some(AdminResult::status(
1000 "No matching field for table.".to_string(),
1001 ));
1002 }
1003 }
1004 }
1005 ResourceKind::Column { table, name } => {
1006 self.ensure_target(AdminTarget::Sql);
1007 let _ = self.set_field_value("table", &table, false);
1008 if self.set_field_value("columns", &name, true).is_none() {
1009 self.last_result = Some(AdminResult::status(
1010 "No matching field for column.".to_string(),
1011 ));
1012 }
1013 }
1014 ResourceKind::KvKey { key } => {
1015 self.ensure_target(AdminTarget::Kv);
1016 if self.set_field_value("key", &key, false).is_none() {
1017 self.last_result = Some(AdminResult::status(
1018 "No matching field for key.".to_string(),
1019 ));
1020 }
1021 }
1022 ResourceKind::ColumnarSegment { id } => {
1023 self.ensure_target(AdminTarget::Columnar);
1024 if self.set_field_value("segment", &id, false).is_none() {
1025 self.last_result = Some(AdminResult::status(
1026 "No matching field for segment.".to_string(),
1027 ));
1028 }
1029 }
1030 ResourceKind::ColumnarColumn { segment_id, name } => {
1031 self.ensure_target(AdminTarget::Columnar);
1032 let _ = self.set_field_value("segment", &segment_id, false);
1033 if self.set_field_value("column", &name, false).is_none() {
1034 self.last_result = Some(AdminResult::status(
1035 "No matching field for column.".to_string(),
1036 ));
1037 }
1038 }
1039 ResourceKind::Info => {}
1040 }
1041 Ok(())
1042 }
1043
1044 fn sync_target_from_resource(&mut self) {
1045 let Some(entry) = self.resources.selected_entry() else {
1046 return;
1047 };
1048 if let Some(target) = target_for_resource(&entry) {
1049 self.ensure_target(target);
1050 }
1051 }
1052
1053 fn ensure_target(&mut self, target: AdminTarget) {
1054 if self.target != target {
1055 self.target = target;
1056 self.selected = 0;
1057 self.last_action = None;
1058 self.reset_form();
1059 }
1060 }
1061
1062 fn set_field_value(&mut self, key: &str, value: &str, append: bool) -> Option<()> {
1063 for (idx, field) in self.form_fields.iter_mut().enumerate() {
1064 if field.key.eq_ignore_ascii_case(key) {
1065 if append && !field.value.trim().is_empty() {
1066 field.value = format!("{},{}", field.value.trim(), value);
1067 } else {
1068 field.value = value.to_string();
1069 }
1070 self.active_field = idx;
1071 return Some(());
1072 }
1073 }
1074 None
1075 }
1076
1077 fn open_selection_for_active_field(&mut self) -> Result<()> {
1078 if self.use_raw_params {
1079 self.last_result = Some(AdminResult::status(
1080 "List selection is unavailable while using raw params.".to_string(),
1081 ));
1082 return Ok(());
1083 }
1084 let Some(field) = self.form_fields.get(self.active_field) else {
1085 return Ok(());
1086 };
1087 let Some(source) = field.list_source else {
1088 self.last_result = Some(AdminResult::status(
1089 "No list is available for this field.".to_string(),
1090 ));
1091 return Ok(());
1092 };
1093 let mut items = load_list_options(&self.backend, &self.form_fields, source)?;
1094 if items.is_empty() {
1095 items = self.list_options_from_resources(source);
1096 }
1097 items.retain(|item| !item.trim().is_empty());
1098 if items.is_empty() {
1099 self.last_result = Some(AdminResult::status(
1100 "No matching resources were found.".to_string(),
1101 ));
1102 return Ok(());
1103 }
1104 self.selection = Some(SelectionOverlay::new(
1105 format!("Select {}", field.label),
1106 items,
1107 self.active_field,
1108 ));
1109 Ok(())
1110 }
1111
1112 fn list_options_from_resources(&self, source: ListSource) -> Vec<String> {
1113 let mut items = Vec::new();
1114 match source {
1115 ListSource::KvKeys => {
1116 for entry in &self.resources.entries {
1117 if let ResourceKind::KvKey { key } = &entry.kind {
1118 items.push(key.clone());
1119 }
1120 }
1121 }
1122 ListSource::SqlTables => {
1123 for entry in &self.resources.entries {
1124 if let ResourceKind::Table { name } = &entry.kind {
1125 items.push(name.clone());
1126 }
1127 }
1128 }
1129 ListSource::SqlColumns => {
1130 let Some(table) = field_value(&self.form_fields, "table") else {
1131 return items;
1132 };
1133 for entry in &self.resources.entries {
1134 if let ResourceKind::Column {
1135 table: entry_table,
1136 name,
1137 } = &entry.kind
1138 {
1139 if entry_table == &table {
1140 items.push(name.clone());
1141 }
1142 }
1143 }
1144 }
1145 ListSource::ColumnarSegments => {
1146 for entry in &self.resources.entries {
1147 if let ResourceKind::ColumnarSegment { id } = &entry.kind {
1148 items.push(id.clone());
1149 }
1150 }
1151 }
1152 ListSource::ColumnarColumns => {
1153 let Some(segment) = field_value(&self.form_fields, "segment") else {
1154 return items;
1155 };
1156 for entry in &self.resources.entries {
1157 if let ResourceKind::ColumnarColumn { segment_id, name } = &entry.kind {
1158 if segment_id == &segment {
1159 items.push(name.clone());
1160 }
1161 }
1162 }
1163 }
1164 }
1165 items.sort();
1166 items.dedup();
1167 items
1168 }
1169}
1170
1171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1172enum AdminInputMode {
1173 Normal,
1174 EditingField,
1175 EditingRaw,
1176}
1177
1178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1179enum AdminFocus {
1180 Table,
1181 Detail,
1182 Status,
1183}
1184
1185#[derive(Debug, Clone)]
1186struct AdminFormField {
1187 key: &'static str,
1188 label: &'static str,
1189 value: String,
1190 placeholder: &'static str,
1191 required: bool,
1192 list_source: Option<ListSource>,
1193}
1194
1195#[derive(Debug, Clone, Copy)]
1196enum ListSource {
1197 KvKeys,
1198 SqlTables,
1199 SqlColumns,
1200 ColumnarSegments,
1201 ColumnarColumns,
1202}
1203
1204#[derive(Debug, Clone)]
1205struct ResourceEntry {
1206 label: String,
1207 kind: ResourceKind,
1208 depth: usize,
1209 selectable: bool,
1210}
1211
1212#[derive(Debug, Clone)]
1213enum ResourceKind {
1214 Section(ResourceSection),
1215 Table { name: String },
1216 Column { table: String, name: String },
1217 KvKey { key: String },
1218 ColumnarSegment { id: String },
1219 ColumnarColumn { segment_id: String, name: String },
1220 Info,
1221}
1222
1223struct ResourceTree {
1224 entries: Vec<ResourceEntry>,
1225 selected: usize,
1226 search: Option<String>,
1227 search_focused: bool,
1228 last_error: Option<String>,
1229 last_status: Option<String>,
1230}
1231
1232#[derive(Debug, Clone, Copy)]
1233enum ResourceSection {
1234 SqlTables,
1235 ColumnarSegments,
1236 KvKeys,
1237}
1238
1239impl ResourceSection {
1240 fn target(self) -> Option<AdminTarget> {
1241 match self {
1242 ResourceSection::SqlTables => Some(AdminTarget::Sql),
1243 ResourceSection::ColumnarSegments => Some(AdminTarget::Columnar),
1244 ResourceSection::KvKeys => Some(AdminTarget::Kv),
1245 }
1246 }
1247}
1248
1249fn target_for_resource(entry: &ResourceEntry) -> Option<AdminTarget> {
1250 match entry.kind {
1251 ResourceKind::Section(section) => section.target(),
1252 ResourceKind::Table { .. } | ResourceKind::Column { .. } => Some(AdminTarget::Sql),
1253 ResourceKind::KvKey { .. } => Some(AdminTarget::Kv),
1254 ResourceKind::ColumnarSegment { .. } | ResourceKind::ColumnarColumn { .. } => {
1255 Some(AdminTarget::Columnar)
1256 }
1257 ResourceKind::Info => None,
1258 }
1259}
1260
1261impl ResourceTree {
1262 fn new(backend: &AdminBackend<'_>) -> Self {
1263 let (entries, last_error, last_status) = match load_resource_entries(backend) {
1264 Ok((entries, status)) => (entries, None, status),
1265 Err(err) => (Vec::new(), Some(err.to_string()), None),
1266 };
1267 Self {
1268 entries,
1269 selected: 0,
1270 search: None,
1271 search_focused: false,
1272 last_error,
1273 last_status,
1274 }
1275 }
1276
1277 fn reload(&mut self, backend: &AdminBackend<'_>) {
1278 match load_resource_entries(backend) {
1279 Ok((entries, status)) => {
1280 self.entries = entries;
1281 self.selected = 0;
1282 self.last_error = None;
1283 self.last_status = status;
1284 }
1285 Err(err) => {
1286 self.entries.clear();
1287 self.selected = 0;
1288 self.last_error = Some(err.to_string());
1289 self.last_status = None;
1290 }
1291 }
1292 }
1293
1294 fn search_term(&self) -> Option<&str> {
1295 self.search
1296 .as_deref()
1297 .filter(|value| !value.trim().is_empty())
1298 }
1299
1300 fn filtered_indices(&self) -> Vec<usize> {
1301 let Some(term) = self.search_term() else {
1302 return (0..self.entries.len()).collect();
1303 };
1304 let term = term.to_lowercase();
1305 let mut include = vec![false; self.entries.len()];
1306 for (idx, entry) in self.entries.iter().enumerate() {
1307 if entry.label.to_lowercase().contains(&term) {
1308 include[idx] = true;
1309 let mut depth = entry.depth;
1310 if depth == 0 {
1311 continue;
1312 }
1313 for parent_idx in (0..idx).rev() {
1314 let parent = &self.entries[parent_idx];
1315 if parent.depth < depth {
1316 include[parent_idx] = true;
1317 depth = parent.depth;
1318 if depth == 0 {
1319 break;
1320 }
1321 }
1322 }
1323 }
1324 }
1325 include
1326 .iter()
1327 .enumerate()
1328 .filter_map(|(idx, keep)| if *keep { Some(idx) } else { None })
1329 .collect()
1330 }
1331
1332 fn filtered_entries(&self) -> Vec<ResourceEntry> {
1333 let indices = self.filtered_indices();
1334 indices
1335 .iter()
1336 .filter_map(|idx| self.entries.get(*idx))
1337 .cloned()
1338 .collect()
1339 }
1340
1341 fn selected_entry(&self) -> Option<ResourceEntry> {
1342 let indices = self.filtered_indices();
1343 let idx = indices.get(self.selected).copied()?;
1344 self.entries.get(idx).cloned()
1345 }
1346
1347 fn ensure_selection_in_range(&mut self) {
1348 let len = self.filtered_indices().len();
1349 if len == 0 {
1350 self.selected = 0;
1351 } else if self.selected >= len {
1352 self.selected = len - 1;
1353 }
1354 }
1355
1356 fn move_up(&mut self) {
1357 if self.selected > 0 {
1358 self.selected -= 1;
1359 }
1360 }
1361
1362 fn move_down(&mut self) {
1363 let len = self.filtered_indices().len();
1364 if self.selected + 1 < len {
1365 self.selected += 1;
1366 }
1367 }
1368
1369 fn move_top(&mut self) {
1370 self.selected = 0;
1371 }
1372
1373 fn move_bottom(&mut self) {
1374 let len = self.filtered_indices().len();
1375 if len > 0 {
1376 self.selected = len - 1;
1377 }
1378 }
1379
1380 fn page_down(&mut self) {
1381 let len = self.filtered_indices().len();
1382 if len == 0 {
1383 return;
1384 }
1385 self.selected = (self.selected + 5).min(len - 1);
1386 }
1387
1388 fn page_up(&mut self) {
1389 self.selected = self.selected.saturating_sub(5);
1390 }
1391
1392 fn push_search(&mut self, ch: char) {
1393 let search = self.search.get_or_insert_with(String::new);
1394 search.push(ch);
1395 self.ensure_selection_in_range();
1396 }
1397
1398 fn pop_search(&mut self) {
1399 if let Some(search) = self.search.as_mut() {
1400 if !search.is_empty() {
1401 search.pop();
1402 } else {
1403 self.reset_search();
1404 }
1405 }
1406 self.ensure_selection_in_range();
1407 }
1408
1409 fn reset_search(&mut self) {
1410 self.search = None;
1411 self.search_focused = false;
1412 self.selected = 0;
1413 }
1414}
1415
1416fn build_form_fields(target: AdminTarget, action: AdminAction) -> Vec<AdminFormField> {
1417 match (target, action) {
1418 (_, AdminAction::Backup) => vec![form_field(
1419 "handle",
1420 "Handle (status)",
1421 "",
1422 "backup-handle",
1423 false,
1424 )],
1425 (_, AdminAction::Restore) => vec![
1426 form_field("source", "Source", "", "s3://bucket/path", false),
1427 form_field("handle", "Handle (status)", "", "restore-handle", false),
1428 ],
1429 (AdminTarget::Sql, AdminAction::Read) => vec![
1430 form_field("query", "Query", "", "SELECT * FROM table", false),
1431 form_field_with_list(
1432 "table",
1433 "Table",
1434 "",
1435 "mytable",
1436 false,
1437 Some(ListSource::SqlTables),
1438 ),
1439 form_field_with_list(
1440 "columns",
1441 "Columns",
1442 "",
1443 "col1,col2",
1444 false,
1445 Some(ListSource::SqlColumns),
1446 ),
1447 ],
1448 (AdminTarget::Sql, _) => vec![form_field(
1449 "query",
1450 "Query",
1451 "",
1452 "SELECT * FROM table",
1453 true,
1454 )],
1455 (AdminTarget::Kv, AdminAction::Read) => vec![
1456 form_field_with_list("key", "Key", "", "mykey", false, Some(ListSource::KvKeys)),
1457 form_field("prefix", "Prefix", "", "app/", false),
1458 ],
1459 (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => vec![
1460 form_field_with_list("key", "Key", "", "mykey", true, Some(ListSource::KvKeys)),
1461 form_field("value", "Value", "", "hello", true),
1462 ],
1463 (AdminTarget::Kv, AdminAction::Delete) => vec![form_field_with_list(
1464 "key",
1465 "Key",
1466 "",
1467 "mykey",
1468 true,
1469 Some(ListSource::KvKeys),
1470 )],
1471 (AdminTarget::Vector, AdminAction::Read) => vec![
1472 form_field("index", "Index", "", "myindex", true),
1473 form_field("query", "Query", "", "[0.1, 0.2]", true),
1474 form_field("k", "Top K", "10", "10", false),
1475 ],
1476 (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => vec![
1477 form_field("index", "Index", "", "myindex", true),
1478 form_field("key", "Key", "", "item1", true),
1479 form_field("vector", "Vector", "", "[0.1, 0.2]", true),
1480 ],
1481 (AdminTarget::Vector, AdminAction::Delete) => vec![
1482 form_field("index", "Index", "", "myindex", true),
1483 form_field("key", "Key", "", "item1", true),
1484 ],
1485 (AdminTarget::Hnsw, AdminAction::Read) => {
1486 vec![form_field("name", "Index", "", "myindex", true)]
1487 }
1488 (AdminTarget::Hnsw, AdminAction::Create) => vec![
1489 form_field("name", "Index", "", "myindex", true),
1490 form_field("dim", "Dimensions", "", "128", true),
1491 form_field("metric", "Metric", "cosine", "cosine", false),
1492 ],
1493 (AdminTarget::Hnsw, AdminAction::Delete) => {
1494 vec![form_field("name", "Index", "", "myindex", true)]
1495 }
1496 (AdminTarget::Columnar, AdminAction::Read) => vec![
1497 form_field("mode", "Mode", "list", "list|scan|stats|index_list", true),
1498 form_field_with_list(
1499 "segment",
1500 "Segment",
1501 "",
1502 "segment_id",
1503 false,
1504 Some(ListSource::ColumnarSegments),
1505 ),
1506 ],
1507 (AdminTarget::Columnar, AdminAction::Create) => vec![
1508 form_field("file", "File", "", "data.csv", false),
1509 form_field_with_list(
1510 "table",
1511 "Table",
1512 "",
1513 "mytable",
1514 false,
1515 Some(ListSource::SqlTables),
1516 ),
1517 form_field_with_list(
1518 "segment",
1519 "Segment",
1520 "",
1521 "segment_id",
1522 false,
1523 Some(ListSource::ColumnarSegments),
1524 ),
1525 form_field_with_list(
1526 "column",
1527 "Column",
1528 "",
1529 "column_name",
1530 false,
1531 Some(ListSource::ColumnarColumns),
1532 ),
1533 form_field("index_type", "Index Type", "", "minmax", false),
1534 ],
1535 (AdminTarget::Columnar, AdminAction::Delete) => vec![
1536 form_field_with_list(
1537 "segment",
1538 "Segment",
1539 "",
1540 "segment_id",
1541 true,
1542 Some(ListSource::ColumnarSegments),
1543 ),
1544 form_field_with_list(
1545 "column",
1546 "Column",
1547 "",
1548 "column_name",
1549 true,
1550 Some(ListSource::ColumnarColumns),
1551 ),
1552 ],
1553 _ => Vec::new(),
1554 }
1555}
1556
1557fn form_field(
1558 key: &'static str,
1559 label: &'static str,
1560 value: &str,
1561 placeholder: &'static str,
1562 required: bool,
1563) -> AdminFormField {
1564 form_field_with_list(key, label, value, placeholder, required, None)
1565}
1566
1567fn form_field_with_list(
1568 key: &'static str,
1569 label: &'static str,
1570 value: &str,
1571 placeholder: &'static str,
1572 required: bool,
1573 list_source: Option<ListSource>,
1574) -> AdminFormField {
1575 AdminFormField {
1576 key,
1577 label,
1578 value: value.to_string(),
1579 placeholder,
1580 required,
1581 list_source,
1582 }
1583}
1584
1585fn load_list_options(
1586 backend: &AdminBackend<'_>,
1587 fields: &[AdminFormField],
1588 source: ListSource,
1589) -> Result<Vec<String>> {
1590 match source {
1591 ListSource::KvKeys => {
1592 let prefix = field_value(fields, "prefix");
1593 let command = AdminCommand::Kv(KvCommand::List { prefix });
1594 let capture = capture_admin_command(backend, command, Some(50))?;
1595 Ok(extract_column_values(
1596 &capture.columns,
1597 &capture.rows,
1598 "key",
1599 ))
1600 }
1601 ListSource::ColumnarSegments => {
1602 let command = AdminCommand::Columnar(ColumnarCommand::List);
1603 let capture = capture_admin_command(backend, command, Some(50))?;
1604 Ok(extract_column_values(
1605 &capture.columns,
1606 &capture.rows,
1607 "segment_id",
1608 ))
1609 }
1610 ListSource::SqlTables => {
1611 let Some(db) = backend.local_db() else {
1612 return Err(CliError::InvalidArgument(
1613 "Table listing is only available for local admin sessions.".to_string(),
1614 ));
1615 };
1616 let mut tables = db
1617 .list_tables_simple()?
1618 .into_iter()
1619 .map(|table| table.name)
1620 .collect::<Vec<_>>();
1621 tables.sort();
1622 tables.dedup();
1623 Ok(tables)
1624 }
1625 ListSource::SqlColumns => {
1626 let table = field_value(fields, "table")
1627 .ok_or_else(|| CliError::InvalidArgument("Select a table first.".to_string()))?;
1628 let Some(db) = backend.local_db() else {
1629 return Err(CliError::InvalidArgument(
1630 "Column listing is only available for local admin sessions.".to_string(),
1631 ));
1632 };
1633 let mut columns = db
1634 .get_table_info_simple(&table)?
1635 .columns
1636 .into_iter()
1637 .map(|column| column.name)
1638 .collect::<Vec<_>>();
1639 columns.sort();
1640 columns.dedup();
1641 Ok(columns)
1642 }
1643 ListSource::ColumnarColumns => {
1644 let segment = field_value(fields, "segment")
1645 .ok_or_else(|| CliError::InvalidArgument("Select a segment first.".to_string()))?;
1646 let Some(db) = backend.local_db() else {
1647 return Err(CliError::InvalidArgument(
1648 "Column listing is only available for local admin sessions.".to_string(),
1649 ));
1650 };
1651 let mut columns = list_columnar_columns_from_segment(db, &segment)?;
1652 columns.sort();
1653 columns.dedup();
1654 Ok(columns)
1655 }
1656 }
1657}
1658
1659fn field_value(fields: &[AdminFormField], key: &str) -> Option<String> {
1660 fields
1661 .iter()
1662 .find(|field| field.key.eq_ignore_ascii_case(key))
1663 .map(|field| field.value.trim().to_string())
1664 .filter(|value| !value.is_empty())
1665}
1666
1667fn capture_admin_command(
1668 backend: &AdminBackend<'_>,
1669 command: AdminCommand,
1670 limit: Option<usize>,
1671) -> Result<CaptureState> {
1672 let request = AdminRequest {
1673 action: AdminAction::Read,
1674 command,
1675 limit,
1676 quiet: true,
1677 ui_mode: UiMode::Batch,
1678 connection_label: String::new(),
1679 output: backend.output_format(),
1680 data_dir: backend.data_dir().map(PathBuf::from),
1681 };
1682 let (formatter, state) = CaptureFormatter::new();
1683 let mut sink = io::sink();
1684 let result = match backend {
1685 AdminBackend::Local { db, batch_mode, .. } => {
1686 execute_local_action(db, batch_mode, request, &mut sink, Box::new(formatter))
1687 }
1688 AdminBackend::Remote {
1689 client, batch_mode, ..
1690 } => {
1691 let runtime = Runtime::new().map_err(|err| {
1692 CliError::InvalidArgument(format!("Failed to start async runtime: {err}"))
1693 })?;
1694 runtime.block_on(execute_remote_action(
1695 client,
1696 batch_mode,
1697 request,
1698 &mut sink,
1699 Box::new(formatter),
1700 ))
1701 }
1702 };
1703 result?;
1704 let capture = state.lock().expect("admin capture lock").clone();
1705 Ok(capture)
1706}
1707
1708fn extract_column_values(columns: &[Column], rows: &[Row], column_name: &str) -> Vec<String> {
1709 let index = columns
1710 .iter()
1711 .position(|column| column.name.eq_ignore_ascii_case(column_name))
1712 .unwrap_or(0);
1713 rows.iter()
1714 .filter_map(|row| row.columns.get(index))
1715 .map(value_to_string)
1716 .collect()
1717}
1718
1719fn list_columnar_columns_from_segment(
1720 db: &alopex_embedded::Database,
1721 segment_id: &str,
1722) -> Result<Vec<String>> {
1723 let (table_id, segment_id) = parse_segment_id(segment_id).ok_or_else(|| {
1724 CliError::InvalidArgument(
1725 "Invalid segment id. Expected format: table_id:segment_id.".to_string(),
1726 )
1727 })?;
1728 let tables = db.list_tables_simple()?;
1729 let table = tables
1730 .into_iter()
1731 .find(|table| table.table_id == table_id)
1732 .ok_or_else(|| {
1733 CliError::InvalidArgument("Unable to resolve table for segment.".to_string())
1734 })?;
1735 let batches = db.read_columnar_segment(&table.name, segment_id, None)?;
1736 let batch = batches
1737 .first()
1738 .ok_or_else(|| CliError::InvalidArgument("Columnar segment is empty.".to_string()))?;
1739 Ok(batch
1740 .schema
1741 .columns
1742 .iter()
1743 .map(|column| column.name.clone())
1744 .collect())
1745}
1746
1747fn parse_segment_id(segment_id: &str) -> Option<(u32, u64)> {
1748 let (table_id, segment_id) = segment_id.split_once(':')?;
1749 let table_id = table_id.parse::<u32>().ok()?;
1750 let segment_id = segment_id.parse::<u64>().ok()?;
1751 Some((table_id, segment_id))
1752}
1753
1754const RESOURCE_LIMIT: usize = 50;
1755const COLUMNAR_COLUMN_LIMIT: usize = 20;
1756
1757fn block_on_with_runtime<F, T>(future: F) -> Result<T>
1758where
1759 F: std::future::Future<Output = T>,
1760{
1761 match Handle::try_current() {
1762 Ok(handle) => Ok(tokio::task::block_in_place(|| handle.block_on(future))),
1763 Err(_) => {
1764 let runtime = Runtime::new().map_err(|err| {
1765 CliError::InvalidArgument(format!("Failed to start async runtime: {err}"))
1766 })?;
1767 Ok(runtime.block_on(future))
1768 }
1769 }
1770}
1771
1772fn load_resource_entries(
1773 backend: &AdminBackend<'_>,
1774) -> Result<(Vec<ResourceEntry>, Option<String>)> {
1775 match backend {
1776 AdminBackend::Remote { client, .. } => load_remote_resources(client),
1777 _ => {
1778 let mut entries = Vec::new();
1779 entries.extend(load_sql_resources(backend)?);
1780 entries.extend(load_columnar_resources(backend)?);
1781 entries.extend(load_kv_resources(backend)?);
1782 Ok((entries, None))
1783 }
1784 }
1785}
1786
1787fn load_remote_resources(client: &HttpClient) -> Result<(Vec<ResourceEntry>, Option<String>)> {
1788 let request = AdminResourcesRequest {
1789 limit: Some(RESOURCE_LIMIT),
1790 include_columnar_columns: Some(true),
1791 columnar_column_limit: Some(COLUMNAR_COLUMN_LIMIT),
1792 kv_prefix: None,
1793 };
1794 let response = block_on_with_runtime(fetch_admin_resources(client, &request))?;
1795 match response {
1796 Ok(response) => {
1797 let status = truncated_status(&response.truncated);
1798 Ok((build_remote_entries(response), status))
1799 }
1800 Err(ClientError::HttpStatus { status, .. })
1801 if status == reqwest::StatusCode::FORBIDDEN
1802 || status == reqwest::StatusCode::UNAUTHORIZED =>
1803 {
1804 Ok((remote_listing_denied_entries(), None))
1805 }
1806 Err(err) => Err(map_client_error(err)),
1807 }
1808}
1809
1810fn truncated_status(
1811 truncated: &crate::client::admin_resources::TruncatedSections,
1812) -> Option<String> {
1813 let mut sections = Vec::new();
1814 if truncated.sql_tables {
1815 sections.push("SQL tables");
1816 }
1817 if truncated.columnar_segments {
1818 sections.push("columnar segments");
1819 }
1820 if truncated.kv_keys {
1821 sections.push("KV keys");
1822 }
1823 if sections.is_empty() {
1824 None
1825 } else {
1826 Some(format!(
1827 "Resources truncated (limit {RESOURCE_LIMIT}): {}.",
1828 sections.join(", ")
1829 ))
1830 }
1831}
1832
1833fn build_remote_entries(
1834 response: crate::client::admin_resources::AdminResourcesResponse,
1835) -> Vec<ResourceEntry> {
1836 let mut entries = Vec::new();
1837
1838 entries.push(ResourceEntry {
1839 label: "SQL Tables".to_string(),
1840 kind: ResourceKind::Section(ResourceSection::SqlTables),
1841 depth: 0,
1842 selectable: false,
1843 });
1844 for table in response.sql_tables {
1845 let table_name = table.name.clone();
1846 entries.push(ResourceEntry {
1847 label: table_name.clone(),
1848 kind: ResourceKind::Table {
1849 name: table_name.clone(),
1850 },
1851 depth: 1,
1852 selectable: true,
1853 });
1854 for column in table.columns {
1855 entries.push(ResourceEntry {
1856 label: column.name.clone(),
1857 kind: ResourceKind::Column {
1858 table: table_name.clone(),
1859 name: column.name,
1860 },
1861 depth: 2,
1862 selectable: true,
1863 });
1864 }
1865 }
1866 if response.truncated.sql_tables {
1867 let label = format!("Truncated: showing first {RESOURCE_LIMIT} tables.");
1868 entries.push(truncated_entry(&label, 1));
1869 }
1870
1871 entries.push(ResourceEntry {
1872 label: "Columnar Segments".to_string(),
1873 kind: ResourceKind::Section(ResourceSection::ColumnarSegments),
1874 depth: 0,
1875 selectable: false,
1876 });
1877 for segment in response.columnar_segments {
1878 let segment_id = segment.id.clone();
1879 entries.push(ResourceEntry {
1880 label: segment_id.clone(),
1881 kind: ResourceKind::ColumnarSegment { id: segment_id },
1882 depth: 1,
1883 selectable: true,
1884 });
1885 if let Some(columns) = segment.columns {
1886 for column in columns {
1887 entries.push(ResourceEntry {
1888 label: column.clone(),
1889 kind: ResourceKind::ColumnarColumn {
1890 segment_id: segment.id.clone(),
1891 name: column,
1892 },
1893 depth: 2,
1894 selectable: true,
1895 });
1896 }
1897 }
1898 }
1899 if response.truncated.columnar_segments {
1900 let label = format!("Truncated: showing first {RESOURCE_LIMIT} segments.");
1901 entries.push(truncated_entry(&label, 1));
1902 }
1903
1904 entries.push(ResourceEntry {
1905 label: "KV Keys".to_string(),
1906 kind: ResourceKind::Section(ResourceSection::KvKeys),
1907 depth: 0,
1908 selectable: false,
1909 });
1910 for key in response.kv_keys {
1911 entries.push(ResourceEntry {
1912 label: key.clone(),
1913 kind: ResourceKind::KvKey { key },
1914 depth: 1,
1915 selectable: true,
1916 });
1917 }
1918 if response.truncated.kv_keys {
1919 let label = format!("Truncated: showing first {RESOURCE_LIMIT} keys.");
1920 entries.push(truncated_entry(&label, 1));
1921 }
1922
1923 entries
1924}
1925
1926fn remote_listing_denied_entries() -> Vec<ResourceEntry> {
1927 let mut entries = Vec::new();
1928 for section in [
1929 ResourceSection::SqlTables,
1930 ResourceSection::ColumnarSegments,
1931 ResourceSection::KvKeys,
1932 ] {
1933 let label = match section {
1934 ResourceSection::SqlTables => "SQL Tables",
1935 ResourceSection::ColumnarSegments => "Columnar Segments",
1936 ResourceSection::KvKeys => "KV Keys",
1937 };
1938 entries.push(ResourceEntry {
1939 label: label.to_string(),
1940 kind: ResourceKind::Section(section),
1941 depth: 0,
1942 selectable: false,
1943 });
1944 entries.push(truncated_entry("Remote listing denied.", 1));
1945 }
1946 entries
1947}
1948
1949fn truncated_entry(label: &str, depth: usize) -> ResourceEntry {
1950 ResourceEntry {
1951 label: label.to_string(),
1952 kind: ResourceKind::Info,
1953 depth,
1954 selectable: false,
1955 }
1956}
1957
1958fn map_client_error(err: ClientError) -> CliError {
1959 match err {
1960 ClientError::Request { source, .. } => {
1961 CliError::ServerConnection(format!("request failed: {source}"))
1962 }
1963 ClientError::InvalidUrl(message) => CliError::InvalidArgument(message),
1964 ClientError::Build(message) => CliError::InvalidArgument(message),
1965 ClientError::Auth(err) => CliError::InvalidArgument(err.to_string()),
1966 ClientError::HttpStatus { status, body } => {
1967 CliError::ServerConnection(format!("server error {status}: {body}"))
1968 }
1969 }
1970}
1971
1972fn load_sql_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
1973 let mut entries = Vec::new();
1974 entries.push(ResourceEntry {
1975 label: "SQL Tables".to_string(),
1976 kind: ResourceKind::Section(ResourceSection::SqlTables),
1977 depth: 0,
1978 selectable: false,
1979 });
1980 let Some(db) = backend.local_db() else {
1981 entries.push(ResourceEntry {
1982 label: "Remote listing unavailable".to_string(),
1983 kind: ResourceKind::Info,
1984 depth: 1,
1985 selectable: false,
1986 });
1987 return Ok(entries);
1988 };
1989 let mut tables = match db.list_tables_simple() {
1990 Ok(tables) => tables,
1991 Err(alopex_embedded::Error::CatalogNotFound(_))
1992 | Err(alopex_embedded::Error::NamespaceNotFound(_, _)) => {
1993 let _ = db.create_catalog(CreateCatalogRequest::new("default"));
1994 let _ = db.create_namespace(CreateNamespaceRequest::new("default", "default"));
1995 match db.list_tables_simple() {
1996 Ok(tables) => tables,
1997 Err(err) => {
1998 entries.push(ResourceEntry {
1999 label: format!("SQL catalog unavailable: {err}"),
2000 kind: ResourceKind::Info,
2001 depth: 1,
2002 selectable: false,
2003 });
2004 return Ok(entries);
2005 }
2006 }
2007 }
2008 Err(err) => return Err(err.into()),
2009 };
2010 tables.sort_by(|a, b| a.name.cmp(&b.name));
2011 for table in tables.into_iter().take(RESOURCE_LIMIT) {
2012 entries.push(ResourceEntry {
2013 label: table.name.clone(),
2014 kind: ResourceKind::Table {
2015 name: table.name.clone(),
2016 },
2017 depth: 1,
2018 selectable: true,
2019 });
2020 for column in table.columns {
2021 entries.push(ResourceEntry {
2022 label: column.name.clone(),
2023 kind: ResourceKind::Column {
2024 table: table.name.clone(),
2025 name: column.name,
2026 },
2027 depth: 2,
2028 selectable: true,
2029 });
2030 }
2031 }
2032 Ok(entries)
2033}
2034
2035fn load_columnar_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
2036 let mut entries = Vec::new();
2037 entries.push(ResourceEntry {
2038 label: "Columnar Segments".to_string(),
2039 kind: ResourceKind::Section(ResourceSection::ColumnarSegments),
2040 depth: 0,
2041 selectable: false,
2042 });
2043 let Some(db) = backend.local_db() else {
2044 entries.push(ResourceEntry {
2045 label: "Remote listing unavailable".to_string(),
2046 kind: ResourceKind::Info,
2047 depth: 1,
2048 selectable: false,
2049 });
2050 return Ok(entries);
2051 };
2052 let mut segments = db.list_columnar_segments()?;
2053 segments.sort();
2054 let mut expanded = 0;
2055 for segment in segments.into_iter().take(RESOURCE_LIMIT) {
2056 entries.push(ResourceEntry {
2057 label: segment.clone(),
2058 kind: ResourceKind::ColumnarSegment {
2059 id: segment.clone(),
2060 },
2061 depth: 1,
2062 selectable: true,
2063 });
2064 if expanded < COLUMNAR_COLUMN_LIMIT {
2065 if let Ok(columns) = list_columnar_columns_from_segment(db, &segment) {
2066 for column in columns {
2067 entries.push(ResourceEntry {
2068 label: column.clone(),
2069 kind: ResourceKind::ColumnarColumn {
2070 segment_id: segment.clone(),
2071 name: column,
2072 },
2073 depth: 2,
2074 selectable: true,
2075 });
2076 }
2077 }
2078 expanded += 1;
2079 }
2080 }
2081 Ok(entries)
2082}
2083
2084fn load_kv_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
2085 let mut entries = Vec::new();
2086 entries.push(ResourceEntry {
2087 label: "KV Keys".to_string(),
2088 kind: ResourceKind::Section(ResourceSection::KvKeys),
2089 depth: 0,
2090 selectable: false,
2091 });
2092 let system_prefixes = [
2093 "__catalog__/",
2094 "hnsw:",
2095 "__alopex_",
2096 "__alopex:",
2097 "vector:",
2098 "columnar:",
2099 ];
2100 let command = AdminCommand::Kv(KvCommand::List { prefix: None });
2101 let capture = capture_admin_command(backend, command, Some(RESOURCE_LIMIT))?;
2102 let keys = extract_column_values(&capture.columns, &capture.rows, "key");
2103 for key in keys.into_iter().filter(|key| {
2104 !system_prefixes.iter().any(|prefix| key.starts_with(prefix))
2105 && !key.trim().is_empty()
2106 && !key.chars().any(|ch| ch.is_control())
2107 }) {
2108 entries.push(ResourceEntry {
2109 label: key.clone(),
2110 kind: ResourceKind::KvKey { key },
2111 depth: 1,
2112 selectable: true,
2113 });
2114 }
2115 Ok(entries)
2116}
2117
2118fn build_params_from_fields(
2119 fields: &[AdminFormField],
2120) -> std::collections::HashMap<String, String> {
2121 let mut params = std::collections::HashMap::new();
2122 for field in fields {
2123 if !field.value.trim().is_empty() {
2124 params.insert(field.key.to_lowercase(), field.value.trim().to_string());
2125 }
2126 }
2127 params
2128}
2129
2130fn validate_params(
2131 action: AdminAction,
2132 target: AdminTarget,
2133 params: &std::collections::HashMap<String, String>,
2134) -> std::result::Result<(), String> {
2135 if matches!(
2136 action,
2137 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export
2138 ) {
2139 return Ok(());
2140 }
2141 match (target, action) {
2142 (AdminTarget::Sql, AdminAction::Read) => {
2143 if params.contains_key("query") {
2144 return Ok(());
2145 }
2146 if let Some(columns) = params.get("columns") {
2147 if !params.contains_key("table") {
2148 return Err("Provide table to use columns.".to_string());
2149 }
2150 if columns.trim().is_empty() {
2151 return Err("Columns cannot be empty when provided.".to_string());
2152 }
2153 }
2154 if params.contains_key("table") {
2155 Ok(())
2156 } else {
2157 Err("Provide query or table.".to_string())
2158 }
2159 }
2160 (AdminTarget::Kv, AdminAction::Read) => {
2161 if params.contains_key("key") || params.contains_key("prefix") {
2162 Ok(())
2163 } else {
2164 Err("Provide either key or prefix.".to_string())
2165 }
2166 }
2167 (AdminTarget::Columnar, AdminAction::Read) => {
2168 let mode = params.get("mode").map(|v| v.as_str()).unwrap_or("list");
2169 if matches!(mode, "scan" | "stats" | "index_list") && !params.contains_key("segment") {
2170 Err("Provide segment for scan/stats/index_list.".to_string())
2171 } else {
2172 Ok(())
2173 }
2174 }
2175 (AdminTarget::Columnar, AdminAction::Create) => {
2176 let has_ingest = params.contains_key("file") && params.contains_key("table");
2177 let has_index = params.contains_key("segment")
2178 && params.contains_key("column")
2179 && params.contains_key("index_type");
2180 if has_ingest || has_index {
2181 Ok(())
2182 } else {
2183 Err("Provide file+table or segment+column+index_type.".to_string())
2184 }
2185 }
2186 _ => {
2187 let missing = required_keys_for(target, action)
2188 .into_iter()
2189 .filter(|key| !params.contains_key(*key))
2190 .collect::<Vec<_>>();
2191 if missing.is_empty() {
2192 Ok(())
2193 } else {
2194 Err(format!("Missing: {}", missing.join(", ")))
2195 }
2196 }
2197 }
2198}
2199
2200fn required_keys_for(target: AdminTarget, action: AdminAction) -> Vec<&'static str> {
2201 match (target, action) {
2202 (AdminTarget::Sql, AdminAction::Read) => Vec::new(),
2203 (AdminTarget::Sql, _) => vec!["query"],
2204 (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => vec!["key", "value"],
2205 (AdminTarget::Kv, AdminAction::Delete) => vec!["key"],
2206 (AdminTarget::Vector, AdminAction::Read) => vec!["index", "query"],
2207 (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => {
2208 vec!["index", "key", "vector"]
2209 }
2210 (AdminTarget::Vector, AdminAction::Delete) => vec!["index", "key"],
2211 (AdminTarget::Hnsw, AdminAction::Read) => vec!["name"],
2212 (AdminTarget::Hnsw, AdminAction::Create) => vec!["name", "dim"],
2213 (AdminTarget::Hnsw, AdminAction::Delete) => vec!["name"],
2214 (AdminTarget::Columnar, AdminAction::Delete) => vec!["segment", "column"],
2215 _ => Vec::new(),
2216 }
2217}
2218
2219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2220pub enum AdminTarget {
2221 Sql,
2222 Kv,
2223 Vector,
2224 Hnsw,
2225 Columnar,
2226}
2227
2228impl AdminTarget {
2229 fn label(self) -> &'static str {
2230 match self {
2231 AdminTarget::Sql => "SQL",
2232 AdminTarget::Kv => "KV",
2233 AdminTarget::Vector => "Vector",
2234 AdminTarget::Hnsw => "HNSW",
2235 AdminTarget::Columnar => "Columnar",
2236 }
2237 }
2238
2239 fn example_for(self, action: AdminAction) -> Option<&'static str> {
2240 match (self, action) {
2241 (AdminTarget::Sql, _) => Some("query=\"SELECT * FROM table\""),
2242 (AdminTarget::Kv, AdminAction::Read) => Some("key=mykey OR prefix=app/"),
2243 (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => {
2244 Some("key=mykey value=hello")
2245 }
2246 (AdminTarget::Kv, AdminAction::Delete) => Some("key=mykey"),
2247 (AdminTarget::Vector, AdminAction::Read) => {
2248 Some("index=myindex query=\"[0.1, 0.2]\" k=10")
2249 }
2250 (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => {
2251 Some("index=myindex key=item1 vector=\"[0.1, 0.2]\"")
2252 }
2253 (AdminTarget::Vector, AdminAction::Delete) => Some("index=myindex key=item1"),
2254 (AdminTarget::Hnsw, AdminAction::Read) => Some("name=myindex"),
2255 (AdminTarget::Hnsw, AdminAction::Create) => Some("name=myindex dim=128 metric=cosine"),
2256 (AdminTarget::Hnsw, AdminAction::Delete) => Some("name=myindex"),
2257 (AdminTarget::Columnar, AdminAction::Read) => Some("mode=list"),
2258 (AdminTarget::Columnar, AdminAction::Create) => Some("file=data.csv table=mytable"),
2259 (AdminTarget::Columnar, AdminAction::Delete) => Some("segment=seg1 column=col1"),
2260 _ => None,
2261 }
2262 }
2263}
2264
2265pub enum AdminBackend<'a> {
2266 Local {
2267 db: &'a alopex_embedded::Database,
2268 batch_mode: &'a BatchMode,
2269 output_format: OutputFormat,
2270 limit: Option<usize>,
2271 quiet: bool,
2272 data_dir: Option<PathBuf>,
2273 },
2274 Remote {
2275 client: &'a HttpClient,
2276 batch_mode: &'a BatchMode,
2277 output_format: OutputFormat,
2278 limit: Option<usize>,
2279 quiet: bool,
2280 data_dir: Option<PathBuf>,
2281 },
2282}
2283
2284impl AdminBackend<'_> {
2285 fn local_db(&self) -> Option<&alopex_embedded::Database> {
2286 match self {
2287 AdminBackend::Local { db, .. } => Some(*db),
2288 AdminBackend::Remote { .. } => None,
2289 }
2290 }
2291
2292 fn output_format(&self) -> OutputFormat {
2293 match self {
2294 AdminBackend::Local { output_format, .. } => *output_format,
2295 AdminBackend::Remote { output_format, .. } => *output_format,
2296 }
2297 }
2298
2299 fn data_dir(&self) -> Option<&Path> {
2300 match self {
2301 AdminBackend::Local { data_dir, .. } => data_dir.as_deref(),
2302 AdminBackend::Remote { data_dir, .. } => data_dir.as_deref(),
2303 }
2304 }
2305
2306 fn limit(&self) -> Option<usize> {
2307 match self {
2308 AdminBackend::Local { limit, .. } => *limit,
2309 AdminBackend::Remote { limit, .. } => *limit,
2310 }
2311 }
2312
2313 fn quiet(&self) -> bool {
2314 match self {
2315 AdminBackend::Local { quiet, .. } => *quiet,
2316 AdminBackend::Remote { quiet, .. } => *quiet,
2317 }
2318 }
2319}
2320
2321pub struct AdminContext<'a> {
2322 pub connection_label: String,
2323 pub auth: AuthCapabilities,
2324 pub backend: AdminBackend<'a>,
2325 pub initial_target: Option<AdminTarget>,
2326}
2327
2328pub fn run_admin_ui(context: AdminContext<'_>) -> Result<()> {
2329 if !is_tty() {
2330 let mut writer = io::stdout().lock();
2331 return write_non_tty_fallback(&mut writer, context.backend.output_format());
2332 }
2333
2334 let app = AdminApp::new(
2335 context.connection_label,
2336 context.auth,
2337 context.backend,
2338 context.initial_target,
2339 );
2340 app.run()
2341}
2342
2343pub fn write_non_tty_fallback<W: Write>(writer: &mut W, output_format: OutputFormat) -> Result<()> {
2344 let mut formatter = create_formatter(output_format);
2345 let columns = vec![
2346 Column::new("Status", DataType::Text),
2347 Column::new("Message", DataType::Text),
2348 ];
2349 let rows = vec![Row::new(vec![
2350 Value::Text("Error".to_string()),
2351 Value::Text("Admin UI is unavailable without a TTY.".to_string()),
2352 ])];
2353 formatter.write_header(writer, &columns)?;
2354 for row in &rows {
2355 formatter.write_row(writer, row)?;
2356 }
2357 formatter.write_footer(writer)
2358}
2359
2360fn default_items() -> Vec<AdminItem> {
2361 vec![
2362 AdminItem {
2363 action: AdminAction::Read,
2364 title: "Read / List",
2365 description: "Browse or query data across databases, tables, and indexes.",
2366 enabled: true,
2367 },
2368 AdminItem {
2369 action: AdminAction::Create,
2370 title: "Create",
2371 description: "Create new databases, tables, indexes, or data objects.",
2372 enabled: true,
2373 },
2374 AdminItem {
2375 action: AdminAction::Update,
2376 title: "Update",
2377 description: "Modify existing records, schemas, or index settings.",
2378 enabled: true,
2379 },
2380 AdminItem {
2381 action: AdminAction::Delete,
2382 title: "Delete",
2383 description: "Remove records, tables, indexes, or data sets.",
2384 enabled: true,
2385 },
2386 AdminItem {
2387 action: AdminAction::Archive,
2388 title: "Archive",
2389 description: "Move data into an archived state for long-term retention.",
2390 enabled: true,
2391 },
2392 AdminItem {
2393 action: AdminAction::Restore,
2394 title: "Restore",
2395 description: "Restore archived data into an active state.",
2396 enabled: true,
2397 },
2398 AdminItem {
2399 action: AdminAction::Backup,
2400 title: "Backup",
2401 description: "Create snapshots or backups of data and metadata.",
2402 enabled: true,
2403 },
2404 AdminItem {
2405 action: AdminAction::Export,
2406 title: "Export",
2407 description: "Export data for external systems or offline analysis.",
2408 enabled: true,
2409 },
2410 ]
2411}
2412
2413fn is_not_implemented(action: AdminAction) -> bool {
2414 let _ = action;
2415 false
2416}
2417
2418fn parse_params(input: &str) -> std::collections::HashMap<String, String> {
2419 let mut params = std::collections::HashMap::new();
2420 let mut token = String::new();
2421 let mut in_quotes: Option<char> = None;
2422 for ch in input.chars() {
2423 if let Some(quote) = in_quotes {
2424 if ch == quote {
2425 in_quotes = None;
2426 } else {
2427 token.push(ch);
2428 }
2429 continue;
2430 }
2431 match ch {
2432 '"' | '\'' => {
2433 in_quotes = Some(ch);
2434 }
2435 ' ' | '\t' | '\n' | '\r' => {
2436 push_param_token(&mut params, &token);
2437 token.clear();
2438 }
2439 _ => token.push(ch),
2440 }
2441 }
2442 push_param_token(&mut params, &token);
2443 params
2444}
2445
2446fn push_param_token(params: &mut std::collections::HashMap<String, String>, token: &str) {
2447 if let Some((key, value)) = token.split_once('=') {
2448 if !key.is_empty() && !value.is_empty() {
2449 params.insert(key.to_lowercase(), value.to_string());
2450 }
2451 }
2452}
2453
2454fn build_command_for(
2455 action: AdminAction,
2456 target: AdminTarget,
2457 params: &std::collections::HashMap<String, String>,
2458) -> Result<Option<AdminCommand>> {
2459 if matches!(
2460 action,
2461 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export
2462 ) {
2463 let handle = params
2464 .get("handle")
2465 .map(|value| value.trim())
2466 .filter(|value| !value.is_empty())
2467 .map(|value| value.to_string());
2468 let source = params
2469 .get("source")
2470 .map(|value| value.trim())
2471 .filter(|value| !value.is_empty())
2472 .map(|value| value.to_string());
2473
2474 let command = match action {
2475 AdminAction::Archive => LifecycleCommand::Archive,
2476 AdminAction::Restore => {
2477 if let Some(handle) = handle {
2478 LifecycleCommand::Restore {
2479 source: None,
2480 command: Some(LifecycleRestoreCommand::Status { handle }),
2481 }
2482 } else {
2483 LifecycleCommand::Restore {
2484 source,
2485 command: None,
2486 }
2487 }
2488 }
2489 AdminAction::Backup => {
2490 if let Some(handle) = handle {
2491 LifecycleCommand::Backup {
2492 command: Some(LifecycleBackupCommand::Status { handle }),
2493 }
2494 } else {
2495 LifecycleCommand::Backup { command: None }
2496 }
2497 }
2498 AdminAction::Export => LifecycleCommand::Export,
2499 _ => return Ok(None),
2500 };
2501 return Ok(Some(AdminCommand::Lifecycle(command)));
2502 }
2503 match target {
2504 AdminTarget::Sql => build_sql_command(action, params),
2505 AdminTarget::Kv => build_kv_command(action, params),
2506 AdminTarget::Vector => build_vector_command(action, params),
2507 AdminTarget::Hnsw => build_hnsw_command(action, params),
2508 AdminTarget::Columnar => build_columnar_command(action, params),
2509 }
2510}
2511
2512fn build_sql_command(
2513 action: AdminAction,
2514 params: &std::collections::HashMap<String, String>,
2515) -> Result<Option<AdminCommand>> {
2516 let query = if let Some(query) = params.get("query").cloned() {
2517 Some(query)
2518 } else if action == AdminAction::Read {
2519 let table = params.get("table").cloned();
2520 let columns = params.get("columns").cloned();
2521 match table {
2522 Some(table) => {
2523 let columns = columns
2524 .filter(|value| !value.trim().is_empty())
2525 .unwrap_or_else(|| "*".to_string());
2526 Some(format!("SELECT {} FROM {}", columns, table))
2527 }
2528 None => None,
2529 }
2530 } else {
2531 None
2532 };
2533 if query.is_none() {
2534 return Ok(None);
2535 }
2536 Ok(Some(AdminCommand::Sql(SqlCommand {
2537 query,
2538 file: None,
2539 fetch_size: None,
2540 max_rows: None,
2541 deadline: None,
2542 tui: false,
2543 })))
2544}
2545
2546fn build_kv_command(
2547 action: AdminAction,
2548 params: &std::collections::HashMap<String, String>,
2549) -> Result<Option<AdminCommand>> {
2550 match action {
2551 AdminAction::Read => {
2552 if let Some(key) = params.get("key") {
2553 return Ok(Some(AdminCommand::Kv(KvCommand::Get { key: key.clone() })));
2554 }
2555 let prefix = params.get("prefix").cloned();
2556 Ok(Some(AdminCommand::Kv(KvCommand::List { prefix })))
2557 }
2558 AdminAction::Create | AdminAction::Update => {
2559 let key = params.get("key").cloned();
2560 let value = params.get("value").cloned();
2561 match (key, value) {
2562 (Some(key), Some(value)) => {
2563 Ok(Some(AdminCommand::Kv(KvCommand::Put { key, value })))
2564 }
2565 _ => Ok(None),
2566 }
2567 }
2568 AdminAction::Delete => {
2569 let key = params.get("key").cloned();
2570 match key {
2571 Some(key) => Ok(Some(AdminCommand::Kv(KvCommand::Delete { key }))),
2572 None => Ok(None),
2573 }
2574 }
2575 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2576 Ok(None)
2577 }
2578 }
2579}
2580
2581fn build_vector_command(
2582 action: AdminAction,
2583 params: &std::collections::HashMap<String, String>,
2584) -> Result<Option<AdminCommand>> {
2585 let index = params.get("index").cloned();
2586 match action {
2587 AdminAction::Read => {
2588 let query = params.get("query").cloned();
2589 let index = match index {
2590 Some(index) => index,
2591 None => return Ok(None),
2592 };
2593 let query = match query {
2594 Some(query) => query,
2595 None => return Ok(None),
2596 };
2597 let k = params
2598 .get("k")
2599 .and_then(|value| value.parse::<usize>().ok())
2600 .unwrap_or(10);
2601 Ok(Some(AdminCommand::Vector(VectorCommand::Search {
2602 index,
2603 query,
2604 k,
2605 progress: false,
2606 })))
2607 }
2608 AdminAction::Create | AdminAction::Update => {
2609 let key = params.get("key").cloned();
2610 let vector = params.get("vector").cloned();
2611 match (index, key, vector) {
2612 (Some(index), Some(key), Some(vector)) => {
2613 Ok(Some(AdminCommand::Vector(VectorCommand::Upsert {
2614 index,
2615 key,
2616 vector,
2617 })))
2618 }
2619 _ => Ok(None),
2620 }
2621 }
2622 AdminAction::Delete => {
2623 let key = params.get("key").cloned();
2624 match (index, key) {
2625 (Some(index), Some(key)) => Ok(Some(AdminCommand::Vector(VectorCommand::Delete {
2626 index,
2627 key,
2628 }))),
2629 _ => Ok(None),
2630 }
2631 }
2632 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2633 Ok(None)
2634 }
2635 }
2636}
2637
2638fn build_hnsw_command(
2639 action: AdminAction,
2640 params: &std::collections::HashMap<String, String>,
2641) -> Result<Option<AdminCommand>> {
2642 match action {
2643 AdminAction::Read => {
2644 let name = match params.get("name").cloned() {
2645 Some(name) => name,
2646 None => return Ok(None),
2647 };
2648 Ok(Some(AdminCommand::Hnsw(HnswCommand::Stats { name })))
2649 }
2650 AdminAction::Create => {
2651 let name = match params.get("name").cloned() {
2652 Some(name) => name,
2653 None => return Ok(None),
2654 };
2655 let dim = match params
2656 .get("dim")
2657 .and_then(|value| value.parse::<usize>().ok())
2658 {
2659 Some(dim) => dim,
2660 None => return Ok(None),
2661 };
2662 let metric = if let Some(value) = params.get("metric") {
2663 parse_metric(value).ok_or_else(|| {
2664 CliError::InvalidArgument(
2665 "Invalid metric. Use metric=cosine|l2|ip.".to_string(),
2666 )
2667 })?
2668 } else {
2669 DistanceMetric::Cosine
2670 };
2671 Ok(Some(AdminCommand::Hnsw(HnswCommand::Create {
2672 name,
2673 dim,
2674 metric,
2675 })))
2676 }
2677 AdminAction::Delete => {
2678 let name = match params.get("name").cloned() {
2679 Some(name) => name,
2680 None => return Ok(None),
2681 };
2682 Ok(Some(AdminCommand::Hnsw(HnswCommand::Drop { name })))
2683 }
2684 AdminAction::Update => Err(CliError::InvalidArgument(
2685 "Update is not supported for HNSW targets.".to_string(),
2686 )),
2687 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2688 Ok(None)
2689 }
2690 }
2691}
2692
2693fn build_columnar_command(
2694 action: AdminAction,
2695 params: &std::collections::HashMap<String, String>,
2696) -> Result<Option<AdminCommand>> {
2697 match action {
2698 AdminAction::Read => {
2699 let mode = params
2700 .get("mode")
2701 .map(|value| value.as_str())
2702 .unwrap_or("list");
2703 match mode {
2704 "scan" => {
2705 let segment = match params.get("segment").cloned() {
2706 Some(segment) => segment,
2707 None => return Ok(None),
2708 };
2709 Ok(Some(AdminCommand::Columnar(ColumnarCommand::Scan {
2710 segment,
2711 progress: false,
2712 })))
2713 }
2714 "stats" => {
2715 let segment = match params.get("segment").cloned() {
2716 Some(segment) => segment,
2717 None => return Ok(None),
2718 };
2719 Ok(Some(AdminCommand::Columnar(ColumnarCommand::Stats {
2720 segment,
2721 })))
2722 }
2723 "index_list" => {
2724 let segment = match params.get("segment").cloned() {
2725 Some(segment) => segment,
2726 None => return Ok(None),
2727 };
2728 Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2729 IndexCommand::List { segment },
2730 ))))
2731 }
2732 "list" => Ok(Some(AdminCommand::Columnar(ColumnarCommand::List))),
2733 _ => Err(CliError::InvalidArgument(
2734 "Unknown columnar mode. Use mode=list|scan|stats|index_list.".to_string(),
2735 )),
2736 }
2737 }
2738 AdminAction::Create => {
2739 if let (Some(file), Some(table)) =
2740 (params.get("file").cloned(), params.get("table").cloned())
2741 {
2742 return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Ingest {
2743 file: std::path::PathBuf::from(file),
2744 table,
2745 delimiter: ',',
2746 header: true,
2747 compression: "lz4".to_string(),
2748 row_group_size: None,
2749 })));
2750 }
2751 if let (Some(segment), Some(column), Some(index_type)) = (
2752 params.get("segment").cloned(),
2753 params.get("column").cloned(),
2754 params.get("index_type").cloned(),
2755 ) {
2756 return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2757 IndexCommand::Create {
2758 segment,
2759 column,
2760 index_type,
2761 },
2762 ))));
2763 }
2764 Ok(None)
2765 }
2766 AdminAction::Delete => {
2767 if let (Some(segment), Some(column)) = (
2768 params.get("segment").cloned(),
2769 params.get("column").cloned(),
2770 ) {
2771 return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2772 IndexCommand::Drop { segment, column },
2773 ))));
2774 }
2775 Ok(None)
2776 }
2777 AdminAction::Update => Err(CliError::InvalidArgument(
2778 "Update is not supported for columnar targets.".to_string(),
2779 )),
2780 AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2781 Ok(None)
2782 }
2783 }
2784}
2785
2786fn parse_metric(value: &str) -> Option<DistanceMetric> {
2787 match value.to_lowercase().as_str() {
2788 "cosine" => Some(DistanceMetric::Cosine),
2789 "l2" => Some(DistanceMetric::L2),
2790 "ip" => Some(DistanceMetric::Ip),
2791 _ => None,
2792 }
2793}
2794
2795fn render_help(frame: &mut ratatui::Frame<'_>, area: Rect) {
2796 let help_width = area.width.saturating_sub(4).min(60);
2797 let help_height = area.height.saturating_sub(4).min(12);
2798 let rect = Rect::new(
2799 area.x + (area.width.saturating_sub(help_width)) / 2,
2800 area.y + (area.height.saturating_sub(help_height)) / 2,
2801 help_width,
2802 help_height,
2803 );
2804
2805 let lines = [
2806 "h/l or Left/Right: move focus",
2807 "Menu: j/k move, / search, e edit, r raw, Enter select, R refresh",
2808 "Input: Up/Down action, Tab field, e edit, o list, r raw, Enter execute",
2809 "Data: j/k scroll",
2810 "a: back",
2811 "?: toggle help",
2812 "q/Esc: quit",
2813 ]
2814 .join("\n");
2815
2816 let help = Paragraph::new(lines)
2817 .block(Block::default().borders(Borders::ALL).title("Help"))
2818 .wrap(Wrap { trim: true });
2819 frame.render_widget(help, rect);
2820}
2821
2822#[derive(Debug, Clone)]
2823struct SelectionOverlay {
2824 title: String,
2825 items: Vec<String>,
2826 selected: usize,
2827 field_index: usize,
2828 search: Option<String>,
2829 search_focused: bool,
2830}
2831
2832impl SelectionOverlay {
2833 fn new(title: String, items: Vec<String>, field_index: usize) -> Self {
2834 Self {
2835 title,
2836 items,
2837 selected: 0,
2838 field_index,
2839 search: None,
2840 search_focused: false,
2841 }
2842 }
2843
2844 fn search_term(&self) -> Option<&str> {
2845 self.search
2846 .as_deref()
2847 .filter(|value| !value.trim().is_empty())
2848 }
2849
2850 fn filtered_indices(&self) -> Vec<usize> {
2851 let Some(term) = self.search_term() else {
2852 return (0..self.items.len()).collect();
2853 };
2854 let term = term.to_lowercase();
2855 self.items
2856 .iter()
2857 .enumerate()
2858 .filter_map(|(idx, item)| {
2859 if item.to_lowercase().contains(&term) {
2860 Some(idx)
2861 } else {
2862 None
2863 }
2864 })
2865 .collect()
2866 }
2867
2868 fn selected_value(&self) -> Option<String> {
2869 let indices = self.filtered_indices();
2870 let idx = indices.get(self.selected).copied()?;
2871 self.items.get(idx).cloned()
2872 }
2873
2874 fn ensure_selection_in_range(&mut self) {
2875 let len = self.filtered_indices().len();
2876 if len == 0 {
2877 self.selected = 0;
2878 } else if self.selected >= len {
2879 self.selected = len - 1;
2880 }
2881 }
2882
2883 fn move_up(&mut self) {
2884 if self.selected > 0 {
2885 self.selected -= 1;
2886 }
2887 }
2888
2889 fn move_down(&mut self) {
2890 let len = self.filtered_indices().len();
2891 if self.selected + 1 < len {
2892 self.selected += 1;
2893 }
2894 }
2895
2896 fn move_top(&mut self) {
2897 self.selected = 0;
2898 }
2899
2900 fn move_bottom(&mut self) {
2901 let len = self.filtered_indices().len();
2902 if len > 0 {
2903 self.selected = len - 1;
2904 }
2905 }
2906
2907 fn push_search(&mut self, ch: char) {
2908 let search = self.search.get_or_insert_with(String::new);
2909 search.push(ch);
2910 self.ensure_selection_in_range();
2911 }
2912
2913 fn pop_search(&mut self) {
2914 if let Some(search) = self.search.as_mut() {
2915 if !search.is_empty() {
2916 search.pop();
2917 } else {
2918 self.reset_search();
2919 }
2920 }
2921 self.ensure_selection_in_range();
2922 }
2923
2924 fn reset_search(&mut self) {
2925 self.search = None;
2926 self.search_focused = false;
2927 self.selected = 0;
2928 }
2929}
2930
2931fn render_selection_overlay(
2932 frame: &mut ratatui::Frame<'_>,
2933 area: Rect,
2934 selection: &SelectionOverlay,
2935) {
2936 let overlay_width = area.width.saturating_sub(6).min(60);
2937 let overlay_height = area.height.saturating_sub(6).min(16);
2938 let rect = Rect::new(
2939 area.x + (area.width.saturating_sub(overlay_width)) / 2,
2940 area.y + (area.height.saturating_sub(overlay_height)) / 2,
2941 overlay_width,
2942 overlay_height,
2943 );
2944
2945 let layout = Layout::default()
2946 .direction(Direction::Vertical)
2947 .constraints([
2948 Constraint::Length(1),
2949 Constraint::Length(1),
2950 Constraint::Min(3),
2951 ])
2952 .split(rect);
2953
2954 let search = selection
2955 .search
2956 .as_ref()
2957 .map(|value| format!("/ {value}"))
2958 .unwrap_or_else(|| "/".to_string());
2959 let search_style = if selection.search_focused {
2960 Style::default().fg(Color::Yellow)
2961 } else {
2962 Style::default().fg(Color::Gray)
2963 };
2964 frame.render_widget(
2965 Paragraph::new(search)
2966 .block(
2967 Block::default()
2968 .borders(Borders::ALL)
2969 .title(selection.title.as_str()),
2970 )
2971 .style(search_style),
2972 layout[0],
2973 );
2974
2975 frame.render_widget(
2976 Paragraph::new("Enter: choose Esc: close /: search g/G: top/bottom j/k: move")
2977 .style(Style::default().fg(Color::DarkGray)),
2978 layout[1],
2979 );
2980
2981 let indices = selection.filtered_indices();
2982 let items = if indices.is_empty() {
2983 vec![ListItem::new(Line::from("No options available."))]
2984 } else {
2985 indices
2986 .iter()
2987 .filter_map(|idx| selection.items.get(*idx))
2988 .map(|item| ListItem::new(Line::from(item.clone())))
2989 .collect::<Vec<_>>()
2990 };
2991 let mut state = ListState::default();
2992 state.select(Some(selection.selected));
2993 let list = List::new(items)
2994 .block(Block::default().borders(Borders::ALL))
2995 .highlight_style(
2996 Style::default()
2997 .bg(Color::Blue)
2998 .fg(Color::White)
2999 .add_modifier(Modifier::BOLD),
3000 )
3001 .highlight_symbol("> ");
3002 frame.render_stateful_widget(list, layout[2], &mut state);
3003}
3004
3005fn cleanup_terminal(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
3006 disable_raw_mode()?;
3007 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
3008 terminal.show_cursor()?;
3009 Ok(())
3010}
3011
3012#[derive(Debug, Default, Clone)]
3013struct AdminResult {
3014 columns: Vec<Column>,
3015 rows: Vec<Row>,
3016 status_message: Option<String>,
3017}
3018
3019impl AdminResult {
3020 fn status(message: String) -> Self {
3021 Self {
3022 columns: Vec::new(),
3023 rows: Vec::new(),
3024 status_message: Some(message),
3025 }
3026 }
3027}
3028
3029#[derive(Default, Clone)]
3030struct CaptureState {
3031 columns: Vec<Column>,
3032 rows: Vec<Row>,
3033}
3034
3035struct CaptureFormatter {
3036 state: Arc<Mutex<CaptureState>>,
3037}
3038
3039impl CaptureFormatter {
3040 fn new() -> (Self, Arc<Mutex<CaptureState>>) {
3041 let state = Arc::new(Mutex::new(CaptureState::default()));
3042 (
3043 Self {
3044 state: Arc::clone(&state),
3045 },
3046 state,
3047 )
3048 }
3049}
3050
3051impl Formatter for CaptureFormatter {
3052 fn write_header(&mut self, _writer: &mut dyn std::io::Write, columns: &[Column]) -> Result<()> {
3053 let mut state = self.state.lock().expect("admin capture lock");
3054 state.columns = columns.to_vec();
3055 Ok(())
3056 }
3057
3058 fn write_row(&mut self, _writer: &mut dyn std::io::Write, row: &Row) -> Result<()> {
3059 self.state
3060 .lock()
3061 .expect("admin capture lock")
3062 .rows
3063 .push(row.clone());
3064 Ok(())
3065 }
3066
3067 fn write_footer(&mut self, _writer: &mut dyn std::io::Write) -> Result<()> {
3068 Ok(())
3069 }
3070
3071 fn supports_streaming(&self) -> bool {
3072 true
3073 }
3074}
3075
3076fn append_result_lines(lines: &mut Vec<Line<'static>>, result: &AdminResult) {
3077 if result.columns.is_empty() && result.rows.is_empty() && result.status_message.is_none() {
3078 return;
3079 }
3080 lines.push(Line::from(""));
3081 lines.push(Line::from(Span::styled(
3082 "Last Result",
3083 Style::default().add_modifier(Modifier::BOLD),
3084 )));
3085 if let Some(message) = &result.status_message {
3086 lines.push(Line::from(message.clone()));
3087 }
3088 if !result.columns.is_empty() {
3089 let header = result
3090 .columns
3091 .iter()
3092 .map(|col| col.name.clone())
3093 .collect::<Vec<_>>()
3094 .join(" | ");
3095 lines.push(Line::from(header));
3096 for row in &result.rows {
3097 let row_text = row
3098 .columns
3099 .iter()
3100 .map(value_to_string)
3101 .collect::<Vec<_>>()
3102 .join(" | ");
3103 lines.push(Line::from(row_text));
3104 }
3105 }
3106}
3107
3108fn value_to_string(value: &Value) -> String {
3109 match value {
3110 Value::Null => "NULL".to_string(),
3111 Value::Bool(b) => b.to_string(),
3112 Value::Int(i) => i.to_string(),
3113 Value::Float(f) => format!("{f:.6}"),
3114 Value::Text(text) => text.clone(),
3115 Value::Bytes(bytes) => format!("{:02x?}", bytes),
3116 Value::Vector(values) => format!(
3117 "[{}]",
3118 values
3119 .iter()
3120 .take(4)
3121 .map(|value| format!("{value:.4}"))
3122 .collect::<Vec<_>>()
3123 .join(", ")
3124 ),
3125 }
3126}
3127
3128#[cfg(test)]
3129mod tests {
3130 use super::*;
3131 use crate::batch::{BatchMode, BatchModeSource};
3132 use alopex_embedded::Database;
3133
3134 fn make_app<'a>(db: &'a Database) -> AdminApp<'a> {
3135 let batch_mode = Box::leak(Box::new(BatchMode {
3136 is_batch: true,
3137 is_tty: true,
3138 source: BatchModeSource::Explicit,
3139 }));
3140 let backend = AdminBackend::Local {
3141 db,
3142 batch_mode,
3143 output_format: OutputFormat::Table,
3144 limit: None,
3145 quiet: true,
3146 data_dir: None,
3147 };
3148 AdminApp::new(
3149 "local",
3150 AuthCapabilities::full(),
3151 backend,
3152 Some(AdminTarget::Kv),
3153 )
3154 }
3155
3156 fn field_value(app: &AdminApp<'_>, key: &str) -> Option<String> {
3157 app.form_fields
3158 .iter()
3159 .find(|field| field.key.eq_ignore_ascii_case(key))
3160 .map(|field| field.value.clone())
3161 }
3162
3163 #[test]
3164 fn resource_tree_filters_keep_parents() {
3165 let entries = vec![
3166 ResourceEntry {
3167 label: "SQL Tables".to_string(),
3168 kind: ResourceKind::Section(ResourceSection::SqlTables),
3169 depth: 0,
3170 selectable: false,
3171 },
3172 ResourceEntry {
3173 label: "users".to_string(),
3174 kind: ResourceKind::Table {
3175 name: "users".to_string(),
3176 },
3177 depth: 1,
3178 selectable: true,
3179 },
3180 ResourceEntry {
3181 label: "email".to_string(),
3182 kind: ResourceKind::Column {
3183 table: "users".to_string(),
3184 name: "email".to_string(),
3185 },
3186 depth: 2,
3187 selectable: true,
3188 },
3189 ];
3190 let tree = ResourceTree {
3191 entries,
3192 selected: 0,
3193 search: Some("email".to_string()),
3194 search_focused: false,
3195 last_error: None,
3196 last_status: None,
3197 };
3198 let indices = tree.filtered_indices();
3199 assert_eq!(indices, vec![0, 1, 2]);
3200 }
3201
3202 #[test]
3203 fn resource_tree_paging_clamps() {
3204 let entries = (0..12)
3205 .map(|idx| ResourceEntry {
3206 label: format!("item-{idx}"),
3207 kind: ResourceKind::Info,
3208 depth: 0,
3209 selectable: true,
3210 })
3211 .collect::<Vec<_>>();
3212 let mut tree = ResourceTree {
3213 entries,
3214 selected: 0,
3215 search: None,
3216 search_focused: false,
3217 last_error: None,
3218 last_status: None,
3219 };
3220 tree.page_down();
3221 assert_eq!(tree.selected, 5);
3222 tree.page_down();
3223 assert_eq!(tree.selected, 10);
3224 tree.page_down();
3225 assert_eq!(tree.selected, 11);
3226 tree.page_up();
3227 assert_eq!(tree.selected, 6);
3228 }
3229
3230 #[test]
3231 fn selection_overlay_filters_values() {
3232 let mut overlay = SelectionOverlay::new(
3233 "Select".to_string(),
3234 vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
3235 0,
3236 );
3237 overlay.search = Some("et".to_string());
3238 overlay.ensure_selection_in_range();
3239 assert_eq!(overlay.selected_value(), Some("beta".to_string()));
3240 overlay.move_down();
3241 assert_eq!(overlay.selected_value(), Some("beta".to_string()));
3242 }
3243
3244 #[test]
3245 fn focus_transitions_follow_table_detail_status() {
3246 let db = Database::open_in_memory().expect("db");
3247 let mut app = make_app(&db);
3248 app.focus = AdminFocus::Table;
3249 assert_eq!(app.focus_right(), AdminFocus::Detail);
3250 app.focus = AdminFocus::Detail;
3251 assert_eq!(app.focus_left(), AdminFocus::Table);
3252 assert_eq!(app.focus_right(), AdminFocus::Status);
3253 app.focus = AdminFocus::Status;
3254 assert_eq!(app.focus_left(), AdminFocus::Detail);
3255 assert_eq!(app.focus_right(), AdminFocus::Status);
3256 }
3257
3258 #[test]
3259 fn resource_selection_sets_sql_fields() {
3260 let db = Database::open_in_memory().expect("db");
3261 let mut app = make_app(&db);
3262 app.resources = ResourceTree {
3263 entries: vec![
3264 ResourceEntry {
3265 label: "SQL Tables".to_string(),
3266 kind: ResourceKind::Section(ResourceSection::SqlTables),
3267 depth: 0,
3268 selectable: false,
3269 },
3270 ResourceEntry {
3271 label: "users".to_string(),
3272 kind: ResourceKind::Table {
3273 name: "users".to_string(),
3274 },
3275 depth: 1,
3276 selectable: true,
3277 },
3278 ],
3279 selected: 1,
3280 search: None,
3281 search_focused: false,
3282 last_error: None,
3283 last_status: None,
3284 };
3285 app.apply_resource_selection().expect("select table");
3286 assert_eq!(app.target, AdminTarget::Sql);
3287 assert_eq!(field_value(&app, "table"), Some("users".to_string()));
3288 }
3289
3290 #[test]
3291 fn resource_selection_sets_kv_key() {
3292 let db = Database::open_in_memory().expect("db");
3293 let mut app = make_app(&db);
3294 app.resources = ResourceTree {
3295 entries: vec![ResourceEntry {
3296 label: "mykey".to_string(),
3297 kind: ResourceKind::KvKey {
3298 key: "mykey".to_string(),
3299 },
3300 depth: 1,
3301 selectable: true,
3302 }],
3303 selected: 0,
3304 search: None,
3305 search_focused: false,
3306 last_error: None,
3307 last_status: None,
3308 };
3309 app.apply_resource_selection().expect("select key");
3310 assert_eq!(app.target, AdminTarget::Kv);
3311 assert_eq!(field_value(&app, "key"), Some("mykey".to_string()));
3312 }
3313}