From 717ad5b5e01e472f73031be7154218a7ff905f2f Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Wed, 17 Jul 2024 18:31:57 +0200 Subject: [PATCH 01/14] Can revoke issued certificates --- central_backend/src/crypto/pki.rs | 72 ++++++++++++++++++--- central_backend/src/devices/devices_list.rs | 24 ++++++- central_backend/src/main.rs | 2 + 3 files changed, 87 insertions(+), 11 deletions(-) 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/devices_list.rs b/central_backend/src/devices/devices_list.rs index 0cbaa65..4dd96ca 100644 --- a/central_backend/src/devices/devices_list.rs +++ b/central_backend/src/devices/devices_list.rs @@ -2,7 +2,7 @@ use crate::app_config::AppConfig; use crate::crypto::pki; use crate::devices::device::{Device, DeviceId, DeviceInfo}; 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,10 @@ pub enum DevicesListError { ValidateDeviceFailedDeviceNotFound, #[error("Validated device failed: the device is already validated!")] ValidateDeviceFailedDeviceAlreadyValidated, + #[error("Requested device was not found")] + DeviceNotFound, + #[error("Requested device is not validated")] + DeviceNotValidated, } pub struct DevicesList(HashMap); @@ -129,12 +133,26 @@ impl DevicesList { 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); diff --git a/central_backend/src/main.rs b/central_backend/src/main.rs index fefc806..efd33af 100644 --- a/central_backend/src/main.rs +++ b/central_backend/src/main.rs @@ -25,6 +25,8 @@ async fn main() -> std::io::Result<()> { pki::refresh_crls().expect("Failed to initialize Root CA!"); + // TODO : schedule CRL auto renewal + // Initialize energy actor let actor = EnergyActor::new() .await From 37406faa3275a7553acc27f2a77917d372680146 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Wed, 17 Jul 2024 18:44:09 +0200 Subject: [PATCH 02/14] Automatically regenerate CRLs at regular interval --- central_backend/Cargo.lock | 115 +++++++++++++++++++++++++++++++++++- central_backend/Cargo.toml | 4 +- central_backend/src/main.rs | 11 +++- 3 files changed, 125 insertions(+), 5 deletions(-) diff --git a/central_backend/Cargo.lock b/central_backend/Cargo.lock index f523954..af6b20c 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" @@ -603,6 +618,8 @@ dependencies = [ "serde", "serde_json", "thiserror", + "tokio", + "tokio_schedule", "uuid", ] @@ -612,6 +629,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 +1095,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 +1255,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" @@ -1425,6 +1485,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" @@ -2093,21 +2172,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 +2246,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" @@ -2380,6 +2482,15 @@ dependencies = [ "wasm-bindgen", ] +[[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..d8e0fcd 100644 --- a/central_backend/Cargo.toml +++ b/central_backend/Cargo.toml @@ -29,4 +29,6 @@ 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" \ No newline at end of file diff --git a/central_backend/src/main.rs b/central_backend/src/main.rs index efd33af..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,9 +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!"); - - // TODO : schedule CRL auto renewal + 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() From 370084b3bb9b21f55cbb3333d6a353b21543bde8 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Wed, 17 Jul 2024 18:57:23 +0200 Subject: [PATCH 03/14] Add devices definitions --- central_backend/src/devices/device.rs | 34 +++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/central_backend/src/devices/device.rs b/central_backend/src/devices/device.rs index 400ca23..c577bdc 100644 --- a/central_backend/src/devices/device.rs +++ b/central_backend/src/devices/device.rs @@ -1,11 +1,20 @@ +//! # Devices entities definition + +/// 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 +28,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 +56,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 +65,38 @@ 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 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)] pub struct DeviceRelayID(uuid::Uuid); +/// Single device relay information #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct DeviceRelay { + /// Device relay id. Should be unique across the whole application 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 of / 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 minimal_uptime: usize, + /// Minimal time this relay shall be left off before it can be turned on again minimal_downtime: usize, + /// Optional minimal runtime requirements for this relay daily_runtime: Option, - depends_on: Vec, - conflicts_with: Vec, + /// 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, } From 7be81fe0e926d35db3d20e7f41b49328606d2fce Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Wed, 17 Jul 2024 23:19:04 +0200 Subject: [PATCH 04/14] Add link to device page --- central_frontend/src/api/DeviceApi.ts | 4 +++ central_frontend/src/routes/DevicesRoute.tsx | 30 ++++++++++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/central_frontend/src/api/DeviceApi.ts b/central_frontend/src/api/DeviceApi.ts index bd7cc69..47ab1b7 100644 --- a/central_frontend/src/api/DeviceApi.ts +++ b/central_frontend/src/api/DeviceApi.ts @@ -37,6 +37,10 @@ export interface Device { relays: DeviceRelay[]; } +export function DeviceURL(d: Device, edit: boolean = false): string { + return `/dev/${d.id}${edit ? "/edit" : ""}`; +} + export class DeviceApi { /** * Get the list of pending devices diff --git a/central_frontend/src/routes/DevicesRoute.tsx b/central_frontend/src/routes/DevicesRoute.tsx index 9b25123..142e3a8 100644 --- a/central_frontend/src/routes/DevicesRoute.tsx +++ b/central_frontend/src/routes/DevicesRoute.tsx @@ -1,5 +1,7 @@ +import DeleteIcon from "@mui/icons-material/Delete"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import VisibilityIcon from "@mui/icons-material/Visibility"; import { - Tooltip, IconButton, Paper, Table, @@ -8,18 +10,18 @@ import { TableContainer, TableHead, TableRow, + Tooltip, } from "@mui/material"; import React from "react"; -import { Device, DeviceApi } 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 { Link, useNavigate } from "react-router-dom"; +import { Device, DeviceApi, DeviceURL } 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 { TimeWidget } from "../widgets/TimeWidget"; export function DevicesRoute(): React.ReactElement { const loadKey = React.useRef(1); @@ -61,6 +63,7 @@ function ValidatedDevicesList(p: { list: Device[]; onReload: () => void; }): React.ReactElement { + const navigate = useNavigate(); const alert = useAlert(); const confirm = useConfirm(); const snackbar = useSnackbar(); @@ -108,7 +111,11 @@ function ValidatedDevicesList(p: { {p.list.map((dev) => ( - + navigate(DeviceURL(dev))} + > {dev.id} @@ -122,6 +129,13 @@ function ValidatedDevicesList(p: { + + + + + + + deleteDevice(dev)}> From 1ce9ca3321b9ef4d5d4b27f0fcd7028cbe9da45e Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Thu, 18 Jul 2024 20:06:46 +0200 Subject: [PATCH 05/14] Display basic device information --- central_backend/src/server/servers.rs | 4 + .../src/server/web_api/devices_controller.rs | 12 +++ central_frontend/src/App.tsx | 4 +- central_frontend/src/api/DeviceApi.ts | 16 ++- central_frontend/src/routes/DeviceRoute.tsx | 102 ++++++++++++++++++ 5 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 central_frontend/src/routes/DeviceRoute.tsx diff --git a/central_backend/src/server/servers.rs b/central_backend/src/server/servers.rs index e272246..446b2b9 100644 --- a/central_backend/src/server/servers.rs +++ b/central_backend/src/server/servers.rs @@ -139,6 +139,10 @@ 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), diff --git a/central_backend/src/server/web_api/devices_controller.rs b/central_backend/src/server/web_api/devices_controller.rs index 1ab0db2..a072b48 100644 --- a/central_backend/src/server/web_api/devices_controller.rs +++ b/central_backend/src/server/web_api/devices_controller.rs @@ -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 diff --git a/central_frontend/src/App.tsx b/central_frontend/src/App.tsx index db625d8..9f7c016 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"; 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 47ab1b7..0362283 100644 --- a/central_frontend/src/api/DeviceApi.ts +++ b/central_frontend/src/api/DeviceApi.ts @@ -37,8 +37,8 @@ export interface Device { relays: DeviceRelay[]; } -export function DeviceURL(d: Device, edit: boolean = false): string { - return `/dev/${d.id}${edit ? "/edit" : ""}`; +export function DeviceURL(d: Device): string { + return `/dev/${encodeURIComponent(d.id)}`; } export class DeviceApi { @@ -76,6 +76,18 @@ 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; + } + /** * Delete a device */ diff --git a/central_frontend/src/routes/DeviceRoute.tsx b/central_frontend/src/routes/DeviceRoute.tsx new file mode 100644 index 0000000..d4d4349 --- /dev/null +++ b/central_frontend/src/routes/DeviceRoute.tsx @@ -0,0 +1,102 @@ +import { useParams } from "react-router-dom"; +import { Device, DeviceApi } from "../api/DeviceApi"; +import React from "react"; +import { AsyncWidget } from "../widgets/AsyncWidget"; +import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer"; +import { + Card, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + Typography, +} from "@mui/material"; + +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 { + return ( + + + + ); +} + +function GeneralDeviceInfo(p: { device: Device }): React.ReactElement { + return ( + + + General device information + + + + + + + + + + + + +
+
+ ); +} + +function DeviceInfoProperty(p: { + icon?: React.ReactElement; + label: string; + value: string; +}): React.ReactElement { + return ( + + {p.label} + {p.value} + + ); +} From baf341d505a031efe5b4740d1c030d319731bf39 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Mon, 22 Jul 2024 18:20:36 +0200 Subject: [PATCH 06/14] Move delete device button to button page --- central_frontend/src/routes/DeviceRoute.tsx | 55 +++++++++++++++++--- central_frontend/src/routes/DevicesRoute.tsx | 36 ------------- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/central_frontend/src/routes/DeviceRoute.tsx b/central_frontend/src/routes/DeviceRoute.tsx index d4d4349..ec3d582 100644 --- a/central_frontend/src/routes/DeviceRoute.tsx +++ b/central_frontend/src/routes/DeviceRoute.tsx @@ -1,18 +1,24 @@ -import { useParams } from "react-router-dom"; -import { Device, DeviceApi } from "../api/DeviceApi"; -import React from "react"; -import { AsyncWidget } from "../widgets/AsyncWidget"; -import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer"; import { - Card, + IconButton, Paper, Table, TableBody, TableCell, TableContainer, TableRow, + Tooltip, Typography, } from "@mui/material"; +import React from "react"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { useParams } from "react-router-dom"; +import { Device, DeviceApi } from "../api/DeviceApi"; +import { AsyncWidget } from "../widgets/AsyncWidget"; +import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer"; +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 DeviceRoute(): React.ReactElement { const { id } = useParams(); @@ -44,8 +50,43 @@ function DeviceRouteInner(p: { device: 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(); + } + }; return ( - + + deleteDevice(p.device)}> + + +
+ } + > ); diff --git a/central_frontend/src/routes/DevicesRoute.tsx b/central_frontend/src/routes/DevicesRoute.tsx index 142e3a8..67cc715 100644 --- a/central_frontend/src/routes/DevicesRoute.tsx +++ b/central_frontend/src/routes/DevicesRoute.tsx @@ -1,4 +1,3 @@ -import DeleteIcon from "@mui/icons-material/Delete"; import RefreshIcon from "@mui/icons-material/Refresh"; import VisibilityIcon from "@mui/icons-material/Visibility"; import { @@ -15,10 +14,6 @@ import { import React from "react"; import { Link, useNavigate } from "react-router-dom"; import { Device, DeviceApi, DeviceURL } 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 { TimeWidget } from "../widgets/TimeWidget"; @@ -64,32 +59,6 @@ function ValidatedDevicesList(p: { onReload: () => void; }): React.ReactElement { const navigate = useNavigate(); - 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(); - } - }; if (p.list.length === 0) { return

There is no device validated yet.

; @@ -136,11 +105,6 @@ function ValidatedDevicesList(p: { - - deleteDevice(dev)}> - - -
))} From 4d5ba939d1ffecb05cb549647362139363cc1dbf Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Mon, 22 Jul 2024 22:19:48 +0200 Subject: [PATCH 07/14] Can update device general information --- central_backend/src/constants.rs | 37 +++++ central_backend/src/devices/device.rs | 28 ++++ central_backend/src/devices/devices_list.rs | 25 ++- central_backend/src/energy/energy_actor.rs | 23 ++- central_backend/src/server/servers.rs | 4 + .../src/server/web_api/devices_controller.rs | 22 ++- .../src/server/web_api/server_controller.rs | 3 + central_frontend/src/App.tsx | 2 +- central_frontend/src/api/DeviceApi.ts | 17 +++ central_frontend/src/api/ServerApi.ts | 11 ++ .../src/dialogs/EditDeviceMetadataDialog.tsx | 87 +++++++++++ central_frontend/src/routes/DeviceRoute.tsx | 143 ------------------ .../src/routes/DeviceRoute/DeviceRoute.tsx | 85 +++++++++++ .../routes/DeviceRoute/DeviceRouteCard.tsx | 24 +++ .../routes/DeviceRoute/GeneralDeviceInfo.tsx | 92 +++++++++++ central_frontend/src/utils/StringsUtils.ts | 8 + .../src/widgets/forms/CheckboxInput.tsx | 21 +++ .../src/widgets/forms/TextInput.tsx | 61 ++++++++ 18 files changed, 546 insertions(+), 147 deletions(-) create mode 100644 central_frontend/src/dialogs/EditDeviceMetadataDialog.tsx delete mode 100644 central_frontend/src/routes/DeviceRoute.tsx create mode 100644 central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx create mode 100644 central_frontend/src/routes/DeviceRoute/DeviceRouteCard.tsx create mode 100644 central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx create mode 100644 central_frontend/src/utils/StringsUtils.ts create mode 100644 central_frontend/src/widgets/forms/CheckboxInput.tsx create mode 100644 central_frontend/src/widgets/forms/TextInput.tsx diff --git a/central_backend/src/constants.rs b/central_backend/src/constants.rs index fb388bf..ad6fdd7 100644 --- a/central_backend/src/constants.rs +++ b/central_backend/src/constants.rs @@ -18,3 +18,40 @@ 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 + } +} + +/// 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, +} + +impl Default for StaticConstraints { + fn default() -> Self { + Self { + dev_name_len: SizeConstraint::new(1, 50), + dev_description_len: SizeConstraint::new(0, 100), + } + } +} diff --git a/central_backend/src/devices/device.rs b/central_backend/src/devices/device.rs index c577bdc..03a2c34 100644 --- a/central_backend/src/devices/device.rs +++ b/central_backend/src/devices/device.rs @@ -1,5 +1,7 @@ //! # Devices entities definition +use crate::constants::StaticConstraints; + /// Device information provided directly by the device during syncrhonisation. /// /// It should not be editable fro the Web UI @@ -100,3 +102,29 @@ pub struct DeviceRelay { /// Specify relays that must be turned off before this relay can be started conflicts_with: Vec, } + +/// 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 + } +} diff --git a/central_backend/src/devices/devices_list.rs b/central_backend/src/devices/devices_list.rs index 4dd96ca..d9928d3 100644 --- a/central_backend/src/devices/devices_list.rs +++ b/central_backend/src/devices/devices_list.rs @@ -1,6 +1,6 @@ use crate::app_config::AppConfig; use crate::crypto::pki; -use crate::devices::device::{Device, DeviceId, DeviceInfo}; +use crate::devices::device::{Device, DeviceGeneralInfo, DeviceId, DeviceInfo}; use crate::utils::time_utils::time_secs; use openssl::x509::{X509Req, X509}; use std::collections::HashMap; @@ -15,6 +15,8 @@ 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")] @@ -133,6 +135,27 @@ 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 diff --git a/central_backend/src/energy/energy_actor.rs b/central_backend/src/energy/energy_actor.rs index 4c93672..773ad30 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}; 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<()>")] diff --git a/central_backend/src/server/servers.rs b/central_backend/src/server/servers.rs index 446b2b9..61a6230 100644 --- a/central_backend/src/server/servers.rs +++ b/central_backend/src/server/servers.rs @@ -147,6 +147,10 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> "/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), diff --git a/central_backend/src/server/web_api/devices_controller.rs b/central_backend/src/server/web_api/devices_controller.rs index a072b48..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; @@ -54,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/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_frontend/src/App.tsx b/central_frontend/src/App.tsx index 9f7c016..18e6d1c 100644 --- a/central_frontend/src/App.tsx +++ b/central_frontend/src/App.tsx @@ -12,7 +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"; +import { DeviceRoute } from "./routes/DeviceRoute/DeviceRoute"; export function App() { if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled) diff --git a/central_frontend/src/api/DeviceApi.ts b/central_frontend/src/api/DeviceApi.ts index 0362283..5024df9 100644 --- a/central_frontend/src/api/DeviceApi.ts +++ b/central_frontend/src/api/DeviceApi.ts @@ -37,6 +37,12 @@ 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)}`; } @@ -88,6 +94,17 @@ export class DeviceApi { ).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/ServerApi.ts b/central_frontend/src/api/ServerApi.ts index 5b78c53..92ce7d1 100644 --- a/central_frontend/src/api/ServerApi.ts +++ b/central_frontend/src/api/ServerApi.ts @@ -2,6 +2,17 @@ import { APIClient } from "./ApiClient"; export interface ServerConfig { auth_disabled: boolean; + constraints: ServerConstraint; +} + +export interface ServerConstraint { + dev_name_len: LenConstraint; + dev_description_len: 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/routes/DeviceRoute.tsx b/central_frontend/src/routes/DeviceRoute.tsx deleted file mode 100644 index ec3d582..0000000 --- a/central_frontend/src/routes/DeviceRoute.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { - IconButton, - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableRow, - Tooltip, - Typography, -} from "@mui/material"; -import React from "react"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { useParams } from "react-router-dom"; -import { Device, DeviceApi } from "../api/DeviceApi"; -import { AsyncWidget } from "../widgets/AsyncWidget"; -import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer"; -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 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 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(); - } - }; - return ( - - deleteDevice(p.device)}> - - - - } - > - - - ); -} - -function GeneralDeviceInfo(p: { device: Device }): React.ReactElement { - return ( - - - General device information - - - - - - - - - - - - -
-
- ); -} - -function DeviceInfoProperty(p: { - icon?: React.ReactElement; - label: string; - value: string; -}): React.ReactElement { - return ( - - {p.label} - {p.value} - - ); -} diff --git a/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx b/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx new file mode 100644 index 0000000..19a65fc --- /dev/null +++ b/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx @@ -0,0 +1,85 @@ +import DeleteIcon from "@mui/icons-material/Delete"; +import { 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"; + +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..40c77db --- /dev/null +++ b/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx @@ -0,0 +1,92 @@ +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; +}): React.ReactElement { + return ( + + {p.label} + {p.value} + + ); +} 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/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} + /> + ); +} From 73163e6e699ff0871f316876ab8f3d25653db323 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Wed, 24 Jul 2024 23:35:58 +0200 Subject: [PATCH 08/14] Can get the full list of relays through the API --- central_backend/src/devices/devices_list.rs | 10 +++++++++- central_backend/src/energy/energy_actor.rs | 15 ++++++++++++++- central_backend/src/server/servers.rs | 9 +++++++++ central_backend/src/server/web_api/mod.rs | 1 + .../src/server/web_api/relays_controller.rs | 10 ++++++++++ 5 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 central_backend/src/server/web_api/relays_controller.rs diff --git a/central_backend/src/devices/devices_list.rs b/central_backend/src/devices/devices_list.rs index d9928d3..095f95f 100644 --- a/central_backend/src/devices/devices_list.rs +++ b/central_backend/src/devices/devices_list.rs @@ -1,6 +1,6 @@ use crate::app_config::AppConfig; use crate::crypto::pki; -use crate::devices::device::{Device, DeviceGeneralInfo, DeviceId, DeviceInfo}; +use crate::devices::device::{Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay}; use crate::utils::time_utils::time_secs; use openssl::x509::{X509Req, X509}; use std::collections::HashMap; @@ -192,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 773ad30..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, DeviceGeneralInfo, 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; @@ -171,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/server/servers.rs b/central_backend/src/server/servers.rs index 61a6230..704ac46 100644 --- a/central_backend/src/server/servers.rs +++ b/central_backend/src/server/servers.rs @@ -107,10 +107,12 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> })) .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 +125,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 +134,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), @@ -155,6 +159,11 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> "/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", 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)) +} From 8a6568797024d7c3a35748bad6ddba8dbfc34868 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Mon, 29 Jul 2024 22:11:13 +0200 Subject: [PATCH 09/14] Start to build relay dialog --- central_backend/src/constants.rs | 18 ++ central_backend/src/devices/device.rs | 8 +- central_frontend/package-lock.json | 124 ++++++-- central_frontend/package.json | 2 + central_frontend/src/api/ServerApi.ts | 6 + .../src/dialogs/EditDeviceRelaysDialog.tsx | 283 ++++++++++++++++++ central_frontend/src/main.tsx | 36 ++- .../src/routes/DeviceRoute/DeviceRelays.tsx | 50 ++++ .../src/routes/DeviceRoute/DeviceRoute.tsx | 12 +- .../routes/DeviceRoute/GeneralDeviceInfo.tsx | 4 +- central_frontend/src/utils/DateUtils.ts | 23 ++ 11 files changed, 518 insertions(+), 48 deletions(-) create mode 100644 central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx create mode 100644 central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx diff --git a/central_backend/src/constants.rs b/central_backend/src/constants.rs index ad6fdd7..792de2b 100644 --- a/central_backend/src/constants.rs +++ b/central_backend/src/constants.rs @@ -45,6 +45,18 @@ pub struct StaticConstraints { 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 { @@ -52,6 +64,12 @@ impl Default for StaticConstraints { 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/devices/device.rs b/central_backend/src/devices/device.rs index 03a2c34..18e2a85 100644 --- a/central_backend/src/devices/device.rs +++ b/central_backend/src/devices/device.rs @@ -67,7 +67,7 @@ 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 + /// 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, @@ -87,13 +87,13 @@ pub struct DeviceRelay { name: String, /// Whether this relay can be turned on or not enabled: bool, - /// Relay priority when selecting relays to turn of / on. 0 = lowest priority + /// 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 + /// 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 + /// 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, 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/api/ServerApi.ts b/central_frontend/src/api/ServerApi.ts index 92ce7d1..798df78 100644 --- a/central_frontend/src/api/ServerApi.ts +++ b/central_frontend/src/api/ServerApi.ts @@ -8,6 +8,12 @@ export interface ServerConfig { 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 { diff --git a/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx new file mode 100644 index 0000000..8639925 --- /dev/null +++ b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx @@ -0,0 +1,283 @@ +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 { 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 { 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` + ); + + // TODO + + 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 ( + + 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, + }, + }; + }) + } + /> + + + + setRelay((r) => { + return { + ...r, + daily_runtime: { + ...r.daily_runtime!, + reset_time: d ? dayjsToTimeOfDay(d) : 0, + }, + }; + }) + } + /> + + + )} + + + + + + + + ); +} + +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 index 19a65fc..50e0368 100644 --- a/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx +++ b/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx @@ -1,5 +1,5 @@ import DeleteIcon from "@mui/icons-material/Delete"; -import { IconButton, Tooltip } from "@mui/material"; +import { Grid, IconButton, Tooltip } from "@mui/material"; import React from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Device, DeviceApi } from "../../api/DeviceApi"; @@ -10,6 +10,7 @@ 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(); @@ -79,7 +80,14 @@ function DeviceRouteInner(p: { } > - + + + + + + + + ); } diff --git a/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx b/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx index 40c77db..7c1ef6e 100644 --- a/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx +++ b/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx @@ -62,6 +62,7 @@ export function GeneralDeviceInfo(p: { {p.label} - {p.value} + {p.value}
); } 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(); +} From 596d22739dc05a95996ccd5450db4f5216e9e6c7 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Mon, 29 Jul 2024 23:13:53 +0200 Subject: [PATCH 10/14] Can select catchup hours --- .../src/dialogs/EditDeviceRelaysDialog.tsx | 16 ++- .../src/widgets/forms/MultipleSelectInput.tsx | 113 ++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 central_frontend/src/widgets/forms/MultipleSelectInput.tsx diff --git a/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx index 8639925..1d3f898 100644 --- a/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx +++ b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx @@ -18,6 +18,7 @@ import { dayjsToTimeOfDay, timeOfDay } from "../utils/DateUtils"; import { lenValid } from "../utils/StringsUtils"; import { CheckboxInput } from "../widgets/forms/CheckboxInput"; import { TextInput } from "../widgets/forms/TextInput"; +import { MultipleSelectInput } from "../widgets/forms/MultipleSelectInput"; export function EditDeviceRelaysDialog(p: { onClose: () => void; @@ -243,16 +244,23 @@ export function EditDeviceRelaysDialog(p: { /> - { + 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!, - reset_time: d ? dayjsToTimeOfDay(d) : 0, + catch_up_hours: d, }, }; }) 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}} + +
+ ); +} From 3004b03d92340d432b6ba1624e8d03675fc78071 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Tue, 30 Jul 2024 22:54:47 +0200 Subject: [PATCH 11/14] Add select for relays --- central_frontend/src/api/DeviceApi.ts | 8 ++-- central_frontend/src/api/RelayApi.ts | 16 +++++++ .../src/dialogs/EditDeviceRelaysDialog.tsx | 39 +++++++++++++++- .../forms/SelectMultipleRelaysInput.tsx | 44 +++++++++++++++++++ 4 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 central_frontend/src/api/RelayApi.ts create mode 100644 central_frontend/src/widgets/forms/SelectMultipleRelaysInput.tsx diff --git a/central_frontend/src/api/DeviceApi.ts b/central_frontend/src/api/DeviceApi.ts index 5024df9..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 { diff --git a/central_frontend/src/api/RelayApi.ts b/central_frontend/src/api/RelayApi.ts new file mode 100644 index 0000000..33f7546 --- /dev/null +++ b/central_frontend/src/api/RelayApi.ts @@ -0,0 +1,16 @@ +import { APIClient } from "./ApiClient"; +import { 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; + } +} diff --git a/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx index 1d3f898..c042692 100644 --- a/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx +++ b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx @@ -17,8 +17,9 @@ 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 { TextInput } from "../widgets/forms/TextInput"; import { MultipleSelectInput } from "../widgets/forms/MultipleSelectInput"; +import { SelectMultipleRelaysInput } from "../widgets/forms/SelectMultipleRelaysInput"; +import { TextInput } from "../widgets/forms/TextInput"; export function EditDeviceRelaysDialog(p: { onClose: () => void; @@ -270,6 +271,42 @@ export function EditDeviceRelaysDialog(p: { )}
+ + 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" + /> + + 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 ( + ( + + )} + /> + ); +} From 5497c36c75b0b4e141bd89233cb72f1aaaaf2d64 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Tue, 30 Jul 2024 23:04:53 +0200 Subject: [PATCH 12/14] Create API routes to request relay information --- central_frontend/src/api/RelayApi.ts | 27 ++++++++++++++++++- .../src/dialogs/EditDeviceRelaysDialog.tsx | 8 ++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/central_frontend/src/api/RelayApi.ts b/central_frontend/src/api/RelayApi.ts index 33f7546..2951884 100644 --- a/central_frontend/src/api/RelayApi.ts +++ b/central_frontend/src/api/RelayApi.ts @@ -1,5 +1,5 @@ import { APIClient } from "./ApiClient"; -import { DeviceRelay } from "./DeviceApi"; +import { Device, DeviceRelay } from "./DeviceApi"; export class RelayApi { /** @@ -13,4 +13,29 @@ export class RelayApi { }) ).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/dialogs/EditDeviceRelaysDialog.tsx b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx index c042692..ca9a161 100644 --- a/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx +++ b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx @@ -10,6 +10,7 @@ import { 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"; @@ -53,7 +54,8 @@ export function EditDeviceRelaysDialog(p: { `${creating ? "Creating" : "Updating"} relay information` ); - // TODO + if (creating) await RelayApi.Create(p.device, relay); + else await RelayApi.Update(relay); snackbar( `The relay have been successfully ${creating ? "created" : "updated"}!` @@ -73,7 +75,9 @@ export function EditDeviceRelaysDialog(p: { return ( - Edit relay information + + {creating ? "Create a new relay" : "Edit relay information"} + General info From 48a2f728de2c0eec156ab116a2fc1a618f390823 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Wed, 31 Jul 2024 23:33:58 +0200 Subject: [PATCH 13/14] Start to check device relay information --- central_backend/src/constants.rs | 4 + central_backend/src/devices/device.rs | 116 +++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/central_backend/src/constants.rs b/central_backend/src/constants.rs index 792de2b..4bd652d 100644 --- a/central_backend/src/constants.rs +++ b/central_backend/src/constants.rs @@ -36,6 +36,10 @@ impl SizeConstraint { 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 diff --git a/central_backend/src/devices/device.rs b/central_backend/src/devices/device.rs index 18e2a85..e92870d 100644 --- a/central_backend/src/devices/device.rs +++ b/central_backend/src/devices/device.rs @@ -1,6 +1,7 @@ //! # Devices entities definition use crate::constants::StaticConstraints; +use std::collections::HashMap; /// Device information provided directly by the device during syncrhonisation. /// @@ -75,13 +76,20 @@ pub struct DailyMinRuntime { 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); +impl Default for DeviceRelayID { + fn default() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + /// Single device relay information -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[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, @@ -103,6 +111,80 @@ pub struct DeviceRelay { 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 @@ -128,3 +210,33 @@ impl DeviceGeneralInfo { 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()); + } +} From e18162b32d46c119b1aa02b73a2a366a1916525d Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Wed, 7 Aug 2024 16:44:30 +0200 Subject: [PATCH 14/14] Can build central in production mode --- Makefile | 11 +++ central_backend/.gitignore | 1 + central_backend/Cargo.lock | 83 +++++++++++++++++++ central_backend/Cargo.toml | 4 +- central_backend/src/server/mod.rs | 1 + central_backend/src/server/servers.rs | 10 ++- .../src/server/web_app_controller.rs | 46 ++++++++++ central_frontend/src/routes/LoginRoute.tsx | 1 - 8 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 Makefile create mode 100644 central_backend/src/server/web_app_controller.rs 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 af6b20c..bf8209b 100644 --- a/central_backend/Cargo.lock +++ b/central_backend/Cargo.lock @@ -610,10 +610,12 @@ dependencies = [ "lazy_static", "libc", "log", + "mime_guess", "openssl", "openssl-sys", "rand", "reqwest", + "rust-embed", "semver", "serde", "serde_json", @@ -1441,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" @@ -1816,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" @@ -1890,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" @@ -2315,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" @@ -2391,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" @@ -2482,6 +2556,15 @@ 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" diff --git a/central_backend/Cargo.toml b/central_backend/Cargo.toml index d8e0fcd..bcd5573 100644 --- a/central_backend/Cargo.toml +++ b/central_backend/Cargo.toml @@ -31,4 +31,6 @@ uuid = { version = "1.9.1", features = ["v4", "serde"] } semver = { version = "1.0.23", features = ["serde"] } lazy-regex = "3.1.0" tokio = { version = "1.38.1", features = ["full"] } -tokio_schedule = "0.3.2" \ No newline at end of file +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/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 704ac46..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,7 +106,7 @@ 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( @@ -181,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_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/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";