mirror of
https://git.alemi.dev/dashboard.git
synced 2024-11-14 03:49:19 +01:00
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:
parent
83a49f07c5
commit
649b0be848
7 changed files with 102 additions and 110 deletions
|
@ -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"] }
|
14
README.md
14
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`
|
||||
|
||||
|
|
|
@ -18,7 +18,6 @@ pub enum FetchError {
|
|||
JQLError(String),
|
||||
RusqliteError(rusqlite::Error),
|
||||
ParseFloatError(ParseFloatError),
|
||||
NoPanelWithThatIdError,
|
||||
}
|
||||
|
||||
impl From::<ureq::Error> for FetchError {
|
||||
|
@ -42,6 +41,7 @@ pub struct ApplicationState {
|
|||
pub file_path: PathBuf,
|
||||
pub file_size: RwLock<u64>,
|
||||
pub panels: RwLock<Vec<Panel>>,
|
||||
pub sources: RwLock<Vec<Source>>,
|
||||
pub storage: Mutex<SQLiteDataStore>,
|
||||
}
|
||||
|
||||
|
@ -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<Vec<Source>>,
|
||||
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
|
@ -107,7 +101,7 @@ pub struct Source {
|
|||
// pub(crate) compiled_query_x: Arc<Mutex<jq_rs::JqProgram>>,
|
||||
pub query_y: String,
|
||||
// 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>>,
|
||||
}
|
||||
|
||||
|
|
|
@ -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<Vec<Value>> {
|
||||
pub fn load_values(&self, source_id: i32) -> rusqlite::Result<Vec<Value>> {
|
||||
let mut values: Vec<Value> = 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<usize> {
|
||||
pub fn put_value(&self, source_id: i32, v: Value) -> rusqlite::Result<usize> {
|
||||
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<Vec<Source>> {
|
||||
pub fn load_sources(&self) -> rusqlite::Result<Vec<Source>> {
|
||||
let mut sources: Vec<Source> = 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::<usize, String>(4)?.as_str()).unwrap())),
|
||||
query_y: row.get(5)?,
|
||||
// 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)),
|
||||
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::<usize, String>(4)?.as_str()).unwrap())),
|
||||
query_y: row.get(5)?,
|
||||
// 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)),
|
||||
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<usize> {
|
||||
let color_u32 : Option<u32> = 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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -17,10 +17,11 @@ pub fn native_save(state:Arc<ApplicationState>) {
|
|||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
20
src/lib.rs
20
src/lib.rs
|
@ -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))))
|
||||
}
|
Loading…
Reference in a new issue