├── .rustfmt.toml ├── Trunk.toml ├── assets ├── favicon.ico ├── icon-256.png ├── icon-1024.png ├── icon_ios_touch_192.png ├── maskable_icon_x512.png ├── sw.js └── manifest.json ├── migration ├── src │ ├── main.rs │ ├── lib.rs │ ├── m20221030_192706_add_last_update.rs │ ├── m20221102_232244_add_join_table.rs │ ├── m20221106_211436_remove_query_x.rs │ ├── m20221102_232858_remove_unused_columns.rs │ └── m20220101_000001_create_table.rs ├── Cargo.toml └── README.md ├── src ├── worker │ ├── mod.rs │ ├── surveyor.rs │ └── visualizer.rs ├── data │ ├── entities │ │ ├── mod.rs │ │ ├── prelude.rs │ │ ├── panel_metric.rs │ │ ├── points.rs │ │ ├── sources.rs │ │ ├── panels.rs │ │ └── metrics.rs │ └── mod.rs ├── gui │ ├── metric.rs │ ├── source.rs │ ├── mod.rs │ ├── panel.rs │ └── scaffold.rs ├── util.rs └── main.rs ├── .editorconfig ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── index.html /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | hard_tabs = true 2 | -------------------------------------------------------------------------------- /Trunk.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | filehash = false 3 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alemidev/dashboard/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /assets/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alemidev/dashboard/HEAD/assets/icon-256.png -------------------------------------------------------------------------------- /assets/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alemidev/dashboard/HEAD/assets/icon-1024.png -------------------------------------------------------------------------------- /assets/icon_ios_touch_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alemidev/dashboard/HEAD/assets/icon_ios_touch_192.png -------------------------------------------------------------------------------- /assets/maskable_icon_x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alemidev/dashboard/HEAD/assets/maskable_icon_x512.png -------------------------------------------------------------------------------- /migration/src/main.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[async_std::main] 4 | async fn main() { 5 | cli::run_cli(migration::Migrator).await; 6 | } 7 | -------------------------------------------------------------------------------- /src/worker/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod surveyor; 2 | pub mod visualizer; 3 | 4 | pub use surveyor::surveyor_loop; 5 | pub use visualizer::{AppState, AppStateView, BackgroundAction}; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Default to Unix-style newlines with a newline ending every file 2 | [*] 3 | end_of_line = lf 4 | insert_final_newline = true 5 | charset = utf-8 6 | indent_style = tab 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /src/data/entities/mod.rs: -------------------------------------------------------------------------------- 1 | //! SeaORM Entity. Generated by sea-orm-codegen 0.10.1 2 | 3 | pub mod prelude; 4 | 5 | pub mod panels; 6 | pub mod panel_metric; 7 | pub mod metrics; 8 | pub mod points; 9 | pub mod sources; 10 | -------------------------------------------------------------------------------- /src/data/entities/prelude.rs: -------------------------------------------------------------------------------- 1 | //! SeaORM Entity. Generated by sea-orm-codegen 0.10.1 2 | 3 | pub use super::metrics::Entity as Metrics; 4 | pub use super::panels::Entity as Panels; 5 | pub use super::points::Entity as Points; 6 | pub use super::sources::Entity as Sources; 7 | pub use super::panel_metric::Entity as PanelMetric; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /migration/target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # These are build files generated by Trunk 14 | dist/ 15 | -------------------------------------------------------------------------------- /assets/sw.js: -------------------------------------------------------------------------------- 1 | var cacheName = 'egui-template-pwa'; 2 | var filesToCache = [ 3 | './', 4 | './index.html', 5 | './eframe_template.js', 6 | './eframe_template_bg.wasm', 7 | ]; 8 | 9 | /* Start the service worker and cache all of the app's content */ 10 | self.addEventListener('install', function (e) { 11 | e.waitUntil( 12 | caches.open(cacheName).then(function (cache) { 13 | return cache.addAll(filesToCache); 14 | }) 15 | ); 16 | }); 17 | 18 | /* Serve cached content when offline */ 19 | self.addEventListener('fetch', function (e) { 20 | e.respondWith( 21 | caches.match(e.request).then(function (response) { 22 | return response || fetch(e.request); 23 | }) 24 | ); 25 | }); 26 | -------------------------------------------------------------------------------- /assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "egui Template PWA", 3 | "short_name": "egui-template-pwa", 4 | "icons": [ 5 | { 6 | "src": "./icon-256.png", 7 | "sizes": "256x256", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "./maskable_icon_x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png", 14 | "purpose": "any maskable" 15 | }, 16 | { 17 | "src": "./icon-1024.png", 18 | "sizes": "1024x1024", 19 | "type": "image/png" 20 | } 21 | ], 22 | "lang": "en-US", 23 | "id": "/index.html", 24 | "start_url": "./index.html", 25 | "display": "standalone", 26 | "background_color": "white", 27 | "theme_color": "white" 28 | } 29 | -------------------------------------------------------------------------------- /migration/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "migration" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [lib] 8 | name = "migration" 9 | path = "src/lib.rs" 10 | 11 | [dependencies] 12 | async-std = { version = "^1", features = ["attributes", "tokio1"] } 13 | chrono = "0.4.22" 14 | 15 | [dependencies.sea-orm-migration] 16 | version = "^0.10.0" 17 | features = [ 18 | # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI. 19 | # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime. 20 | # e.g. 21 | "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature 22 | "sqlx-sqlite", # `DATABASE_DRIVER` feature 23 | "sqlx-postgres", 24 | ] 25 | -------------------------------------------------------------------------------- /src/data/entities/panel_metric.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::entity::prelude::*; 2 | 3 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 4 | #[sea_orm(table_name = "panel_metric")] 5 | pub struct Model { 6 | #[sea_orm(primary_key, auto_increment = true)] 7 | pub id: i64, 8 | pub panel_id: i64, 9 | pub metric_id: i64, 10 | } 11 | 12 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 13 | pub enum Relation { 14 | #[sea_orm( 15 | belongs_to = "super::panels::Entity", 16 | from = "Column::PanelId", 17 | to = "super::panels::Column::Id" 18 | )] 19 | Panel, 20 | 21 | #[sea_orm( 22 | belongs_to = "super::metrics::Entity", 23 | from = "Column::MetricId", 24 | to = "super::metrics::Column::Id" 25 | )] 26 | Metric, 27 | } 28 | 29 | impl ActiveModelBehavior for ActiveModel {} 30 | -------------------------------------------------------------------------------- /migration/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use sea_orm_migration::prelude::*; 2 | 3 | mod m20220101_000001_create_table; 4 | mod m20221030_192706_add_last_update; 5 | mod m20221102_232244_add_join_table; 6 | mod m20221102_232858_remove_unused_columns; 7 | mod m20221106_211436_remove_query_x; 8 | 9 | pub struct Migrator; 10 | 11 | #[async_trait::async_trait] 12 | impl MigratorTrait for Migrator { 13 | fn migrations() -> Vec> { 14 | vec![ 15 | Box::new(m20220101_000001_create_table::Migration), 16 | Box::new(m20221030_192706_add_last_update::Migration), 17 | Box::new(m20221102_232244_add_join_table::Migration), 18 | Box::new(m20221102_232858_remove_unused_columns::Migration), 19 | Box::new(m20221106_211436_remove_query_x::Migration), 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dashboard" 3 | version = "0.4.0" 4 | edition = "2021" 5 | 6 | [features] 7 | web = ["chrono/wasmbind", "eframe/persistence"] 8 | 9 | [dependencies] 10 | rand = "0.8" 11 | dirs = "4" 12 | git-version = "0.3.5" 13 | chrono = "0.4" 14 | tracing = "0.1" 15 | serde = { version = "1", features = ["derive"] } 16 | serde_json = "1" 17 | csv = "1.1" 18 | jql = { version = "4", default-features = false } 19 | eframe = "0.19" 20 | futures = "0.3" 21 | reqwest = { version = "0.11", features = ["json"] } 22 | sea-orm = { version = "0.10", features = [ "runtime-tokio-rustls", "sqlx-sqlite", "sqlx-postgres", "macros" ] } 23 | clap = { version = "4", features = ["derive"] } 24 | tokio = { version = "1", features = ["full"] } 25 | tracing-subscriber = "0.3" 26 | ctrlc = "3.2.3" 27 | 28 | [profile.dev.package."*"] 29 | opt-level = 3 30 | -------------------------------------------------------------------------------- /src/data/entities/points.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::entity::prelude::*; 2 | 3 | use eframe::egui::plot::PlotPoint; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] 6 | #[sea_orm(table_name = "points")] 7 | pub struct Model { 8 | #[sea_orm(primary_key, auto_increment = false)] 9 | pub id: i64, 10 | pub metric_id: i64, 11 | pub x: f64, 12 | pub y: f64, 13 | } 14 | 15 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 16 | pub enum Relation { 17 | #[sea_orm( 18 | belongs_to = "super::metrics::Entity", 19 | from = "Column::MetricId", 20 | to = "super::metrics::Column::Id" 21 | )] 22 | Metric, 23 | } 24 | 25 | impl Related for Entity { 26 | fn to() -> RelationDef { Relation::Metric.def() } 27 | } 28 | 29 | impl ActiveModelBehavior for ActiveModel {} 30 | 31 | impl Into for Model { 32 | fn into(self) -> PlotPoint { 33 | PlotPoint { x: self.x, y: self.y } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /migration/src/m20221030_192706_add_last_update.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager. 10 | alter_table( 11 | Table::alter() 12 | .table(Sources::Table) 13 | .add_column( 14 | ColumnDef::new(Sources::LastUpdate) 15 | .big_integer() 16 | .not_null() 17 | .default(0) 18 | ) 19 | .to_owned() 20 | ) 21 | .await 22 | } 23 | 24 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 25 | manager 26 | .alter_table( 27 | Table::alter() 28 | .table(Sources::Table) 29 | .drop_column(Sources::LastUpdate) 30 | .to_owned() 31 | ) 32 | .await 33 | } 34 | } 35 | 36 | #[derive(Iden)] 37 | enum Sources { 38 | Table, 39 | LastUpdate, 40 | } 41 | -------------------------------------------------------------------------------- /src/data/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod entities; 2 | 3 | use std::num::ParseFloatError; 4 | 5 | #[derive(Debug)] 6 | pub enum FetchError { 7 | ReqwestError(reqwest::Error), 8 | IoError(std::io::Error), 9 | JQLError(String), 10 | ParseFloatError(ParseFloatError), 11 | DbError(sea_orm::DbErr), 12 | } 13 | 14 | impl From for FetchError { 15 | fn from(e: reqwest::Error) -> Self { 16 | FetchError::ReqwestError(e) 17 | } 18 | } 19 | impl From for FetchError { 20 | fn from(e: std::io::Error) -> Self { 21 | FetchError::IoError(e) 22 | } 23 | } 24 | impl From for FetchError { 25 | // TODO wtf? why does JQL error as a String? 26 | fn from(e: String) -> Self { 27 | FetchError::JQLError(e) 28 | } 29 | } 30 | impl From for FetchError { 31 | fn from(e: ParseFloatError) -> Self { 32 | FetchError::ParseFloatError(e) 33 | } 34 | } 35 | impl From for FetchError { 36 | fn from(e: sea_orm::DbErr) -> Self { 37 | FetchError::DbError(e) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /migration/README.md: -------------------------------------------------------------------------------- 1 | # Running Migrator CLI 2 | 3 | - Generate a new migration file 4 | ```sh 5 | cargo run -- migrate generate MIGRATION_NAME 6 | ``` 7 | - Apply all pending migrations 8 | ```sh 9 | cargo run 10 | ``` 11 | ```sh 12 | cargo run -- up 13 | ``` 14 | - Apply first 10 pending migrations 15 | ```sh 16 | cargo run -- up -n 10 17 | ``` 18 | - Rollback last applied migrations 19 | ```sh 20 | cargo run -- down 21 | ``` 22 | - Rollback last 10 applied migrations 23 | ```sh 24 | cargo run -- down -n 10 25 | ``` 26 | - Drop all tables from the database, then reapply all migrations 27 | ```sh 28 | cargo run -- fresh 29 | ``` 30 | - Rollback all applied migrations, then reapply all migrations 31 | ```sh 32 | cargo run -- refresh 33 | ``` 34 | - Rollback all applied migrations 35 | ```sh 36 | cargo run -- reset 37 | ``` 38 | - Check the status of all migrations 39 | ```sh 40 | cargo run -- status 41 | ``` 42 | -------------------------------------------------------------------------------- /migration/src/m20221102_232244_add_join_table.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .create_table( 11 | Table::create() 12 | .table(PanelMetric::Table) 13 | .if_not_exists() 14 | .col( 15 | ColumnDef::new(PanelMetric::Id) 16 | .big_integer() 17 | .not_null() 18 | .auto_increment() 19 | .primary_key(), 20 | ) 21 | .col(ColumnDef::new(PanelMetric::PanelId).big_integer().not_null()) 22 | .col(ColumnDef::new(PanelMetric::MetricId).big_integer().not_null()) 23 | .to_owned(), 24 | ).await 25 | } 26 | 27 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 28 | manager 29 | .drop_table(Table::drop().table(PanelMetric::Table).to_owned()) 30 | .await 31 | } 32 | } 33 | 34 | #[derive(Iden)] 35 | enum PanelMetric { 36 | Table, 37 | Id, 38 | PanelId, 39 | MetricId, 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 alemidev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/data/entities/sources.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::entity::prelude::*; 2 | use chrono::Utc; 3 | 4 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 5 | #[sea_orm(table_name = "sources")] 6 | pub struct Model { 7 | #[sea_orm(primary_key, auto_increment = false)] 8 | pub id: i64, 9 | pub name: String, 10 | pub enabled: bool, 11 | pub url: String, 12 | pub interval: i32, 13 | pub last_update: i64, 14 | pub position: i32, 15 | } 16 | 17 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 18 | pub enum Relation { 19 | #[sea_orm(has_many = "super::metrics::Entity")] 20 | Metric, 21 | } 22 | 23 | impl Related for Entity { 24 | fn to() -> RelationDef { 25 | Relation::Metric.def() 26 | } 27 | } 28 | 29 | impl ActiveModelBehavior for ActiveModel {} 30 | 31 | impl Model { 32 | pub fn cooldown(&self) -> i64 { 33 | let elapsed = Utc::now().timestamp() - self.last_update; 34 | (self.interval as i64) - elapsed 35 | } 36 | 37 | pub fn ready(&self) -> bool { 38 | self.cooldown() <= 0 39 | 40 | } 41 | } 42 | 43 | impl Default for Model { 44 | fn default() -> Self { 45 | Model { 46 | id: 0, 47 | name: "".into(), 48 | enabled: false, 49 | url: "".into(), 50 | interval: 60, 51 | last_update: 0, 52 | position: 0, 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/data/entities/panels.rs: -------------------------------------------------------------------------------- 1 | //! SeaORM Entity. Generated by sea-orm-codegen 0.10.1 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "panels")] 7 | pub struct Model { 8 | #[sea_orm(primary_key, auto_increment = true)] 9 | pub id: i64, 10 | pub name: String, 11 | pub view_scroll: bool, 12 | pub view_size: i32, 13 | pub height: i32, 14 | pub position: i32, 15 | pub reduce_view: bool, 16 | pub view_chunks: i32, 17 | pub view_offset: i32, 18 | pub average_view: bool, 19 | } 20 | 21 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 22 | pub enum Relation {} 23 | 24 | impl Related for Entity { 25 | fn to() -> RelationDef { 26 | super::panel_metric::Relation::Metric.def() 27 | } 28 | 29 | fn via() -> Option { 30 | Some(super::panel_metric::Relation::Panel.def().rev()) 31 | } 32 | } 33 | 34 | impl ActiveModelBehavior for ActiveModel {} 35 | 36 | impl Default for Model { 37 | fn default() -> Self { 38 | Model { 39 | id: 0, 40 | name: "".into(), 41 | view_scroll: true, 42 | view_size: 1000, 43 | height: 100, 44 | position: 0, 45 | reduce_view: false, 46 | view_chunks: 10, 47 | view_offset: 0, 48 | average_view: true, 49 | } 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/gui/metric.rs: -------------------------------------------------------------------------------- 1 | use eframe::{egui::{Ui, Sense, color_picker::show_color_at, TextEdit}, epaint::Color32}; 2 | 3 | use crate::{data::entities, util::unpack_color}; 4 | 5 | fn color_square(ui: &mut Ui, color:Color32) { 6 | let size = ui.spacing().interact_size; 7 | let (rect, response) = ui.allocate_exact_size(size, Sense::click()); 8 | if ui.is_rect_visible(rect) { 9 | let visuals = ui.style().interact(&response); 10 | let rect = rect.expand(visuals.expansion); 11 | 12 | show_color_at(ui.painter(), color, rect); 13 | 14 | let rounding = visuals.rounding.at_most(2.0); 15 | ui.painter() 16 | .rect_stroke(rect, rounding, (2.0, visuals.bg_fill)); // fill is intentional, because default style has no border 17 | } 18 | } 19 | 20 | pub fn metric_line_ui(ui: &mut Ui, metric: &entities::metrics::Model) { 21 | let mut name = metric.name.clone(); 22 | let mut query = metric.query.clone(); 23 | ui.horizontal(|ui| { 24 | // ui.color_edit_button_srgba(&mut unpack_color(metric.color)); 25 | color_square(ui, unpack_color(metric.color)); 26 | let unit = (ui.available_width() - 65.0) / 5.0; 27 | TextEdit::singleline(&mut name) 28 | .desired_width(unit * 2.0) 29 | .interactive(false) 30 | .hint_text("name") 31 | .show(ui); 32 | ui.separator(); 33 | TextEdit::singleline(&mut query) 34 | .desired_width(unit * 3.0) 35 | .interactive(false) 36 | .hint_text("query") 37 | .show(ui); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /src/data/entities/metrics.rs: -------------------------------------------------------------------------------- 1 | //! SeaORM Entity. Generated by sea-orm-codegen 0.10.1 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | use crate::data::FetchError; 6 | 7 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 8 | #[sea_orm(table_name = "metrics")] 9 | pub struct Model { 10 | #[sea_orm(primary_key, auto_increment = false)] 11 | pub id: i64, 12 | pub name: String, 13 | pub source_id: i64, 14 | pub query: String, 15 | pub color: i32, 16 | pub position: i32, 17 | } 18 | 19 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 20 | pub enum Relation { 21 | #[sea_orm( 22 | belongs_to = "super::sources::Entity", 23 | from = "Column::SourceId", 24 | to = "super::sources::Column::Id" 25 | )] 26 | Source, 27 | 28 | #[sea_orm(has_many = "super::points::Entity")] 29 | Point, 30 | } 31 | 32 | impl Related for Entity { 33 | fn to() -> RelationDef { Relation::Source.def() } 34 | } 35 | 36 | impl Related for Entity { 37 | fn to() -> RelationDef { Relation::Point.def() } 38 | } 39 | 40 | impl Related for Entity { 41 | fn to() -> RelationDef { 42 | super::panel_metric::Relation::Panel.def() 43 | } 44 | 45 | fn via() -> Option { 46 | Some(super::panel_metric::Relation::Metric.def().rev()) 47 | } 48 | } 49 | 50 | impl ActiveModelBehavior for ActiveModel {} 51 | 52 | impl Model { 53 | pub fn extract(&self, value: &serde_json::Value) -> Result, FetchError> { 54 | Ok(jql::walker(value, self.query.as_str())?.as_f64()) 55 | } 56 | } 57 | 58 | impl Default for Model { 59 | fn default() -> Self { 60 | Model { 61 | id: 0, 62 | name: "".into(), 63 | source_id: 0, 64 | query: "".into(), 65 | color: 0, 66 | position: 0, 67 | } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /migration/src/m20221106_211436_remove_query_x.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .alter_table( 11 | Table::alter() 12 | .table(Metrics::Table) 13 | .drop_column(Metrics::QueryX) 14 | .to_owned() 15 | ).await?; 16 | 17 | manager 18 | .alter_table( 19 | Table::alter() 20 | .table(Metrics::Table) 21 | .rename_column(Metrics::QueryY, Metrics::Query) 22 | .to_owned() 23 | ).await?; 24 | 25 | manager 26 | .alter_table( 27 | Table::alter() 28 | .table(Panels::Table) 29 | .drop_column(Panels::Timeserie) 30 | .to_owned() 31 | ).await?; 32 | 33 | Ok(()) 34 | } 35 | 36 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 37 | manager 38 | .alter_table( 39 | Table::alter() 40 | .table(Metrics::Table) 41 | .rename_column(Metrics::Query, Metrics::QueryY) 42 | .to_owned() 43 | ).await?; 44 | 45 | manager. 46 | alter_table( 47 | Table::alter() 48 | .table(Metrics::Table) 49 | .add_column( 50 | ColumnDef::new(Metrics::QueryX) 51 | .float() 52 | .not_null() 53 | .default(0.0) 54 | ) 55 | .to_owned() 56 | ).await?; 57 | 58 | manager. 59 | alter_table( 60 | Table::alter() 61 | .table(Panels::Table) 62 | .add_column( 63 | ColumnDef::new(Panels::Timeserie) 64 | .boolean() 65 | .not_null() 66 | .default(true) 67 | ) 68 | .to_owned() 69 | ).await?; 70 | 71 | Ok(()) 72 | } 73 | } 74 | 75 | #[derive(Iden)] 76 | enum Metrics { 77 | Table, 78 | QueryX, 79 | QueryY, 80 | Query, 81 | } 82 | 83 | #[derive(Iden)] 84 | enum Panels { 85 | Table, 86 | Timeserie, 87 | } 88 | -------------------------------------------------------------------------------- /migration/src/m20221102_232858_remove_unused_columns.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager. 10 | alter_table( 11 | Table::alter() 12 | .table(Panels::Table) 13 | .drop_column(Panels::Width) 14 | .drop_column(Panels::LimitView) 15 | .drop_column(Panels::ShiftView) 16 | .to_owned() 17 | ) 18 | .await?; 19 | manager. 20 | alter_table( 21 | Table::alter() 22 | .table(Metrics::Table) 23 | .drop_column(Metrics::PanelId) 24 | .to_owned() 25 | ) 26 | .await?; 27 | Ok(()) 28 | } 29 | 30 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 31 | manager 32 | .alter_table( 33 | Table::alter() 34 | .table(Panels::Table) 35 | .add_column( 36 | ColumnDef::new(Panels::Width) 37 | .integer() 38 | .not_null() 39 | .default(100) 40 | ) 41 | .add_column( 42 | ColumnDef::new(Panels::LimitView) 43 | .boolean() 44 | .not_null() 45 | .default(true) 46 | ) 47 | .add_column( 48 | ColumnDef::new(Panels::ShiftView) 49 | .boolean() 50 | .not_null() 51 | .default(false) 52 | ) 53 | .to_owned() 54 | ) 55 | .await?; 56 | manager 57 | .alter_table( 58 | Table::alter() 59 | .table(Metrics::Table) 60 | .add_column( 61 | ColumnDef::new(Metrics::PanelId) 62 | .big_integer() 63 | .not_null() 64 | .default(0) 65 | ) 66 | .to_owned() 67 | ) 68 | .await?; 69 | Ok(()) 70 | } 71 | } 72 | 73 | #[derive(Iden)] 74 | enum Panels { 75 | Table, 76 | Width, 77 | LimitView, 78 | ShiftView, 79 | } 80 | 81 | #[derive(Iden)] 82 | enum Metrics { 83 | Table, 84 | PanelId, 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dashboard 2 | ![screenshot](https://data.alemi.dev/dashboard.png) 3 | A data aggregating dashboard, capable of periodically fetching, parsing, archiving and plotting data. 4 | 5 | ### Name 6 | Do you have a good name idea for this project? [Let me know](https://alemi.dev/suggestions/What%27s%20a%20good%20name%20for%20the%20project%3F)! 7 | 8 | ## How it works 9 | This software periodically (customizable interval) makes a GET request to given URL, then applies all metric JQL queries to the JSON output, then inserts all extracted points into its underlying SQLite. 10 | Each panel displays all points gathered respecting limits, without redrawing until user interacts with UI or data changes. 11 | If no "x" query is specified, current time will be used (as timestamp) for each sample "x" coordinate, making this software especially useful for timeseries. 12 | 13 | ## Usage 14 | This program will work on a database stored in `$HOME/.local/share/dashboard.db`. By default, nothing will be shown. 15 | To add sources or panels, toggle edit mode (top left). Once in edit mode you can: 16 | * Add panels (top bar) 17 | * Add sources (in source sidebar, bottom) 18 | * Edit panels (name, height, display options) 19 | * Edit sources (name, color, query, panel) 20 | Each change is effective as soon as you type it, but won't persist a restart if you don't "save" it. Just close and reopen if you mess something up! 21 | 22 | ## Features 23 | * parse JSON apis with [JQL syntax](https://github.com/yamafaktory/jql) 24 | * embedded SQLite, no need for external database 25 | * import/export metrics data to/from CSV 26 | * split data from 1 fetch to many metrics 27 | * customize source color and name, toggle them (visibility or fetching) 28 | * customize panels (size, span, offset) 29 | * reduce data points with average or sampling 30 | * per-source query interval 31 | * light/dark mode 32 | * log panel endlessly tracking errors 33 | * tiny performance impact 34 | 35 | ## Drawbacks 36 | * Log panel has no limit, thus very long runtimes will make it slower 37 | * Being monolithic, this project doesn't scale well with large data needs 38 | * Untested on Windows and MacOS 39 | * No limit on points displayed might slow down the UI, use the `reduce` feature 40 | * All fields are editable at the same time 41 | 42 | # Installation 43 | `cargo build --release`, then drop it in your `~/.local/bin`. Done, have fun hoarding data! 44 | -------------------------------------------------------------------------------- /src/gui/source.rs: -------------------------------------------------------------------------------- 1 | use eframe::egui::{ScrollArea, Ui, DragValue, TextEdit, Checkbox}; 2 | 3 | use crate::gui::App; 4 | use crate::data::entities; 5 | 6 | use super::metric::metric_line_ui; 7 | 8 | pub fn source_panel_ui(app: &mut App, ui: &mut Ui) { 9 | let panel_width = ui.available_width(); 10 | let mut orphaned_metrics = app.view.metrics.borrow().clone(); 11 | ScrollArea::vertical() 12 | .max_width(panel_width) 13 | .show(ui, |ui| { 14 | // TODO only vertical! 15 | { 16 | let sources = app.view.sources.borrow(); 17 | ui.heading("Sources"); 18 | ui.separator(); 19 | for source in sources.iter() { 20 | ui.add_space(5.0); 21 | ui.horizontal(|ui| { 22 | ui.vertical(|ui| { 23 | ui.add_space(8.0); 24 | if ui.small_button("#").clicked() { 25 | app.editing.push(source.clone().into()); 26 | } 27 | }); 28 | ui.vertical(|ui| { // actual sources list container 29 | ui.group(|ui| { 30 | ui.horizontal(|ui| { 31 | source_line_ui(ui, source); 32 | }); 33 | let metrics = app 34 | .view 35 | .metrics 36 | .borrow(); 37 | for (_j, metric) in metrics.iter().enumerate() { 38 | if metric.source_id == source.id { 39 | orphaned_metrics.retain(|m| m.id != metric.id); 40 | ui.horizontal(|ui| { 41 | metric_line_ui(ui, metric); 42 | if ui.small_button("#").clicked() { 43 | // TODO don't add duplicates 44 | app.editing.push(metric.clone().into()); 45 | } 46 | }); 47 | } 48 | } 49 | }); 50 | }); 51 | }); 52 | } 53 | ui.add_space(5.0); 54 | ui.horizontal(|ui| { // 1 more for uncategorized sources 55 | ui.vertical(|ui| { 56 | ui.add_space(8.0); 57 | if ui.small_button("+").clicked() { 58 | app.editing.push(entities::sources::Model::default().into()); 59 | } 60 | }); 61 | ui.vertical(|ui| { // actual sources list container 62 | ui.group(|ui| { 63 | ui.horizontal(|ui| { 64 | source_line_ui(ui, &app.buffer_source); 65 | }); 66 | for metric in orphaned_metrics.iter() { 67 | ui.horizontal(|ui| { 68 | metric_line_ui(ui, metric); 69 | if ui.small_button("#").clicked() { 70 | // TODO don't add duplicates 71 | app.editing.push(metric.clone().into()); 72 | } 73 | }); 74 | } 75 | // Add an empty metric to insert new ones 76 | ui.horizontal(|ui| { 77 | metric_line_ui(ui, &mut app.buffer_metric); 78 | if ui.small_button("+").clicked() { 79 | app.editing.push(entities::metrics::Model::default().into()); 80 | } 81 | }); 82 | }); 83 | }); 84 | }); 85 | } 86 | }); 87 | } 88 | 89 | pub fn source_line_ui(ui: &mut Ui, source: &entities::sources::Model) { 90 | let mut interval = source.interval.clone(); 91 | let mut name = source.name.clone(); 92 | let mut enabled = source.enabled.clone(); 93 | ui.horizontal(|ui| { 94 | ui.add_enabled(false, Checkbox::new(&mut enabled, "")); 95 | TextEdit::singleline(&mut name) 96 | .desired_width(ui.available_width() - 58.0) 97 | .interactive(false) 98 | .hint_text("name") 99 | .show(ui); 100 | ui.add_enabled(false, DragValue::new(&mut interval).clamp_range(1..=3600)); 101 | }); 102 | } 103 | -------------------------------------------------------------------------------- /src/worker/surveyor.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use chrono::Utc; 4 | use sea_orm::{DatabaseConnection, ActiveValue::NotSet, Set, EntityTrait}; 5 | use tokio::sync::watch; 6 | use tracing::error; 7 | 8 | use crate::data::{entities, FetchError}; 9 | 10 | async fn fetch(url: &str) -> Result { 11 | Ok(reqwest::get(url).await?.json().await?) 12 | } 13 | 14 | pub async fn surveyor_loop( 15 | db: DatabaseConnection, 16 | interval:i64, 17 | cache_time:i64, 18 | run: watch::Receiver, 19 | index: usize, 20 | ) { 21 | let mut last_activation = Utc::now().timestamp(); 22 | let mut last_fetch = 0; 23 | let mut sources = vec![]; 24 | let mut metrics = Arc::new(vec![]); 25 | 26 | while *run.borrow() { 27 | // sleep until next activation 28 | let delta_time = (interval as i64) - (Utc::now().timestamp() - last_activation); 29 | if delta_time > 0 { 30 | tokio::time::sleep(std::time::Duration::from_secs(delta_time as u64)).await; 31 | } 32 | last_activation = Utc::now().timestamp(); 33 | 34 | if Utc::now().timestamp() - last_fetch > cache_time { 35 | // TODO do both concurrently 36 | match entities::sources::Entity::find().all(&db).await { 37 | Ok(srcs) => sources = srcs, 38 | Err(e) => { 39 | error!(target: "surveyor", "[{}] Could not fetch sources: {:?}", index, e); 40 | continue; 41 | } 42 | } 43 | match entities::metrics::Entity::find().all(&db).await { 44 | Ok(mtrcs) => metrics = Arc::new(mtrcs), 45 | Err(e) => { 46 | error!(target: "surveyor", "[{}] Could not fetch metrics: {:?}", index, e); 47 | continue; 48 | } 49 | } 50 | last_fetch = Utc::now().timestamp(); 51 | } 52 | 53 | for source in sources.iter_mut() { 54 | if !source.enabled || !source.ready() { 55 | continue; 56 | } 57 | 58 | let metrics_snapshot = metrics.clone(); 59 | let db_clone = db.clone(); 60 | let source_clone = source.clone(); 61 | let now = Utc::now().timestamp(); 62 | source.last_update = now; // TODO kinda meh 63 | // we set this before knowing about fetch result, to avoid re-running a fetch 64 | // next time this loop runs. But the task only sets last_update on db if fetch succeeds, 65 | // so if an error happens the client and server last_update fields will differ until fetched 66 | // again. This could be avoided by keeping track of which threads are trying which sources, 67 | // but also only trying to fetch at certain intervals to stay aligned might be desirable. 68 | tokio::spawn(async move { 69 | match fetch(&source_clone.url).await { 70 | Ok(res) => { 71 | if let Err(e) = entities::sources::Entity::update( 72 | entities::sources::ActiveModel{id: Set(source_clone.id), last_update: Set(now), ..Default::default()} 73 | ).exec(&db_clone).await { 74 | error!(target: "surveyor", "[{}] Failed setting last_update ({:?}) for source {:?} but successfully fetched '{}', aborting", index, e, source_clone, res); 75 | return; 76 | } 77 | let now = Utc::now().timestamp() as f64; 78 | for metric in metrics_snapshot.iter().filter(|x| source_clone.id == x.source_id) { 79 | match metric.extract(&res) { 80 | // note that Err and None mean different things: Err for broken queries, None for 81 | // missing values. Only first one is reported 82 | Ok(value) => { 83 | if let Some(v) = value { 84 | if let Err(e) = entities::points::Entity::insert( 85 | entities::points::ActiveModel { 86 | id: NotSet, metric_id: Set(metric.id), x: Set(now), y: Set(v), 87 | }).exec(&db_clone).await { 88 | error!(target: "surveyor", "[{}] Could not insert record ({},{}) : {:?}", index, now, v, e); 89 | } 90 | } 91 | }, 92 | Err(e) => error!(target: "surveyor", "[{}] Failed extracting '{}' from {}: {:?}", index, metric.name, source_clone.name, e), 93 | } 94 | } 95 | }, 96 | Err(e) => error!(target: "surveyor", "[{}] Failed fetching {}: {:?}", index, source_clone.name, e), 97 | } 98 | }); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /migration/src/m20220101_000001_create_table.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | 4 | // I wish I had used SeaOrm since the beginning: 5 | // this first migration wouldn't be so beefy! 6 | 7 | #[derive(DeriveMigrationName)] 8 | pub struct Migration; 9 | 10 | #[async_trait::async_trait] 11 | impl MigrationTrait for Migration { 12 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 13 | manager 14 | .create_table( 15 | Table::create() 16 | .table(Panels::Table) 17 | .if_not_exists() 18 | .col( 19 | ColumnDef::new(Panels::Id) 20 | .big_integer() 21 | .not_null() 22 | .auto_increment() 23 | .primary_key(), 24 | ) 25 | .col(ColumnDef::new(Panels::Name).string().not_null()) 26 | .col(ColumnDef::new(Panels::Position).integer().not_null()) 27 | .col(ColumnDef::new(Panels::Timeserie).boolean().not_null()) 28 | .col(ColumnDef::new(Panels::Height).integer().not_null()) 29 | .col(ColumnDef::new(Panels::Width).integer().not_null()) 30 | .col(ColumnDef::new(Panels::ViewScroll).boolean().not_null()) 31 | .col(ColumnDef::new(Panels::LimitView).boolean().not_null()) 32 | .col(ColumnDef::new(Panels::ViewSize).integer().not_null()) 33 | .col(ColumnDef::new(Panels::ReduceView).boolean().not_null()) 34 | .col(ColumnDef::new(Panels::ViewChunks).integer().not_null()) 35 | .col(ColumnDef::new(Panels::ShiftView).boolean().not_null()) 36 | .col(ColumnDef::new(Panels::ViewOffset).integer().not_null()) 37 | .col(ColumnDef::new(Panels::AverageView).boolean().not_null()) 38 | .to_owned(), 39 | ).await?; 40 | manager 41 | .create_table( 42 | Table::create() 43 | .table(Sources::Table) 44 | .if_not_exists() 45 | .col( 46 | ColumnDef::new(Sources::Id) 47 | .big_integer() 48 | .not_null() 49 | .auto_increment() 50 | .primary_key(), 51 | ) 52 | .col(ColumnDef::new(Sources::Name).string().not_null()) 53 | .col(ColumnDef::new(Sources::Position).integer().not_null()) 54 | .col(ColumnDef::new(Sources::Enabled).boolean().not_null()) 55 | .col(ColumnDef::new(Sources::Url).string().not_null()) 56 | .col(ColumnDef::new(Sources::Interval).integer().not_null()) 57 | .to_owned(), 58 | ).await?; 59 | manager 60 | .create_table( 61 | Table::create() 62 | .table(Metrics::Table) 63 | .if_not_exists() 64 | .col( 65 | ColumnDef::new(Metrics::Id) 66 | .big_integer() 67 | .not_null() 68 | .auto_increment() 69 | .primary_key(), 70 | ) 71 | .col(ColumnDef::new(Metrics::Name).string().not_null()) 72 | .col(ColumnDef::new(Metrics::Position).integer().not_null()) 73 | .col(ColumnDef::new(Metrics::PanelId).big_integer().not_null()) 74 | .col(ColumnDef::new(Metrics::SourceId).big_integer().not_null()) 75 | .col(ColumnDef::new(Metrics::QueryX).string().not_null()) 76 | .col(ColumnDef::new(Metrics::QueryY).string().not_null()) 77 | .col(ColumnDef::new(Metrics::Color).integer().not_null()) 78 | .to_owned(), 79 | ).await?; 80 | manager 81 | .create_table( 82 | Table::create() 83 | .table(Points::Table) 84 | .if_not_exists() 85 | .col( 86 | ColumnDef::new(Points::Id) 87 | .big_integer() 88 | .not_null() 89 | .auto_increment() 90 | .primary_key(), 91 | ) 92 | .col(ColumnDef::new(Points::MetricId).big_integer().not_null()) 93 | .col(ColumnDef::new(Points::X).double().not_null()) 94 | .col(ColumnDef::new(Points::Y).double().not_null()) 95 | .to_owned(), 96 | ).await?; 97 | Ok(()) 98 | } 99 | 100 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 101 | manager 102 | .drop_table(Table::drop().table(Panels::Table).to_owned()) 103 | .await?; 104 | manager 105 | .drop_table(Table::drop().table(Sources::Table).to_owned()) 106 | .await?; 107 | manager 108 | .drop_table(Table::drop().table(Metrics::Table).to_owned()) 109 | .await?; 110 | manager 111 | .drop_table(Table::drop().table(Points::Table).to_owned()) 112 | .await?; 113 | Ok(()) 114 | } 115 | } 116 | 117 | #[derive(Iden)] 118 | enum Panels { 119 | Table, 120 | Id, 121 | Name, 122 | Position, 123 | Timeserie, 124 | Height, 125 | Width, 126 | ViewScroll, 127 | LimitView, 128 | ViewSize, 129 | ReduceView, 130 | ViewChunks, 131 | ShiftView, 132 | ViewOffset, 133 | AverageView, 134 | } 135 | 136 | #[derive(Iden)] 137 | enum Sources { 138 | Table, 139 | Id, 140 | Name, 141 | Position, 142 | Enabled, 143 | Url, 144 | Interval, 145 | } 146 | 147 | #[derive(Iden)] 148 | enum Metrics { 149 | Table, 150 | Id, 151 | Name, 152 | Position, 153 | PanelId, 154 | SourceId, 155 | QueryX, 156 | QueryY, 157 | Color, 158 | } 159 | 160 | #[derive(Iden)] 161 | enum Points { 162 | Table, 163 | Id, 164 | MetricId, 165 | X, 166 | Y, 167 | } 168 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | eframe template 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 136 | 137 | 138 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /src/gui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod panel; 2 | pub mod source; 3 | pub mod metric; 4 | 5 | mod scaffold; 6 | 7 | use chrono::Utc; 8 | use eframe::egui::{CentralPanel, Context, SidePanel, TopBottomPanel, Window}; 9 | use tokio::sync::{watch, mpsc}; 10 | use tracing::error; 11 | 12 | use crate::{data::entities, worker::{visualizer::AppStateView, BackgroundAction}}; 13 | use panel::main_content; 14 | use scaffold::{ 15 | // confirmation_popup_delete_metric, confirmation_popup_delete_source, footer, 16 | header, 17 | }; 18 | use source::source_panel_ui; 19 | 20 | use self::scaffold::{footer, EditingModel, popup_edit_ui}; 21 | 22 | pub struct App { 23 | view: AppStateView, 24 | db_uri: String, 25 | db_uri_tx: mpsc::Sender, 26 | last_db_uri: String, 27 | interval: i64, 28 | last_redraw: i64, 29 | 30 | panels: Vec, 31 | width_tx: watch::Sender, 32 | logger_view: watch::Receiver>, 33 | 34 | // buffer_panel: entities::panels::Model, 35 | buffer_source: entities::sources::Model, 36 | buffer_metric: entities::metrics::Model, 37 | 38 | edit: bool, 39 | editing: Vec, 40 | sidebar: bool, 41 | _padding: bool, 42 | // windows: Vec>, 43 | } 44 | 45 | impl App { 46 | pub fn new( 47 | _cc: &eframe::CreationContext, 48 | initial_uri: Option, 49 | db_uri_tx: mpsc::Sender, 50 | interval: i64, 51 | view: AppStateView, 52 | width_tx: watch::Sender, 53 | logger_view: watch::Receiver>, 54 | ) -> Self { 55 | let panels = view.panels.borrow().clone(); 56 | if let Some(initial_uri) = &initial_uri { 57 | if let Err(e) = db_uri_tx.blocking_send(initial_uri.clone()) { 58 | error!(target: "app", "Could not send initial uri: {:?}", e); 59 | } 60 | } 61 | Self { 62 | db_uri_tx, interval, panels, width_tx, view, logger_view, 63 | last_db_uri: "[disconnected]".into(), 64 | db_uri: initial_uri.unwrap_or("".into()), 65 | buffer_source: entities::sources::Model::default(), 66 | buffer_metric: entities::metrics::Model::default(), 67 | last_redraw: 0, 68 | edit: false, 69 | editing: vec![], 70 | sidebar: true, 71 | _padding: false, 72 | // windows: vec![], 73 | } 74 | } 75 | 76 | pub fn save_all_panels(&self) { // TODO can probably remove this and invoke op() directly 77 | let msg = BackgroundAction::UpdateAllPanels { panels: self.panels.clone() }; 78 | self.op(msg); 79 | } 80 | 81 | pub fn refresh_data(&self) { 82 | let flush_clone = self.view.flush.clone(); 83 | std::thread::spawn(move || { 84 | if let Err(e) = flush_clone.blocking_send(()) { 85 | error!(target: "app-background", "Could not request flush: {:?}", e); 86 | } 87 | }); 88 | } 89 | 90 | pub fn op(&self, op: BackgroundAction) { 91 | let op_clone = self.view.op.clone(); 92 | std::thread::spawn(move || { 93 | if let Err(e) = op_clone.blocking_send(op) { 94 | error!(target: "app-background", "Could not send operation: {:?}", e); 95 | } 96 | }); 97 | } 98 | 99 | fn update_db_uri(&self) { 100 | let db_uri_clone = self.db_uri_tx.clone(); 101 | let db_uri_str = self.db_uri.clone(); 102 | let flush_clone = self.view.flush.clone(); 103 | std::thread::spawn(move || { 104 | if let Err(e) = db_uri_clone.blocking_send(db_uri_str) { 105 | error!(target: "app-background", "Could not send new db uri : {:?}", e); 106 | } 107 | if let Err(e) = flush_clone.blocking_send(()) { 108 | error!(target: "app-background", "Could not request data flush : {:?}", e); 109 | } 110 | }); 111 | } 112 | } 113 | 114 | impl eframe::App for App { 115 | fn update(&mut self, ctx: &Context, frame: &mut eframe::Frame) { 116 | TopBottomPanel::top("header").show(ctx, |ui| { 117 | header(self, ui, frame); 118 | }); 119 | 120 | TopBottomPanel::bottom("footer").show(ctx, |ui| { 121 | footer(ctx, ui, self.logger_view.clone(), self.last_db_uri.clone(), self.view.points.borrow().len()); 122 | }); 123 | 124 | for m in self.editing.iter_mut() { 125 | Window::new(m.id_repr()) 126 | .default_width(150.0) 127 | .show(ctx, |ui| popup_edit_ui(ui, m, &self.view.sources.borrow(), &self.view.metrics.borrow())); 128 | } 129 | 130 | if self.sidebar { 131 | SidePanel::left("sources-bar") 132 | .width_range(280.0..=800.0) 133 | .default_width(if self.edit { 450.0 } else { 330.0 }) 134 | .show(ctx, |ui| source_panel_ui(self, ui)); 135 | } 136 | 137 | CentralPanel::default().show(ctx, |ui| { 138 | main_content(self, ctx, ui); 139 | }); 140 | 141 | if let Some(viewsize) = 142 | self.panels 143 | .iter() 144 | .chain(self.view.panels.borrow().iter()) 145 | .map(|p| p.view_size + p.view_offset) 146 | .max() 147 | { 148 | if let Err(e) = self.width_tx.send(viewsize as i64) { 149 | error!(target: "app", "Could not update fetch size : {:?}", e); 150 | } 151 | } 152 | 153 | if Utc::now().timestamp() > self.last_redraw + self.interval { 154 | ctx.request_repaint(); 155 | self.last_redraw = Utc::now().timestamp(); 156 | } 157 | 158 | for m in self.editing.iter() { 159 | if m.should_fetch() { 160 | self.op(m.to_msg(self.view.clone())); // TODO cloning is super wasteful 161 | } 162 | } 163 | 164 | self.editing.retain(|v| v.modifying()); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Local, NaiveDateTime, Utc}; 2 | use eframe::egui::{Color32, plot::PlotPoint}; 3 | use tokio::sync::{watch, mpsc}; 4 | use tracing::error; 5 | use std::{error::Error, path::PathBuf, collections::VecDeque}; 6 | use tracing_subscriber::Layer; 7 | 8 | use super::data::entities; 9 | 10 | // if you're handling more than terabytes of data, it's the future and you ought to update this code! 11 | const _PREFIXES: &'static [&'static str] = &["", "k", "M", "G", "T"]; 12 | 13 | pub fn _serialize_values(values: &Vec, metric: &entities::metrics::Model, path: PathBuf) -> Result<(), Box> { 14 | let mut wtr = csv::Writer::from_writer(std::fs::File::create(path)?); 15 | // DAMN! VVVVV 16 | let name = metric.name.as_str(); 17 | let q = metric.query.as_str(); 18 | wtr.write_record(&[name, q, "1"])?; 19 | // DAMN! AAAAA 20 | for v in values { 21 | wtr.serialize(("", v.x, v.y))?; 22 | } 23 | wtr.flush()?; 24 | Ok(()) 25 | } 26 | 27 | pub fn _deserialize_values(path: PathBuf) -> Result<(String, String, String, Vec), Box> { 28 | let mut values = Vec::new(); 29 | 30 | let mut rdr = csv::Reader::from_reader(std::fs::File::open(path)?); 31 | let mut name = "N/A".to_string(); 32 | let mut query_x = "".to_string(); 33 | let mut query_y = "".to_string(); 34 | if rdr.has_headers() { 35 | let record = rdr.headers()?; 36 | name = record[0].to_string(); 37 | query_x = record[1].to_string(); 38 | query_y = record[2].to_string(); 39 | } 40 | for result in rdr.records() { 41 | if let Ok(record) = result { 42 | values.push(PlotPoint { x: record[1].parse::()?, y: record[2].parse::()? }); 43 | } 44 | } 45 | 46 | Ok(( 47 | name, 48 | query_x, 49 | query_y, 50 | values, 51 | )) 52 | } 53 | 54 | #[allow(dead_code)] 55 | pub fn human_size(size: u64) -> String { 56 | let mut buf: f64 = size as f64; 57 | let mut prefix: usize = 0; 58 | while buf > 1024.0 && prefix < _PREFIXES.len() - 1 { 59 | buf /= 1024.0; 60 | prefix += 1; 61 | } 62 | 63 | return format!("{:.3} {}B", buf, _PREFIXES[prefix]); 64 | } 65 | 66 | pub fn timestamp_to_str(t: i64, date: bool, time: bool) -> String { 67 | format!( 68 | "{}", 69 | DateTime::::from(DateTime::::from_utc( 70 | NaiveDateTime::from_timestamp(t, 0), 71 | Utc 72 | )) 73 | .format(if date && time { 74 | "%Y/%m/%d %H:%M:%S" 75 | } else if date { 76 | "%Y/%m/%d" 77 | } else if time { 78 | "%H:%M:%S" 79 | } else { 80 | "%s" 81 | }) 82 | ) 83 | } 84 | 85 | pub fn unpack_color(c: i32) -> Color32 { 86 | let r: u8 = (c >> 0) as u8; 87 | let g: u8 = (c >> 8) as u8; 88 | let b: u8 = (c >> 16) as u8; 89 | let a: u8 = (c >> 24) as u8; 90 | return Color32::from_rgba_unmultiplied(r, g, b, a); 91 | } 92 | 93 | #[allow(dead_code)] 94 | pub fn repack_color(c: Color32) -> i32 { 95 | let mut out: i32 = 0; 96 | let mut offset = 0; 97 | for el in c.to_array() { 98 | out |= ((el & 0xFF) as i32) << offset; 99 | offset += 8; 100 | } 101 | return out; 102 | } 103 | 104 | pub struct InternalLogger { 105 | size: usize, 106 | view_tx: watch::Sender>, 107 | view_rx: watch::Receiver>, 108 | msg_tx : mpsc::UnboundedSender, 109 | msg_rx : mpsc::UnboundedReceiver, 110 | } 111 | 112 | impl InternalLogger { 113 | pub fn new(size: usize) -> Self { 114 | let (view_tx, view_rx) = watch::channel(vec![]); 115 | let (msg_tx, msg_rx) = mpsc::unbounded_channel(); 116 | InternalLogger { size, view_tx, view_rx, msg_tx, msg_rx } 117 | } 118 | 119 | pub fn view(&self) -> watch::Receiver> { 120 | self.view_rx.clone() 121 | } 122 | 123 | pub fn layer(&self) -> InternalLoggerLayer { 124 | InternalLoggerLayer::new(self.msg_tx.clone()) 125 | } 126 | 127 | pub async fn worker(mut self, run: watch::Receiver) { 128 | let mut messages = VecDeque::new(); 129 | while *run.borrow() { 130 | tokio::select!{ 131 | msg = self.msg_rx.recv() => { 132 | match msg { 133 | Some(msg) => { 134 | messages.push_back(msg); 135 | while messages.len() > self.size { 136 | messages.pop_front(); 137 | } 138 | if let Err(e) = self.view_tx.send(messages.clone().into()) { 139 | error!(target: "internal-logger", "Failed sending log line: {:?}", e); 140 | } 141 | }, 142 | None => break, 143 | } 144 | }, 145 | _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}, 146 | // unblock so it checks again run and exits cleanly 147 | } 148 | } 149 | } 150 | } 151 | 152 | pub struct InternalLoggerLayer { 153 | msg_tx: mpsc::UnboundedSender, 154 | } 155 | 156 | impl InternalLoggerLayer { 157 | pub fn new(msg_tx: mpsc::UnboundedSender) -> Self { 158 | InternalLoggerLayer { msg_tx } 159 | } 160 | } 161 | 162 | impl Layer for InternalLoggerLayer 163 | where 164 | S: tracing::Subscriber, 165 | { 166 | fn on_event( 167 | &self, 168 | event: &tracing::Event<'_>, 169 | _ctx: tracing_subscriber::layer::Context<'_, S>, 170 | ) { 171 | let mut msg_visitor = LogMessageVisitor { 172 | msg: "".to_string(), 173 | }; 174 | event.record(&mut msg_visitor); 175 | let out = format!( 176 | "{} [{}] {}: {}", 177 | Local::now().format("%H:%M:%S"), 178 | event.metadata().level(), 179 | event.metadata().target(), 180 | msg_visitor.msg 181 | ); 182 | 183 | self.msg_tx.send(out).unwrap_or_default(); 184 | } 185 | } 186 | 187 | struct LogMessageVisitor { 188 | msg: String, 189 | } 190 | 191 | impl tracing::field::Visit for LogMessageVisitor { 192 | fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { 193 | if field.name() == "message" { 194 | self.msg = format!("{}: '{:?}' ", field.name(), &value); 195 | } 196 | } 197 | 198 | fn record_str(&mut self, field: &tracing::field::Field, value: &str) { 199 | if field.name() == "message" { 200 | self.msg = value.to_string(); 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod gui; 2 | mod data; 3 | mod util; 4 | mod worker; 5 | 6 | use std::sync::Arc; 7 | 8 | use tracing::metadata::LevelFilter; 9 | use tracing_subscriber::prelude::*; 10 | use tracing::{info, error}; 11 | use tracing_subscriber::filter::filter_fn; 12 | 13 | use eframe::egui::Context; 14 | use clap::{Parser, Subcommand}; 15 | use tokio::sync::{watch, mpsc}; 16 | use sea_orm::Database; 17 | 18 | use worker::visualizer::AppState; 19 | use worker::surveyor_loop; 20 | use util::{InternalLogger, InternalLoggerLayer}; 21 | use gui::{ 22 | // util::InternalLogger, 23 | App 24 | }; 25 | 26 | /// Data gatherer and visualization tool 27 | #[derive(Parser, Debug)] 28 | #[command(author, version, about)] 29 | struct CliArgs { 30 | /// Which mode to run in 31 | #[clap(subcommand)] 32 | mode: Mode, 33 | 34 | /// Check interval for background worker 35 | #[arg(short, long, default_value_t = 10)] 36 | interval: u64, 37 | 38 | /// How often sources and metrics are refreshed 39 | #[arg(short, long, default_value_t = 300)] 40 | cache_time: u64, 41 | 42 | /// How many log lines to keep in memory 43 | #[arg(long, default_value_t = 1000)] 44 | log_size: u64, 45 | 46 | 47 | #[arg(long)] 48 | log_file: Option, 49 | } 50 | 51 | #[derive(Subcommand, Clone, Debug)] 52 | enum Mode { 53 | /// Run as background service fetching sources from db 54 | Worker { 55 | /// Connection string for database to use 56 | #[arg(required = true)] 57 | db_uris: Vec, 58 | }, 59 | /// Run as foreground user interface displaying collected data 60 | GUI { 61 | /// Immediately connect to this database on startup 62 | #[arg(short, long)] 63 | db_uri: Option, 64 | }, 65 | } 66 | 67 | fn setup_tracing(layer: Option, log_to_file:Option) { 68 | let file_layer = if let Some(path) = log_to_file { 69 | let file = std::fs::File::create(path).expect("Cannot open requested log file for writing"); 70 | Some(tracing_subscriber::fmt::layer().with_ansi(false).with_writer(Arc::new(file))) 71 | } else { 72 | None 73 | }; 74 | 75 | tracing_subscriber::registry() 76 | .with(LevelFilter::INFO) 77 | .with(filter_fn(|x| x.target() != "sqlx::query")) 78 | .with(tracing_subscriber::fmt::layer()) // stdout log 79 | .with(file_layer) 80 | .with(layer) 81 | .init(); 82 | } 83 | 84 | fn main() { 85 | let args = CliArgs::parse(); 86 | 87 | // TODO is there an alternative to this ugly botch? 88 | let (ctx_tx, ctx_rx) = watch::channel::>(None); 89 | 90 | let (run_tx, run_rx) = watch::channel(true); 91 | 92 | match args.mode { 93 | Mode::Worker { db_uris } => { 94 | setup_tracing(None, args.log_file); 95 | 96 | let worker = std::thread::spawn(move || { 97 | tokio::runtime::Builder::new_multi_thread() 98 | .enable_all() 99 | .build() 100 | .unwrap() 101 | .block_on(async { 102 | let mut jobs = vec![]; 103 | 104 | for (i, db_uri) in db_uris.iter().enumerate() { 105 | let db = match Database::connect(db_uri.clone()).await { 106 | Ok(v) => v, 107 | Err(e) => { 108 | error!(target: "worker", "Could not connect to db #{}: {:?}", i, e); 109 | return; 110 | } 111 | }; 112 | 113 | info!(target: "worker", "Connected to #{}: '{}'", i, db_uri); 114 | 115 | jobs.push( 116 | tokio::spawn( 117 | surveyor_loop( 118 | db, 119 | args.interval as i64, 120 | args.cache_time as i64, 121 | run_rx.clone(), 122 | i, 123 | ) 124 | ) 125 | ); 126 | } 127 | 128 | for (i, job) in jobs.into_iter().enumerate() { 129 | if let Err(e) = job.await { 130 | error!(target: "worker", "Could not join task #{}: {:?}", i, e); 131 | } 132 | } 133 | 134 | info!(target: "worker", "Stopping background worker"); 135 | }) 136 | }); 137 | 138 | let (sigint_tx, sigint_rx) = std::sync::mpsc::channel(); // TODO can I avoid using a std channel? 139 | ctrlc::set_handler(move || 140 | sigint_tx.send(()).expect("Could not send signal on channel") 141 | ).expect("Could not set SIGINT handler"); 142 | 143 | sigint_rx.recv().expect("Could not receive signal from channel"); 144 | info!(target: "launcher", "Received SIGINT, stopping..."); 145 | 146 | run_tx.send(false).unwrap_or(()); // ignore errors 147 | worker.join().expect("Failed joining worker thread"); 148 | }, 149 | 150 | Mode::GUI { db_uri } => { 151 | let (uri_tx, uri_rx) = mpsc::channel(10); 152 | let (width_tx, width_rx) = watch::channel(0); 153 | 154 | let logger = InternalLogger::new(args.log_size as usize); 155 | let logger_view = logger.view(); 156 | 157 | setup_tracing(Some(logger.layer()), args.log_file); 158 | 159 | let state = match AppState::new( 160 | width_rx, 161 | uri_rx, 162 | args.interval as i64, 163 | args.cache_time as i64, 164 | ) { 165 | Ok(s) => s, 166 | Err(e) => { 167 | error!(target: "launcher", "Could not create application state: {:?}", e); 168 | return; 169 | } 170 | }; 171 | let view = state.view(); 172 | 173 | let worker = std::thread::spawn(move || { 174 | tokio::runtime::Builder::new_current_thread() 175 | .enable_all() 176 | .build() 177 | .unwrap() 178 | .block_on(async { 179 | let mut jobs = vec![]; 180 | 181 | let mut run_rx_clone_clone = run_rx.clone(); 182 | 183 | jobs.push( 184 | tokio::spawn(async move { 185 | loop { 186 | // TODO probably state-worker can request a repaint directly, if we pass the 187 | // channel used to receive ctx 188 | tokio::select!{ // block on `run` too so that application can exit quickly 189 | _ = run_rx_clone_clone.changed() => { 190 | if ! *run_rx_clone_clone.borrow() { break; } 191 | }, 192 | _ = tokio::time::sleep(std::time::Duration::from_secs(args.interval)) => { 193 | if let Some(ctx) = &*ctx_rx.borrow() { 194 | ctx.request_repaint(); 195 | } 196 | }, 197 | } 198 | } 199 | }) 200 | ); 201 | 202 | jobs.push( 203 | tokio::spawn(logger.worker(run_rx.clone())) 204 | ); 205 | 206 | jobs.push( 207 | tokio::spawn( 208 | state.worker(run_rx.clone()) 209 | ) 210 | ); 211 | 212 | for (i, job) in jobs.into_iter().enumerate() { 213 | if let Err(e) = job.await { 214 | error!(target: "worker", "Could not join task #{}: {:?}", i, e); 215 | } 216 | } 217 | 218 | info!(target: "worker", "Stopping background worker"); 219 | }) 220 | }); 221 | 222 | let native_options = eframe::NativeOptions::default(); 223 | 224 | info!(target: "launcher", "Starting native GUI"); 225 | 226 | eframe::run_native( 227 | // TODO replace this with a loop that ends so we can cleanly exit the background worker 228 | "dashboard", 229 | native_options, 230 | Box::new( 231 | move |cc| { 232 | if let Err(_e) = ctx_tx.send(Some(cc.egui_ctx.clone())) { 233 | error!(target: "launcher", "Could not share reference to egui context (won't be able to periodically refresh window)"); 234 | }; 235 | Box::new( 236 | App::new( 237 | cc, 238 | db_uri, 239 | uri_tx, 240 | args.interval as i64, 241 | view, 242 | width_tx, 243 | logger_view, 244 | ) 245 | ) 246 | } 247 | ), 248 | ); 249 | 250 | info!(target: "launcher", "GUI quit, stopping background worker..."); 251 | 252 | run_tx.send(false).unwrap_or(()); // ignore errors 253 | 254 | worker.join().expect("Failed joining worker thread"); 255 | } 256 | } 257 | 258 | } 259 | -------------------------------------------------------------------------------- /src/gui/panel.rs: -------------------------------------------------------------------------------- 1 | use chrono::{Local, Utc}; 2 | use eframe::{egui::{ 3 | plot::{Corner, GridMark, Legend, Line, Plot}, 4 | Ui, ScrollArea, collapsing_header::CollapsingState, Context, Layout, Slider, DragValue, 5 | }, emath::Vec2}; 6 | 7 | use crate::util::{timestamp_to_str, unpack_color}; 8 | use crate::gui::App; 9 | use crate::data::entities; 10 | 11 | use super::scaffold::EditingModel; 12 | 13 | pub fn main_content(app: &mut App, ctx: &Context, ui: &mut Ui) { 14 | let panel_metric = app.view.panel_metric.borrow(); 15 | let metrics = app.view.metrics.borrow(); 16 | let points = app.view.points.borrow(); 17 | ScrollArea::vertical().show(ui, |ui| { 18 | ui.separator(); 19 | if app.edit { 20 | for mut panel in app.panels.iter_mut() { 21 | CollapsingState::load_with_default_open( 22 | ctx, 23 | ui.make_persistent_id(format!("panel-{}-compressable", panel.id)), 24 | true, 25 | ) 26 | .show_header(ui, |ui| { 27 | panel_title_ui_edit(ui, &mut panel, &mut app.editing, &metrics, &panel_metric); 28 | }) 29 | .body(|ui| panel_body_ui(ui, panel, &metrics, &points, &panel_metric)); 30 | ui.separator(); 31 | } 32 | } else { 33 | for panel in app.view.panels.borrow().iter() { 34 | CollapsingState::load_with_default_open( 35 | ctx, 36 | ui.make_persistent_id(format!("panel-{}-compressable", panel.id)), 37 | true, 38 | ) 39 | .show_header(ui, |ui| { 40 | panel_title_ui(ui, &panel, &mut app.editing, &metrics, &panel_metric); 41 | }) 42 | .body(|ui| panel_body_ui(ui, panel, &metrics, &points, &panel_metric)); 43 | ui.separator(); 44 | } 45 | } 46 | }); 47 | } 48 | 49 | pub fn panel_title_ui( 50 | ui: &mut Ui, 51 | panel: &entities::panels::Model, 52 | editing: &mut Vec, 53 | metrics: &Vec, 54 | panel_metric: &Vec, 55 | ) { // TODO make edit UI in separate func 56 | ui.horizontal(|ui| { 57 | ui.separator(); 58 | if ui.small_button("#").clicked() { 59 | // TODO don't add duplicates 60 | editing.push( 61 | EditingModel::make_edit_panel(panel.clone(), metrics, panel_metric) 62 | ); 63 | } 64 | ui.separator(); 65 | ui.heading(panel.name.as_str()); 66 | }); 67 | } 68 | 69 | pub fn panel_title_ui_edit( 70 | ui: &mut Ui, 71 | panel: &mut entities::panels::Model, 72 | editing: &mut Vec, 73 | metrics: &Vec, 74 | panel_metric: &Vec, 75 | ) { // TODO make edit UI in separate func 76 | ui.horizontal(|ui| { 77 | ui.separator(); 78 | if ui.small_button("#").clicked() { 79 | // TODO don't add duplicates 80 | editing.push( 81 | EditingModel::make_edit_panel(panel.clone(), metrics, panel_metric) 82 | ); 83 | } 84 | ui.separator(); 85 | ui.heading(panel.name.as_str()); 86 | //ui.separator(); 87 | //ui.checkbox(&mut panel.timeserie, "timeserie"); 88 | ui.with_layout(Layout::right_to_left(eframe::emath::Align::Min), |ui| { 89 | ui.horizontal(|ui| { 90 | ui.toggle_value(&mut panel.view_scroll, "🔒"); 91 | ui.separator(); 92 | ui.add( 93 | DragValue::new(&mut panel.view_size) 94 | .speed(10) 95 | .suffix(" min") 96 | .clamp_range(0..=2147483647i32), 97 | ); 98 | ui.separator(); 99 | ui.add( 100 | DragValue::new(&mut panel.view_offset) 101 | .speed(10) 102 | .suffix(" min") 103 | .clamp_range(0..=2147483647i32), 104 | ); 105 | ui.separator(); 106 | if panel.reduce_view { 107 | ui.add( 108 | DragValue::new(&mut panel.view_chunks) 109 | .speed(1) 110 | .prefix("x") 111 | .clamp_range(1..=1000), // TODO allow to average larger spans maybe? 112 | ); 113 | ui.toggle_value(&mut panel.average_view, "avg"); 114 | } 115 | ui.toggle_value(&mut panel.reduce_view, "reduce"); 116 | ui.separator(); 117 | ui.add(Slider::new(&mut panel.height, 0..=500).text("height")); 118 | }); 119 | }); 120 | }); 121 | } 122 | 123 | pub fn panel_body_ui( 124 | ui: &mut Ui, 125 | panel: &entities::panels::Model, 126 | metrics: &Vec, 127 | points: &Vec, 128 | panel_metric: &Vec, 129 | ) { 130 | let mut p = Plot::new(format!("plot-{}", panel.name)) 131 | .height(panel.height as f32) 132 | .allow_scroll(false) 133 | .legend(Legend::default().position(Corner::LeftTop)); 134 | 135 | if panel.view_scroll { 136 | p = p.set_margin_fraction(Vec2 { x: 0.0, y: 0.1 }); 137 | } 138 | 139 | 140 | if panel.view_scroll { 141 | let now = (Utc::now().timestamp() as f64) - (60.0 * panel.view_offset as f64); 142 | p = p.include_x(now) 143 | .include_x(now + (panel.view_size as f64 * 3.0)) 144 | .include_x(now - (panel.view_size as f64 * 60.0)); // ??? TODO 145 | } 146 | p = p 147 | .x_axis_formatter(|x, _range| timestamp_to_str(x as i64, true, false)) 148 | .label_formatter(|name, value| { 149 | if !name.is_empty() { 150 | return format!( 151 | "{}\nx = {}\ny = {:.1}", 152 | name, 153 | timestamp_to_str(value.x as i64, false, true), 154 | value.y 155 | ); 156 | } else { 157 | return format!( 158 | "x = {}\ny = {:.1}", 159 | timestamp_to_str(value.x as i64, false, true), 160 | value.y 161 | ); 162 | } 163 | }) 164 | .x_grid_spacer(|grid| { 165 | let offset = Local::now().offset().local_minus_utc() as i64; 166 | let (start, end) = grid.bounds; 167 | let mut counter = (start as i64) - ((start as i64) % 3600); 168 | let mut out: Vec = Vec::new(); 169 | loop { 170 | counter += 3600; 171 | if counter > end as i64 { 172 | break; 173 | } 174 | if (counter + offset) % 86400 == 0 { 175 | out.push(GridMark { 176 | value: counter as f64, 177 | step_size: 86400 as f64, 178 | }) 179 | } else if counter % 3600 == 0 { 180 | out.push(GridMark { 181 | value: counter as f64, 182 | step_size: 3600 as f64, 183 | }); 184 | } 185 | } 186 | return out; 187 | }); 188 | 189 | let mut lines : Vec = Vec::new(); 190 | let now = Utc::now().timestamp() as f64; 191 | let off = (panel.view_offset as f64) * 60.0; // TODO multiplying x60 makes sense only for timeseries 192 | let size = (panel.view_size as f64) * 60.0; // TODO multiplying x60 makes sense only for timeseries 193 | let min_x = now - size - off; 194 | let max_x = now - off; 195 | let chunk_size = if panel.reduce_view { Some(panel.view_chunks) } else { None }; 196 | let metric_ids : Vec = panel_metric.iter().filter(|x| x.panel_id == panel.id).map(|x| x.metric_id).collect(); 197 | for metric in metrics { 198 | if metric_ids.contains(&metric.id) { 199 | let mut values : Vec<[f64;2]> = points 200 | .iter() 201 | .filter(|v| v.metric_id == metric.id) 202 | .filter(|v| v.x > min_x as f64) 203 | .filter(|v| v.x < max_x as f64) 204 | .map(|v| [v.x, v.y]) 205 | .collect(); 206 | if let Some(chunk_size) = chunk_size { // TODO make this less of a mess 207 | let iter = values.chunks(chunk_size as usize); 208 | values = iter.map(|x| 209 | if panel.average_view { avg_value(x) } else { 210 | if x.len() > 0 { x[x.len()-1] } else { [0.0, 0.0 ]} 211 | }).collect(); 212 | } 213 | lines.push( 214 | Line::new(values) 215 | .name(metric.name.as_str()) 216 | .color(unpack_color(metric.color)) 217 | ); 218 | } 219 | } 220 | 221 | p.show(ui, |plot_ui| { 222 | for line in lines { 223 | plot_ui.line(line); 224 | } 225 | }); 226 | } 227 | 228 | fn avg_value(values: &[[f64;2]]) -> [f64;2] { 229 | let mut x = 0.0; 230 | let mut y = 0.0; 231 | for v in values { 232 | x += v[0]; 233 | y += v[1]; 234 | } 235 | return [ 236 | x / values.len() as f64, 237 | y / values.len() as f64, 238 | ]; 239 | } 240 | -------------------------------------------------------------------------------- /src/gui/scaffold.rs: -------------------------------------------------------------------------------- 1 | use eframe::{Frame, egui::{collapsing_header::CollapsingState, Context, Ui, Layout, ScrollArea, global_dark_light_mode_switch, TextEdit, Checkbox, Slider, ComboBox, DragValue}, emath::Align}; 2 | use sea_orm::{Set, Unchanged, ActiveValue::NotSet}; 3 | use tokio::sync::watch; 4 | 5 | use crate::{gui::App, data::entities, util::{unpack_color, repack_color}, worker::{BackgroundAction, AppStateView}}; 6 | 7 | // TODO make this not super specific! 8 | pub fn _confirmation_popup_delete_metric(_app: &mut App, ui: &mut Ui, _metric_index: usize) { 9 | ui.heading("Are you sure you want to delete this metric?"); 10 | ui.label("This will remove all its metrics and delete all points from archive. This action CANNOT BE UNDONE!"); 11 | ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { 12 | ui.horizontal(|ui| { 13 | if ui.button("\n yes \n").clicked() { 14 | // let store = app.data.storage.lock().expect("Storage Mutex poisoned"); 15 | // let mut metrics = app.data.metrics.write().expect("Metrics RwLock poisoned"); 16 | // store.delete_metric(metrics[metric_index].id).expect("Failed deleting metric"); 17 | // store.delete_values(metrics[metric_index].id).expect("Failed deleting values"); 18 | // metrics.remove(metric_index); 19 | // app.deleting_metric = None; 20 | } 21 | if ui.button("\n no \n").clicked() { 22 | // app.deleting_metric = None; 23 | } 24 | }); 25 | }); 26 | } 27 | 28 | // TODO make this not super specific! 29 | pub fn _confirmation_popup_delete_source(_app: &mut App, ui: &mut Ui, _source_index: usize) { 30 | ui.heading("Are you sure you want to delete this source?"); 31 | ui.label("This will remove all its metrics and delete all points from archive. This action CANNOT BE UNDONE!"); 32 | ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { 33 | ui.horizontal(|ui| { 34 | if ui.button("\n yes \n").clicked() { 35 | // let store = app.data.storage.lock().expect("Storage Mutex poisoned"); 36 | // let mut sources = app.data.sources.write().expect("sources RwLock poisoned"); 37 | // let mut metrics = app.data.metrics.write().expect("Metrics RwLock poisoned"); 38 | // let mut to_remove = Vec::new(); 39 | // for j in 0..metrics.len() { 40 | // if metrics[j].source_id == app.input_source.id { 41 | // store.delete_values(metrics[j].id).expect("Failed deleting values"); 42 | // store.delete_metric(metrics[j].id).expect("Failed deleting Metric"); 43 | // to_remove.push(j); 44 | // } 45 | // } 46 | // for index in to_remove { 47 | // metrics.remove(index); 48 | // } 49 | // store.delete_source(sources[source_index].id).expect("Failed deleting source"); 50 | // sources.remove(source_index); 51 | // app.deleting_source = None; 52 | } 53 | if ui.button("\n no \n").clicked() { 54 | // app.deleting_source = None; 55 | } 56 | }); 57 | }); 58 | } 59 | 60 | pub struct EditingModel { 61 | pub id: i64, 62 | m: EditingModelType, 63 | new: bool, 64 | valid: bool, 65 | ready: bool, 66 | } 67 | 68 | impl EditingModel { 69 | pub fn id_repr(&self) -> String { 70 | let prefix = match self.m { 71 | EditingModelType::EditingPanel { panel: _, opts: _ } => "panel", 72 | EditingModelType::EditingSource { source: _ } => "source", 73 | EditingModelType::EditingMetric { metric: _ } => "metric", 74 | }; 75 | format!("edit {} #{}", prefix, self.id) 76 | } 77 | 78 | pub fn should_fetch(&self) -> bool { 79 | return self.ready && self.valid; 80 | } 81 | 82 | pub fn modifying(&self) -> bool { 83 | return !self.ready; 84 | } 85 | 86 | pub fn make_edit_panel( 87 | panel: entities::panels::Model, 88 | metrics: &Vec, 89 | panel_metric: &Vec 90 | ) -> EditingModel { 91 | let metric_ids : Vec = panel_metric.iter().filter(|x| x.panel_id == panel.id).map(|x| x.metric_id).collect(); 92 | let mut opts = vec![false; metrics.len()]; 93 | for i in 0..metrics.len() { 94 | if metric_ids.contains(&metrics[i].id) { 95 | opts[i] = true; 96 | } 97 | } 98 | EditingModel { 99 | id: panel.id, 100 | new: if panel.id > 0 { false } else { true }, 101 | m: EditingModelType::EditingPanel { panel, opts }, 102 | valid: false, 103 | ready: false, 104 | } 105 | } 106 | 107 | pub fn to_msg(&self, view:AppStateView) -> BackgroundAction { 108 | match &self.m { 109 | EditingModelType::EditingPanel { panel, opts: metrics } => 110 | BackgroundAction::UpdatePanel { 111 | panel: entities::panels::ActiveModel { 112 | id: if self.new { NotSet } else { Unchanged(panel.id) }, 113 | name: Set(panel.name.clone()), 114 | view_scroll: Set(panel.view_scroll), 115 | view_size: Set(panel.view_size), 116 | height: Set(panel.height), 117 | position: Set(panel.position), 118 | reduce_view: Set(panel.reduce_view), 119 | view_chunks: Set(panel.view_chunks), 120 | view_offset: Set(panel.view_offset), 121 | average_view: Set(panel.average_view), 122 | }, 123 | metrics: view.metrics.borrow().iter() 124 | .enumerate() 125 | .filter(|(i, _x)| *metrics.get(*i).unwrap_or(&false)) 126 | .map(|(_i, m)| entities::panel_metric::ActiveModel { 127 | id: NotSet, 128 | panel_id: Set(panel.id), 129 | metric_id: Set(m.id), 130 | }) 131 | .collect(), 132 | }, 133 | EditingModelType::EditingSource { source } => 134 | BackgroundAction::UpdateSource { 135 | source: entities::sources::ActiveModel { 136 | id: if self.new { NotSet } else { Unchanged(source.id) }, 137 | name: Set(source.name.clone()), 138 | enabled: Set(source.enabled), 139 | url: Set(source.url.clone()), 140 | interval: Set(source.interval), 141 | last_update: Set(source.last_update), 142 | position: Set(source.position), 143 | } 144 | }, 145 | EditingModelType::EditingMetric { metric } => 146 | BackgroundAction::UpdateMetric { 147 | metric: entities::metrics::ActiveModel { 148 | id: if self.new { NotSet} else { Unchanged(metric.id) }, 149 | name: Set(metric.name.clone()), 150 | source_id: Set(metric.source_id), 151 | color: Set(metric.color), 152 | query: Set(metric.query.clone()), 153 | position: Set(metric.position), 154 | } 155 | }, 156 | } 157 | } 158 | } 159 | 160 | impl From for EditingModel { 161 | fn from(s: entities::sources::Model) -> Self { 162 | EditingModel { 163 | new: if s.id == 0 { true } else { false }, 164 | id: s.id, m: EditingModelType::EditingSource { source: s }, valid: false, ready: false, 165 | } 166 | } 167 | } 168 | 169 | impl From for EditingModel { 170 | fn from(m: entities::metrics::Model) -> Self { 171 | EditingModel { 172 | new: if m.id == 0 { true } else { false }, 173 | id: m.id, m: EditingModelType::EditingMetric { metric: m }, valid: false, ready: false, 174 | } 175 | } 176 | } 177 | 178 | impl From for EditingModel { 179 | fn from(p: entities::panels::Model) -> Self { 180 | EditingModel { 181 | new: if p.id == 0 { true } else { false }, 182 | id: p.id, m: EditingModelType::EditingPanel { panel: p , opts: vec![] }, valid: false, ready: false, 183 | } 184 | } 185 | } 186 | 187 | pub enum EditingModelType { 188 | EditingPanel { panel : entities::panels::Model, opts: Vec }, 189 | EditingSource { source: entities::sources::Model }, 190 | EditingMetric { metric: entities::metrics::Model }, 191 | } 192 | 193 | pub fn popup_edit_ui( 194 | ui: &mut Ui, 195 | model: &mut EditingModel, 196 | sources: &Vec, 197 | metrics: &Vec 198 | ) { 199 | match &mut model.m { 200 | EditingModelType::EditingPanel { panel, opts } => { 201 | TextEdit::singleline(&mut panel.name) 202 | .hint_text("name") 203 | .show(ui); 204 | ui.horizontal(|ui| { 205 | ui.label("position"); 206 | ui.add(DragValue::new(&mut panel.position).clamp_range(0..=1000)); 207 | }); 208 | ui.horizontal(|ui| { 209 | ui.add( 210 | DragValue::new(&mut panel.view_size) 211 | .speed(10) 212 | .suffix(" min") 213 | .clamp_range(0..=2147483647i32), 214 | ); 215 | ui.separator(); 216 | ui.add( 217 | DragValue::new(&mut panel.view_offset) 218 | .speed(10) 219 | .suffix(" min") 220 | .clamp_range(0..=2147483647i32), 221 | ); 222 | }); 223 | ui.horizontal(|ui| { 224 | ui.toggle_value(&mut panel.reduce_view, "reduce"); 225 | if panel.reduce_view { 226 | ui.add( 227 | DragValue::new(&mut panel.view_chunks) 228 | .speed(1) 229 | .suffix("×") 230 | .clamp_range(1..=1000) 231 | ); 232 | ui.toggle_value(&mut panel.average_view, "avg"); 233 | } 234 | }); 235 | ui.label("metrics:"); 236 | ui.group(|ui| { 237 | for (i, metric) in metrics.iter().enumerate() { 238 | if i >= opts.len() { // TODO safe but jank: always starts with all off 239 | opts.push(false); 240 | } 241 | ui.checkbox(&mut opts[i], &metric.name); 242 | } 243 | }); 244 | }, 245 | EditingModelType::EditingSource { source } => { 246 | ui.horizontal(|ui| { 247 | ui.add(Checkbox::new(&mut source.enabled, "")); 248 | TextEdit::singleline(&mut source.name) 249 | .hint_text("name") 250 | .show(ui); 251 | }); 252 | ui.horizontal(|ui| { 253 | ui.label("position"); 254 | ui.add(DragValue::new(&mut source.position).clamp_range(0..=1000)); 255 | }); 256 | TextEdit::singleline(&mut source.url) 257 | .hint_text("url") 258 | .show(ui); 259 | ui.add(Slider::new(&mut source.interval, 1..=3600).text("interval")); 260 | }, 261 | EditingModelType::EditingMetric { metric } => { 262 | ui.horizontal(|ui| { 263 | let mut color_buf = unpack_color(metric.color); 264 | ui.color_edit_button_srgba(&mut color_buf); 265 | metric.color = repack_color(color_buf); 266 | TextEdit::singleline(&mut metric.name) 267 | .hint_text("name") 268 | .show(ui); 269 | }); 270 | ui.horizontal(|ui| { 271 | ui.label("position"); 272 | ui.add(DragValue::new(&mut metric.position).clamp_range(0..=1000)); 273 | }); 274 | ComboBox::from_id_source(format!("source-selector-{}", metric.id)) 275 | .selected_text(format!("source: {:02}", metric.source_id)) 276 | .show_ui(ui, |ui| { 277 | ui.selectable_value(&mut metric.source_id, -1, "None"); 278 | for s in sources.iter() { 279 | ui.selectable_value(&mut metric.source_id, s.id, s.name.as_str()); 280 | } 281 | }); 282 | TextEdit::singleline(&mut metric.query) 283 | .hint_text("query") 284 | .show(ui); 285 | }, 286 | } 287 | ui.separator(); 288 | ui.horizontal(|ui| { 289 | if ui.button(" save ").clicked() { 290 | model.valid = true; 291 | model.ready = true; 292 | } 293 | ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { 294 | if ui.button(" close ").clicked() { 295 | model.valid = false; 296 | model.ready = true; 297 | } 298 | }); 299 | }); 300 | } 301 | 302 | pub fn header(app: &mut App, ui: &mut Ui, frame: &mut Frame) { 303 | ui.horizontal(|ui| { 304 | global_dark_light_mode_switch(ui); 305 | ui.heading("dashboard"); 306 | ui.separator(); 307 | ui.checkbox(&mut app.sidebar, "sources"); 308 | ui.separator(); 309 | if ui.button("refresh").clicked() { 310 | app.refresh_data(); 311 | } 312 | TextEdit::singleline(&mut app.db_uri) 313 | .hint_text("db uri") 314 | .show(ui); 315 | if ui.button("connect").clicked() { 316 | app.update_db_uri(); 317 | app.last_db_uri = app.db_uri.split("/").last().unwrap_or("").to_string(); 318 | } 319 | ui.separator(); 320 | let last_edit = app.edit; // replace panels when going into edit mode 321 | ui.checkbox(&mut app.edit, "edit"); 322 | if app.edit { 323 | if !last_edit { // TODO kinda cheap fix having it down here 324 | app.panels = app.view.panels.borrow().clone(); 325 | } 326 | if ui.button("+").clicked() { 327 | app.editing.push(entities::panels::Model::default().into()); 328 | } 329 | if ui.button("reset").clicked() { 330 | app.panels = app.view.panels.borrow().clone(); 331 | } 332 | if ui.button("save").clicked() { 333 | app.save_all_panels(); 334 | app.edit = false; 335 | } 336 | } 337 | ui.separator(); 338 | ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { 339 | ui.horizontal(|ui| { 340 | if ui.small_button("×").clicked() { 341 | frame.close(); 342 | } 343 | }); 344 | }); 345 | }); 346 | } 347 | 348 | pub fn footer(ctx: &Context, ui: &mut Ui, diagnostics: watch::Receiver>, db_path: String, records: usize) { 349 | CollapsingState::load_with_default_open( 350 | ctx, 351 | ui.make_persistent_id("footer-logs"), 352 | false, 353 | ) 354 | .show_header(ui, |ui| { 355 | ui.horizontal(|ui| { 356 | ui.separator(); 357 | ui.label(db_path); // TODO maybe calculate it just once? 358 | ui.separator(); 359 | ui.label(format!("{} records loaded", records)); // TODO put thousands separator 360 | // ui.label(human_size( 361 | // *data 362 | // .file_size 363 | // .read() 364 | // .expect("Filesize RwLock poisoned"), 365 | // )); 366 | ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { 367 | ui.horizontal(|ui| { 368 | ui.label(format!( 369 | "v{}-{}", 370 | env!("CARGO_PKG_VERSION"), 371 | git_version::git_version!() 372 | )); 373 | ui.separator(); 374 | ui.hyperlink_to("", "mailto:me@alemi.dev"); 375 | ui.label("alemi"); 376 | }); 377 | }); 378 | }); 379 | }) 380 | .body(|ui| { 381 | ui.set_height(200.0); 382 | ScrollArea::vertical().show(ui, |ui| { 383 | ui.separator(); 384 | for msg in diagnostics.borrow().iter() { 385 | ui.label(msg); 386 | } 387 | ui.separator(); 388 | }); 389 | }); 390 | } 391 | -------------------------------------------------------------------------------- /src/worker/visualizer.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use sea_orm::{TransactionTrait, DatabaseConnection, EntityTrait, Condition, ColumnTrait, QueryFilter, Set, QueryOrder, Order, ActiveModelTrait, ActiveValue::{NotSet, self}, Database, DbErr}; 3 | use tokio::sync::{watch, mpsc}; 4 | use tracing::{info, error, warn}; 5 | use std::collections::VecDeque; 6 | 7 | use crate::data::{entities, FetchError}; 8 | 9 | #[derive(Clone)] 10 | pub struct AppStateView { 11 | pub panels: watch::Receiver>, 12 | pub sources: watch::Receiver>, 13 | pub metrics: watch::Receiver>, 14 | pub panel_metric: watch::Receiver>, 15 | pub points: watch::Receiver>, 16 | pub flush: mpsc::Sender<()>, 17 | pub op: mpsc::Sender, 18 | } 19 | 20 | impl AppStateView { 21 | pub async fn request_flush(&self) -> bool { 22 | match self.flush.send(()).await { 23 | Ok(_) => true, 24 | Err(_) => false, 25 | } 26 | } 27 | } 28 | 29 | struct AppStateTransmitters { 30 | panels: watch::Sender>, 31 | sources: watch::Sender>, 32 | metrics: watch::Sender>, 33 | points: watch::Sender>, 34 | panel_metric: watch::Sender>, 35 | } 36 | 37 | pub struct AppState { 38 | tx: AppStateTransmitters, 39 | 40 | db_uri: mpsc::Receiver, 41 | 42 | panels: Vec, 43 | sources: Vec, 44 | metrics: Vec, 45 | panel_metric: Vec, 46 | last_refresh: i64, 47 | 48 | points: VecDeque, 49 | last_check: i64, 50 | 51 | flush: mpsc::Receiver<()>, 52 | op: mpsc::Receiver, 53 | 54 | interval: i64, 55 | cache_age: i64, 56 | 57 | width: watch::Receiver, 58 | last_width: i64, 59 | 60 | view: AppStateView, 61 | } 62 | 63 | async fn sleep(t:i64) { 64 | if t > 0 { 65 | tokio::time::sleep(std::time::Duration::from_secs(t as u64)).await 66 | } 67 | } 68 | 69 | impl AppState { 70 | pub fn new( 71 | width: watch::Receiver, 72 | db_uri: mpsc::Receiver, 73 | interval: i64, 74 | cache_age: i64, 75 | ) -> Result { 76 | let (panel_tx, panel_rx) = watch::channel(vec![]); 77 | let (source_tx, source_rx) = watch::channel(vec![]); 78 | let (metric_tx, metric_rx) = watch::channel(vec![]); 79 | let (point_tx, point_rx) = watch::channel(vec![]); 80 | let (panel_metric_tx, panel_metric_rx) = watch::channel(vec![]); 81 | // let (view_tx, view_rx) = watch::channel(0); 82 | let (flush_tx, flush_rx) = mpsc::channel(10); 83 | let (op_tx, op_rx) = mpsc::channel(100); 84 | 85 | Ok(AppState { 86 | panels: vec![], 87 | sources: vec![], 88 | metrics: vec![], 89 | panel_metric: vec![], 90 | last_refresh: 0, 91 | points: VecDeque::new(), 92 | last_check: 0, 93 | last_width: 0, 94 | flush: flush_rx, 95 | op: op_rx, 96 | view: AppStateView { 97 | panels: panel_rx, 98 | sources: source_rx, 99 | metrics: metric_rx, 100 | points: point_rx, 101 | panel_metric: panel_metric_rx, 102 | flush: flush_tx, 103 | op: op_tx, 104 | }, 105 | tx: AppStateTransmitters { 106 | panels: panel_tx, 107 | sources: source_tx, 108 | metrics: metric_tx, 109 | points: point_tx, 110 | panel_metric: panel_metric_tx, 111 | }, 112 | width, 113 | db_uri, 114 | interval, 115 | cache_age, 116 | }) 117 | } 118 | 119 | pub fn view(&self) -> AppStateView { 120 | self.view.clone() 121 | } 122 | 123 | pub async fn fetch(&mut self, db: &DatabaseConnection) -> Result<(), sea_orm::DbErr> { 124 | // TODO parallelize all this stuff 125 | self.panels = entities::panels::Entity::find() 126 | .order_by(entities::panels::Column::Position, Order::Asc) 127 | .order_by(entities::panels::Column::Id, Order::Asc) 128 | .all(db).await?; 129 | if let Err(e) = self.tx.panels.send(self.panels.clone()) { 130 | error!(target: "state-manager", "Could not send panels update: {:?}", e); 131 | } 132 | 133 | self.sources = entities::sources::Entity::find() 134 | .order_by(entities::sources::Column::Position, Order::Asc) 135 | .order_by(entities::sources::Column::Id, Order::Asc) 136 | .all(db).await?; 137 | if let Err(e) = self.tx.sources.send(self.sources.clone()) { 138 | error!(target: "state-manager", "Could not send sources update: {:?}", e); 139 | } 140 | 141 | self.metrics = entities::metrics::Entity::find() 142 | .order_by(entities::metrics::Column::Position, Order::Asc) 143 | .order_by(entities::metrics::Column::SourceId, Order::Asc) 144 | .order_by(entities::metrics::Column::Id, Order::Asc) 145 | .all(db).await?; 146 | if let Err(e) = self.tx.metrics.send(self.metrics.clone()) { 147 | error!(target: "state-manager", "Could not send metrics update: {:?}", e); 148 | } 149 | 150 | self.panel_metric = entities::panel_metric::Entity::find() 151 | .all(db).await?; 152 | if let Err(e) = self.tx.panel_metric.send(self.panel_metric.clone()) { 153 | error!(target: "state-manager", "Could not send panel-metric update: {:?}", e); 154 | } 155 | 156 | self.last_refresh = chrono::Utc::now().timestamp(); 157 | Ok(()) 158 | } 159 | 160 | pub fn _cache_age(&self) -> i64 { 161 | chrono::Utc::now().timestamp() - self.last_refresh 162 | } 163 | 164 | pub async fn parse_op(&mut self, op:BackgroundAction, db: &DatabaseConnection) -> Result<(), DbErr> { 165 | match op { 166 | BackgroundAction::UpdateAllPanels { panels } => { 167 | // TODO this is kinda rough, can it be done better? 168 | let pnls = panels.clone(); 169 | if let Err(e) = db.transaction::<_, (), sea_orm::DbErr>(|txn| { 170 | Box::pin(async move { 171 | entities::panels::Entity::delete_many().exec(txn).await?; 172 | entities::panels::Entity::insert_many( 173 | pnls.iter().map(|v| entities::panels::ActiveModel{ 174 | id: Set(v.id), 175 | name: Set(v.name.clone()), 176 | view_scroll: Set(v.view_scroll), 177 | view_size: Set(v.view_size), 178 | height: Set(v.height), 179 | position: Set(v.position), 180 | reduce_view: Set(v.reduce_view), 181 | view_chunks: Set(v.view_chunks), 182 | view_offset: Set(v.view_offset), 183 | average_view: Set(v.average_view), 184 | }).collect::>() 185 | ).exec(txn).await?; 186 | Ok(()) 187 | }) 188 | }).await { 189 | error!(target: "state-manager", "Could not update panels on database: {:?}", e); 190 | } else { 191 | if let Err(e) = self.tx.panels.send(panels.clone()) { 192 | error!(target: "state-manager", "Could not send panels update: {:?}", e); 193 | } 194 | self.panels = panels; 195 | } 196 | }, 197 | BackgroundAction::UpdatePanel { panel, metrics } => { 198 | let panel_id = match panel.id { 199 | ActiveValue::Unchanged(pid) => Some(pid), 200 | _ => None, 201 | }; 202 | let op = if panel.id == NotSet { panel.insert(db) } else { panel.update(db) }; 203 | op.await?; 204 | // TODO chained if is trashy 205 | if let Some(panel_id) = panel_id { 206 | if let Err(e) = db.transaction::<_, (), sea_orm::DbErr>(|txn| { 207 | Box::pin(async move { 208 | entities::panel_metric::Entity::delete_many() 209 | .filter( 210 | Condition::all() 211 | .add(entities::panel_metric::Column::PanelId.eq(panel_id)) 212 | ) 213 | .exec(txn).await?; 214 | entities::panel_metric::Entity::insert_many(metrics).exec(txn).await?; 215 | Ok(()) 216 | }) 217 | }).await { 218 | error!(target: "state-manager", "Could not update panels on database: {:?}", e); 219 | } 220 | } else { 221 | self.view.request_flush().await; 222 | } 223 | }, 224 | BackgroundAction::UpdateSource { source } => { 225 | let op = if source.id == NotSet { source.insert(db) } else { source.update(db) }; 226 | op.await?; 227 | self.view.request_flush().await; 228 | }, 229 | BackgroundAction::UpdateMetric { metric } => { 230 | let op = if metric.id == NotSet { metric.insert(db) } else { metric.update(db) }; 231 | if let Err(e) = op.await { 232 | error!(target: "state-manager", "Could not update metric: {:?}", e); 233 | } else { 234 | self.view.request_flush().await; 235 | } 236 | }, 237 | // _ => todo!(), 238 | } 239 | Ok(()) 240 | } 241 | 242 | pub async fn flush_data(&mut self, db: &DatabaseConnection) -> Result<(), DbErr> { 243 | let now = Utc::now().timestamp(); 244 | self.fetch(db).await?; 245 | self.last_width = *self.width.borrow() * 60; // TODO it's in minutes somewhere... 246 | self.points = entities::points::Entity::find() 247 | .filter( 248 | Condition::all() 249 | .add(entities::points::Column::X.gte((now - self.last_width) as f64)) 250 | .add(entities::points::Column::X.lte(now as f64)) 251 | ) 252 | .order_by(entities::points::Column::X, Order::Asc) 253 | .all(db) 254 | .await?.into(); 255 | if let Err(e) = self.tx.points.send(self.points.clone().into()) { 256 | warn!(target: "state-manager", "Could not send new points: {:?}", e); // TODO should be an err? 257 | } 258 | self.last_check = now; 259 | Ok(()) 260 | } 261 | 262 | pub async fn update_points(&mut self, db: &DatabaseConnection) -> Result<(), DbErr> { 263 | let mut changes = false; 264 | let now = Utc::now().timestamp(); 265 | let new_width = *self.width.borrow() * 60; // TODO it's in minutes somewhere... 266 | 267 | // fetch previous points 268 | if new_width != self.last_width { 269 | let previous_points = entities::points::Entity::find() 270 | .filter( 271 | Condition::all() 272 | .add(entities::points::Column::X.gte(now - new_width)) 273 | .add(entities::points::Column::X.lte(now - self.last_width)) 274 | ) 275 | .order_by(entities::points::Column::X, Order::Desc) 276 | .all(db) 277 | .await?; 278 | for p in previous_points { 279 | self.points.push_front(p); 280 | changes = true; 281 | } 282 | } 283 | 284 | // fetch new points, use last_width otherwise it fetches same points as above 285 | let lower_bound = std::cmp::max(self.last_check, now - self.last_width); 286 | let new_points = entities::points::Entity::find() 287 | .filter( 288 | Condition::all() 289 | .add(entities::points::Column::X.gte(lower_bound as f64)) 290 | .add(entities::points::Column::X.lte(now as f64)) 291 | ) 292 | .order_by(entities::points::Column::X, Order::Asc) 293 | .all(db) 294 | .await?; 295 | 296 | for p in new_points { 297 | self.points.push_back(p); 298 | changes = true; 299 | } 300 | 301 | // remove old points 302 | while let Some(p) = self.points.get(0) { 303 | if (p.x as i64) >= now - new_width { 304 | break; 305 | } 306 | self.points.pop_front(); 307 | changes = true; 308 | } 309 | 310 | // update 311 | self.last_width = new_width; 312 | self.last_check = now; 313 | if changes { 314 | if let Err(e) = self.tx.points.send(self.points.clone().into()) { 315 | warn!(target: "state-manager", "Could not send changes to main thread: {:?}", e); 316 | } 317 | } 318 | Ok(()) 319 | } 320 | 321 | pub async fn worker(mut self, run:watch::Receiver) { 322 | let mut now; 323 | let Some(first_db_uri) = self.db_uri.recv().await else { 324 | warn!(target: "state-manager", "No initial database URI, skipping first connection"); 325 | return; 326 | }; 327 | 328 | let mut db = Database::connect(first_db_uri.clone()).await.unwrap(); 329 | 330 | info!(target: "state-manager", "Connected to '{}'", first_db_uri); 331 | 332 | while *run.borrow() { 333 | now = Utc::now().timestamp(); 334 | tokio::select!{ 335 | res = self.db_uri.recv() => { 336 | match res { 337 | Some(uri) => { 338 | match Database::connect(uri.clone()).await { 339 | Ok(new_db) => { 340 | info!("Connected to '{}'", uri); 341 | db = new_db; 342 | self.last_check = 0; 343 | self.last_refresh = 0; 344 | }, 345 | Err(e) => error!(target: "state-manager", "Could not connect to db: {:?}", e), 346 | }; 347 | }, 348 | None => { error!(target: "state-manager", "URI channel closed"); break; }, 349 | } 350 | }, 351 | res = self.op.recv() => { 352 | match res { 353 | Some(op) => match self.parse_op(op, &db).await { 354 | Ok(()) => { }, 355 | Err(e) => error!(target: "state-manager", "Failed executing operation: {:?}", e), 356 | }, 357 | None => { error!(target: "state-manager", "Operations channel closed"); break; }, 358 | } 359 | } 360 | res = self.flush.recv() => { 361 | match res { 362 | Some(()) => match self.flush_data(&db).await { 363 | Ok(()) => { }, 364 | Err(e) => error!(target: "state-manager", "Could not flush away current data: {:?}", e), 365 | }, 366 | None => { error!(target: "state-manager", "Flush channel closed"); break; }, 367 | } 368 | }, 369 | _ = sleep(self.cache_age - (now - self.last_refresh)) => { 370 | if let Err(e) = self.fetch(&db).await { 371 | error!(target: "state-manager", "Could not fetch from db: {:?}", e); 372 | } 373 | }, 374 | _ = sleep(self.interval - (now - self.last_check)) => { 375 | if let Err(e) = self.update_points(&db).await { 376 | error!(target: "state-manager", "Could not update points: {:?}", e); 377 | } 378 | } 379 | } 380 | } 381 | } 382 | } 383 | 384 | #[derive(Debug)] 385 | pub enum BackgroundAction { 386 | UpdateAllPanels { panels: Vec }, 387 | UpdatePanel { panel : entities::panels::ActiveModel, metrics: Vec }, 388 | UpdateSource { source: entities::sources::ActiveModel }, 389 | UpdateMetric { metric: entities::metrics::ActiveModel }, 390 | // InsertPanel { panel : entities::panels::ActiveModel }, 391 | // InsertSource { source: entities::sources::ActiveModel }, 392 | // InsertMetric { metric: entities::metrics::ActiveModel }, 393 | } 394 | --------------------------------------------------------------------------------