feat: allow to move sources, side panel

now sources are all edited in one side panel, which opens in edit mode.
Easily move sources across panels.
Implementation has a lot of room for improvement (too many loops) but it
works for now
This commit is contained in:
əlemi 2022-06-09 02:52:17 +02:00
parent 83a49f07c5
commit 649b0be848
Signed by: alemi
GPG key ID: A4895B84D311642C
7 changed files with 102 additions and 110 deletions

View file

@ -1,6 +1,6 @@
[package] [package]
name = "dashboard" name = "dashboard"
version = "0.1.1" version = "0.1.2"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
@ -17,4 +17,4 @@ serde_json = "1"
rusqlite = { version = "0.27" } rusqlite = { version = "0.27" }
jql = { version = "4", default-features = false } jql = { version = "4", default-features = false }
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
eframe = { version = "0.18", features = ["persistence"] } eframe = { version = "0.18", features = ["persistence"] }

View file

@ -1,2 +1,14 @@
# dashboard # 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`

View file

@ -18,7 +18,6 @@ pub enum FetchError {
JQLError(String), JQLError(String),
RusqliteError(rusqlite::Error), RusqliteError(rusqlite::Error),
ParseFloatError(ParseFloatError), ParseFloatError(ParseFloatError),
NoPanelWithThatIdError,
} }
impl From::<ureq::Error> for FetchError { impl From::<ureq::Error> for FetchError {
@ -42,6 +41,7 @@ pub struct ApplicationState {
pub file_path: PathBuf, pub file_path: PathBuf,
pub file_size: RwLock<u64>, pub file_size: RwLock<u64>,
pub panels: RwLock<Vec<Panel>>, pub panels: RwLock<Vec<Panel>>,
pub sources: RwLock<Vec<Source>>,
pub storage: Mutex<SQLiteDataStore>, pub storage: Mutex<SQLiteDataStore>,
} }
@ -50,12 +50,14 @@ impl ApplicationState {
let storage = SQLiteDataStore::new(path.clone()).unwrap(); let storage = SQLiteDataStore::new(path.clone()).unwrap();
let panels = storage.load_panels().unwrap(); let panels = storage.load_panels().unwrap();
let sources = storage.load_sources().unwrap();
return ApplicationState{ return ApplicationState{
run: true, run: true,
file_size: RwLock::new(std::fs::metadata(path.clone()).unwrap().len()), file_size: RwLock::new(std::fs::metadata(path.clone()).unwrap().len()),
file_path: path, file_path: path,
panels: RwLock::new(panels), panels: RwLock::new(panels),
sources: RwLock::new(sources),
storage: Mutex::new(storage), 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> { 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") let source = self.storage.lock().expect("Storage Mutex poisoned")
.new_source(panel_id, name, url, query_x, query_y, color, visible)?; .new_source(panel_id, name, url, query_x, query_y, color, visible)?;
let panels = self.panels.read().expect("Panels RwLock poisoned"); self.sources.write().expect("Sources RwLock poisoned").push(source);
for panel in &*panels { return Ok(());
if panel.id == panel_id {
panel.sources.write().expect("Sources RwLock poisoned").push(source);
return Ok(());
}
}
Err(FetchError::NoPanelWithThatIdError)
} }
} }
@ -88,8 +84,6 @@ pub struct Panel {
pub timeserie: bool, pub timeserie: bool,
pub(crate) width: i32, pub(crate) width: i32,
pub(crate) height: i32, pub(crate) height: i32,
pub(crate) sources: RwLock<Vec<Source>>,
} }
impl Panel { impl Panel {
@ -107,7 +101,7 @@ pub struct Source {
// pub(crate) compiled_query_x: Arc<Mutex<jq_rs::JqProgram>>, // pub(crate) compiled_query_x: Arc<Mutex<jq_rs::JqProgram>>,
pub query_y: String, pub query_y: String,
// pub(crate) compiled_query_y: Arc<Mutex<jq_rs::JqProgram>>, // pub(crate) compiled_query_y: Arc<Mutex<jq_rs::JqProgram>>,
// pub(crate) panel_id: i32, pub(crate) panel_id: i32,
pub(crate) data: RwLock<Vec<Value>>, pub(crate) data: RwLock<Vec<Value>>,
} }

View file

@ -48,7 +48,6 @@ impl SQLiteDataStore {
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS points ( "CREATE TABLE IF NOT EXISTS points (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
panel_id INT NOT NULL,
source_id INT NOT NULL, source_id INT NOT NULL,
x FLOAT NOT NULL, x FLOAT NOT NULL,
y 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<Vec<Value>> { pub fn load_values(&self, source_id: i32) -> rusqlite::Result<Vec<Value>> {
let mut values: Vec<Value> = Vec::new(); let mut values: Vec<Value> = Vec::new();
let mut statement = self let mut statement = self
.conn .conn
.prepare("SELECT x, y FROM points WHERE panel_id = ? AND source_id = ?")?; .prepare("SELECT x, y FROM points WHERE source_id = ?")?;
let values_iter = statement.query_map(params![panel_id, source_id], |row| { let values_iter = statement.query_map(params![source_id], |row| {
Ok(Value { Ok(Value {
x: row.get(0)?, x: row.get(0)?,
y: row.get(1)?, y: row.get(1)?,
@ -82,21 +81,21 @@ impl SQLiteDataStore {
Ok(values) Ok(values)
} }
pub fn put_value(&self, panel_id: i32, source_id: i32, v: Value) -> rusqlite::Result<usize> { pub fn put_value(&self, source_id: i32, v: Value) -> rusqlite::Result<usize> {
self.conn.execute( self.conn.execute(
"INSERT INTO points(panel_id, source_id, x, y) VALUES (?, ?, ?, ?)", "INSERT INTO points(source_id, x, y) VALUES (?, ?, ?)",
params![panel_id, source_id, v.x, v.y], params![source_id, v.x, v.y],
) )
} }
pub fn load_sources(&self, panel_id: i32) -> rusqlite::Result<Vec<Source>> { pub fn load_sources(&self) -> rusqlite::Result<Vec<Source>> {
let mut sources: Vec<Source> = Vec::new(); let mut sources: Vec<Source> = Vec::new();
let mut statement = self let mut statement = self
.conn .conn
.prepare("SELECT * FROM sources WHERE panel_id = ?")?; .prepare("SELECT * FROM sources")?;
let sources_iter = statement.query_map(params![panel_id], |row| { let sources_iter = statement.query_map([], |row| {
Ok(Source { Ok(Source {
id: row.get(0)?, id: row.get(0)?,
name: row.get(1)?, name: row.get(1)?,
@ -107,7 +106,7 @@ impl SQLiteDataStore {
// compiled_query_x: Arc::new(Mutex::new(jq_rs::compile(row.get::<usize, String>(4)?.as_str()).unwrap())), // compiled_query_x: Arc::new(Mutex::new(jq_rs::compile(row.get::<usize, String>(4)?.as_str()).unwrap())),
query_y: row.get(5)?, query_y: row.get(5)?,
// compiled_query_y: Arc::new(Mutex::new(jq_rs::compile(row.get::<usize, String>(5)?.as_str()).unwrap())), // compiled_query_y: Arc::new(Mutex::new(jq_rs::compile(row.get::<usize, String>(5)?.as_str()).unwrap())),
// panel_id: row.get(6)?, panel_id: row.get(6)?,
color: unpack_color(row.get(7).unwrap_or(0)), color: unpack_color(row.get(7).unwrap_or(0)),
visible: row.get(8)?, visible: row.get(8)?,
data: RwLock::new(Vec::new()), data: RwLock::new(Vec::new()),
@ -116,7 +115,7 @@ impl SQLiteDataStore {
for source in sources_iter { for source in sources_iter {
if let Ok(mut s) = source { 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); sources.push(s);
} }
} }
@ -154,7 +153,7 @@ impl SQLiteDataStore {
// compiled_query_x: Arc::new(Mutex::new(jq_rs::compile(row.get::<usize, String>(4)?.as_str()).unwrap())), // compiled_query_x: Arc::new(Mutex::new(jq_rs::compile(row.get::<usize, String>(4)?.as_str()).unwrap())),
query_y: row.get(5)?, query_y: row.get(5)?,
// compiled_query_y: Arc::new(Mutex::new(jq_rs::compile(row.get::<usize, String>(5)?.as_str()).unwrap())), // compiled_query_y: Arc::new(Mutex::new(jq_rs::compile(row.get::<usize, String>(5)?.as_str()).unwrap())),
// panel_id: row.get(6)?, panel_id: row.get(6)?,
color: unpack_color(row.get(7).unwrap_or(0)), color: unpack_color(row.get(7).unwrap_or(0)),
visible: row.get(8)?, visible: row.get(8)?,
data: RwLock::new(Vec::new()), data: RwLock::new(Vec::new()),
@ -171,6 +170,7 @@ impl SQLiteDataStore {
pub fn update_source( pub fn update_source(
&self, &self,
source_id: i32, source_id: i32,
panel_id: i32,
name: &str, name: &str,
url: &str, url: &str,
interval: i32, interval: i32,
@ -181,8 +181,8 @@ impl SQLiteDataStore {
) -> rusqlite::Result<usize> { ) -> rusqlite::Result<usize> {
let color_u32 : Option<u32> = if color == Color32::TRANSPARENT { None } else { Some(repack_color(color)) }; let color_u32 : Option<u32> = if color == Color32::TRANSPARENT { None } else { Some(repack_color(color)) };
self.conn.execute( self.conn.execute(
"UPDATE sources SET name = ?, url = ?, interval = ?, query_x = ?, query_y = ?, color = ?, visible = ? WHERE 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, color_u32, visible, source_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)?, timeserie: row.get(4)?,
width: row.get(5)?, width: row.get(5)?,
height: row.get(6)?, height: row.get(6)?,
sources: RwLock::new(Vec::new()),
}) })
})?; })?;
for panel in panels_iter { for panel in panels_iter {
if let Ok(mut p) = panel { if let Ok(p) = panel {
p.sources = RwLock::new(self.load_sources(p.id)?);
panels.push(p); panels.push(p);
} }
} }
@ -232,7 +230,6 @@ impl SQLiteDataStore {
timeserie: row.get(4)?, timeserie: row.get(4)?,
width: row.get(5)?, width: row.get(5)?,
height: row.get(6)?, height: row.get(6)?,
sources: RwLock::new(Vec::new()),
}) })
})? { })? {
if let Ok(p) = panel { if let Ok(p) = panel {

View file

@ -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::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| {
let mut panels = self.data.panels.write().unwrap(); // TODO only lock as write when editing 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 { for panel in &mut *panels {
let mut sources = panel.sources.write().unwrap(); // TODO only lock as write when editing
ui.group(|ui| { ui.group(|ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.heading(panel.name.as_str()); ui.heading(panel.name.as_str());
ui.separator(); ui.separator();
for source in &mut *sources { for source in &*sources {
if self.edit { if source.panel_id == panel.id {
ui.checkbox(&mut source.visible, ""); 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 { if self.filter {
ui.add(egui::Slider::new(&mut panel.view_size, 1..=1440).text("samples")); ui.add(egui::Slider::new(&mut panel.view_size, 1..=1440).text("samples"));
@ -161,23 +191,6 @@ impl eframe::App for App {
ui.separator(); 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)) let mut p = Plot::new(format!("plot-{}", panel.name))
.height(panel.height as f32) .height(panel.height as f32)
.allow_scroll(false); .allow_scroll(false);
@ -201,8 +214,8 @@ impl eframe::App for App {
} }
p.show(ui, |plot_ui| { p.show(ui, |plot_ui| {
for source in &mut *sources { for source in &*sources {
if source.visible { if source.visible && source.panel_id == panel.id {
let line = if self.filter { 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()) Line::new(source.values_filter((Utc::now().timestamp() - (panel.view_size as i64 * 60)) as f64)).name(source.name.as_str())
} else { } else {

View file

@ -17,10 +17,11 @@ pub fn native_save(state:Arc<ApplicationState>) {
panel.width, panel.width,
panel.height panel.height
).unwrap(); ).unwrap();
let sources = panel.sources.read().unwrap(); let sources = state.sources.read().unwrap();
for source in &*sources { for source in &*sources {
storage.update_source( storage.update_source(
source.id, source.id,
source.panel_id,
source.name.as_str(), source.name.as_str(),
source.url.as_str(), source.url.as_str(),
source.interval, source.interval,
@ -54,30 +55,25 @@ impl BackgroundWorker for NativeBackgroundWorker {
} }
last_check = Utc::now().timestamp_millis(); last_check = Utc::now().timestamp_millis();
let panels = state.panels.read().unwrap(); let sources = state.sources.read().unwrap();
for i in 0..panels.len() { for j in 0..sources.len() {
let sources = panels[i].sources.read().unwrap(); let s_id = sources[j].id;
let p_id = panels[i].id; if !sources[j].valid() {
for j in 0..sources.len() { let mut last_update = sources[j].last_fetch.write().unwrap();
let s_id = sources[j].id; *last_update = Utc::now();
if !sources[j].valid() { 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(); let mut last_update = sources[j].last_fetch.write().unwrap();
*last_update = Utc::now(); *last_update = Utc::now(); // overwrite it so fetches comply with API slowdowns and get desynched among them
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
});
}
} }
} }

View file

@ -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))))
}