feat: allow to save and load metric data

metrics can be saved and loaded to/from csv.
The files are ok-ish and it's reasonably fast. File format could still
change. Also some small fixes and tweaks, like bigger buttons in
confirmation prompts and source name in logs.
This commit is contained in:
əlemi 2022-06-28 01:09:15 +02:00
parent dabff2b8ac
commit 9d217e9dd7
No known key found for this signature in database
GPG key ID: BBCBFE5D7244634E
8 changed files with 256 additions and 130 deletions

View file

@ -1,6 +1,6 @@
[package] [package]
name = "dashboard" name = "dashboard"
version = "0.3.3" version = "0.3.4"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
@ -21,7 +21,9 @@ tracing = "0.1" # egui / eframe use tracing
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
csv = "1.1"
rusqlite = "0.27" rusqlite = "0.27"
jql = { version = "4", default-features = false } jql = { version = "4", default-features = false }
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
rfd = "0.9"
eframe = "0.18" eframe = "0.18"

View file

@ -92,6 +92,7 @@ impl ApplicationState {
false, false,
false, false,
false, false,
true,
self.panels.read().expect("Panels RwLock poisoned").len() as i32, // todo can this be made more compact and without acquisition? self.panels.read().expect("Panels RwLock poisoned").len() as i32, // todo can this be made more compact and without acquisition?
)?; // TODO make values customizable and useful )?; // TODO make values customizable and useful
self.panels self.panels

View file

@ -102,13 +102,25 @@ impl SQLiteDataStore {
Ok(values) Ok(values)
} }
pub fn put_value(&self, metric_id: i32, v: Value) -> rusqlite::Result<usize> { pub fn put_value(&self, metric_id: i32, v: &Value) -> rusqlite::Result<usize> {
self.conn.execute( self.conn.execute(
"INSERT INTO points(metric_id, x, y) VALUES (?, ?, ?)", "INSERT INTO points(metric_id, x, y) VALUES (?, ?, ?)",
params![metric_id, v.x, v.y], params![metric_id, v.x, v.y],
) )
} }
pub fn put_values(&mut self, metric_id: i32, values: &Vec<Value>) -> rusqlite::Result<()> {
let tx = self.conn.transaction()?;
for v in values {
tx.execute(
"INSERT INTO points(metric_id, x, y) VALUES (?, ?, ?)",
params![metric_id, v.x, v.y],
)?;
}
tx.commit()?;
Ok(())
}
pub fn delete_values(&self, metric_id: i32) -> rusqlite::Result<usize> { pub fn delete_values(&self, metric_id: i32) -> rusqlite::Result<usize> {
self.conn.execute( self.conn.execute(
"DELETE FROM points WHERE metric_id = ?", "DELETE FROM points WHERE metric_id = ?",
@ -327,11 +339,12 @@ impl SQLiteDataStore {
limit: bool, limit: bool,
reduce: bool, reduce: bool,
shift: bool, shift: bool,
average: bool,
position: i32, position: i32,
) -> rusqlite::Result<Panel> { ) -> rusqlite::Result<Panel> {
self.conn.execute( self.conn.execute(
"INSERT INTO panels (name, view_scroll, view_size, timeserie, width, height, limit_view, position, reduce_view, view_chunks, shift_view, view_offset) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO panels (name, view_scroll, view_size, timeserie, width, height, limit_view, position, reduce_view, view_chunks, shift_view, view_offset, average_view) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![name, view_scroll, view_size, timeserie, width, height, limit, position, reduce, view_chunks, shift, view_offset] params![name, view_scroll, view_size, timeserie, width, height, limit, position, reduce, view_chunks, shift, view_offset, average]
)?; )?;
let mut statement = self.conn.prepare("SELECT * FROM panels WHERE name = ?")?; let mut statement = self.conn.prepare("SELECT * FROM panels WHERE name = ?")?;
for panel in statement.query_map(params![name], |row| { for panel in statement.query_map(params![name], |row| {

View file

@ -13,7 +13,7 @@ pub fn confirmation_popup_delete_metric(app: &mut App, ui: &mut Ui, metric_index
ui.label("This will remove all its metrics and delete all points from archive. This action CANNOT BE UNDONE!"); ui.label("This will remove all its metrics and delete all points from archive. This action CANNOT BE UNDONE!");
ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { ui.with_layout(Layout::top_down(Align::RIGHT), |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button("yes").clicked() { if ui.button("\n yes \n").clicked() {
let store = app.data.storage.lock().expect("Storage Mutex poisoned"); let store = app.data.storage.lock().expect("Storage Mutex poisoned");
let mut metrics = app.data.metrics.write().expect("Metrics RwLock poisoned"); let mut metrics = app.data.metrics.write().expect("Metrics RwLock poisoned");
store.delete_metric(metrics[metric_index].id).expect("Failed deleting metric"); store.delete_metric(metrics[metric_index].id).expect("Failed deleting metric");
@ -21,7 +21,7 @@ pub fn confirmation_popup_delete_metric(app: &mut App, ui: &mut Ui, metric_index
metrics.remove(metric_index); metrics.remove(metric_index);
app.deleting_metric = None; app.deleting_metric = None;
} }
if ui.button(" no ").clicked() { if ui.button("\n no \n").clicked() {
app.deleting_metric = None; app.deleting_metric = None;
} }
}); });
@ -34,7 +34,7 @@ pub fn confirmation_popup_delete_source(app: &mut App, ui: &mut Ui, source_index
ui.label("This will remove all its metrics and delete all points from archive. This action CANNOT BE UNDONE!"); ui.label("This will remove all its metrics and delete all points from archive. This action CANNOT BE UNDONE!");
ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { ui.with_layout(Layout::top_down(Align::RIGHT), |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button("YEAH").clicked() { if ui.button("\n yes \n").clicked() {
let store = app.data.storage.lock().expect("Storage Mutex poisoned"); let store = app.data.storage.lock().expect("Storage Mutex poisoned");
let mut sources = app.data.sources.write().expect("sources RwLock poisoned"); let mut sources = app.data.sources.write().expect("sources RwLock poisoned");
let mut metrics = app.data.metrics.write().expect("Metrics RwLock poisoned"); let mut metrics = app.data.metrics.write().expect("Metrics RwLock poisoned");
@ -53,7 +53,7 @@ pub fn confirmation_popup_delete_source(app: &mut App, ui: &mut Ui, source_index
sources.remove(source_index); sources.remove(source_index);
app.deleting_source = None; app.deleting_source = None;
} }
if ui.button(" NO WAY ").clicked() { if ui.button("\n no \n").clicked() {
app.deleting_source = None; app.deleting_source = None;
} }
}); });

View file

@ -1,26 +1,36 @@
use eframe::{egui::{Ui, TextEdit, DragValue, Checkbox, ScrollArea, Layout}, emath::Align}; use eframe::{
egui::{Checkbox, DragValue, Layout, ScrollArea, TextEdit, Ui},
emath::Align, epaint::Color32,
};
use rfd::FileDialog;
use tracing::error; use tracing::error;
use crate::app::{data::source::{Source, Metric}, App}; use crate::app::{
data::source::{Metric, Source},
util::{deserialize_values, serialize_values},
App,
};
use super::metric::{metric_edit_ui, metric_display_ui}; use super::metric::{metric_display_ui, metric_edit_ui};
pub fn source_panel(app: &mut App, ui: &mut Ui) { pub fn source_panel(app: &mut App, ui: &mut Ui) {
let mut to_swap: Option<usize> = None; let mut to_swap: Option<usize> = None;
let mut to_insert: Vec<Metric> = Vec::new();
// let mut to_delete: Option<usize> = None; // let mut to_delete: Option<usize> = None;
let panels = app.data.panels.read().expect("Panels RwLock poisoned"); let panels = app.data.panels.read().expect("Panels RwLock poisoned");
let panel_width = ui.available_width(); let panel_width = ui.available_width();
ScrollArea::both().max_width(panel_width).show(ui, |ui| { ScrollArea::vertical()
.max_width(panel_width)
.show(ui, |ui| {
// TODO only vertical! // TODO only vertical!
{ {
let mut sources = let mut sources = app.data.sources.write().expect("Sources RwLock poisoned");
app.data.sources.write().expect("Sources RwLock poisoned");
let sources_count = sources.len(); let sources_count = sources.len();
ui.heading("Sources"); ui.heading("Sources");
ui.separator(); ui.separator();
for (i, source) in sources.iter_mut().enumerate() { for (i, source) in sources.iter_mut().enumerate() {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if app.edit { if app.edit { // show buttons to move sources up and down
ui.vertical(|ui| { ui.vertical(|ui| {
ui.add_space(10.0); ui.add_space(10.0);
if ui.small_button("+").clicked() { if ui.small_button("+").clicked() {
@ -35,25 +45,48 @@ pub fn source_panel(app: &mut App, ui: &mut Ui) {
} }
}); });
} }
ui.vertical(|ui| { ui.vertical(|ui| { // actual sources list container
let remaining_width = ui.available_width(); let remaining_width = ui.available_width();
if app.edit { if app.edit {
ui.group(|ui| { ui.group(|ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
source_edit_ui( source_edit_ui(ui, source, remaining_width - 34.0);
ui,
source,
remaining_width - 34.0,
);
if ui.small_button("×").clicked() { if ui.small_button("×").clicked() {
app.deleting_metric = None; app.deleting_metric = None;
app.deleting_source = Some(i); app.deleting_source = Some(i);
} }
}); });
for (j, metric) in app.data.metrics.write().expect("Metrics RwLock poisoned").iter_mut().enumerate() { let mut metrics = app
.data
.metrics
.write()
.expect("Metrics RwLock poisoned");
for (j, metric) in metrics.iter_mut().enumerate() {
if metric.source_id == source.id { if metric.source_id == source.id {
ui.horizontal(|ui| { ui.horizontal(|ui| {
metric_edit_ui(ui, metric, Some(&panels), remaining_width - 31.0); metric_edit_ui(
ui,
metric,
Some(&panels),
remaining_width - 53.0,
);
if ui.small_button("s").clicked() {
let path = FileDialog::new()
.add_filter("csv", &["csv"])
.set_file_name(format!("{}-{}.csv", source.name, metric.name).as_str())
.save_file();
if let Some(path) = path {
serialize_values(
&*metric
.data
.read()
.expect("Values RwLock poisoned"),
metric,
path,
)
.expect("Could not serialize data");
}
}
if ui.small_button("×").clicked() { if ui.small_button("×").clicked() {
app.deleting_source = None; app.deleting_source = None;
app.deleting_metric = Some(j); app.deleting_metric = Some(j);
@ -66,17 +99,55 @@ pub fn source_panel(app: &mut App, ui: &mut Ui) {
ui, ui,
&mut app.input_metric, &mut app.input_metric,
None, None,
remaining_width - 30.0, remaining_width - 53.0,
); );
if ui.small_button(" + ").clicked() { // TODO find a better ui.add_space(2.0);
if let Err(e) = app if ui.small_button(" + ").clicked() {
.data // TODO find a better
.add_metric(&app.input_metric, source) if let Err(e) =
app.data.add_metric(&app.input_metric, source)
{ {
error!(target: "ui", "Error adding metric : {:?}", e); error!(target: "ui", "Error adding metric : {:?}", e);
} }
} }
ui.add_space(1.0); // DAMN! ui.add_space(1.0); // DAMN!
if ui.small_button("o").clicked() {
let path = FileDialog::new()
.add_filter("csv", &["csv"])
.pick_file();
if let Some(path) = path {
match deserialize_values(path) {
Ok((name, query_x, query_y, data)) => {
let mut store = app
.data
.storage
.lock()
.expect("Storage Mutex poisoned");
match store.new_metric(
name.as_str(),
source.id,
query_x.as_str(),
query_y.as_str(),
-1,
Color32::TRANSPARENT,
metrics.len() as i32,
) {
Ok(verified_metric) => {
store.put_values(verified_metric.id, &data).unwrap();
*verified_metric.data.write().expect("Values RwLock poisoned") = data;
to_insert.push(verified_metric);
}
Err(e) => {
error!(target: "ui", "could not save metric into archive : {:?}", e);
}
}
}
Err(e) => {
error!(target: "ui", "Could not deserialize metric from file : {:?}", e);
}
}
}
}
if ui.small_button("×").clicked() { if ui.small_button("×").clicked() {
app.input_metric = Metric::default(); app.input_metric = Metric::default();
} }
@ -85,11 +156,7 @@ pub fn source_panel(app: &mut App, ui: &mut Ui) {
} else { } else {
let metrics = let metrics =
app.data.metrics.read().expect("Metrics RwLock poisoned"); app.data.metrics.read().expect("Metrics RwLock poisoned");
source_display_ui( source_display_ui(ui, source, remaining_width);
ui,
source,
remaining_width,
);
for metric in metrics.iter() { for metric in metrics.iter() {
if metric.source_id == source.id { if metric.source_id == source.id {
metric_display_ui(ui, metric, ui.available_width()); metric_display_ui(ui, metric, ui.available_width());
@ -118,11 +185,7 @@ pub fn source_panel(app: &mut App, ui: &mut Ui) {
}); });
}); });
}); });
source_edit_ui( source_edit_ui(ui, &mut app.input_source, panel_width - 10.0);
ui,
&mut app.input_source,
panel_width - 10.0,
);
ui.add_space(5.0); ui.add_space(5.0);
if app.padding { if app.padding {
ui.add_space(300.0); ui.add_space(300.0);
@ -139,12 +202,21 @@ pub fn source_panel(app: &mut App, ui: &mut Ui) {
let mut sources = app.data.sources.write().expect("Sources RwLock poisoned"); let mut sources = app.data.sources.write().expect("Sources RwLock poisoned");
sources.swap(i - 1, i); sources.swap(i - 1, i);
} }
if to_insert.len() > 0 {
let mut metrics = app.data.metrics.write().expect("Metrics RwLock poisoned");
for m in to_insert {
metrics.push(m);
}
}
} }
pub fn source_display_ui(ui: &mut Ui, source: &mut Source, _width: f32) { pub fn source_display_ui(ui: &mut Ui, source: &mut Source, _width: f32) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add_enabled(false, Checkbox::new(&mut source.enabled, "")); ui.add_enabled(false, Checkbox::new(&mut source.enabled, ""));
ui.add_enabled(false, DragValue::new(&mut source.interval).clamp_range(1..=120)); ui.add_enabled(
false,
DragValue::new(&mut source.interval).clamp_range(1..=120),
);
ui.heading(&source.name).on_hover_text(&source.url); ui.heading(&source.name).on_hover_text(&source.url);
}); });
} }

View file

@ -54,18 +54,18 @@ impl eframe::App for App {
}); });
if let Some(index) = self.deleting_metric { if let Some(index) = self.deleting_metric {
Window::new(format!("Delete Metric #{}", index)) Window::new(format!("Delete Metric #{}?", index))
.show(ctx, |ui| confirmation_popup_delete_metric(self, ui, index)); .show(ctx, |ui| confirmation_popup_delete_metric(self, ui, index));
} }
if let Some(index) = self.deleting_source { if let Some(index) = self.deleting_source {
Window::new(format!("Delete Source #{}", index)) Window::new(format!("Delete Source #{}?", index))
.show(ctx, |ui| confirmation_popup_delete_source(self, ui, index)); .show(ctx, |ui| confirmation_popup_delete_source(self, ui, index));
} }
if self.sources { if self.sources {
SidePanel::left("sources-bar") SidePanel::left("sources-bar")
.width_range(280.0..=800.0) .width_range(if self.edit { 400.0..=1000.0 } else { 280.0..=680.0 })
.default_width(330.0) .default_width(if self.edit { 450.0 } else { 330.0 })
.show(ctx, |ui| source_panel(self, ui)); .show(ctx, |ui| source_panel(self, ui));
} }

View file

@ -1,13 +1,50 @@
use chrono::{DateTime, Local, NaiveDateTime, Utc}; use chrono::{DateTime, Local, NaiveDateTime, Utc};
use eframe::egui::Color32; use eframe::egui::{Color32, plot::Value};
use std::sync::Arc; use std::{sync::Arc, error::Error, path::PathBuf};
use tracing_subscriber::Layer; use tracing_subscriber::Layer;
use super::data::ApplicationState; use super::data::{ApplicationState, source::Metric};
// if you're handling more than terabytes of data, it's the future and you ought to update this code! // if you're handling more than terabytes of data, it's the future and you ought to update this code!
const PREFIXES: &'static [&'static str] = &["", "k", "M", "G", "T"]; const PREFIXES: &'static [&'static str] = &["", "k", "M", "G", "T"];
pub fn serialize_values(values: &Vec<Value>, metric: &Metric, path: PathBuf) -> Result<(), Box<dyn Error>> {
let mut wtr = csv::Writer::from_writer(std::fs::File::create(path)?);
wtr.write_record(&[metric.name.as_str(), metric.query_x.as_str(), metric.query_y.as_str()])?;
for v in values {
wtr.serialize(("", v.x, v.y))?;
}
wtr.flush()?;
Ok(())
}
pub fn deserialize_values(path: PathBuf) -> Result<(String, String, String, Vec<Value>), Box<dyn Error>> {
let mut values = Vec::new();
let mut rdr = csv::Reader::from_reader(std::fs::File::open(path)?);
let mut name = "N/A".to_string();
let mut query_x = "".to_string();
let mut query_y = "".to_string();
if rdr.has_headers() {
let record = rdr.headers()?;
name = record[0].to_string();
query_x = record[1].to_string();
query_y = record[2].to_string();
}
for result in rdr.records() {
if let Ok(record) = result {
values.push(Value { x: record[1].parse::<f64>()?, y: record[2].parse::<f64>()? });
}
}
Ok((
name,
query_x,
query_y,
values,
))
}
pub fn human_size(size: u64) -> String { pub fn human_size(size: u64) -> String {
let mut buf: f64 = size as f64; let mut buf: f64 = size as f64;
let mut prefix: usize = 0; let mut prefix: usize = 0;

View file

@ -88,6 +88,7 @@ impl BackgroundWorker for NativeBackgroundWorker {
.expect("Sources RwLock poisoned"); .expect("Sources RwLock poisoned");
*last_update = Utc::now(); *last_update = Utc::now();
let state2 = state.clone(); let state2 = state.clone();
let source_name = sources[j].name.clone();
let url = sources[j].url.clone(); let url = sources[j].url.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
// TODO this can overspawn if a request takes longer than the refresh interval! // TODO this can overspawn if a request takes longer than the refresh interval!
@ -100,12 +101,12 @@ impl BackgroundWorker for NativeBackgroundWorker {
match metric.extract(&res) { match metric.extract(&res) {
Ok(v) => { Ok(v) => {
metric.data.write().expect("Data RwLock poisoned").push(v); metric.data.write().expect("Data RwLock poisoned").push(v);
if let Err(e) = store.put_value(metric.id, v) { if let Err(e) = store.put_value(metric.id, &v) {
warn!(target:"background-worker", "Could not put sample for source #{} in db: {:?}", s_id, e); warn!(target:"background-worker", "Could not put sample for source #{} in db: {:?}", s_id, e);
} }
} }
Err(e) => { Err(e) => {
warn!(target:"background-worker", "[{}] Could not extract value from result: {:?}", metric.name, e); // TODO: more info! warn!(target:"background-worker", "[{}|{}] Could not extract value from result: {:?}", source_name, metric.name, e); // TODO: more info!
} }
} }
} }