feat!: toggle sources/edit, many lines per source

Some UI tweaks (like suffixes in sliders)
Restructured data and storage: many Metrics per one Source. Each Metric
is a Line on a Panel. Fetches are handled per Source. Metrics cannot be
moved across Sources. Data points are linked to a Metric.
Also made "show sources" and "edit mode" two distinct toggles, and thus
implemented a non-edit view for sources.
Also bumped version to 0.3.0
This commit is contained in:
əlemi 2022-06-21 02:43:03 +02:00
parent bd3acb188b
commit f249ca372a
Signed by: alemi
GPG key ID: A4895B84D311642C
11 changed files with 495 additions and 241 deletions

View file

@ -1,6 +1,6 @@
[package] [package]
name = "dashboard" name = "dashboard"
version = "0.2.1" version = "0.3.0"
edition = "2021" edition = "2021"
[[bin]] [[bin]]

View file

@ -1,7 +1,7 @@
pub mod source; pub mod source;
pub mod store; pub mod store;
use self::source::{Panel, Source}; use self::source::{Panel, Source, Metric};
use self::store::SQLiteDataStore; use self::store::SQLiteDataStore;
use std::num::ParseFloatError; use std::num::ParseFloatError;
use std::path::PathBuf; use std::path::PathBuf;
@ -50,6 +50,7 @@ pub struct ApplicationState {
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 sources: RwLock<Vec<Source>>,
pub metrics: RwLock<Vec<Metric>>,
pub storage: Mutex<SQLiteDataStore>, pub storage: Mutex<SQLiteDataStore>,
pub diagnostics: RwLock<Vec<String>>, pub diagnostics: RwLock<Vec<String>>,
} }
@ -60,6 +61,7 @@ impl ApplicationState {
let panels = storage.load_panels()?; let panels = storage.load_panels()?;
let sources = storage.load_sources()?; let sources = storage.load_sources()?;
let metrics = storage.load_metrics()?;
return Ok(ApplicationState { return Ok(ApplicationState {
run: true, run: true,
@ -67,6 +69,7 @@ impl ApplicationState {
file_path: path, file_path: path,
panels: RwLock::new(panels), panels: RwLock::new(panels),
sources: RwLock::new(sources), sources: RwLock::new(sources),
metrics: RwLock::new(metrics),
storage: Mutex::new(storage), storage: Mutex::new(storage),
diagnostics: RwLock::new(Vec::new()), diagnostics: RwLock::new(Vec::new()),
}); });
@ -104,14 +107,10 @@ impl ApplicationState {
.lock() .lock()
.expect("Storage Mutex poisoned") .expect("Storage Mutex poisoned")
.new_source( .new_source(
source.panel_id,
source.name.as_str(), source.name.as_str(),
source.enabled, source.enabled,
source.url.as_str(), source.url.as_str(),
source.interval, source.interval,
source.query_x.as_str(),
source.query_y.as_str(),
source.color,
self.sources.read().expect("Sources RwLock poisoned").len() as i32, self.sources.read().expect("Sources RwLock poisoned").len() as i32,
)?; )?;
self.sources self.sources
@ -120,4 +119,25 @@ impl ApplicationState {
.push(verified_source); .push(verified_source);
return Ok(()); return Ok(());
} }
pub fn add_metric(&self, metric: &Metric, source: &Source) -> Result<(), FetchError> {
let verified_metric = self
.storage
.lock()
.expect("Storage Mutex poisoned")
.new_metric(
metric.name.as_str(),
source.id,
metric.query_x.as_str(),
metric.query_y.as_str(),
metric.panel_id,
metric.color,
self.metrics.read().expect("Sources RwLock poisoned").len() as i32, // TODO use source.metrics.len()
)?;
self.metrics
.write()
.expect("Sources RwLock poisoned")
.push(verified_metric);
return Ok(());
}
} }

View file

@ -3,6 +3,7 @@ use chrono::{DateTime, Utc};
use eframe::egui::plot::{Value, Values}; use eframe::egui::plot::{Value, Values};
use eframe::epaint::Color32; use eframe::epaint::Color32;
use std::sync::RwLock; use std::sync::RwLock;
use tracing::info;
#[derive(Debug)] #[derive(Debug)]
pub struct Panel { pub struct Panel {
@ -46,14 +47,7 @@ pub struct Source {
pub enabled: bool, pub enabled: bool,
pub url: String, pub url: String,
pub interval: i32, pub interval: i32,
pub color: Color32,
pub(crate) last_fetch: RwLock<DateTime<Utc>>, pub(crate) last_fetch: RwLock<DateTime<Utc>>,
pub query_x: String,
// 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) data: RwLock<Vec<Value>>,
} }
impl Default for Source { impl Default for Source {
@ -64,12 +58,7 @@ impl Default for Source {
enabled: false, enabled: false,
url: "".to_string(), url: "".to_string(),
interval: 60, interval: 60,
color: Color32::TRANSPARENT,
last_fetch: RwLock::new(Utc::now()), last_fetch: RwLock::new(Utc::now()),
query_x: "".to_string(),
query_y: "".to_string(),
panel_id: -1,
data: RwLock::new(Vec::new()),
} }
} }
} }
@ -81,7 +70,10 @@ fn avg_value(values: &[Value]) -> Value {
x += v.x; x += v.x;
y += v.y; y += v.y;
} }
return Value { x: x / values.len() as f64, y: y / values.len() as f64 }; return Value {
x: x / values.len() as f64,
y: y / values.len() as f64,
};
} }
impl Source { impl Source {
@ -90,8 +82,64 @@ impl Source {
return (Utc::now() - *last_fetch).num_seconds() < self.interval as i64; return (Utc::now() - *last_fetch).num_seconds() < self.interval as i64;
} }
// TODO optimize this with caching! pub fn fetch(&self) -> Result<serde_json::Value, FetchError> {
pub fn values(&self, min_x: Option<f64>, max_x: Option<f64>, chunk_size: Option<u32>) -> Values { fetch(self.url.as_str())
}
}
pub fn fetch(url: &str) -> Result<serde_json::Value, FetchError> {
return Ok(ureq::get(url).call()?.into_json()?);
}
#[derive(Debug)]
pub struct Metric {
pub(crate) id: i32,
pub name: String,
pub source_id: i32,
pub color: Color32,
pub query_x: String,
pub query_y: String,
pub(crate) panel_id: i32,
pub(crate) data: RwLock<Vec<Value>>,
}
impl Default for Metric {
fn default() -> Self {
Metric {
id: -1,
name: "".to_string(),
source_id: -1,
color: Color32::TRANSPARENT,
query_x: "".to_string(),
query_y: "".to_string(),
panel_id: -1,
data: RwLock::new(Vec::new()),
}
}
}
impl Metric {
pub fn extract(&self, value: &serde_json::Value) -> Result<Value, FetchError> {
let x: f64;
if self.query_x.len() > 0 {
x = jql::walker(value, self.query_x.as_str())?
.as_f64()
.ok_or(FetchError::JQLError("X query is null".to_string()))?; // TODO what if it's given to us as a string?
} else {
x = Utc::now().timestamp() as f64;
}
let y = jql::walker(value, self.query_y.as_str())?
.as_f64()
.ok_or(FetchError::JQLError("Y query is null".to_string()))?;
Ok(Value { x, y })
}
pub fn values(
&self,
min_x: Option<f64>,
max_x: Option<f64>,
chunk_size: Option<u32>,
) -> Values {
let mut values = self.data.read().expect("Values RwLock poisoned").clone(); let mut values = self.data.read().expect("Values RwLock poisoned").clone();
if let Some(min_x) = min_x { if let Some(min_x) = min_x {
values.retain(|x| x.x > min_x); values.retain(|x| x.x > min_x);
@ -100,27 +148,12 @@ impl Source {
values.retain(|x| x.x < max_x); values.retain(|x| x.x < max_x);
} }
if let Some(chunk_size) = chunk_size { if let Some(chunk_size) = chunk_size {
if chunk_size > 0 { // TODO make this nested if prettier if chunk_size > 0 {
// TODO make this nested if prettier
let iter = values.chunks(chunk_size as usize); let iter = values.chunks(chunk_size as usize);
values = iter.map(|x| avg_value(x) ).collect(); values = iter.map(|x| avg_value(x)).collect();
} }
} }
Values::from_values(values) Values::from_values(values)
} }
} }
pub fn fetch(url: &str, query_x: &str, query_y: &str) -> Result<Value, FetchError> {
let res = ureq::get(url).call()?.into_json()?;
let x: f64;
if query_x.len() > 0 {
x = jql::walker(&res, query_x)?
.as_f64()
.ok_or(FetchError::JQLError("X query is null".to_string()))?; // TODO what if it's given to us as a string?
} else {
x = Utc::now().timestamp() as f64;
}
let y = jql::walker(&res, query_y)?
.as_f64()
.ok_or(FetchError::JQLError("Y query is null".to_string()))?;
return Ok(Value { x, y });
}

View file

@ -8,6 +8,8 @@ use eframe::egui::{plot::Value, Color32};
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
use std::sync::RwLock; use std::sync::RwLock;
use super::source::Metric;
pub trait DataStorage { pub trait DataStorage {
fn add_panel(&self, name: &str); fn add_panel(&self, name: &str);
} }
@ -46,19 +48,38 @@ impl SQLiteDataStore {
enabled BOOL NOT NULL, enabled BOOL NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
interval INT NOT NULL, interval INT NOT NULL,
query_x TEXT NOT NULL,
query_y TEXT NOT NULL,
panel_id INT NOT NULL,
color INT NULL,
position INT NOT NULL position INT NOT NULL
);", );",
[], [],
)?; )?;
conn.execute(
"CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source_id INT NOT NULL,
query_x TEXT NOT NULL,
query_y TEXT NOT NULL,
panel_id INT NOT NULL,
color INT NOT NULL,
position INT NOT NULL
);",
[],
)?;
// BEGIN TRANSACTION;
// CREATE TEMPORARY TABLE t1_backup(a,b);
// INSERT INTO t1_backup SELECT a,b FROM t1;
// DROP TABLE t1;
// CREATE TABLE t1(a,b);
// INSERT INTO t1 SELECT a,b FROM t1_backup;
// DROP TABLE t1_backup;
// COMMIT;
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS points ( "CREATE TABLE IF NOT EXISTS points (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
source_id INT NOT NULL, metric_id INT NOT NULL,
x FLOAT NOT NULL, x FLOAT NOT NULL,
y FLOAT NOT NULL y FLOAT NOT NULL
);", );",
@ -68,12 +89,12 @@ impl SQLiteDataStore {
Ok(SQLiteDataStore { conn }) Ok(SQLiteDataStore { conn })
} }
pub fn load_values(&self, source_id: i32) -> rusqlite::Result<Vec<Value>> { pub fn load_values(&self, metric_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 source_id = ?")?; .prepare("SELECT x, y FROM points WHERE metric_id = ?")?;
let values_iter = statement.query_map(params![source_id], |row| { let values_iter = statement.query_map(params![metric_id], |row| {
Ok(Value { Ok(Value {
x: row.get(0)?, x: row.get(0)?,
y: row.get(1)?, y: row.get(1)?,
@ -89,10 +110,10 @@ impl SQLiteDataStore {
Ok(values) Ok(values)
} }
pub fn put_value(&self, source_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(source_id, x, y) VALUES (?, ?, ?)", "INSERT INTO points(metric_id, x, y) VALUES (?, ?, ?)",
params![source_id, v.x, v.y], params![metric_id, v.x, v.y],
) )
} }
@ -107,17 +128,11 @@ impl SQLiteDataStore {
url: row.get(3)?, url: row.get(3)?,
interval: row.get(4)?, interval: row.get(4)?,
last_fetch: RwLock::new(Utc.ymd(1970, 1, 1).and_hms(0, 0, 0)), last_fetch: RwLock::new(Utc.ymd(1970, 1, 1).and_hms(0, 0, 0)),
query_x: row.get(5)?,
query_y: row.get(6)?,
panel_id: row.get(7)?,
color: unpack_color(row.get(8).unwrap_or(0)),
data: RwLock::new(Vec::new()),
}) })
})?; })?;
for source in sources_iter { for source in sources_iter {
if let Ok(mut s) = source { if let Ok(s) = source {
s.data = RwLock::new(self.load_values(s.id)?);
sources.push(s); sources.push(s);
} }
} }
@ -128,41 +143,28 @@ impl SQLiteDataStore {
// jank! TODO make it not jank! // jank! TODO make it not jank!
pub fn new_source( pub fn new_source(
&self, &self,
panel_id: i32,
name: &str, name: &str,
enabled: bool, enabled: bool,
url: &str, url: &str,
interval: i32, interval: i32,
query_x: &str,
query_y: &str,
color: Color32,
position: i32, position: i32,
) -> rusqlite::Result<Source> { ) -> rusqlite::Result<Source> {
let color_u32: Option<u32> = if color == Color32::TRANSPARENT {
None
} else {
Some(repack_color(color))
};
self.conn.execute( self.conn.execute(
"INSERT INTO sources(name, enabled, url, interval, query_x, query_y, panel_id, color, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO sources(name, enabled, url, interval, position) VALUES (?, ?, ?, ?, ?)",
params![name, enabled, url, interval, query_x, query_y, panel_id, color_u32, position], params![name, enabled, url, interval, position],
)?; )?;
let mut statement = self let mut statement = self
.conn .conn
.prepare("SELECT * FROM sources WHERE name = ? AND panel_id = ?")?; .prepare("SELECT * FROM sources WHERE name = ? AND url = ? ORDER BY id DESC")?;
for panel in statement.query_map(params![name, panel_id], |row| { for panel in statement.query_map(params![name, url], |row| {
Ok(Source { Ok(Source {
id: row.get(0)?, id: row.get(0)?,
name: row.get(1)?, name: row.get(1)?,
enabled: row.get(2)?, enabled: row.get(2)?,
url: row.get(3)?, url: row.get(3)?,
interval: row.get(4)?, interval: row.get(4)?,
// position: row.get(5)?,
last_fetch: RwLock::new(Utc.ymd(1970, 1, 1).and_hms(0, 0, 0)), last_fetch: RwLock::new(Utc.ymd(1970, 1, 1).and_hms(0, 0, 0)),
query_x: row.get(5)?,
query_y: row.get(6)?,
panel_id: row.get(7)?,
color: unpack_color(row.get(8).unwrap_or(0)),
data: RwLock::new(Vec::new()),
}) })
})? { })? {
if let Ok(p) = panel { if let Ok(p) = panel {
@ -175,25 +177,16 @@ impl SQLiteDataStore {
pub fn update_source( pub fn update_source(
&self, &self,
source_id: i32, id: i32,
panel_id: i32,
name: &str, name: &str,
enabled: bool, enabled: bool,
url: &str, url: &str,
interval: i32, interval: i32,
query_x: &str,
query_y: &str,
color: Color32,
position: i32, position: i32,
) -> rusqlite::Result<usize> { ) -> rusqlite::Result<usize> {
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 = ?, enabled = ?, url = ?, interval = ?, query_x = ?, query_y = ?, panel_id = ?, color = ?, position = ? WHERE id = ?", "UPDATE sources SET name = ?, enabled = ?, url = ?, interval = ?, position = ? WHERE id = ?",
params![name, enabled, url, interval, query_x, query_y, panel_id, color_u32, position, source_id], params![name, enabled, url, interval, position, id],
) )
} }
@ -201,6 +194,93 @@ impl SQLiteDataStore {
// self.conn.execute("DELETE FROM sources WHERE id = ?", params![id]) // self.conn.execute("DELETE FROM sources WHERE id = ?", params![id])
// } // }
pub fn load_metrics(&self) -> rusqlite::Result<Vec<Metric>> {
let mut metrics: Vec<Metric> = Vec::new();
let mut statement = self.conn.prepare("SELECT * FROM metrics ORDER BY position")?;
let metrics_iter = statement.query_map([], |row| {
Ok(Metric {
id: row.get(0)?,
name: row.get(1)?,
source_id: row.get(2)?,
query_x: row.get(3)?,
query_y: row.get(4)?,
panel_id: row.get(5)?,
color: unpack_color(row.get(6).unwrap_or(0)),
// position: row.get(7)?,
data: RwLock::new(Vec::new()),
})
})?;
for metric in metrics_iter {
if let Ok(m) = metric {
*m.data.write().expect("Points RwLock poisoned") = self.load_values(m.id)?;
metrics.push(m);
}
}
Ok(metrics)
}
// jank! TODO make it not jank!
pub fn new_metric(
&self,
name: &str,
source_id: i32,
query_x: &str,
query_y: &str,
panel_id: i32,
color: Color32,
position: i32,
) -> rusqlite::Result<Metric> {
self.conn.execute(
"INSERT INTO metrics(name, source_id, query_x, query_y, panel_id, color, position) VALUES (?, ?, ?, ?, ?, ?, ?)",
params![name, source_id, query_x, query_y, panel_id, repack_color(color), position],
)?;
let mut statement = self
.conn
.prepare("SELECT * FROM metrics WHERE source_id = ? AND panel_id = ? AND name = ? ORDER BY id DESC")?;
for metric in statement.query_map(params![source_id, panel_id, name], |row| {
Ok(Metric {
id: row.get(0)?,
name: row.get(1)?,
source_id: row.get(2)?,
query_x: row.get(3)?,
query_y: row.get(4)?,
panel_id: row.get(5)?,
color: unpack_color(row.get(6).unwrap_or(0)),
// position: row.get(7)?,
data: RwLock::new(Vec::new()),
})
})? {
if let Ok(m) = metric {
return Ok(m);
}
}
Err(rusqlite::Error::QueryReturnedNoRows)
}
pub fn update_metric(
&self,
id: i32,
name: &str,
source_id: i32,
query_x: &str,
query_y: &str,
panel_id: i32,
color: Color32,
position: i32,
) -> rusqlite::Result<usize> {
self.conn.execute(
"UPDATE metrics SET name = ?, query_x = ?, query_y = ?, panel_id = ?, color = ?, position = ? WHERE id = ? AND source_id = ?",
params![name, query_x, query_y, panel_id, repack_color(color), position, id, source_id],
)
}
// pub fn delete_metric(&self, id:i32) -> rusqlite::Result<usize> {
// self.conn.execute("DELETE FROM metrics WHERE id = ?", params![id])
// }
pub fn load_panels(&self) -> rusqlite::Result<Vec<Panel>> { pub fn load_panels(&self) -> rusqlite::Result<Vec<Panel>> {
let mut panels: Vec<Panel> = Vec::new(); let mut panels: Vec<Panel> = Vec::new();
let mut statement = self let mut statement = self

67
src/app/gui/metric.rs Normal file
View file

@ -0,0 +1,67 @@
use eframe::{egui::{Ui, TextEdit, ComboBox, Layout, Sense, color_picker::show_color_at}, emath::Align, epaint::Color32};
use crate::app::data::source::{Panel, Metric};
fn color_square(ui: &mut Ui, color:Color32) {
let size = ui.spacing().interact_size;
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
if ui.is_rect_visible(rect) {
let visuals = ui.style().interact(&response);
let rect = rect.expand(visuals.expansion);
show_color_at(ui.painter(), color, rect);
let rounding = visuals.rounding.at_most(2.0);
ui.painter()
.rect_stroke(rect, rounding, (2.0, visuals.bg_fill)); // fill is intentional, because default style has no border
}
}
pub fn metric_display_ui(ui: &mut Ui, metric: &Metric, _width: f32) {
ui.horizontal(|ui| {
color_square(ui, metric.color);
ui.label(&metric.name);
ui.with_layout(Layout::top_down(Align::RIGHT), |ui| {
ui.horizontal(|ui| {
ui.label(format!("panel: {}", metric.panel_id));
if metric.query_y.len() > 0 {
ui.label(format!("y: {}", metric.query_y));
}
if metric.query_x.len() > 0 {
ui.label(format!("x: {}", metric.query_x));
}
})
});
});
}
pub fn metric_edit_ui(ui: &mut Ui, metric: &mut Metric, panels: Option<&Vec<Panel>>, width: f32) {
let text_width = width - 190.0;
ui.horizontal(|ui| {
ui.color_edit_button_srgba(&mut metric.color);
TextEdit::singleline(&mut metric.name)
.desired_width(text_width / 2.0)
.hint_text("name")
.show(ui);
ui.separator();
TextEdit::singleline(&mut metric.query_x)
.desired_width(text_width / 4.0)
.hint_text("x")
.show(ui);
TextEdit::singleline(&mut metric.query_y)
.desired_width(text_width / 4.0)
.hint_text("y")
.show(ui);
if let Some(panels) = panels {
ComboBox::from_id_source(format!("panel-selector-{}", metric.id))
.width(60.0)
.selected_text(format!("panel: {}", metric.panel_id))
.show_ui(ui, |ui| {
ui.selectable_value(&mut metric.panel_id, -1, "None");
for p in panels {
ui.selectable_value(&mut metric.panel_id, p.id, p.name.as_str());
}
});
}
});
}

View file

@ -1,2 +1,3 @@
pub mod panel; pub mod panel;
pub mod source; pub mod source;
pub mod metric;

View file

@ -5,7 +5,7 @@ use eframe::{egui::{
}, emath::Vec2}; }, emath::Vec2};
use crate::app::{ use crate::app::{
data::source::{Panel, Source}, data::source::{Panel, Metric},
util::timestamp_to_str, util::timestamp_to_str,
}; };
@ -24,30 +24,30 @@ pub fn panel_title_ui(ui: &mut Ui, panel: &mut Panel, extra: bool) {
ui.toggle_value(&mut panel.view_scroll, "🔒"); ui.toggle_value(&mut panel.view_scroll, "🔒");
ui.separator(); ui.separator();
if panel.limit { if panel.limit {
ui.label("min"); // TODO makes no sense if it's not a timeserie
ui.add( ui.add(
DragValue::new(&mut panel.view_size) DragValue::new(&mut panel.view_size)
.speed(10) .speed(10)
.suffix(" min")
.clamp_range(0..=2147483647i32), .clamp_range(0..=2147483647i32),
); );
} }
ui.toggle_value(&mut panel.limit, "limit"); ui.toggle_value(&mut panel.limit, "limit");
ui.separator(); ui.separator();
if panel.shift { if panel.shift {
ui.label("min");
ui.add( ui.add(
DragValue::new(&mut panel.view_offset) DragValue::new(&mut panel.view_offset)
.speed(10) .speed(10)
.suffix(" min")
.clamp_range(0..=2147483647i32), .clamp_range(0..=2147483647i32),
); );
} }
ui.toggle_value(&mut panel.shift, "offset"); ui.toggle_value(&mut panel.shift, "offset");
ui.separator(); ui.separator();
if panel.reduce { if panel.reduce {
ui.label("x");
ui.add( ui.add(
DragValue::new(&mut panel.view_chunks) DragValue::new(&mut panel.view_chunks)
.speed(1) .speed(1)
.prefix("x")
.clamp_range(1..=1000), // TODO allow to average larger spans maybe? .clamp_range(1..=1000), // TODO allow to average larger spans maybe?
); );
} }
@ -63,7 +63,7 @@ pub fn panel_title_ui(ui: &mut Ui, panel: &mut Panel, extra: bool) {
}); });
} }
pub fn panel_body_ui(ui: &mut Ui, panel: &mut Panel, sources: &Vec<Source>) { pub fn panel_body_ui(ui: &mut Ui, panel: &mut Panel, metrics: &Vec<Metric>) {
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)
@ -135,11 +135,13 @@ pub fn panel_body_ui(ui: &mut Ui, panel: &mut Panel, sources: &Vec<Source>) {
let min_x = if panel.limit { Some(_now - _size - _off) } else { None }; let min_x = if panel.limit { Some(_now - _size - _off) } else { None };
let max_x = if panel.shift { Some(_now - _off) } else { None }; let max_x = if panel.shift { Some(_now - _off) } else { None };
let chunk_size = if panel.reduce { Some(panel.view_chunks) } else { None }; let chunk_size = if panel.reduce { Some(panel.view_chunks) } else { None };
for source in sources { for metric in metrics {
if source.panel_id == panel.id { if metric.panel_id == panel.id {
// let chunks = None; // let chunks = None;
let line = Line::new(source.values(min_x, max_x, chunk_size)).name(source.name.as_str()); let line = Line::new(metric.values(min_x, max_x, chunk_size))
plot_ui.line(line.color(source.color)); .name(metric.name.as_str())
.color(metric.color);
plot_ui.line(line);
} }
} }
}); });

View file

@ -1,81 +1,41 @@
use eframe::egui::{Ui, TextEdit, ComboBox, Slider, DragValue}; use eframe::{egui::{Ui, TextEdit, DragValue, Checkbox}};
use crate::app::data::source::{Panel, Source}; use crate::app::data::source::{Panel, Source, Metric};
#[allow(dead_code)] use super::metric::{metric_edit_ui, metric_display_ui};
pub fn source_edit_inline_ui(ui: &mut Ui, source: &mut Source, panels: &Vec<Panel>) {
TextEdit::singleline(&mut source.name)
.hint_text("name")
.desired_width(50.0)
.show(ui);
TextEdit::singleline(&mut source.url)
.hint_text("url")
.desired_width(160.0)
.show(ui);
TextEdit::singleline(&mut source.query_x)
.hint_text("x")
.desired_width(30.0)
.show(ui);
TextEdit::singleline(&mut source.query_y)
.hint_text("y")
.desired_width(30.0)
.show(ui);
ComboBox::from_id_source("panel")
.selected_text(format!("panel [{}]", source.panel_id))
.width(70.0)
.show_ui(ui, |ui| {
for p in panels {
ui.selectable_value(&mut source.panel_id, p.id, p.name.as_str());
}
});
ui.checkbox(&mut source.enabled, "enabled");
ui.add(Slider::new(&mut source.interval, 1..=60));
ui.color_edit_button_srgba(&mut source.color);
}
pub fn source_edit_ui(ui: &mut Ui, source: &mut Source, panels: &Vec<Panel>, width: f32) { pub fn source_display_ui(ui: &mut Ui, source: &mut Source, metrics: &Vec<Metric>, _width: f32) {
ui.group(|ui| { ui.horizontal(|ui| {
ui.vertical(|ui| { ui.add_enabled(false, Checkbox::new(&mut source.enabled, ""));
ui.horizontal(|ui| { ui.add_enabled(false, DragValue::new(&mut source.interval).clamp_range(1..=120));
let text_width = width - 25.0; ui.heading(&source.name).on_hover_text(&source.url);
ui.checkbox(&mut source.enabled, "");
TextEdit::singleline(&mut source.name)
.desired_width(text_width / 4.0)
.hint_text("name")
.show(ui);
TextEdit::singleline(&mut source.url)
.desired_width(text_width * 3.0 / 4.0)
.hint_text("url")
.show(ui);
});
ui.horizontal(|ui| {
let text_width : f32 ;
if width > 400.0 {
ui.add(Slider::new(&mut source.interval, 1..=120));
text_width = width - 330.0
} else {
ui.add(DragValue::new(&mut source.interval).clamp_range(1..=120));
text_width = width - 225.0
}
TextEdit::singleline(&mut source.query_x)
.desired_width(text_width / 2.0)
.hint_text("x")
.show(ui);
TextEdit::singleline(&mut source.query_y)
.desired_width(text_width / 2.0)
.hint_text("y")
.show(ui);
ComboBox::from_id_source(format!("panel-{}", source.id))
.width(60.0)
.selected_text(format!("panel: {}", source.panel_id))
.show_ui(ui, |ui| {
ui.selectable_value(&mut source.panel_id, -1, "None");
for p in panels {
ui.selectable_value(&mut source.panel_id, p.id, p.name.as_str());
}
});
ui.color_edit_button_srgba(&mut source.color);
});
});
}); });
for metric in metrics.iter() {
if metric.source_id == source.id {
metric_display_ui(ui, metric, ui.available_width());
}
}
}
pub fn source_edit_ui(ui: &mut Ui, source: &mut Source, metrics: Option<&mut Vec<Metric>>, panels: &Vec<Panel>, width: f32) {
ui.horizontal(|ui| {
let text_width = width - 100.0;
ui.checkbox(&mut source.enabled, "");
ui.add(DragValue::new(&mut source.interval).clamp_range(1..=3600));
TextEdit::singleline(&mut source.name)
.desired_width(text_width / 4.0)
.hint_text("name")
.show(ui);
TextEdit::singleline(&mut source.url)
.desired_width(text_width * 3.0 / 4.0)
.hint_text("url")
.show(ui);
});
if let Some(metrics) = metrics {
for metric in metrics.iter_mut() {
if metric.source_id == source.id {
metric_edit_ui(ui, metric, Some(panels), width - 10.0);
}
}
}
} }

View file

@ -11,18 +11,21 @@ use eframe::emath::Align;
use std::sync::Arc; use std::sync::Arc;
use tracing::error; use tracing::error;
use self::data::source::{Panel, Source}; use self::data::source::{Metric, Panel, Source};
use self::data::ApplicationState; use self::data::ApplicationState;
use self::gui::metric::metric_edit_ui;
use self::gui::panel::{panel_body_ui, panel_edit_inline_ui, panel_title_ui}; use self::gui::panel::{panel_body_ui, panel_edit_inline_ui, panel_title_ui};
use self::gui::source::source_edit_ui; use self::gui::source::{source_display_ui, source_edit_ui};
use self::util::human_size; use self::util::human_size;
use self::worker::native_save; use self::worker::native_save;
pub struct App { pub struct App {
data: Arc<ApplicationState>, data: Arc<ApplicationState>,
input_metric: Metric,
input_source: Source, input_source: Source,
input_panel: Panel, input_panel: Panel,
edit: bool, edit: bool,
sources: bool,
padding: bool, padding: bool,
} }
@ -30,9 +33,11 @@ impl App {
pub fn new(_cc: &eframe::CreationContext, data: Arc<ApplicationState>) -> Self { pub fn new(_cc: &eframe::CreationContext, data: Arc<ApplicationState>) -> Self {
Self { Self {
data, data,
input_metric: Metric::default(),
input_panel: Panel::default(), input_panel: Panel::default(),
input_source: Source::default(), input_source: Source::default(),
edit: false, edit: false,
sources: true,
padding: false, padding: false,
} }
} }
@ -45,6 +50,8 @@ impl eframe::App for App {
global_dark_light_mode_switch(ui); global_dark_light_mode_switch(ui);
ui.heading("dashboard"); ui.heading("dashboard");
ui.separator(); ui.separator();
ui.checkbox(&mut self.sources, "sources");
ui.separator();
ui.checkbox(&mut self.edit, "edit"); ui.checkbox(&mut self.edit, "edit");
if self.edit { if self.edit {
if ui.button("save").clicked() { if ui.button("save").clicked() {
@ -118,48 +125,98 @@ impl eframe::App for App {
}); });
}); });
}); });
if self.edit { if self.sources {
let mut to_swap: Option<usize> = None; let mut to_swap: Option<usize> = None;
// let mut to_delete: Option<usize> = None; // let mut to_delete: Option<usize> = None;
SidePanel::left("sources-bar") SidePanel::left("sources-bar")
.width_range(280.0..=800.0) .width_range(280.0..=800.0)
.default_width(500.0) .default_width(350.0)
.show(ctx, |ui| { .show(ctx, |ui| {
let panels = self.data.panels.read().expect("Panels RwLock poisoned"); let panels = self.data.panels.read().expect("Panels RwLock poisoned");
ScrollArea::vertical().show(ui, |ui| { let panel_width = ui.available_width();
let panel_width = ui.available_width(); ScrollArea::both().max_width(panel_width).show(ui, |ui| {
// TODO only vertical!
{ {
let mut sources = self.data.sources.write().expect("Sources RwLock poisoned"); let mut sources =
self.data.sources.write().expect("Sources RwLock poisoned");
let sources_count = sources.len(); let sources_count = sources.len();
ui.heading("Sources");
ui.separator();
for (index, source) in sources.iter_mut().enumerate() { for (index, source) in sources.iter_mut().enumerate() {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if self.edit {
ui.vertical(|ui| {
ui.add_space(10.0);
if ui.small_button("+").clicked() {
if index > 0 {
to_swap = Some(index); // TODO kinda jank but is there a better way?
}
}
if ui.small_button("").clicked() {
if index < sources_count - 1 {
to_swap = Some(index + 1); // TODO kinda jank but is there a better way?
}
}
});
}
ui.vertical(|ui| { ui.vertical(|ui| {
ui.add_space(10.0); let remaining_width = ui.available_width();
if ui.small_button("+").clicked() { if self.edit {
if index > 0 { ui.group(|ui| {
to_swap = Some(index); // TODO kinda jank but is there a better way? source_edit_ui(
} ui,
} source,
if ui.small_button("").clicked() { Some(&mut *self.data.metrics.write().expect("Metrics RwLock poisoned")),
if index < sources_count - 1 { &panels,
to_swap = Some(index + 1); // TODO kinda jank but is there a better way? remaining_width,
} );
ui.horizontal(|ui| {
metric_edit_ui(
ui,
&mut self.input_metric,
None,
remaining_width - 10.0,
);
ui.add_space(5.0);
if ui.button(" × ").clicked() {
self.input_metric = Metric::default();
}
ui.separator();
if ui.button(" + ").clicked() {
if let Err(e) = self
.data
.add_metric(&self.input_metric, source)
{
error!(target: "ui", "Error adding metric : {:?}", e);
}
}
})
});
} else {
let metrics =
self.data.metrics.read().expect("Metrics RwLock poisoned");
source_display_ui(
ui,
source,
&metrics,
remaining_width,
);
ui.separator();
} }
}); });
let remaining_width = ui.available_width();
source_edit_ui(ui, source, &panels, remaining_width);
}); });
} }
} }
ui.add_space(20.0); if self.edit {
ui.separator(); ui.add_space(20.0);
ui.horizontal(|ui| { ui.separator();
ui.heading("new source"); ui.horizontal(|ui| {
ui.heading("new source");
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("add").clicked() { if ui.button("add").clicked() {
if let Err(e) = self.data.add_source(&self.input_source) { if let Err(e) = self.data.add_source(&self.input_source) {
error!(target: "ui", "Error adding souce : {:?}", e); error!(target: "ui", "Error adding source : {:?}", e);
} else { } else {
self.input_source.id += 1; self.input_source.id += 1;
} }
@ -167,10 +224,17 @@ impl eframe::App for App {
ui.toggle_value(&mut self.padding, "#"); ui.toggle_value(&mut self.padding, "#");
}); });
}); });
}); });
source_edit_ui(ui, &mut self.input_source, &panels, panel_width); source_edit_ui(
if self.padding { ui,
ui.add_space(300.0); &mut self.input_source,
None,
&panels,
panel_width,
);
if self.padding {
ui.add_space(300.0);
}
} }
}); });
}); });
@ -191,7 +255,7 @@ impl eframe::App for App {
ScrollArea::vertical().show(ui, |ui| { ScrollArea::vertical().show(ui, |ui| {
let mut panels = self.data.panels.write().expect("Panels RwLock poisoned"); // TODO only lock as write when editing let mut panels = self.data.panels.write().expect("Panels RwLock poisoned"); // TODO only lock as write when editing
let panels_count = panels.len(); let panels_count = panels.len();
let sources = self.data.sources.read().expect("Sources RwLock poisoned"); // TODO only lock as write when editing let metrics = self.data.metrics.read().expect("Metrics RwLock poisoned"); // TODO only lock as write when editing
for (index, panel) in panels.iter_mut().enumerate() { for (index, panel) in panels.iter_mut().enumerate() {
if index > 0 { if index > 0 {
ui.separator(); ui.separator();
@ -220,19 +284,31 @@ impl eframe::App for App {
} }
panel_title_ui(ui, panel, self.edit); panel_title_ui(ui, panel, self.edit);
}) })
.body(|ui| panel_body_ui(ui, panel, &sources)); .body(|ui| panel_body_ui(ui, panel, &metrics));
} }
}); });
}); });
if let Some(i) = to_delete { if let Some(i) = to_delete {
// TODO can this be done in background? idk // TODO can this be done in background? idk
let mut panels = self.data.panels.write().expect("Panels RwLock poisoned"); let mut panels = self.data.panels.write().expect("Panels RwLock poisoned");
if let Err(e) = self.data.storage.lock().expect("Storage Mutex poisoned").delete_panel(panels[i].id) { if let Err(e) = self
.data
.storage
.lock()
.expect("Storage Mutex poisoned")
.delete_panel(panels[i].id)
{
error!(target: "ui", "Could not delete panel : {:?}", e); error!(target: "ui", "Could not delete panel : {:?}", e);
} else { } else {
for source in self.data.sources.write().expect("Sources RwLock poisoned").iter_mut() { for metric in self
if source.panel_id == panels[i].id { .data
source.panel_id = -1; .metrics
.write()
.expect("Sources RwLock poisoned")
.iter_mut()
{
if metric.panel_id == panels[i].id {
metric.panel_id = -1;
} }
} }
panels.remove(i); panels.remove(i);

View file

@ -46,6 +46,7 @@ pub fn unpack_color(c: u32) -> Color32 {
return Color32::from_rgba_unmultiplied(r, g, b, a); return Color32::from_rgba_unmultiplied(r, g, b, a);
} }
#[allow(dead_code)]
pub fn repack_color(c: Color32) -> u32 { pub fn repack_color(c: Color32) -> u32 {
let mut out: u32 = 0; let mut out: u32 = 0;
let mut offset = 0; let mut offset = 0;

View file

@ -26,22 +26,33 @@ pub fn native_save(state: Arc<ApplicationState>) {
) { ) {
warn!(target: "native-save", "Could not update panel #{} : {:?}", panel.id, e); warn!(target: "native-save", "Could not update panel #{} : {:?}", panel.id, e);
} }
let sources = state.sources.read().expect("Sources RwLock poisoned"); }
for (index, source) in sources.iter().enumerate() { let sources = state.sources.read().expect("Sources RwLock poisoned");
if let Err(e) = storage.update_source( for (index, source) in sources.iter().enumerate() {
source.id, if let Err(e) = storage.update_source(
source.panel_id, source.id,
source.name.as_str(), source.name.as_str(),
source.enabled, source.enabled,
source.url.as_str(), source.url.as_str(),
source.interval, source.interval,
source.query_x.as_str(), index as i32,
source.query_y.as_str(), ) {
source.color, warn!(target: "native-save", "Could not update source #{} : {:?}", source.id, e);
index as i32, }
) { }
warn!(target: "native-save", "Could not update source #{} : {:?}", source.id, e); let metrics = state.metrics.read().expect("Metrics RwLock poisoned");
} for (index, metric) in metrics.iter().enumerate() {
if let Err(e) = storage.update_metric(
metric.id,
metric.name.as_str(),
metric.source_id,
metric.query_x.as_str(),
metric.query_y.as_str(),
metric.panel_id,
metric.color,
index as i32,
) {
warn!(target: "native-save", "Could not update metric #{} : {:?}", metric.id, e);
} }
} }
}); });
@ -78,30 +89,33 @@ impl BackgroundWorker for NativeBackgroundWorker {
*last_update = Utc::now(); *last_update = Utc::now();
let state2 = state.clone(); let state2 = state.clone();
let url = sources[j].url.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 || { 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!
match fetch(url.as_str(), query_x.as_str(), query_y.as_str()) { match fetch(url.as_str()) {
Ok(v) => { Ok(res) => {
let store = let store =
state2.storage.lock().expect("Storage mutex poisoned"); state2.storage.lock().expect("Storage mutex poisoned");
if let Err(e) = store.put_value(s_id, v) { for metric in state2.metrics.read().expect("Metrics RwLock poisoned").iter() {
warn!(target:"background-worker", "Could not put sample for source #{} in db: {:?}", s_id, e); if metric.source_id == s_id {
} else { match metric.extract(&res) {
let sources = Ok(v) => {
state2.sources.read().expect("Sources RwLock poisoned"); metric.data.write().expect("Data RwLock poisoned").push(v);
sources[j] if let Err(e) = store.put_value(metric.id, v) {
.data warn!(target:"background-worker", "Could not put sample for source #{} in db: {:?}", s_id, e);
.write() }
.expect("Source data RwLock poisoned") }
.push(v); Err(e) => {
let mut last_update = sources[j] warn!(target:"background-worker", "[{}] Could not extract value from result: {:?}", metric.name, e); // TODO: more info!
.last_fetch }
.write() }
.expect("Source last update RwLock poisoned"); }
*last_update = Utc::now(); // overwrite it so fetches comply with API slowdowns and get desynched among them
} }
let sources = state2.sources.read().expect("Sources RwLock poisoned");
let mut last_update = sources[j]
.last_fetch
.write()
.expect("Source last update RwLock poisoned");
*last_update = Utc::now(); // overwrite it so fetches comply with API slowdowns and get desynched among them
} }
Err(e) => { Err(e) => {
warn!(target:"background-worker", "Could not fetch value from {} : {:?}", url, e); warn!(target:"background-worker", "Could not fetch value from {} : {:?}", url, e);