From a78d3842520d4d8ad9b014f6847070fd19b1c21a Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 25 Nov 2021 09:09:16 +0000 Subject: [PATCH] WIP: GUI for seeing the metrics --- bare-metrics-gui/src/graph.rs | 327 ++++++++++++++++++++++++++++++++++ bare-metrics-gui/src/main.rs | 29 +++ 2 files changed, 356 insertions(+) create mode 100644 bare-metrics-gui/src/graph.rs create mode 100644 bare-metrics-gui/src/main.rs diff --git a/bare-metrics-gui/src/graph.rs b/bare-metrics-gui/src/graph.rs new file mode 100644 index 0000000..1dbc726 --- /dev/null +++ b/bare-metrics-gui/src/graph.rs @@ -0,0 +1,327 @@ +use bare_metrics_core::structures::{Frame, MetricDescriptor, MetricId, UnixTimestampMilliseconds}; +use bare_metrics_reader::{MetricsLogReader, SeekToken}; +use eframe::egui::{ + Color32, Frame as EguiFrame, PointerButton, Pos2, Rect, Sense, Stroke, Ui, Vec2, +}; +use log::{debug, error, info}; +use std::collections::{BTreeMap, HashMap}; +use std::io::{Read, Seek}; +use std::ops::RangeInclusive; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Receiver; +use std::sync::{Arc, RwLock}; +use std::time::SystemTime; + +/// Make a checkpoint every 10 minutes? +/// This should probably be tunable since it will likely vary per-source ... +pub const CHECKPOINT_EVERY_MILLISECONDS: u64 = 600_000_000; + +#[derive(Clone, Debug)] +pub struct MetricsWindow { + time_range: RangeInclusive, + wanted_time_points: u32, + metrics: MetricDescriptorTable, + histograms: HashMap, + counters: HashMap, + gauges: HashMap, +} + +#[derive(Clone, Debug)] +pub struct HistogramWindow { + map: Vec<( + UnixTimestampMilliseconds, + UnixTimestampMilliseconds, + HistogramWindow, + )>, +} + +#[derive(Clone, Debug)] +pub struct ScalarWindow { + points: Vec<(UnixTimestampMilliseconds, f64)>, +} + +pub struct MetricsLogReadingRequester { + shared: Arc, + // todo sender: +} + +pub struct MetricsLogReadingShared { + /// The current window of data. + pub current_window: RwLock, + /// True if a new window of data is being loaded. + pub loading_new_window: AtomicBool, +} + +pub enum MetricsLogReaderMessage { + LoadNewWindow { + /// Callback that gets called when there's a state change. + /// The bool represents whether the loading is finished or not. + on_state_change: Box () + 'static + Send>, + + new_time_range: RangeInclusive, + new_wanted_time_points: u32, + }, +} + +impl MetricsLogReadingRequester { + pub fn new_manager( + reader: MetricsLogReader, + ) -> MetricsLogReadingRequester { + let shared = Arc::new(MetricsLogReadingShared { + current_window: RwLock::new(MetricsWindow { + time_range: 0.0..=0.0, + wanted_time_points: 0, + metrics: Default::default(), + histograms: Default::default(), + counters: Default::default(), + gauges: Default::default(), + }), + loading_new_window: AtomicBool::new(false), + }); + let manager_shared_ref = shared.clone(); + let requester = MetricsLogReadingRequester { shared }; + + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::Builder::new() + .name("metricslogreader".to_string()) + .spawn(move || { + if let Err(err) = + MetricsLogReaderManager::new_and_run(manager_shared_ref, reader, rx) + { + error!("Error in background log reader: {:?}", err); + } + }) + .unwrap(); + + requester + } +} + +/// We don't track histograms because they don't 'accumulate'; the histograms are emitted every +/// frame when there are any samples. +#[derive(Clone, Debug)] +pub struct MetricsState { + at: UnixTimestampMilliseconds, + gauges: HashMap, + counters: HashMap, +} + +pub struct MetricsLogReaderManager { + shared_ref: Arc, + reader: MetricsLogReader, + rx: Receiver, + metric_descriptors: HashMap, + metric_descriptors_dirty: bool, + metric_descriptor_table: MetricDescriptorTable, + checkpoints: Vec<(MetricsState, SeekToken)>, +} + +/// We use B-Tree maps because they sort properly. That's useful for ensuring the UI is consistent +/// between re-runs and makes it easier to confidently order the UI and assign colours etc... +pub type MetricDescriptorTable = BTreeMap, MetricId>>; + +impl MetricsLogReaderManager { + fn initial_scan(&mut self) -> anyhow::Result<()> { + let mut state = MetricsState { + at: UnixTimestampMilliseconds(0), + gauges: HashMap::with_capacity(0), + counters: HashMap::with_capacity(0), + }; + let mut next_checkpoint_at = 0u64; + + while let Some((old_seek_token, old_end_ts, frame)) = self.reader.read_frame_rewindable()? { + state.at = old_end_ts; + if state.at.0 > next_checkpoint_at { + // Make a checkpoint here! + self.checkpoints.push((state.clone(), old_seek_token)); + next_checkpoint_at = state.at.0 + CHECKPOINT_EVERY_MILLISECONDS; + } + + // Integrate the state... + // We have no need for histogram state because they emit every time there are any + // samples (not just on changes). + for (metric_id, count) in frame.counter_updates { + state.counters.insert(metric_id, count.0); + } + for (metric_id, value) in frame.gauge_updates { + state.gauges.insert(metric_id, value); + } + + // Also keep track of new metrics + for (new_metric, metric_descriptor) in frame.new_metrics { + self.metric_descriptors + .insert(new_metric, metric_descriptor); + self.metric_descriptors_dirty = true; + } + } + + Ok(()) + } + + fn update_metric_descriptors(&mut self) { + if !self.metric_descriptors_dirty { + return; + } + self.metric_descriptors_dirty = false; + + let mut metric_names_to_labels_to_ids: MetricDescriptorTable = BTreeMap::new(); + for (metric_id, metric_descriptor) in self.metric_descriptors.iter() { + let labels_to_ids = metric_names_to_labels_to_ids + .entry(metric_descriptor.name.to_string()) + .or_insert_with(BTreeMap::new); + labels_to_ids.insert(metric_descriptor.labels.clone(), *metric_id); + } + + self.metric_descriptor_table = metric_names_to_labels_to_ids; + } + + fn find_checkpoint_just_before( + &mut self, + just_before_or_at: UnixTimestampMilliseconds, + ) -> anyhow::Result { + todo!() + } + + fn seek_to_first_before( + &mut self, + just_before_or_at: UnixTimestampMilliseconds, + ) -> anyhow::Result<(SeekToken, Option)> { + todo!() + } + + fn run(&mut self) -> anyhow::Result<()> { + info!("Starting manager"); + self.initial_scan()?; + self.update_metric_descriptors(); + info!("Initial scan done."); + + while let Ok(msg) = self.rx.recv() { + match msg { + MetricsLogReaderMessage::LoadNewWindow { + on_state_change, + new_time_range, + new_wanted_time_points, + } => { + self.shared_ref + .loading_new_window + .store(true, Ordering::SeqCst); + on_state_change(false); + todo!(); + + let start = if *new_time_range.start() == f64::NEG_INFINITY { + // autoscale by getting the earliest time point of the metrics log + self.reader.header.start_time.0 + } else { + (*new_time_range.start() * 1000.0) as u64 + }; + let end = if *new_time_range.end() == f64::INFINITY { + // get the first result before the end of time... + // or in other words, the last result. + let (_, frame) = + self.seek_to_first_before(UnixTimestampMilliseconds(u64::MAX))?; + if let Some(frame) = frame { + frame.end_time.0 + } else { + // well, we have no choice. Just use the current time. + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + } else { + (*new_time_range.end() * 1000.0) as u64 + }; + } + } + } + + Ok(()) + } + + fn new_and_run( + shared_ref: Arc, + reader: MetricsLogReader, + rx: Receiver, + ) -> anyhow::Result<()> { + let mut manager = MetricsLogReaderManager { + shared_ref, + reader, + rx, + metric_descriptors: Default::default(), + metric_descriptors_dirty: false, + metric_descriptor_table: Default::default(), + checkpoints: Default::default(), + }; + + manager.run() + } +} + +/// Renderer for a bare metrics graph. +/// Should support rendering both line graphs and histograms. +/// Input interactions: +/// - drag-select an area to zoom in +/// - hover along line to show tooltip with statistics +/// - double click to zoom back out? +/// - right click for context menu with more options (this might be handled at a layer higher +/// up; we'll see...) +pub struct Graph {} + +pub struct GraphState { + pub x_scale: f64, + pub y_min: f64, + pub y_max: f64, + pub x_last_time: UnixTimestampMilliseconds, +} + +impl Graph { + pub fn draw(ui: &mut Ui) { + let context_menu_id = ui.id().with("context menu"); + + EguiFrame::dark_canvas(ui.style()).show(ui, |ui| { + // if wanted, we could do this: + // ui.ctx().request_repaint(); + + // store scaling stuff in here? + //ui.memory().data. + + let desired_size = ui.available_width() * Vec2::new(1.0, 0.35); + let response = ui.allocate_response(desired_size, Sense::click_and_drag()); + + if response.clicked_by(PointerButton::Secondary) { + ui.memory().open_popup(context_menu_id); + } + + // not clickable: + // egui::popup::show_tooltip_at(ui.ctx(), context_menu_id, Some(Pos2::new(320.0, 320.0)), |ui| { + // ui.button("hrm?"); + // }); + + egui::popup_below_widget(ui, context_menu_id, &response, |ui| { + ui.button("Blah"); + }); + + let display_rect = response.rect; + + // Scale the graph to the appropriate axes. + let x_axis = 0.0..=1.0; + // This range is reversed because screen coordinates go down, but we'd like them to go + // up since this is more of a mathematical graph. + let y_axis = 1.0..=0.0; + + let display_transform = + emath::RectTransform::from_to(Rect::from_x_y_ranges(x_axis, y_axis), display_rect); + + let stroke = Stroke::new(2.0, Color32::GREEN); + + ui.painter().line_segment( + [ + display_transform * Pos2::new(0.1, 0.3), + display_transform * Pos2::new(0.9, 0.6), + ], + stroke, + ); + }); + } +} diff --git a/bare-metrics-gui/src/main.rs b/bare-metrics-gui/src/main.rs new file mode 100644 index 0000000..f66a892 --- /dev/null +++ b/bare-metrics-gui/src/main.rs @@ -0,0 +1,29 @@ +use crate::graph::Graph; +use eframe::egui::{CentralPanel, CtxRef}; +use eframe::epi::{App, Frame}; +use eframe::NativeOptions; + +pub mod graph; + +pub struct MetricsGui {} + +impl App for MetricsGui { + fn update(&mut self, ctx: &CtxRef, _frame: &mut Frame<'_>) { + CentralPanel::default().show(ctx, |ui| { + ui.label("Hah"); + Graph::draw(ui); + }); + } + + fn name(&self) -> &str { + "Bare Metrics GUI" + } +} + +fn main() { + let app = MetricsGui {}; + + let native_options = NativeOptions::default(); + + eframe::run_native(Box::new(app), native_options); +}