diff --git a/Cargo.toml b/Cargo.toml index 3755ccf..117deb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dashboard" -version = "0.1.1" +version = "0.1.2" edition = "2021" [[bin]] @@ -17,4 +17,4 @@ serde_json = "1" rusqlite = { version = "0.27" } jql = { version = "4", default-features = false } ureq = { version = "2", features = ["json"] } -eframe = { version = "0.18", features = ["persistence"] } +eframe = { version = "0.18", features = ["persistence"] } \ No newline at end of file diff --git a/README.md b/README.md index eaaabbf..f41e4c3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,14 @@ # dashboard -My custom dashboard, displaying stats and controls +A data aggregating dashboard, capable of periodically fetching, parsing, archiving and plotting data. + +## Features + +## Usage +This program will work on a database stored in `$HOME/.local/share/dashboard.db`. By default, nothing will be shown. +Start editing your dashboard by toggling edit mode on, and add one or more panels (from top bar). +You can now add sources to your panel(s): put an URL pointing to any REST api, dashboard will make a periodic GET request. +Specify how to access data with "y" fields. A JQL query will be used to parse the json data. A value to fetch X data can also be given, if not specified, current time will be used as X when inserting values. +Done! Edit anything to your pleasure, remember to save after editing to make your changes persist, and leave the dashboard hoarding data. +## Install +idk, `cargo build --release` + diff --git a/src/app/data/mod.rs b/src/app/data/mod.rs index b5f39ca..fc242ee 100644 --- a/src/app/data/mod.rs +++ b/src/app/data/mod.rs @@ -18,7 +18,6 @@ pub enum FetchError { JQLError(String), RusqliteError(rusqlite::Error), ParseFloatError(ParseFloatError), - NoPanelWithThatIdError, } impl From:: for FetchError { @@ -42,6 +41,7 @@ pub struct ApplicationState { pub file_path: PathBuf, pub file_size: RwLock, pub panels: RwLock>, + pub sources: RwLock>, pub storage: Mutex, } @@ -50,12 +50,14 @@ impl ApplicationState { let storage = SQLiteDataStore::new(path.clone()).unwrap(); let panels = storage.load_panels().unwrap(); + let sources = storage.load_sources().unwrap(); return ApplicationState{ run: true, file_size: RwLock::new(std::fs::metadata(path.clone()).unwrap().len()), file_path: path, panels: RwLock::new(panels), + sources: RwLock::new(sources), storage: Mutex::new(storage), }; } @@ -69,14 +71,8 @@ impl ApplicationState { pub fn add_source(&self, panel_id:i32, name:&str, url:&str, query_x:&str, query_y:&str, color:Color32, visible:bool) -> Result<(), FetchError> { let source = self.storage.lock().expect("Storage Mutex poisoned") .new_source(panel_id, name, url, query_x, query_y, color, visible)?; - let panels = self.panels.read().expect("Panels RwLock poisoned"); - for panel in &*panels { - if panel.id == panel_id { - panel.sources.write().expect("Sources RwLock poisoned").push(source); - return Ok(()); - } - } - Err(FetchError::NoPanelWithThatIdError) + self.sources.write().expect("Sources RwLock poisoned").push(source); + return Ok(()); } } @@ -88,8 +84,6 @@ pub struct Panel { pub timeserie: bool, pub(crate) width: i32, pub(crate) height: i32, - pub(crate) sources: RwLock>, - } impl Panel { @@ -107,7 +101,7 @@ pub struct Source { // pub(crate) compiled_query_x: Arc>, pub query_y: String, // pub(crate) compiled_query_y: Arc>, - // pub(crate) panel_id: i32, + pub(crate) panel_id: i32, pub(crate) data: RwLock>, } diff --git a/src/app/data/store.rs b/src/app/data/store.rs index eebee11..dda9ba1 100644 --- a/src/app/data/store.rs +++ b/src/app/data/store.rs @@ -48,7 +48,6 @@ impl SQLiteDataStore { conn.execute( "CREATE TABLE IF NOT EXISTS points ( id INTEGER PRIMARY KEY, - panel_id INT NOT NULL, source_id INT NOT NULL, x FLOAT NOT NULL, y FLOAT NOT NULL @@ -61,12 +60,12 @@ impl SQLiteDataStore { - pub fn load_values(&self, panel_id: i32, source_id: i32) -> rusqlite::Result> { + pub fn load_values(&self, source_id: i32) -> rusqlite::Result> { let mut values: Vec = Vec::new(); let mut statement = self .conn - .prepare("SELECT x, y FROM points WHERE panel_id = ? AND source_id = ?")?; - let values_iter = statement.query_map(params![panel_id, source_id], |row| { + .prepare("SELECT x, y FROM points WHERE source_id = ?")?; + let values_iter = statement.query_map(params![source_id], |row| { Ok(Value { x: row.get(0)?, y: row.get(1)?, @@ -82,21 +81,21 @@ impl SQLiteDataStore { Ok(values) } - pub fn put_value(&self, panel_id: i32, source_id: i32, v: Value) -> rusqlite::Result { + pub fn put_value(&self, source_id: i32, v: Value) -> rusqlite::Result { self.conn.execute( - "INSERT INTO points(panel_id, source_id, x, y) VALUES (?, ?, ?, ?)", - params![panel_id, source_id, v.x, v.y], + "INSERT INTO points(source_id, x, y) VALUES (?, ?, ?)", + params![source_id, v.x, v.y], ) } - pub fn load_sources(&self, panel_id: i32) -> rusqlite::Result> { + pub fn load_sources(&self) -> rusqlite::Result> { let mut sources: Vec = Vec::new(); let mut statement = self .conn - .prepare("SELECT * FROM sources WHERE panel_id = ?")?; - let sources_iter = statement.query_map(params![panel_id], |row| { + .prepare("SELECT * FROM sources")?; + let sources_iter = statement.query_map([], |row| { Ok(Source { id: row.get(0)?, name: row.get(1)?, @@ -107,7 +106,7 @@ impl SQLiteDataStore { // compiled_query_x: Arc::new(Mutex::new(jq_rs::compile(row.get::(4)?.as_str()).unwrap())), query_y: row.get(5)?, // compiled_query_y: Arc::new(Mutex::new(jq_rs::compile(row.get::(5)?.as_str()).unwrap())), - // panel_id: row.get(6)?, + panel_id: row.get(6)?, color: unpack_color(row.get(7).unwrap_or(0)), visible: row.get(8)?, data: RwLock::new(Vec::new()), @@ -116,7 +115,7 @@ impl SQLiteDataStore { for source in sources_iter { if let Ok(mut s) = source { - s.data = RwLock::new(self.load_values(panel_id, s.id)?); + s.data = RwLock::new(self.load_values(s.id)?); sources.push(s); } } @@ -154,7 +153,7 @@ impl SQLiteDataStore { // compiled_query_x: Arc::new(Mutex::new(jq_rs::compile(row.get::(4)?.as_str()).unwrap())), query_y: row.get(5)?, // compiled_query_y: Arc::new(Mutex::new(jq_rs::compile(row.get::(5)?.as_str()).unwrap())), - // panel_id: row.get(6)?, + panel_id: row.get(6)?, color: unpack_color(row.get(7).unwrap_or(0)), visible: row.get(8)?, data: RwLock::new(Vec::new()), @@ -171,6 +170,7 @@ impl SQLiteDataStore { pub fn update_source( &self, source_id: i32, + panel_id: i32, name: &str, url: &str, interval: i32, @@ -181,8 +181,8 @@ impl SQLiteDataStore { ) -> rusqlite::Result { let color_u32 : Option = if color == Color32::TRANSPARENT { None } else { Some(repack_color(color)) }; self.conn.execute( - "UPDATE sources SET name = ?, url = ?, interval = ?, query_x = ?, query_y = ?, color = ?, visible = ? WHERE id = ?", - params![name, url, interval, query_x, query_y, color_u32, visible, source_id], + "UPDATE sources SET name = ?, url = ?, interval = ?, query_x = ?, query_y = ?, panel_id = ?, color = ?, visible = ? WHERE id = ?", + params![name, url, interval, query_x, query_y, panel_id, color_u32, visible, source_id], ) } @@ -202,13 +202,11 @@ impl SQLiteDataStore { timeserie: row.get(4)?, width: row.get(5)?, height: row.get(6)?, - sources: RwLock::new(Vec::new()), }) })?; for panel in panels_iter { - if let Ok(mut p) = panel { - p.sources = RwLock::new(self.load_sources(p.id)?); + if let Ok(p) = panel { panels.push(p); } } @@ -232,7 +230,6 @@ impl SQLiteDataStore { timeserie: row.get(4)?, width: row.get(5)?, height: row.get(6)?, - sources: RwLock::new(Vec::new()), }) })? { if let Ok(p) = panel { diff --git a/src/app/mod.rs b/src/app/mod.rs index 906d87c..1b53b1d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -126,29 +126,59 @@ impl eframe::App for App { }); }); }); + if self.edit { + egui::SidePanel::left("sources-bar").show(ctx, |ui| { + let mut sources = self.data.sources.write().expect("Sources RwLock poisoned"); + egui::ScrollArea::vertical().show(ui, |ui| { + for source in &mut *sources { + ui.group(|ui| { + ui.horizontal(|ui| { + ui.checkbox(&mut source.visible, ""); + eframe::egui::TextEdit::singleline(&mut source.name).hint_text("name").desired_width(80.0).show(ui); + eframe::egui::TextEdit::singleline(&mut source.url).hint_text("url").desired_width(300.0).show(ui); + }); + ui.horizontal(|ui| { + ui.add(egui::Slider::new(&mut source.interval, 1..=60)); + eframe::egui::TextEdit::singleline(&mut source.query_x).hint_text("x").desired_width(50.0).show(ui); + eframe::egui::TextEdit::singleline(&mut source.query_y).hint_text("y").desired_width(50.0).show(ui); + egui::ComboBox::from_id_source(format!("panel-{}", source.id)) + .selected_text(format!("panel [{}]", source.panel_id)) + .width(70.0) + .show_ui(ui, |ui| { + let pnls = self.data.panels.read().expect("Panels RwLock poisoned"); + for p in &*pnls { + ui.selectable_value(&mut source.panel_id, p.id, p.name.as_str()); + } + }); + ui.color_edit_button_srgba(&mut source.color); + }); + }); + } + }); + }); + } egui::CentralPanel::default().show(ctx, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { let mut panels = self.data.panels.write().unwrap(); // TODO only lock as write when editing + let sources = self.data.sources.read().unwrap(); // TODO only lock as write when editing for panel in &mut *panels { - let mut sources = panel.sources.write().unwrap(); // TODO only lock as write when editing ui.group(|ui| { ui.vertical(|ui| { ui.horizontal(|ui| { ui.heading(panel.name.as_str()); ui.separator(); - for source in &mut *sources { - if self.edit { - ui.checkbox(&mut source.visible, ""); + for source in &*sources { + if source.panel_id == panel.id { + if source.visible { + ui.label( + RichText::new(source.name.as_str()) + .color(if source.color == Color32::TRANSPARENT { Color32::GRAY } else { source.color }) + ); + } else { + ui.label(RichText::new(source.name.as_str()).color(Color32::BLACK)); + } + ui.separator(); } - if source.visible { - ui.label( - RichText::new(source.name.as_str()) - .color(if source.color == Color32::TRANSPARENT { Color32::GRAY } else { source.color }) - ); - } else { - ui.label(RichText::new(source.name.as_str()).color(Color32::BLACK)); - } - ui.separator(); } if self.filter { ui.add(egui::Slider::new(&mut panel.view_size, 1..=1440).text("samples")); @@ -161,23 +191,6 @@ impl eframe::App for App { ui.separator(); }); - - if self.edit { - for source in &mut *sources { - ui.horizontal(|ui| { - ui.add(egui::Slider::new(&mut source.interval, 1..=60)); - eframe::egui::TextEdit::singleline(&mut source.url).hint_text("url").desired_width(300.0).show(ui); - if !panel.timeserie { - eframe::egui::TextEdit::singleline(&mut source.query_x).hint_text("x").desired_width(50.0).show(ui); - } - eframe::egui::TextEdit::singleline(&mut source.query_y).hint_text("y").desired_width(50.0).show(ui); - ui.color_edit_button_srgba(&mut source.color); - ui.separator(); - ui.label(source.name.as_str()); - }); - } - } - let mut p = Plot::new(format!("plot-{}", panel.name)) .height(panel.height as f32) .allow_scroll(false); @@ -201,8 +214,8 @@ impl eframe::App for App { } p.show(ui, |plot_ui| { - for source in &mut *sources { - if source.visible { + for source in &*sources { + if source.visible && source.panel_id == panel.id { let line = if self.filter { Line::new(source.values_filter((Utc::now().timestamp() - (panel.view_size as i64 * 60)) as f64)).name(source.name.as_str()) } else { diff --git a/src/app/worker.rs b/src/app/worker.rs index 065ca36..b3e5e7a 100644 --- a/src/app/worker.rs +++ b/src/app/worker.rs @@ -17,10 +17,11 @@ pub fn native_save(state:Arc) { panel.width, panel.height ).unwrap(); - let sources = panel.sources.read().unwrap(); + let sources = state.sources.read().unwrap(); for source in &*sources { storage.update_source( source.id, + source.panel_id, source.name.as_str(), source.url.as_str(), source.interval, @@ -54,30 +55,25 @@ impl BackgroundWorker for NativeBackgroundWorker { } last_check = Utc::now().timestamp_millis(); - let panels = state.panels.read().unwrap(); - for i in 0..panels.len() { - let sources = panels[i].sources.read().unwrap(); - let p_id = panels[i].id; - for j in 0..sources.len() { - let s_id = sources[j].id; - if !sources[j].valid() { + let sources = state.sources.read().unwrap(); + for j in 0..sources.len() { + let s_id = sources[j].id; + if !sources[j].valid() { + let mut last_update = sources[j].last_fetch.write().unwrap(); + *last_update = Utc::now(); + let state2 = state.clone(); + let url = sources[j].url.clone(); + let query_x = sources[j].query_x.clone(); + let query_y = sources[j].query_y.clone(); + std::thread::spawn(move || { // TODO this can overspawn if a request takes longer than the refresh interval! + let v = fetch(url.as_str(), query_x.as_str(), query_y.as_str()).unwrap(); + let store = state2.storage.lock().unwrap(); + store.put_value(s_id, v).unwrap(); + let sources = state2.sources.read().unwrap(); + sources[j].data.write().unwrap().push(v); let mut last_update = sources[j].last_fetch.write().unwrap(); - *last_update = Utc::now(); - let state2 = state.clone(); - let url = sources[j].url.clone(); - let query_x = sources[j].query_x.clone(); - let query_y = sources[j].query_y.clone(); - std::thread::spawn(move || { // TODO this can overspawn if a request takes longer than the refresh interval! - let v = fetch(url.as_str(), query_x.as_str(), query_y.as_str()).unwrap(); - let store = state2.storage.lock().unwrap(); - store.put_value(p_id, s_id, v).unwrap(); - let panels = state2.panels.read().unwrap(); - let sources = panels[i].sources.read().unwrap(); - sources[j].data.write().unwrap().push(v); - let mut last_update = sources[j].last_fetch.write().unwrap(); - *last_update = Utc::now(); // overwrite it so fetches comply with API slowdowns and get desynched among them - }); - } + *last_update = Utc::now(); // overwrite it so fetches comply with API slowdowns and get desynched among them + }); } } diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 4cdb633..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -mod app; - -pub use app::App; - -// When compiling for web: - -#[cfg(target_arch = "wasm32")] -use eframe::wasm_bindgen::{self, prelude::*}; - -#[cfg(target_arch = "wasm32")] -#[wasm_bindgen] -pub fn start(canvas_id: &str) -> Result<(), eframe::wasm_bindgen::JsValue> { - // Make sure panics are logged using `console.error`. - console_error_panic_hook::set_once(); - - // Redirect tracing to console.log and friends: - tracing_wasm::set_as_global_default(); - - eframe::start_web(canvas_id, Box::new(|cc| Box::new(App::new(cc)))) -}