diff --git a/Cargo.lock b/Cargo.lock index 09852d2e..065813e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -787,8 +787,18 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core 0.20.10", + "darling_macro 0.20.10", ] [[package]] @@ -805,17 +815,42 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.68", +] + [[package]] name = "darling_macro" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ - "darling_core", + "darling_core 0.14.4", "quote", "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core 0.20.10", + "quote", + "syn 2.0.68", +] + [[package]] name = "deranged" version = "0.3.11" @@ -836,6 +871,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_setters" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8ef033054e131169b8f0f9a7af8f5533a9436fadf3c500ed547f730f07090d" +dependencies = [ + "darling 0.20.10", + "proc-macro2", + "quote", + "syn 2.0.68", +] + [[package]] name = "devise" version = "0.4.1" @@ -1846,7 +1893,7 @@ version = "0.85.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bbec4da219dcb02bb32afd762a7ac4dffd47ed92b7e35ac9a7b961d21327117" dependencies = [ - "darling", + "darling 0.14.4", "proc-macro2", "quote", "serde_json", @@ -3548,9 +3595,11 @@ version = "1.0.1" dependencies = [ "anyhow", "assertables", + "chrono", "clap", "clap_complete", "clockabilly", + "derive_setters", "dirs", "k8s-openapi", "kube", @@ -3558,6 +3607,7 @@ dependencies = [ "reqwest 0.11.27", "rstest", "serde", + "serde_json", "serde_yaml", "sk-api", "sk-core", diff --git a/Cargo.toml b/Cargo.toml index 57640e94..84a50859 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,9 +30,11 @@ anyhow = { version = "1.0.75", features = ["backtrace"] } async-recursion = "1.0.5" async-trait = "0.1.80" bytes = "1.5.0" +chrono = "0.4.38" clap = { version = "4.3.21", features = ["cargo", "derive", "string"] } clap_complete = "4.5.6" clockabilly = "0.1.0" +derive_setters = "0.1.6" dirs = "5.0.1" either = "1.12.0" futures = "0.3.28" @@ -42,6 +44,7 @@ object_store = { version = "0.11.0", features = ["aws", "gcp", "azure", "http"] # remove this fork once https://github.com/uutils/parse_datetime/pull/80 is merged and a new version released parse_datetime_fork = { version = "0.6.0-custom" } paste = "1.0.14" +ratatui = "0.28.1" regex = "1.10.2" reqwest = { version = "0.11.18", default-features = false, features = ["json", "rustls-tls"] } rmp-serde = "1.1.2" @@ -64,7 +67,6 @@ hyper = "0.14.27" mockall = "0.11.4" rstest = "0.18.2" tracing-test = "0.2.4" -ratatui = "0.28.1" [workspace.dependencies.kube] version = "0.85.0" diff --git a/sk-cli/Cargo.toml b/sk-cli/Cargo.toml index 18c3bd46..20122e94 100644 --- a/sk-cli/Cargo.toml +++ b/sk-cli/Cargo.toml @@ -21,12 +21,15 @@ kube = { workspace = true } k8s-openapi = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } serde_yaml = { workspace = true } sk-api = { workspace = true } sk-core = { workspace = true } sk-store = { workspace = true } tokio = { workspace = true } ratatui = { workspace = true } +chrono = { workspace = true } +derive_setters = { workspace = true } [dev-dependencies] assertables = { workspace = true } diff --git a/sk-cli/src/main.rs b/sk-cli/src/main.rs index e2bb4cc9..b621ebd6 100644 --- a/sk-cli/src/main.rs +++ b/sk-cli/src/main.rs @@ -50,7 +50,7 @@ enum SkSubcommand { Version, #[command(about = "explore or prepare trace data for simulation")] - Xray, + Xray(xray::Args), } #[tokio::main] @@ -68,6 +68,6 @@ async fn main() -> EmptyResult { println!("skctl {}", crate_version!()); Ok(()) }, - SkSubcommand::Xray => xray::cmd(), + SkSubcommand::Xray(args) => xray::cmd(args).await, } } diff --git a/sk-cli/src/xray/event.rs b/sk-cli/src/xray/event.rs index 8303ed65..ad632be3 100644 --- a/sk-cli/src/xray/event.rs +++ b/sk-cli/src/xray/event.rs @@ -12,8 +12,15 @@ use super::{ pub(super) fn handle_event(_model: &Model) -> anyhow::Result { if let Event::Key(key) = read()? { - if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { - return Ok(Message::Quit); + if key.kind == KeyEventKind::Press { + return Ok(match key.code { + KeyCode::Char(' ') => Message::Select, + KeyCode::Down | KeyCode::Char('j') => Message::Down, + KeyCode::Esc => Message::Deselect, + KeyCode::Up | KeyCode::Char('k') => Message::Up, + KeyCode::Char('q') => Message::Quit, + _ => Message::Unknown, + }); } } Ok(Message::Unknown) diff --git a/sk-cli/src/xray/mod.rs b/sk-cli/src/xray/mod.rs index 39a0f860..6da65205 100644 --- a/sk-cli/src/xray/mod.rs +++ b/sk-cli/src/xray/mod.rs @@ -1,11 +1,17 @@ mod event; mod model; mod update; +mod util; mod view; use ratatui::backend::Backend; use ratatui::Terminal; +use sk_core::external_storage::{ + ObjectStoreWrapper, + SkObjectStore, +}; use sk_core::prelude::*; +use sk_store::TraceStore; use self::event::handle_event; use self::model::{ @@ -18,8 +24,18 @@ use self::update::{ }; use self::view::view; -pub fn cmd() -> EmptyResult { - let model = Model::new(); +#[derive(clap::Args)] +pub struct Args { + #[arg(long_help = "location of the input trace file")] + pub trace_path: String, +} + +pub async fn cmd(args: &Args) -> EmptyResult { + let object_store = SkObjectStore::new(&args.trace_path)?; + let trace_data = object_store.get().await?.to_vec(); + let store = TraceStore::import(trace_data, &None)?; + + let model = Model::new(&args.trace_path, store); let term = ratatui::init(); let res = run_loop(term, model); ratatui::restore(); @@ -28,10 +44,8 @@ pub fn cmd() -> EmptyResult { fn run_loop(mut term: Terminal, mut model: Model) -> EmptyResult { while model.app_state != ApplicationState::Done { - term.draw(|frame| view(&model, frame))?; - + term.draw(|frame| view(&mut model, frame))?; let msg: Message = handle_event(&model)?; - update(&mut model, msg); } Ok(()) diff --git a/sk-cli/src/xray/model.rs b/sk-cli/src/xray/model.rs index d8761ded..db7ac025 100644 --- a/sk-cli/src/xray/model.rs +++ b/sk-cli/src/xray/model.rs @@ -1,3 +1,10 @@ +use ratatui::widgets::ListState; +use sk_store::{ + TraceEvent, + TraceStorable, + TraceStore, +}; + #[derive(Eq, PartialEq)] pub(super) enum ApplicationState { Running, @@ -6,10 +13,31 @@ pub(super) enum ApplicationState { pub(super) struct Model { pub(super) app_state: ApplicationState, + pub(super) base_trace: TraceStore, + pub(super) trace_path: String, + pub(super) events: Vec, + + pub(super) event_list_state: ListState, + pub(super) object_list_state: ListState, + pub(super) object_contents_list_state: ListState, + pub(super) event_selected: bool, + pub(super) object_selected: bool, } impl Model { - pub(super) fn new() -> Model { - Model { app_state: ApplicationState::Running } + pub(super) fn new(trace_path: &str, base_trace: TraceStore) -> Model { + let events = base_trace.iter().map(|(evt, _)| evt).cloned().collect(); + Model { + app_state: ApplicationState::Running, + base_trace, + trace_path: trace_path.into(), + events, + + event_list_state: ListState::default().with_selected(Some(0)), + object_list_state: ListState::default(), + object_contents_list_state: ListState::default(), + event_selected: false, + object_selected: false, + } } } diff --git a/sk-cli/src/xray/update.rs b/sk-cli/src/xray/update.rs index 889c9547..10762fe0 100644 --- a/sk-cli/src/xray/update.rs +++ b/sk-cli/src/xray/update.rs @@ -4,13 +4,46 @@ use super::{ }; pub(super) enum Message { + Deselect, + Down, Quit, + Select, Unknown, + Up, } pub(super) fn update(model: &mut Model, msg: Message) { match msg { + Message::Deselect => { + if model.object_selected { + model.object_selected = false; + model.object_contents_list_state.select(None); + } else { + model.event_selected = false; + } + }, + Message::Down => match (model.event_selected, model.object_selected) { + (true, true) => model.object_contents_list_state.select_next(), + (true, false) => model.object_list_state.select_next(), + (false, _) => model.event_list_state.select_next(), + }, Message::Quit => model.app_state = ApplicationState::Done, + Message::Select => match (model.event_selected, model.object_selected) { + (true, true) => (), + (true, false) => { + model.object_selected = true; + model.object_contents_list_state.select(Some(0)); + }, + (false, _) => { + model.event_selected = true; + model.object_list_state.select(Some(0)); + }, + }, Message::Unknown => (), + Message::Up => match (model.event_selected, model.object_selected) { + (true, true) => model.object_contents_list_state.select_previous(), + (true, false) => model.object_list_state.select_previous(), + (false, _) => model.event_list_state.select_previous(), + }, } } diff --git a/sk-cli/src/xray/util.rs b/sk-cli/src/xray/util.rs new file mode 100644 index 00000000..1fa074cc --- /dev/null +++ b/sk-cli/src/xray/util.rs @@ -0,0 +1,10 @@ +use chrono::TimeDelta; + +pub(super) fn format_duration(d: TimeDelta) -> String { + let day_str = match d.num_days() { + x if x > 0 => format!("{x}d "), + _ => String::new(), + }; + + format!("{}{:02}:{:02}:{:02}", day_str, d.num_hours() % 24, d.num_minutes() % 60, d.num_seconds() % 60) +} diff --git a/sk-cli/src/xray/view.rs b/sk-cli/src/xray/view.rs index 34241869..b2c7a750 100644 --- a/sk-cli/src/xray/view.rs +++ b/sk-cli/src/xray/view.rs @@ -1,10 +1,154 @@ -use ratatui::style::Stylize; -use ratatui::widgets::Paragraph; -use ratatui::Frame; +use std::iter::repeat; +use chrono::TimeDelta; +use ratatui::prelude::*; +use ratatui::widgets::{ + Block, + Borders, + Clear, + List, + Padding, + Paragraph, +}; +use sk_core::k8s::KubeResourceExt; +use sk_store::TraceStorable; + +use super::util::format_duration; use super::Model; -pub(super) fn view(_model: &Model, frame: &mut Frame) { - let greeting = Paragraph::new("Hello Ratatui! (press 'q' to quit)").white().on_blue(); - frame.render_widget(greeting, frame.area()); +pub(super) fn view(model: &mut Model, frame: &mut Frame) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Percentage(100), Constraint::Min(5)]) + .split(frame.area()); + let (top, bottom) = (layout[0], layout[1]); + + let events_border = Block::bordered().title(model.trace_path.clone()); + let object_border = Block::bordered(); + + if top.width > 120 { + let lr_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(top); + let (left, right) = (lr_layout[0], lr_layout[1]); + + render_event_list(model, frame, events_border.inner(left)); + render_object(model, frame, object_border.inner(right)); + + frame.render_widget(events_border, left); + frame.render_widget(object_border, right); + } else { + render_event_list(model, frame, events_border.inner(top)); + frame.render_widget(events_border, top); + + if model.object_selected { + let popup = Rect { + x: top.width / 10, + y: top.height / 10, + width: 4 * top.width / 5, + height: 4 * top.height / 5, + }; + frame.render_widget(Clear, popup); + render_object(model, frame, object_border.inner(popup)); + frame.render_widget(object_border, popup); + } + } + + let greeting2 = Paragraph::new("Hello SimKube!\nUse arrows to navigate, space to select, 'q' to quit.") + .white() + .block(Block::new().borders(Borders::ALL)); + frame.render_widget(greeting2, bottom); +} + +fn render_event_list(model: &mut Model, frame: &mut Frame, layout: Rect) { + // Here's some sortof obnoxious code; we'd like to have the event list "expand" so that you can + // see the applied and deleted objects for that particular event. The way we do this is split + // our layout into three sublayouts; the first includes all the events up to the selected one, + // then we nest in one level and display the applied and deleted objects, then we unnest and + // display the rest of the events + let num_events = model.base_trace.num_events(); + let start_ts = model.base_trace.start_ts().unwrap_or(0); + + let (sel_index_inclusive, sel_event) = if model.event_selected { + let sel_index = model.event_list_state.selected().unwrap(); + (sel_index + 1, Some(&model.events[sel_index])) + } else { + (model.events.len(), None) + }; + + // Add one so the selected event is included on top + let mut root_items_1 = Vec::with_capacity(sel_index_inclusive); + let mut root_items_2 = Vec::with_capacity(num_events.saturating_sub(sel_index_inclusive)); + + for (i, evt) in model.events.iter().enumerate() { + let d = TimeDelta::new(evt.ts - start_ts, 0).unwrap(); + let d_str = + format!("{} ({} applied/{} deleted)", format_duration(d), evt.applied_objs.len(), evt.deleted_objs.len()); + if i < sel_index_inclusive { + root_items_1.push(d_str); + } else { + root_items_2.push(d_str); + } + } + + let sublist_items = sel_event.map_or(vec![], |evt| { + let mut items: Vec<_> = evt + .applied_objs + .iter() + .zip(repeat("+")) + .chain(evt.deleted_objs.iter().zip(repeat("-"))) + .map(|(obj, op)| format!(" {} {}", op, obj.namespaced_name())) + .collect(); + if items.is_empty() { + items.push(String::new()); + } + items + }); + + let nested_layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![ + // We know how many lines we have; use max constraints here so the lists are next to + // each other. The last one can be min(0) and take up the rest of the space + Constraint::Max(root_items_1.len() as u16), + Constraint::Max(sublist_items.len() as u16), + Constraint::Min(0), + ]) + .split(layout); + + let list_part_one = List::new(root_items_1) + .highlight_style(Style::new().add_modifier(Modifier::REVERSED)) + .highlight_symbol(">> "); + let sublist = List::new(sublist_items) + .highlight_style(Style::new().bg(Color::Blue)) + .highlight_symbol("++ ") + .style(Style::new().italic()); + let list_part_two = List::new(root_items_2).block(Block::new().padding(Padding::left(3))); + + frame.render_stateful_widget(list_part_one, nested_layout[0], &mut model.event_list_state); + frame.render_stateful_widget(sublist, nested_layout[1], &mut model.object_list_state); + frame.render_widget(list_part_two, nested_layout[2]) +} + +fn render_object(model: &mut Model, frame: &mut Frame, layout: Rect) { + if model.event_selected { + let evt_idx = model.event_list_state.selected().unwrap(); + let obj_idx = model.object_list_state.selected().unwrap(); + let applied_len = model.events[evt_idx].applied_objs.len(); + let deleted_len = model.events[evt_idx].deleted_objs.len(); + + let obj = if obj_idx >= applied_len { + if obj_idx - applied_len > deleted_len { + return; + } + &model.events[evt_idx].deleted_objs[obj_idx - applied_len] + } else { + &model.events[evt_idx].applied_objs[obj_idx] + }; + + let obj_str = serde_json::to_string_pretty(obj).unwrap(); + let contents = List::new(obj_str.split('\n')).highlight_style(Style::new().bg(Color::Blue)); + frame.render_stateful_widget(contents, layout, &mut model.object_contents_list_state); + } } diff --git a/sk-store/src/trace_store.rs b/sk-store/src/trace_store.rs index 953dba2d..6ae6f320 100644 --- a/sk-store/src/trace_store.rs +++ b/sk-store/src/trace_store.rs @@ -110,6 +110,10 @@ impl TraceStore { }) } + pub fn num_events(&self) -> usize { + self.events.len() + } + pub(crate) fn collect_events( &self, start_ts: i64,