diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ee23212 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +DOCKER_TEMP_DIR=temp + +all: frontend backend + +frontend: + cd central_frontend && npm run build && cd .. + rm -rf central_backend/static + mv central_frontend/dist central_backend/static + +backend: frontend + cd central_backend && cargo clippy -- -D warnings && cargo build --release diff --git a/central_backend/.gitignore b/central_backend/.gitignore index f0767f5..e45beac 100644 --- a/central_backend/.gitignore +++ b/central_backend/.gitignore @@ -1,3 +1,4 @@ target .idea storage +static diff --git a/central_backend/Cargo.lock b/central_backend/Cargo.lock index f523954..bf8209b 100644 --- a/central_backend/Cargo.lock +++ b/central_backend/Cargo.lock @@ -386,6 +386,21 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.14" @@ -595,14 +610,18 @@ dependencies = [ "lazy_static", "libc", "log", + "mime_guess", "openssl", "openssl-sys", "rand", "reqwest", + "rust-embed", "semver", "serde", "serde_json", "thiserror", + "tokio", + "tokio_schedule", "uuid", ] @@ -612,6 +631,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.5", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1064,6 +1097,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "hkdf" version = "0.12.4" @@ -1218,6 +1257,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.5.0" @@ -1381,6 +1443,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.7.4" @@ -1425,6 +1497,25 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "object" version = "0.36.0" @@ -1737,6 +1828,40 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust-embed" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1811,6 +1936,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.23" @@ -2093,21 +2227,34 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.38.0" +version = "1.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df" dependencies = [ "backtrace", "bytes", "libc", "mio", + "num_cpus", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.48.0", ] +[[package]] +name = "tokio-macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -2154,6 +2301,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio_schedule" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c291c554da3518d6ef69c76ea35aabc78f736185a16b6017f6d1c224dac2e0" +dependencies = [ + "chrono", + "tokio", +] + [[package]] name = "tower" version = "0.4.13" @@ -2213,6 +2370,15 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -2289,6 +2455,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2380,6 +2556,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.5", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/central_backend/Cargo.toml b/central_backend/Cargo.toml index 68a4928..bcd5573 100644 --- a/central_backend/Cargo.toml +++ b/central_backend/Cargo.toml @@ -29,4 +29,8 @@ actix-remote-ip = "0.1.0" futures-util = "0.3.30" uuid = { version = "1.9.1", features = ["v4", "serde"] } semver = { version = "1.0.23", features = ["serde"] } -lazy-regex = "3.1.0" \ No newline at end of file +lazy-regex = "3.1.0" +tokio = { version = "1.38.1", features = ["full"] } +tokio_schedule = "0.3.2" +mime_guess = "2.0.5" +rust-embed = "8.5.0" \ No newline at end of file diff --git a/central_backend/src/constants.rs b/central_backend/src/constants.rs index fb388bf..4bd652d 100644 --- a/central_backend/src/constants.rs +++ b/central_backend/src/constants.rs @@ -18,3 +18,62 @@ pub const MAX_SESSION_DURATION: u64 = 3600 * 24; /// List of routes that do not require authentication pub const ROUTES_WITHOUT_AUTH: [&str; 2] = ["/web_api/server/config", "/web_api/auth/password_auth"]; + +#[derive(serde::Serialize)] +pub struct SizeConstraint { + /// Minimal string length + min: usize, + /// Maximal string length + max: usize, +} + +impl SizeConstraint { + pub fn new(min: usize, max: usize) -> Self { + Self { min, max } + } + + pub fn validate(&self, val: &str) -> bool { + let len = val.trim().len(); + len >= self.min && len <= self.max + } + + pub fn validate_usize(&self, val: usize) -> bool { + val >= self.min && val <= self.max + } +} + +/// Backend static constraints +#[derive(serde::Serialize)] +pub struct StaticConstraints { + /// Device name constraint + pub dev_name_len: SizeConstraint, + /// Device description constraint + pub dev_description_len: SizeConstraint, + /// Relay name constraint + pub relay_name_len: SizeConstraint, + /// Relay priority constraint + pub relay_priority: SizeConstraint, + /// Relay consumption constraint + pub relay_consumption: SizeConstraint, + /// Relay minimal uptime + pub relay_minimal_uptime: SizeConstraint, + /// Relay minimal downtime + pub relay_minimal_downtime: SizeConstraint, + /// Relay daily minimal uptime + pub relay_daily_minimal_runtime: SizeConstraint, +} + +impl Default for StaticConstraints { + fn default() -> Self { + Self { + dev_name_len: SizeConstraint::new(1, 50), + dev_description_len: SizeConstraint::new(0, 100), + relay_name_len: SizeConstraint::new(1, 100), + relay_priority: SizeConstraint::new(0, 999999), + relay_consumption: SizeConstraint::new(0, 999999), + relay_minimal_uptime: SizeConstraint::new(0, 9999999), + relay_minimal_downtime: SizeConstraint::new(0, 9999999), + relay_daily_minimal_runtime: SizeConstraint::new(0, 3600 * 24), + } + } +} diff --git a/central_backend/src/crypto/pki.rs b/central_backend/src/crypto/pki.rs index 66ae61a..b8d8562 100644 --- a/central_backend/src/crypto/pki.rs +++ b/central_backend/src/crypto/pki.rs @@ -13,10 +13,11 @@ use openssl::pkey::{PKey, Private}; use openssl::x509::extension::{ BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAlternativeName, SubjectKeyIdentifier, }; -use openssl::x509::{X509Crl, X509Name, X509NameBuilder, X509Req, X509}; +use openssl::x509::{CrlStatus, X509Crl, X509Name, X509NameBuilder, X509Req, X509}; use openssl_sys::{ - X509_CRL_add0_revoked, X509_CRL_set1_lastUpdate, X509_CRL_set1_nextUpdate, + X509_CRL_add0_revoked, X509_CRL_new, X509_CRL_set1_lastUpdate, X509_CRL_set1_nextUpdate, X509_CRL_set_issuer_name, X509_CRL_set_version, X509_CRL_sign, X509_REVOKED_dup, + X509_REVOKED_new, X509_REVOKED_set_revocationDate, X509_REVOKED_set_serialNumber, }; use crate::app_config::AppConfig; @@ -365,7 +366,7 @@ pub fn initialize_server_ca() -> anyhow::Result<()> { } /// Initialize or refresh a CRL -fn refresh_crl(d: &CertData) -> anyhow::Result<()> { +fn refresh_crl(d: &CertData, new_cert: Option<&X509>) -> anyhow::Result<()> { let crl_path = d.crl.as_ref().ok_or(PKIError::MissingCRL)?; let old_crl = if crl_path.exists() { @@ -373,7 +374,9 @@ fn refresh_crl(d: &CertData) -> anyhow::Result<()> { // Check if revocation is un-needed let next_update = crl.next_update().ok_or(PKIError::MissingCRLNextUpdate)?; - if next_update.compare(Asn1Time::days_from_now(0)?.as_ref())? == Ordering::Greater { + if next_update.compare(Asn1Time::days_from_now(0)?.as_ref())? == Ordering::Greater + && new_cert.is_none() + { return Ok(()); } @@ -386,7 +389,7 @@ fn refresh_crl(d: &CertData) -> anyhow::Result<()> { // based on https://github.com/openssl/openssl/blob/master/crypto/x509/x509_vfy.c unsafe { - let crl = openssl_sys::X509_CRL_new(); + let crl = X509_CRL_new(); if crl.is_null() { return Err(PKIError::GenCRLError("Could not construct CRL!").into()); } @@ -420,6 +423,31 @@ fn refresh_crl(d: &CertData) -> anyhow::Result<()> { } } + // If requested, add new entry + if let Some(new_cert) = new_cert { + let entry = X509_REVOKED_new(); + if entry.is_null() { + return Err(PKIError::GenCRLError("X509_CRL_new for new entry").into()); + } + + if X509_REVOKED_set_serialNumber(entry, new_cert.serial_number().as_ptr()) == 0 { + return Err( + PKIError::GenCRLError("X509_REVOKED_set_serialNumber for new entry").into(), + ); + } + + let revocation_date = Asn1Time::days_from_now(0)?; + if X509_REVOKED_set_revocationDate(entry, revocation_date.as_ptr()) == 0 { + return Err( + PKIError::GenCRLError("X509_REVOKED_set_revocationDate for new entry").into(), + ); + } + + if X509_CRL_add0_revoked(crl, X509_REVOKED_dup(entry)) == 0 { + return Err(PKIError::GenCRLError("X509_CRL_add0_revoked for new entry").into()); + } + } + let md = MessageDigest::sha256(); if X509_CRL_sign(crl, d.key.as_ptr(), md.as_ptr()) == 0 { return Err(PKIError::GenCRLError("X509_CRL_sign").into()); @@ -434,9 +462,9 @@ fn refresh_crl(d: &CertData) -> anyhow::Result<()> { /// Refresh revocation lists pub fn refresh_crls() -> anyhow::Result<()> { - refresh_crl(&CertData::load_root_ca()?)?; - refresh_crl(&CertData::load_web_ca()?)?; - refresh_crl(&CertData::load_devices_ca()?)?; + refresh_crl(&CertData::load_root_ca()?, None)?; + refresh_crl(&CertData::load_web_ca()?, None)?; + refresh_crl(&CertData::load_devices_ca()?, None)?; Ok(()) } @@ -451,3 +479,31 @@ pub fn gen_certificate_for_device(csr: &X509Req) -> anyhow::Result { Ok(String::from_utf8(cert)?) } + +/// Check if a certificate is revoked +fn is_revoked(cert: &X509, ca: &CertData) -> anyhow::Result { + let crl = X509Crl::from_pem(&std::fs::read( + ca.crl.as_ref().ok_or(PKIError::MissingCRL)?, + )?)?; + + let res = crl.get_by_cert(cert); + + Ok(matches!(res, CrlStatus::Revoked(_))) +} + +/// Revoke a certificate +pub fn revoke(cert: &X509, ca: &CertData) -> anyhow::Result<()> { + // Check if certificate is already revoked + if is_revoked(cert, ca)? { + // No op + return Ok(()); + } + + refresh_crl(ca, Some(cert))?; + Ok(()) +} + +/// Revoke a device certificate +pub fn revoke_device_cert(cert: &X509) -> anyhow::Result<()> { + revoke(cert, &CertData::load_devices_ca()?) +} diff --git a/central_backend/src/devices/device.rs b/central_backend/src/devices/device.rs index 400ca23..e92870d 100644 --- a/central_backend/src/devices/device.rs +++ b/central_backend/src/devices/device.rs @@ -1,11 +1,23 @@ +//! # Devices entities definition + +use crate::constants::StaticConstraints; +use std::collections::HashMap; + +/// Device information provided directly by the device during syncrhonisation. +/// +/// It should not be editable fro the Web UI #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct DeviceInfo { + /// Device reference reference: String, + /// Device firmware / software version version: semver::Version, + /// Maximum number of relay that the device can support max_relays: usize, } impl DeviceInfo { + /// Identify errors in device information definition pub fn error(&self) -> Option<&str> { if self.reference.trim().is_empty() { return Some("Given device reference is empty or blank!"); @@ -19,14 +31,19 @@ impl DeviceInfo { } } +/// Device identifier #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)] pub struct DeviceId(pub String); +/// Single device information #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Device { /// The device ID pub id: DeviceId, /// Information about the device + /// + /// These information shall not be editable from the webui. They are automatically updated during + /// device synchronization pub info: DeviceInfo, /// Time at which device was initially enrolled pub time_create: u64, @@ -42,6 +59,8 @@ pub struct Device { /// Specify whether the device is enabled or not pub enabled: bool, /// Information about the relays handled by the device + /// + /// There cannot be more than [info.max_relays] relays pub relays: Vec, } @@ -49,24 +68,175 @@ pub struct Device { /// time of a device #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct DailyMinRuntime { + /// Minimum time, in seconds, that this relay should run each day pub min_runtime: usize, + /// The seconds in the days (from 00:00) where the counter is reset pub reset_time: usize, + /// The hours during which the relay should be turned on to reach expected runtime pub catch_up_hours: Vec, } -#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)] pub struct DeviceRelayID(uuid::Uuid); -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct DeviceRelay { - id: DeviceRelayID, - name: String, - enabled: bool, - priority: usize, - consumption: usize, - minimal_uptime: usize, - minimal_downtime: usize, - daily_runtime: Option, - depends_on: Vec, - conflicts_with: Vec, +impl Default for DeviceRelayID { + fn default() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +/// Single device relay information +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)] +pub struct DeviceRelay { + /// Device relay id. Should be unique across the whole application + #[serde(default)] + id: DeviceRelayID, + /// Human-readable name for the relay + name: String, + /// Whether this relay can be turned on or not + enabled: bool, + /// Relay priority when selecting relays to turn on. 0 = lowest priority + priority: usize, + /// Estimated consumption of the electrical equipment triggered by the relay + consumption: usize, + /// Minimal time this relay shall be left on before it can be turned off (in seconds) + minimal_uptime: usize, + /// Minimal time this relay shall be left off before it can be turned on again (in seconds) + minimal_downtime: usize, + /// Optional minimal runtime requirements for this relay + daily_runtime: Option, + /// Specify relay that must be turned on before this relay can be started + depends_on: Vec, + /// Specify relays that must be turned off before this relay can be started + conflicts_with: Vec, +} + +impl DeviceRelay { + /// Check device relay for errors + pub fn error(&self, list: &[DeviceRelay]) -> Option<&'static str> { + let constraints = StaticConstraints::default(); + if !constraints.relay_name_len.validate(&self.name) { + return Some("Invalid relay name length!"); + } + + if !constraints.relay_priority.validate_usize(self.priority) { + return Some("Invalid relay priority!"); + } + + if !constraints + .relay_consumption + .validate_usize(self.consumption) + { + return Some("Invalid consumption!"); + } + + if !constraints + .relay_minimal_uptime + .validate_usize(self.minimal_uptime) + { + return Some("Invalid minimal uptime!"); + } + + if !constraints + .relay_minimal_downtime + .validate_usize(self.minimal_downtime) + { + return Some("Invalid minimal uptime!"); + } + + if let Some(daily) = &self.daily_runtime { + if !constraints + .relay_daily_minimal_runtime + .validate_usize(daily.min_runtime) + { + return Some("Invalid minimal daily runtime!"); + } + + if daily.reset_time > 3600 * 24 { + return Some("Invalid daily reset time!"); + } + + if daily.catch_up_hours.is_empty() { + return Some("No catchup hours defined!"); + } + + if daily.catch_up_hours.iter().any(|h| h > &23) { + return Some("At least one catch up hour is invalid!"); + } + } + + let relays_map = list.iter().map(|r| (r.id, r)).collect::>(); + + if self.depends_on.iter().any(|d| !relays_map.contains_key(d)) { + return Some("A specified dependent relay does not exists!"); + } + + if self + .conflicts_with + .iter() + .any(|d| !relays_map.contains_key(d)) + { + return Some("A specified conflicting relay does not exists!"); + } + + // TODO : check for loops + + None + } +} + +/// Device general information +/// +/// This structure is used to update device information +#[derive(serde::Deserialize, Debug, Clone)] +pub struct DeviceGeneralInfo { + pub name: String, + pub description: String, + pub enabled: bool, +} + +impl DeviceGeneralInfo { + /// Check for errors in the structure + pub fn error(&self) -> Option<&'static str> { + let constraints = StaticConstraints::default(); + if !constraints.dev_name_len.validate(&self.name) { + return Some("Invalid device name length!"); + } + + if !constraints.dev_description_len.validate(&self.description) { + return Some("Invalid device description length!"); + } + + None + } +} + +#[cfg(test)] +mod tests { + use crate::devices::device::DeviceRelay; + + #[test] + fn check_device_relay_error() { + let unitary = DeviceRelay { + name: "unitary".to_string(), + ..Default::default() + }; + + let bad_name = DeviceRelay { + name: "".to_string(), + ..Default::default() + }; + + let dep_on_unitary = DeviceRelay { + name: "dep_on_unitary".to_string(), + depends_on: vec![unitary.id], + ..Default::default() + }; + + assert_eq!(unitary.error(&[]), None); + assert_eq!(unitary.error(&[unitary.clone(), bad_name.clone()]), None); + assert!(bad_name.error(&[]).is_some()); + assert_eq!(dep_on_unitary.error(&[unitary.clone()]), None); + assert!(dep_on_unitary.error(&[]).is_some()); + } } diff --git a/central_backend/src/devices/devices_list.rs b/central_backend/src/devices/devices_list.rs index 0cbaa65..095f95f 100644 --- a/central_backend/src/devices/devices_list.rs +++ b/central_backend/src/devices/devices_list.rs @@ -1,8 +1,8 @@ use crate::app_config::AppConfig; use crate::crypto::pki; -use crate::devices::device::{Device, DeviceId, DeviceInfo}; +use crate::devices::device::{Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay}; use crate::utils::time_utils::time_secs; -use openssl::x509::X509Req; +use openssl::x509::{X509Req, X509}; use std::collections::HashMap; #[derive(thiserror::Error, Debug)] @@ -15,6 +15,12 @@ pub enum DevicesListError { ValidateDeviceFailedDeviceNotFound, #[error("Validated device failed: the device is already validated!")] ValidateDeviceFailedDeviceAlreadyValidated, + #[error("Update device failed: the device does not exists!")] + UpdateDeviceFailedDeviceNotFound, + #[error("Requested device was not found")] + DeviceNotFound, + #[error("Requested device is not validated")] + DeviceNotValidated, } pub struct DevicesList(HashMap); @@ -129,12 +135,47 @@ impl DevicesList { Ok(()) } + /// Update a device general information + pub fn update_general_info( + &mut self, + id: &DeviceId, + general_info: DeviceGeneralInfo, + ) -> anyhow::Result<()> { + let dev = self + .0 + .get_mut(id) + .ok_or(DevicesListError::UpdateDeviceFailedDeviceNotFound)?; + + dev.name = general_info.name; + dev.description = general_info.description; + dev.enabled = general_info.enabled; + dev.time_update = time_secs(); + + self.persist_dev_config(id)?; + + Ok(()) + } + + /// Get single certificate information + fn get_cert(&self, id: &DeviceId) -> anyhow::Result { + let dev = self + .get_single(id) + .ok_or(DevicesListError::DeviceNotFound)?; + if !dev.validated { + return Err(DevicesListError::DeviceNotValidated.into()); + } + + Ok(X509::from_pem(&std::fs::read( + AppConfig::get().device_cert_path(id), + )?)?) + } + /// Delete a device pub fn delete(&mut self, id: &DeviceId) -> anyhow::Result<()> { let crt_path = AppConfig::get().device_cert_path(id); if crt_path.is_file() { - // TODO : implement - unimplemented!("Certificate revocation not implemented yet!"); + let cert = self.get_cert(id)?; + pki::revoke_device_cert(&cert)?; } let csr_path = AppConfig::get().device_csr_path(id); @@ -151,4 +192,12 @@ impl DevicesList { Ok(()) } + + /// Get the full list of relays + pub fn relays_list(&mut self) -> Vec { + self.0 + .iter() + .flat_map(|(_id, d)| d.relays.clone()) + .collect() + } } diff --git a/central_backend/src/energy/energy_actor.rs b/central_backend/src/energy/energy_actor.rs index 4c93672..91067d2 100644 --- a/central_backend/src/energy/energy_actor.rs +++ b/central_backend/src/energy/energy_actor.rs @@ -1,5 +1,5 @@ use crate::constants; -use crate::devices::device::{Device, DeviceId, DeviceInfo}; +use crate::devices::device::{Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay}; use crate::devices::devices_list::DevicesList; use crate::energy::consumption; use crate::energy::consumption::EnergyConsumption; @@ -109,6 +109,27 @@ impl Handler for EnergyActor { } } +/// Update a device general information +#[derive(Message)] +#[rtype(result = "anyhow::Result<()>")] +pub struct UpdateDeviceGeneralInfo(pub DeviceId, pub DeviceGeneralInfo); + +impl Handler for EnergyActor { + type Result = anyhow::Result<()>; + + fn handle(&mut self, msg: UpdateDeviceGeneralInfo, _ctx: &mut Context) -> Self::Result { + log::info!( + "Requested to update device general info {:?}... {:#?}", + &msg.0, + &msg.1 + ); + + self.devices.update_general_info(&msg.0, msg.1)?; + + Ok(()) + } +} + /// Delete a device #[derive(Message)] #[rtype(result = "anyhow::Result<()>")] @@ -150,3 +171,16 @@ impl Handler for EnergyActor { self.devices.get_single(&msg.0) } } + +/// Get the full list of relays +#[derive(Message)] +#[rtype(result = "Vec")] +pub struct GetRelaysList; + +impl Handler for EnergyActor { + type Result = Vec; + + fn handle(&mut self, _msg: GetRelaysList, _ctx: &mut Context) -> Self::Result { + self.devices.relays_list() + } +} diff --git a/central_backend/src/main.rs b/central_backend/src/main.rs index fefc806..3f40d90 100644 --- a/central_backend/src/main.rs +++ b/central_backend/src/main.rs @@ -5,6 +5,7 @@ use central_backend::energy::energy_actor::EnergyActor; use central_backend::server::servers; use central_backend::utils::files_utils::create_directory_if_missing; use futures::future; +use tokio_schedule::{every, Job}; #[actix_web::main] async fn main() -> std::io::Result<()> { @@ -23,7 +24,15 @@ async fn main() -> std::io::Result<()> { pki::initialize_devices_ca().expect("Failed to initialize devices CA!"); pki::initialize_server_ca().expect("Failed to initialize server certificate!"); + // Initialize CRL pki::refresh_crls().expect("Failed to initialize Root CA!"); + let refresh_crl = every(1).hour().perform(|| async { + log::info!("Periodic refresh of CRLs..."); + if let Err(e) = pki::refresh_crls() { + log::error!("Failed to perform auto refresh of CRLs! {e}"); + } + }); + tokio::spawn(refresh_crl); // Initialize energy actor let actor = EnergyActor::new() diff --git a/central_backend/src/server/mod.rs b/central_backend/src/server/mod.rs index 0fa62e3..b009b16 100644 --- a/central_backend/src/server/mod.rs +++ b/central_backend/src/server/mod.rs @@ -8,5 +8,6 @@ pub mod devices_api; pub mod servers; pub mod unsecure_server; pub mod web_api; +pub mod web_app_controller; pub type WebEnergyActor = web::Data; diff --git a/central_backend/src/server/servers.rs b/central_backend/src/server/servers.rs index e272246..985d412 100644 --- a/central_backend/src/server/servers.rs +++ b/central_backend/src/server/servers.rs @@ -6,6 +6,7 @@ use crate::server::auth_middleware::AuthChecker; use crate::server::devices_api::{mgmt_controller, utils_controller}; use crate::server::unsecure_server::*; use crate::server::web_api::*; +use crate::server::web_app_controller; use actix_cors::Cors; use actix_identity::config::LogoutBehaviour; use actix_identity::IdentityMiddleware; @@ -105,12 +106,14 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> .app_data(web::Data::new(RemoteIPConfig { proxy: AppConfig::get().proxy_ip.clone(), })) - .route("/", web::get().to(server_controller::secure_home)) + //.route("/", web::get().to(server_controller::secure_home)) // Web API + // Server controller .route( "/web_api/server/config", web::get().to(server_controller::config), ) + // Auth controller .route( "/web_api/auth/password_auth", web::post().to(auth_controller::password_auth), @@ -123,6 +126,7 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> "/web_api/auth/sign_out", web::get().to(auth_controller::sign_out), ) + // Energy controller .route( "/web_api/energy/curr_consumption", web::get().to(energy_controller::curr_consumption), @@ -131,6 +135,7 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> "/web_api/energy/cached_consumption", web::get().to(energy_controller::cached_consumption), ) + // Devices controller .route( "/web_api/devices/list_pending", web::get().to(devices_controller::list_pending), @@ -139,14 +144,27 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> "/web_api/devices/list_validated", web::get().to(devices_controller::list_validated), ) + .route( + "/web_api/device/{id}", + web::get().to(devices_controller::get_single), + ) .route( "/web_api/device/{id}/validate", web::post().to(devices_controller::validate_device), ) + .route( + "/web_api/device/{id}", + web::patch().to(devices_controller::update_device), + ) .route( "/web_api/device/{id}", web::delete().to(devices_controller::delete_device), ) + // Relays API + .route( + "/web_api/relays/list", + web::get().to(relays_controller::get_list), + ) // Devices API .route( "/devices_api/utils/time", @@ -164,6 +182,13 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> "/devices_api/mgmt/get_certificate", web::get().to(mgmt_controller::get_certificate), ) + // Web app + .route("/", web::get().to(web_app_controller::root_index)) + .route( + "/assets/{tail:.*}", + web::get().to(web_app_controller::serve_assets_content), + ) + .route("/{tail:.*}", web::get().to(web_app_controller::root_index)) }) .bind_openssl(&AppConfig::get().listen_address, builder)? .run() diff --git a/central_backend/src/server/web_api/devices_controller.rs b/central_backend/src/server/web_api/devices_controller.rs index 1ab0db2..fd65701 100644 --- a/central_backend/src/server/web_api/devices_controller.rs +++ b/central_backend/src/server/web_api/devices_controller.rs @@ -1,4 +1,4 @@ -use crate::devices::device::DeviceId; +use crate::devices::device::{DeviceGeneralInfo, DeviceId}; use crate::energy::energy_actor; use crate::server::custom_error::HttpResult; use crate::server::WebEnergyActor; @@ -33,6 +33,18 @@ pub struct DeviceInPath { id: DeviceId, } +/// Get a single device information +pub async fn get_single(actor: WebEnergyActor, id: web::Path) -> HttpResult { + let Some(dev) = actor + .send(energy_actor::GetSingleDevice(id.id.clone())) + .await? + else { + return Ok(HttpResponse::NotFound().json("Requested device was not found!")); + }; + + Ok(HttpResponse::Ok().json(dev)) +} + /// Validate a device pub async fn validate_device(actor: WebEnergyActor, id: web::Path) -> HttpResult { actor @@ -42,6 +54,26 @@ pub async fn validate_device(actor: WebEnergyActor, id: web::Path) Ok(HttpResponse::Accepted().finish()) } +/// Update a device information +pub async fn update_device( + actor: WebEnergyActor, + id: web::Path, + update: web::Json, +) -> HttpResult { + if let Some(e) = update.error() { + return Ok(HttpResponse::BadRequest().json(e)); + } + + actor + .send(energy_actor::UpdateDeviceGeneralInfo( + id.id.clone(), + update.0.clone(), + )) + .await??; + + Ok(HttpResponse::Accepted().finish()) +} + /// Delete a device pub async fn delete_device(actor: WebEnergyActor, id: web::Path) -> HttpResult { actor diff --git a/central_backend/src/server/web_api/mod.rs b/central_backend/src/server/web_api/mod.rs index e4c75a5..eb48e75 100644 --- a/central_backend/src/server/web_api/mod.rs +++ b/central_backend/src/server/web_api/mod.rs @@ -1,4 +1,5 @@ pub mod auth_controller; pub mod devices_controller; pub mod energy_controller; +pub mod relays_controller; pub mod server_controller; diff --git a/central_backend/src/server/web_api/relays_controller.rs b/central_backend/src/server/web_api/relays_controller.rs new file mode 100644 index 0000000..45256f3 --- /dev/null +++ b/central_backend/src/server/web_api/relays_controller.rs @@ -0,0 +1,10 @@ +use crate::energy::energy_actor; +use crate::server::custom_error::HttpResult; +use crate::server::WebEnergyActor; +use actix_web::HttpResponse; + +/// Get the full list of relays +pub async fn get_list(actor: WebEnergyActor) -> HttpResult { + let list = actor.send(energy_actor::GetRelaysList).await?; + Ok(HttpResponse::Ok().json(list)) +} diff --git a/central_backend/src/server/web_api/server_controller.rs b/central_backend/src/server/web_api/server_controller.rs index bda9c78..9b0626c 100644 --- a/central_backend/src/server/web_api/server_controller.rs +++ b/central_backend/src/server/web_api/server_controller.rs @@ -1,4 +1,5 @@ use crate::app_config::AppConfig; +use crate::constants::StaticConstraints; use actix_web::HttpResponse; pub async fn secure_home() -> HttpResponse { @@ -10,12 +11,14 @@ pub async fn secure_home() -> HttpResponse { #[derive(serde::Serialize)] struct ServerConfig { auth_disabled: bool, + constraints: StaticConstraints, } impl Default for ServerConfig { fn default() -> Self { Self { auth_disabled: AppConfig::get().unsecure_disable_login, + constraints: Default::default(), } } } diff --git a/central_backend/src/server/web_app_controller.rs b/central_backend/src/server/web_app_controller.rs new file mode 100644 index 0000000..5dbd129 --- /dev/null +++ b/central_backend/src/server/web_app_controller.rs @@ -0,0 +1,46 @@ +#[cfg(debug_assertions)] +pub use serve_static_debug::{root_index, serve_assets_content}; +#[cfg(not(debug_assertions))] +pub use serve_static_release::{root_index, serve_assets_content}; + +#[cfg(debug_assertions)] +mod serve_static_debug { + use actix_web::{HttpResponse, Responder}; + + pub async fn root_index() -> impl Responder { + HttpResponse::Ok() + .body("Solar energy secure home: Hello world! Debug=on for Solar platform!") + } + + pub async fn serve_assets_content() -> impl Responder { + HttpResponse::NotFound().body("Hello world! Static assets are not served in debug mode") + } +} + +#[cfg(not(debug_assertions))] +mod serve_static_release { + use actix_web::{web, HttpResponse, Responder}; + use rust_embed::RustEmbed; + + #[derive(RustEmbed)] + #[folder = "static/"] + struct Asset; + + fn handle_embedded_file(path: &str, can_fallback: bool) -> HttpResponse { + match (Asset::get(path), can_fallback) { + (Some(content), _) => HttpResponse::Ok() + .content_type(mime_guess::from_path(path).first_or_octet_stream().as_ref()) + .body(content.data.into_owned()), + (None, false) => HttpResponse::NotFound().body("404 Not Found"), + (None, true) => handle_embedded_file("index.html", false), + } + } + + pub async fn root_index() -> impl Responder { + handle_embedded_file("index.html", false) + } + + pub async fn serve_assets_content(path: web::Path) -> impl Responder { + handle_embedded_file(&format!("assets/{}", path.as_ref()), false) + } +} diff --git a/central_frontend/package-lock.json b/central_frontend/package-lock.json index ddad6ed..3957242 100644 --- a/central_frontend/package-lock.json +++ b/central_frontend/package-lock.json @@ -15,7 +15,9 @@ "@mdi/react": "^1.6.1", "@mui/icons-material": "^5.15.21", "@mui/material": "^5.15.21", + "@mui/x-date-pickers": "^7.11.1", "date-and-time": "^3.3.0", + "dayjs": "^1.11.12", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0" @@ -337,9 +339,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz", - "integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz", + "integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1265,12 +1267,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.15.20", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.20.tgz", - "integrity": "sha512-BK8F94AIqSrnaPYXf2KAOjGZJgWfvqAVQ2gVR3EryvQFtuBnG6RwodxrCvd3B48VuMy6Wsk897+lQMUxJyk+6g==", + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.5.tgz", + "integrity": "sha512-CSLg0YkpDqg0aXOxtjo3oTMd3XWMxvNb5d0v4AYVqwOltU8q6GvnZjhWyCLjGSCrcgfwm6/VDjaKLPlR14wxIA==", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.15.20", + "@mui/utils": "^5.16.5", "prop-types": "^15.8.1" }, "engines": { @@ -1291,9 +1293,9 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz", - "integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==", + "version": "5.16.4", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.4.tgz", + "integrity": "sha512-0+mnkf+UiAmTVB8PZFqOhqf729Yh0Cxq29/5cA3VAyDVTRIUUQ8FXQhiAhUIbijFmM72rY80ahFPXIm4WDbzcA==", "dependencies": { "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.11.0", @@ -1322,15 +1324,15 @@ } }, "node_modules/@mui/system": { - "version": "5.15.20", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.20.tgz", - "integrity": "sha512-LoMq4IlAAhxzL2VNUDBTQxAb4chnBe8JvRINVNDiMtHE2PiPOoHlhOPutSxEbaL5mkECPVWSv6p8JEV+uykwIA==", + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.5.tgz", + "integrity": "sha512-uzIUGdrWddUx1HPxW4+B2o4vpgKyRxGe/8BxbfXVDPNPHX75c782TseoCnR/VyfnZJfqX87GcxDmnZEE1c031g==", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.15.20", - "@mui/styled-engine": "^5.15.14", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.20", + "@mui/private-theming": "^5.16.5", + "@mui/styled-engine": "^5.16.4", + "@mui/types": "^7.2.15", + "@mui/utils": "^5.16.5", "clsx": "^2.1.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -1361,9 +1363,9 @@ } }, "node_modules/@mui/types": { - "version": "7.2.14", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.14.tgz", - "integrity": "sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==", + "version": "7.2.15", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.15.tgz", + "integrity": "sha512-nbo7yPhtKJkdf9kcVOF8JZHPZTmqXjJ/tI0bdWgHg5tp9AnIN4Y7f7wm9T+0SyGYJk76+GYZ8Q5XaTYAsUHN0Q==", "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0" }, @@ -1374,14 +1376,16 @@ } }, "node_modules/@mui/utils": { - "version": "5.15.20", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.20.tgz", - "integrity": "sha512-mAbYx0sovrnpAu1zHc3MDIhPqL8RPVC5W5xcO1b7PiSCJPtckIZmBkp8hefamAvUiAV8gpfMOM6Zb+eSisbI2A==", + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.5.tgz", + "integrity": "sha512-CwhcA9y44XwK7k2joL3Y29mRUnoBt+gOZZdGyw7YihbEwEErJYBtDwbZwVgH68zAljGe/b+Kd5bzfl63Gi3R2A==", "dependencies": { "@babel/runtime": "^7.23.9", - "@types/prop-types": "^15.7.11", + "@mui/types": "^7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^18.2.0" + "react-is": "^18.3.1" }, "engines": { "node": ">=12.0.0" @@ -1400,6 +1404,71 @@ } } }, + "node_modules/@mui/x-date-pickers": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.11.1.tgz", + "integrity": "sha512-CflouzTNSv0YeOA8iiYpJMtqGlwGC8LI9EE9egDGhatR9Mn5geRDTXsm0rRG/4pMOfaRxyJc6Yzr/axBhEXM7w==", + "dependencies": { + "@babel/runtime": "^7.24.8", + "@mui/base": "^5.0.0-beta.40", + "@mui/system": "^5.16.5", + "@mui/utils": "^5.16.5", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14", + "date-fns": "^2.25.0 || ^3.2.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2211,6 +2280,11 @@ "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.3.0.tgz", "integrity": "sha512-UguWfh9LkUecVrGSE0B7SpAnGRMPATmpwSoSij24/lDnwET3A641abfDBD/TdL0T+E04f8NWlbMkD9BscVvIZg==" }, + "node_modules/dayjs": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz", + "integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==" + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", diff --git a/central_frontend/package.json b/central_frontend/package.json index 10295f0..4e6bd48 100644 --- a/central_frontend/package.json +++ b/central_frontend/package.json @@ -17,7 +17,9 @@ "@mdi/react": "^1.6.1", "@mui/icons-material": "^5.15.21", "@mui/material": "^5.15.21", + "@mui/x-date-pickers": "^7.11.1", "date-and-time": "^3.3.0", + "dayjs": "^1.11.12", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0" diff --git a/central_frontend/src/App.tsx b/central_frontend/src/App.tsx index db625d8..18e6d1c 100644 --- a/central_frontend/src/App.tsx +++ b/central_frontend/src/App.tsx @@ -12,6 +12,7 @@ import { HomeRoute } from "./routes/HomeRoute"; import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; import { PendingDevicesRoute } from "./routes/PendingDevicesRoute"; import { DevicesRoute } from "./routes/DevicesRoute"; +import { DeviceRoute } from "./routes/DeviceRoute/DeviceRoute"; export function App() { if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled) @@ -21,8 +22,9 @@ export function App() { createRoutesFromElements( }> } /> - } /> } /> + } /> + } /> } /> ) diff --git a/central_frontend/src/api/DeviceApi.ts b/central_frontend/src/api/DeviceApi.ts index bd7cc69..4820405 100644 --- a/central_frontend/src/api/DeviceApi.ts +++ b/central_frontend/src/api/DeviceApi.ts @@ -12,8 +12,10 @@ export interface DailyMinRuntime { catch_up_hours: number[]; } +export type RelayID = string; + export interface DeviceRelay { - id: string; + id: RelayID; name: string; enabled: boolean; priority: number; @@ -21,8 +23,8 @@ export interface DeviceRelay { minimal_uptime: number; minimal_downtime: number; daily_runtime?: DailyMinRuntime; - depends_on: DeviceRelay[]; - conflicts_with: DeviceRelay[]; + depends_on: RelayID[]; + conflicts_with: RelayID[]; } export interface Device { @@ -37,6 +39,16 @@ export interface Device { relays: DeviceRelay[]; } +export interface UpdatedInfo { + name: string; + description: string; + enabled: boolean; +} + +export function DeviceURL(d: Device): string { + return `/dev/${encodeURIComponent(d.id)}`; +} + export class DeviceApi { /** * Get the list of pending devices @@ -72,6 +84,29 @@ export class DeviceApi { }); } + /** + * Get the information about a single device + */ + static async GetSingle(id: string): Promise { + return ( + await APIClient.exec({ + uri: `/device/${encodeURIComponent(id)}`, + method: "GET", + }) + ).data; + } + + /** + * Update a device general information + */ + static async Update(d: Device, info: UpdatedInfo): Promise { + await APIClient.exec({ + uri: `/device/${encodeURIComponent(d.id)}`, + method: "PATCH", + jsonData: info, + }); + } + /** * Delete a device */ diff --git a/central_frontend/src/api/RelayApi.ts b/central_frontend/src/api/RelayApi.ts new file mode 100644 index 0000000..2951884 --- /dev/null +++ b/central_frontend/src/api/RelayApi.ts @@ -0,0 +1,41 @@ +import { APIClient } from "./ApiClient"; +import { Device, DeviceRelay } from "./DeviceApi"; + +export class RelayApi { + /** + * Get the full list of relays + */ + static async GetList(): Promise { + return ( + await APIClient.exec({ + method: "GET", + uri: "/relays/list", + }) + ).data; + } + + /** + * Create a new relay + */ + static async Create(device: Device, relay: DeviceRelay): Promise { + await APIClient.exec({ + method: "POST", + uri: "/relay/create", + jsonData: { + ...relay, + device_id: device.id, + }, + }); + } + + /** + * Update a relay information + */ + static async Update(relay: DeviceRelay): Promise { + await APIClient.exec({ + method: "PUT", + uri: `/relay/${relay.id}`, + jsonData: relay, + }); + } +} diff --git a/central_frontend/src/api/ServerApi.ts b/central_frontend/src/api/ServerApi.ts index 5b78c53..798df78 100644 --- a/central_frontend/src/api/ServerApi.ts +++ b/central_frontend/src/api/ServerApi.ts @@ -2,6 +2,23 @@ import { APIClient } from "./ApiClient"; export interface ServerConfig { auth_disabled: boolean; + constraints: ServerConstraint; +} + +export interface ServerConstraint { + dev_name_len: LenConstraint; + dev_description_len: LenConstraint; + relay_name_len: LenConstraint; + relay_priority: LenConstraint; + relay_consumption: LenConstraint; + relay_minimal_uptime: LenConstraint; + relay_minimal_downtime: LenConstraint; + relay_daily_minimal_runtime: LenConstraint; +} + +export interface LenConstraint { + min: number; + max: number; } let config: ServerConfig | null = null; diff --git a/central_frontend/src/dialogs/EditDeviceMetadataDialog.tsx b/central_frontend/src/dialogs/EditDeviceMetadataDialog.tsx new file mode 100644 index 0000000..342daf3 --- /dev/null +++ b/central_frontend/src/dialogs/EditDeviceMetadataDialog.tsx @@ -0,0 +1,87 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from "@mui/material"; +import React from "react"; +import { Device, DeviceApi } from "../api/DeviceApi"; +import { ServerApi } from "../api/ServerApi"; +import { useAlert } from "../hooks/context_providers/AlertDialogProvider"; +import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider"; +import { useSnackbar } from "../hooks/context_providers/SnackbarProvider"; +import { lenValid } from "../utils/StringsUtils"; +import { CheckboxInput } from "../widgets/forms/CheckboxInput"; +import { TextInput } from "../widgets/forms/TextInput"; + +export function EditDeviceMetadataDialog(p: { + onClose: () => void; + device: Device; + onUpdated: () => void; +}): React.ReactElement { + const loadingMessage = useLoadingMessage(); + const alert = useAlert(); + const snackbar = useSnackbar(); + + const [name, setName] = React.useState(p.device.name); + const [description, setDescription] = React.useState(p.device.description); + const [enabled, setEnabled] = React.useState(p.device.enabled); + + const onSubmit = async () => { + try { + loadingMessage.show("Updating device information"); + await DeviceApi.Update(p.device, { + name, + description, + enabled, + }); + + snackbar("The device information have been successfully updated!"); + p.onUpdated(); + } catch (e) { + console.error("Failed to update device general information!" + e); + alert(`Failed to update device general information! ${e}`); + } finally { + loadingMessage.hide(); + } + }; + + const canSubmit = + lenValid(name, ServerApi.Config.constraints.dev_name_len) && + lenValid(description, ServerApi.Config.constraints.dev_description_len); + + return ( + + Edit device general information + + setName(s ?? "")} + size={ServerApi.Config.constraints.dev_name_len} + /> + setDescription(s ?? "")} + size={ServerApi.Config.constraints.dev_description_len} + /> + + + + + + + + ); +} diff --git a/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx new file mode 100644 index 0000000..ca9a161 --- /dev/null +++ b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx @@ -0,0 +1,332 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + Typography, +} from "@mui/material"; +import { TimePicker } from "@mui/x-date-pickers"; +import React from "react"; +import { Device, DeviceRelay } from "../api/DeviceApi"; +import { RelayApi } from "../api/RelayApi"; +import { ServerApi } from "../api/ServerApi"; +import { useAlert } from "../hooks/context_providers/AlertDialogProvider"; +import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider"; +import { useSnackbar } from "../hooks/context_providers/SnackbarProvider"; +import { dayjsToTimeOfDay, timeOfDay } from "../utils/DateUtils"; +import { lenValid } from "../utils/StringsUtils"; +import { CheckboxInput } from "../widgets/forms/CheckboxInput"; +import { MultipleSelectInput } from "../widgets/forms/MultipleSelectInput"; +import { SelectMultipleRelaysInput } from "../widgets/forms/SelectMultipleRelaysInput"; +import { TextInput } from "../widgets/forms/TextInput"; + +export function EditDeviceRelaysDialog(p: { + onClose: () => void; + relay?: DeviceRelay; + device: Device; + onUpdated: () => void; +}): React.ReactElement { + const loadingMessage = useLoadingMessage(); + const alert = useAlert(); + const snackbar = useSnackbar(); + + const [relay, setRelay] = React.useState( + p.relay ?? { + id: "", + name: "relay", + enabled: false, + priority: 1, + consumption: 500, + minimal_downtime: 60 * 5, + minimal_uptime: 60 * 5, + depends_on: [], + conflicts_with: [], + } + ); + + const creating = !p.relay; + + const onSubmit = async () => { + try { + loadingMessage.show( + `${creating ? "Creating" : "Updating"} relay information` + ); + + if (creating) await RelayApi.Create(p.device, relay); + else await RelayApi.Update(relay); + + snackbar( + `The relay have been successfully ${creating ? "created" : "updated"}!` + ); + p.onUpdated(); + } catch (e) { + console.error("Failed to update device relay information!" + e); + alert(`Failed to ${creating ? "create" : "update"} relay! ${e}`); + } finally { + loadingMessage.hide(); + } + }; + + const canSubmit = + lenValid(relay.name, ServerApi.Config.constraints.relay_name_len) && + relay.priority >= 0; + + return ( + + + {creating ? "Create a new relay" : "Edit relay information"} + + + General info + + + + setRelay((r) => { + return { + ...r, + name: v ?? "", + }; + }) + } + size={ServerApi.Config.constraints.dev_name_len} + /> + + + + setRelay((r) => { + return { + ...r, + enabled: v, + }; + }) + } + /> + + + + setRelay((r) => { + return { + ...r, + priority: Number(v) ?? 0, + }; + }) + } + size={ServerApi.Config.constraints.relay_priority} + helperText="Relay priority when selecting relays to turn on. 0 = lowest priority" + /> + + + + setRelay((r) => { + return { + ...r, + consumption: Number(v) ?? 0, + }; + }) + } + size={ServerApi.Config.constraints.relay_consumption} + helperText="Estimated consumption of device powered by relay" + /> + + + + setRelay((r) => { + return { + ...r, + minimal_uptime: Number(v) ?? 0, + }; + }) + } + size={ServerApi.Config.constraints.relay_minimal_uptime} + helperText="Minimal time this relay shall be left on before it can be turned off (in seconds)" + /> + + + + setRelay((r) => { + return { + ...r, + minimal_downtime: Number(v) ?? 0, + }; + }) + } + size={ServerApi.Config.constraints.relay_minimal_downtime} + helperText="Minimal time this relay shall be left on before it can be turned off (in seconds)" + /> + + + + Daily runtime + + + + setRelay((r) => { + return { + ...r, + daily_runtime: v + ? { reset_time: 0, min_runtime: 3600, catch_up_hours: [] } + : undefined, + }; + }) + } + /> + + + {!!relay.daily_runtime && ( + <> + + + setRelay((r) => { + return { + ...r, + daily_runtime: { + ...r.daily_runtime!, + min_runtime: Number(v), + }, + }; + }) + } + size={ + ServerApi.Config.constraints.relay_daily_minimal_runtime + } + helperText="Minimum time, in seconds, that this relay should run each day" + /> + + + + setRelay((r) => { + return { + ...r, + daily_runtime: { + ...r.daily_runtime!, + reset_time: d ? dayjsToTimeOfDay(d) : 0, + }, + }; + }) + } + /> + + + { + return { + label: `${i.toString().padStart(2, "0")}:00`, + value: i, + }; + })} + selected={relay.daily_runtime!.catch_up_hours} + onChange={(d) => + setRelay((r) => { + return { + ...r, + daily_runtime: { + ...r.daily_runtime!, + catch_up_hours: d, + }, + }; + }) + } + /> + + + )} + + + Constraints + + + + setRelay((r) => { + return { + ...r, + depends_on: v, + }; + }) + } + helperText="Relays that must be already up for this relay to be started" + /> + + + + setRelay((r) => { + return { + ...r, + conflicts_with: v, + }; + }) + } + helperText="Relays that must be off before this relay can be started" + /> + + + + + + + + + ); +} + +function DialogFormTitle(p: React.PropsWithChildren): React.ReactElement { + return ( + <> + + {p.children} + + ); +} diff --git a/central_frontend/src/main.tsx b/central_frontend/src/main.tsx index 03a21cb..782a9ed 100644 --- a/central_frontend/src/main.tsx +++ b/central_frontend/src/main.tsx @@ -13,24 +13,28 @@ import { SnackbarProvider } from "./hooks/context_providers/SnackbarProvider"; import "./index.css"; import { ServerApi } from "./api/ServerApi"; import { AsyncWidget } from "./widgets/AsyncWidget"; +import { LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - - await ServerApi.LoadConfig()} - errMsg="Failed to connect to backend to retrieve static config!" - build={() => } - /> - - - - - + + + + + + + await ServerApi.LoadConfig()} + errMsg="Failed to connect to backend to retrieve static config!" + build={() => } + /> + + + + + + ); diff --git a/central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx b/central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx new file mode 100644 index 0000000..8d39676 --- /dev/null +++ b/central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx @@ -0,0 +1,50 @@ +import AddIcon from "@mui/icons-material/Add"; +import { IconButton, Tooltip } from "@mui/material"; +import React from "react"; +import { Device, DeviceRelay } from "../../api/DeviceApi"; +import { DeviceRouteCard } from "./DeviceRouteCard"; +import { EditDeviceRelaysDialog } from "../../dialogs/EditDeviceRelaysDialog"; + +export function DeviceRelays(p: { + device: Device; + onReload: () => void; +}): React.ReactElement { + const [dialogOpen, setDialogOpen] = React.useState(false); + const [currRelay, setCurrRelay] = React.useState(); + + const createNewRelay = () => { + setDialogOpen(true); + setCurrRelay(undefined); + }; + + return ( + <> + {dialogOpen && ( + setDialogOpen(false)} + relay={currRelay} + onUpdated={() => { + setDialogOpen(false); + p.onReload(); + }} + /> + )} + + = p.device.info.max_relays} + > + + + + } + > + TODO : relays list ({p.device.relays.length}) relays now) + + + ); +} diff --git a/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx b/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx new file mode 100644 index 0000000..50e0368 --- /dev/null +++ b/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx @@ -0,0 +1,93 @@ +import DeleteIcon from "@mui/icons-material/Delete"; +import { Grid, IconButton, Tooltip } from "@mui/material"; +import React from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { Device, DeviceApi } from "../../api/DeviceApi"; +import { useAlert } from "../../hooks/context_providers/AlertDialogProvider"; +import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider"; +import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider"; +import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider"; +import { AsyncWidget } from "../../widgets/AsyncWidget"; +import { SolarEnergyRouteContainer } from "../../widgets/SolarEnergyRouteContainer"; +import { GeneralDeviceInfo } from "./GeneralDeviceInfo"; +import { DeviceRelays } from "./DeviceRelays"; + +export function DeviceRoute(): React.ReactElement { + const { id } = useParams(); + const [device, setDevice] = React.useState(); + + const loadKey = React.useRef(1); + + const load = async () => { + setDevice(await DeviceApi.GetSingle(id!)); + }; + + const reload = () => { + loadKey.current += 1; + setDevice(undefined); + }; + + return ( + } + /> + ); +} + +function DeviceRouteInner(p: { + device: Device; + onReload: () => void; +}): React.ReactElement { + const alert = useAlert(); + const confirm = useConfirm(); + const snackbar = useSnackbar(); + const loadingMessage = useLoadingMessage(); + const navigate = useNavigate(); + + const deleteDevice = async (d: Device) => { + try { + if ( + !(await confirm( + `Do you really want to delete the device ${d.id}? The operation cannot be reverted!` + )) + ) + return; + + loadingMessage.show("Deleting device..."); + await DeviceApi.Delete(d); + + snackbar("The device has been successfully deleted!"); + navigate("/devices"); + } catch (e) { + console.error(`Failed to delete device! ${e})`); + alert("Failed to delete device!"); + } finally { + loadingMessage.hide(); + } + }; + return ( + + deleteDevice(p.device)}> + + + + } + > + + + + + + + + + + ); +} diff --git a/central_frontend/src/routes/DeviceRoute/DeviceRouteCard.tsx b/central_frontend/src/routes/DeviceRoute/DeviceRouteCard.tsx new file mode 100644 index 0000000..a6e3003 --- /dev/null +++ b/central_frontend/src/routes/DeviceRoute/DeviceRouteCard.tsx @@ -0,0 +1,24 @@ +import { Card, Paper, Typography } from "@mui/material"; + +export function DeviceRouteCard( + p: React.PropsWithChildren<{ title: string; actions?: React.ReactElement }> +): React.ReactElement { + return ( + +
+ + {p.title} + + {p.actions} +
+ {p.children} +
+ ); +} diff --git a/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx b/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx new file mode 100644 index 0000000..7c1ef6e --- /dev/null +++ b/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx @@ -0,0 +1,94 @@ +import EditIcon from "@mui/icons-material/Edit"; +import { + IconButton, + Table, + TableBody, + TableCell, + TableRow, + Tooltip, +} from "@mui/material"; +import React from "react"; +import { Device } from "../../api/DeviceApi"; +import { EditDeviceMetadataDialog } from "../../dialogs/EditDeviceMetadataDialog"; +import { formatDate } from "../../widgets/TimeWidget"; +import { DeviceRouteCard } from "./DeviceRouteCard"; + +export function GeneralDeviceInfo(p: { + device: Device; + onReload: () => void; +}): React.ReactElement { + const [dialogOpen, setDialogOpen] = React.useState(false); + + return ( + <> + {dialogOpen && ( + setDialogOpen(false)} + onUpdated={p.onReload} + /> + )} + + setDialogOpen(true)}> + + + + } + > + + + + + + + + + + + + + +
+
+ + ); +} + +function DeviceInfoProperty(p: { + icon?: React.ReactElement; + label: string; + value: string; + color?: string; +}): React.ReactElement { + return ( + + {p.label} + {p.value} + + ); +} diff --git a/central_frontend/src/routes/DevicesRoute.tsx b/central_frontend/src/routes/DevicesRoute.tsx index 9b25123..67cc715 100644 --- a/central_frontend/src/routes/DevicesRoute.tsx +++ b/central_frontend/src/routes/DevicesRoute.tsx @@ -1,5 +1,6 @@ +import RefreshIcon from "@mui/icons-material/Refresh"; +import VisibilityIcon from "@mui/icons-material/Visibility"; import { - Tooltip, IconButton, Paper, Table, @@ -8,18 +9,14 @@ import { TableContainer, TableHead, TableRow, + Tooltip, } from "@mui/material"; import React from "react"; -import { Device, DeviceApi } from "../api/DeviceApi"; +import { Link, useNavigate } from "react-router-dom"; +import { Device, DeviceApi, DeviceURL } from "../api/DeviceApi"; import { AsyncWidget } from "../widgets/AsyncWidget"; import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer"; -import RefreshIcon from "@mui/icons-material/Refresh"; import { TimeWidget } from "../widgets/TimeWidget"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { useAlert } from "../hooks/context_providers/AlertDialogProvider"; -import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider"; -import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider"; -import { useSnackbar } from "../hooks/context_providers/SnackbarProvider"; export function DevicesRoute(): React.ReactElement { const loadKey = React.useRef(1); @@ -61,32 +58,7 @@ function ValidatedDevicesList(p: { list: Device[]; onReload: () => void; }): React.ReactElement { - const alert = useAlert(); - const confirm = useConfirm(); - const snackbar = useSnackbar(); - const loadingMessage = useLoadingMessage(); - - const deleteDevice = async (d: Device) => { - try { - if ( - !(await confirm( - `Do you really want to delete the device ${d.id}? The operation cannot be reverted!` - )) - ) - return; - - loadingMessage.show("Deleting device..."); - await DeviceApi.Delete(d); - - snackbar("The device has been successfully deleted!"); - p.onReload(); - } catch (e) { - console.error(`Failed to delete device! ${e})`); - alert("Failed to delete device!"); - } finally { - loadingMessage.hide(); - } - }; + const navigate = useNavigate(); if (p.list.length === 0) { return

There is no device validated yet.

; @@ -108,7 +80,11 @@ function ValidatedDevicesList(p: { {p.list.map((dev) => ( - + navigate(DeviceURL(dev))} + > {dev.id} @@ -122,10 +98,12 @@ function ValidatedDevicesList(p: { - - deleteDevice(dev)}> - - + + + + + + diff --git a/central_frontend/src/routes/LoginRoute.tsx b/central_frontend/src/routes/LoginRoute.tsx index 52fd48f..db20f04 100644 --- a/central_frontend/src/routes/LoginRoute.tsx +++ b/central_frontend/src/routes/LoginRoute.tsx @@ -10,7 +10,6 @@ import Paper from "@mui/material/Paper"; import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; import * as React from "react"; -import { useAlert } from "../hooks/context_providers/AlertDialogProvider"; import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider"; import { AuthApi } from "../api/AuthApi"; diff --git a/central_frontend/src/utils/DateUtils.ts b/central_frontend/src/utils/DateUtils.ts index 1b74dea..48d716e 100644 --- a/central_frontend/src/utils/DateUtils.ts +++ b/central_frontend/src/utils/DateUtils.ts @@ -1,6 +1,29 @@ +import dayjs, { Dayjs } from "dayjs"; + /** * Get current UNIX time, in seconds */ export function time(): number { return Math.floor(new Date().getTime() / 1000); } + +/** + * Get dayjs representation of given time of day + */ +export function timeOfDay(time: number): Dayjs { + const hours = Math.floor(time / 3600); + const minutes = Math.floor(time / 60) - hours * 60; + + return dayjs( + `2022-04-17T${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}` + ); +} + +/** + * Get time of day (in secs) from a given dayjs representation + */ +export function dayjsToTimeOfDay(d: Dayjs): number { + return d.hour() * 3600 + d.minute() * 60 + d.second(); +} diff --git a/central_frontend/src/utils/StringsUtils.ts b/central_frontend/src/utils/StringsUtils.ts new file mode 100644 index 0000000..29121d7 --- /dev/null +++ b/central_frontend/src/utils/StringsUtils.ts @@ -0,0 +1,8 @@ +import { LenConstraint } from "../api/ServerApi"; + +/** + * Check whether a string length is valid or not + */ +export function lenValid(s: string, c: LenConstraint): boolean { + return s.length >= c.min && s.length <= c.max; +} diff --git a/central_frontend/src/widgets/forms/CheckboxInput.tsx b/central_frontend/src/widgets/forms/CheckboxInput.tsx new file mode 100644 index 0000000..078c1b4 --- /dev/null +++ b/central_frontend/src/widgets/forms/CheckboxInput.tsx @@ -0,0 +1,21 @@ +import { Checkbox, FormControlLabel } from "@mui/material"; + +export function CheckboxInput(p: { + editable: boolean; + label: string; + checked: boolean | undefined; + onValueChange: (v: boolean) => void; +}): React.ReactElement { + return ( + p.onValueChange(e.target.checked)} + /> + } + label={p.label} + /> + ); +} diff --git a/central_frontend/src/widgets/forms/MultipleSelectInput.tsx b/central_frontend/src/widgets/forms/MultipleSelectInput.tsx new file mode 100644 index 0000000..72cd80c --- /dev/null +++ b/central_frontend/src/widgets/forms/MultipleSelectInput.tsx @@ -0,0 +1,113 @@ +import { + FormControl, + InputLabel, + Select, + OutlinedInput, + Box, + Chip, + MenuItem, + Theme, + useTheme, + SelectChangeEvent, + FormHelperText, +} from "@mui/material"; +import React from "react"; + +export interface Value { + label: string; + value: E; +} + +const ITEM_HEIGHT = 48; +const ITEM_PADDING_TOP = 8; + +const MenuProps = { + PaperProps: { + style: { + maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, + width: 250, + }, + }, +}; + +function getStyles(v: Value, selected: readonly E[], theme: Theme) { + return { + fontWeight: + selected.find((e) => e === v.value) === undefined + ? theme.typography.fontWeightRegular + : theme.typography.fontWeightMedium, + }; +} + +export function MultipleSelectInput(p: { + values: Value[]; + selected: E[]; + label: string; + onChange: (selected: E[]) => void; + helperText?: string; +}): React.ReactElement { + const [labelId] = React.useState(`id-multi-${Math.random()}`); + + const theme = useTheme(); + + const handleChange = (event: SelectChangeEvent) => { + const { + target: { value }, + } = event; + + const values: any[] = + typeof value === "string" ? value.split(",") : (value as any); + + const newVals = values.map( + (v) => p.values.find((e) => String(e.value) === String(v))!.value + ); + + // Values that appear multiple times are toggled + const setVal = new Set(); + for (const el of newVals) { + if (!setVal.has(el)) setVal.add(el); + else setVal.delete(el); + } + + p.onChange([...setVal]); + }; + + return ( +
+ + {p.label} + + {p.helperText && {p.helperText}} + +
+ ); +} diff --git a/central_frontend/src/widgets/forms/SelectMultipleRelaysInput.tsx b/central_frontend/src/widgets/forms/SelectMultipleRelaysInput.tsx new file mode 100644 index 0000000..adbcf0b --- /dev/null +++ b/central_frontend/src/widgets/forms/SelectMultipleRelaysInput.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { DeviceRelay, RelayID } from "../../api/DeviceApi"; +import { RelayApi } from "../../api/RelayApi"; +import { AsyncWidget } from "../AsyncWidget"; +import { MultipleSelectInput } from "./MultipleSelectInput"; + +export function SelectMultipleRelaysInput(p: { + label: string; + value: RelayID[]; + onValueChange: (ids: RelayID[]) => void; + exclude?: RelayID[]; + helperText?: string; +}): React.ReactElement { + const [list, setList] = React.useState(); + + const load = async () => { + setList(await RelayApi.GetList()); + }; + + const values = + list?.map((r) => { + return { + label: r.name, + value: r.id, + }; + }) ?? []; + + return ( + ( + + )} + /> + ); +} diff --git a/central_frontend/src/widgets/forms/TextInput.tsx b/central_frontend/src/widgets/forms/TextInput.tsx new file mode 100644 index 0000000..e670729 --- /dev/null +++ b/central_frontend/src/widgets/forms/TextInput.tsx @@ -0,0 +1,61 @@ +import { TextField } from "@mui/material"; +import { LenConstraint } from "../../api/ServerApi"; + +/** + * Text property edition + */ +export function TextInput(p: { + label?: string; + editable: boolean; + value?: string; + onValueChange?: (newVal: string | undefined) => void; + size?: LenConstraint; + checkValue?: (s: string) => boolean; + multiline?: boolean; + minRows?: number; + maxRows?: number; + type?: React.HTMLInputTypeAttribute; + style?: React.CSSProperties; + helperText?: string; +}): React.ReactElement { + if (!p.editable && (p.value ?? "") === "") return <>; + + let valueError = undefined; + if (p.value && p.value.length > 0) { + if (p.size?.min && p.type !== "number" && p.value.length < p.size.min) + valueError = "Value is too short!"; + if (p.checkValue && !p.checkValue(p.value)) valueError = "Invalid value!"; + if ( + p.type === "number" && + p.size && + (Number(p.value) > p.size.max || Number(p.value) < p.size.min) + ) + valueError = "Invalide size range!"; + } + + return ( + + p.onValueChange?.( + e.target.value.length === 0 ? undefined : e.target.value + ) + } + inputProps={{ + maxLength: p.size?.max, + }} + InputProps={{ + readOnly: !p.editable, + type: p.type, + }} + variant={"standard"} + style={p.style ?? { width: "100%", marginBottom: "15px" }} + multiline={p.multiline} + minRows={p.minRows} + maxRows={p.maxRows} + error={valueError !== undefined} + helperText={valueError ?? p.helperText} + /> + ); +}