Merge branch 'master' of ssh://gitea.communiquons.org:52001/pierre/SolarEnergy
This commit is contained in:
commit
6b9d5e9d85
11
Makefile
Normal file
11
Makefile
Normal file
@ -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
|
1
central_backend/.gitignore
vendored
1
central_backend/.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
target
|
||||
.idea
|
||||
storage
|
||||
static
|
||||
|
198
central_backend/Cargo.lock
generated
198
central_backend/Cargo.lock
generated
@ -386,6 +386,21 @@ dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.14"
|
||||
@ -595,14 +610,18 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"rust-embed",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio_schedule",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@ -612,6 +631,20 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
|
||||
dependencies = [
|
||||
"android-tzdata",
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-targets 0.52.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@ -1064,6 +1097,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
@ -1218,6 +1257,29 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.5.0"
|
||||
@ -1381,6 +1443,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.7.4"
|
||||
@ -1425,6 +1497,25 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.0"
|
||||
@ -1737,6 +1828,40 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.24"
|
||||
@ -1811,6 +1936,15 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.23"
|
||||
@ -2093,21 +2227,34 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.38.0"
|
||||
version = "1.38.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
|
||||
checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"num_cpus",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
@ -2154,6 +2301,16 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio_schedule"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c291c554da3518d6ef69c76ea35aabc78f736185a16b6017f6d1c224dac2e0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
@ -2213,6 +2370,15 @@ version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
|
||||
dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.15"
|
||||
@ -2289,6 +2455,16 @@ version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
@ -2380,6 +2556,24 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
|
@ -29,4 +29,8 @@ actix-remote-ip = "0.1.0"
|
||||
futures-util = "0.3.30"
|
||||
uuid = { version = "1.9.1", features = ["v4", "serde"] }
|
||||
semver = { version = "1.0.23", features = ["serde"] }
|
||||
lazy-regex = "3.1.0"
|
||||
lazy-regex = "3.1.0"
|
||||
tokio = { version = "1.38.1", features = ["full"] }
|
||||
tokio_schedule = "0.3.2"
|
||||
mime_guess = "2.0.5"
|
||||
rust-embed = "8.5.0"
|
@ -18,3 +18,62 @@ pub const MAX_SESSION_DURATION: u64 = 3600 * 24;
|
||||
/// List of routes that do not require authentication
|
||||
pub const ROUTES_WITHOUT_AUTH: [&str; 2] =
|
||||
["/web_api/server/config", "/web_api/auth/password_auth"];
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct SizeConstraint {
|
||||
/// Minimal string length
|
||||
min: usize,
|
||||
/// Maximal string length
|
||||
max: usize,
|
||||
}
|
||||
|
||||
impl SizeConstraint {
|
||||
pub fn new(min: usize, max: usize) -> Self {
|
||||
Self { min, max }
|
||||
}
|
||||
|
||||
pub fn validate(&self, val: &str) -> bool {
|
||||
let len = val.trim().len();
|
||||
len >= self.min && len <= self.max
|
||||
}
|
||||
|
||||
pub fn validate_usize(&self, val: usize) -> bool {
|
||||
val >= self.min && val <= self.max
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend static constraints
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct StaticConstraints {
|
||||
/// Device name constraint
|
||||
pub dev_name_len: SizeConstraint,
|
||||
/// Device description constraint
|
||||
pub dev_description_len: SizeConstraint,
|
||||
/// Relay name constraint
|
||||
pub relay_name_len: SizeConstraint,
|
||||
/// Relay priority constraint
|
||||
pub relay_priority: SizeConstraint,
|
||||
/// Relay consumption constraint
|
||||
pub relay_consumption: SizeConstraint,
|
||||
/// Relay minimal uptime
|
||||
pub relay_minimal_uptime: SizeConstraint,
|
||||
/// Relay minimal downtime
|
||||
pub relay_minimal_downtime: SizeConstraint,
|
||||
/// Relay daily minimal uptime
|
||||
pub relay_daily_minimal_runtime: SizeConstraint,
|
||||
}
|
||||
|
||||
impl Default for StaticConstraints {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dev_name_len: SizeConstraint::new(1, 50),
|
||||
dev_description_len: SizeConstraint::new(0, 100),
|
||||
relay_name_len: SizeConstraint::new(1, 100),
|
||||
relay_priority: SizeConstraint::new(0, 999999),
|
||||
relay_consumption: SizeConstraint::new(0, 999999),
|
||||
relay_minimal_uptime: SizeConstraint::new(0, 9999999),
|
||||
relay_minimal_downtime: SizeConstraint::new(0, 9999999),
|
||||
relay_daily_minimal_runtime: SizeConstraint::new(0, 3600 * 24),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<String> {
|
||||
|
||||
Ok(String::from_utf8(cert)?)
|
||||
}
|
||||
|
||||
/// Check if a certificate is revoked
|
||||
fn is_revoked(cert: &X509, ca: &CertData) -> anyhow::Result<bool> {
|
||||
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()?)
|
||||
}
|
||||
|
@ -1,11 +1,23 @@
|
||||
//! # Devices entities definition
|
||||
|
||||
use crate::constants::StaticConstraints;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Device information provided directly by the device during syncrhonisation.
|
||||
///
|
||||
/// It should not be editable fro the Web UI
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DeviceInfo {
|
||||
/// Device reference
|
||||
reference: String,
|
||||
/// Device firmware / software version
|
||||
version: semver::Version,
|
||||
/// Maximum number of relay that the device can support
|
||||
max_relays: usize,
|
||||
}
|
||||
|
||||
impl DeviceInfo {
|
||||
/// Identify errors in device information definition
|
||||
pub fn error(&self) -> Option<&str> {
|
||||
if self.reference.trim().is_empty() {
|
||||
return Some("Given device reference is empty or blank!");
|
||||
@ -19,14 +31,19 @@ impl DeviceInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Device identifier
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)]
|
||||
pub struct DeviceId(pub String);
|
||||
|
||||
/// Single device information
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Device {
|
||||
/// The device ID
|
||||
pub id: DeviceId,
|
||||
/// Information about the device
|
||||
///
|
||||
/// These information shall not be editable from the webui. They are automatically updated during
|
||||
/// device synchronization
|
||||
pub info: DeviceInfo,
|
||||
/// Time at which device was initially enrolled
|
||||
pub time_create: u64,
|
||||
@ -42,6 +59,8 @@ pub struct Device {
|
||||
/// Specify whether the device is enabled or not
|
||||
pub enabled: bool,
|
||||
/// Information about the relays handled by the device
|
||||
///
|
||||
/// There cannot be more than [info.max_relays] relays
|
||||
pub relays: Vec<DeviceRelay>,
|
||||
}
|
||||
|
||||
@ -49,24 +68,175 @@ pub struct Device {
|
||||
/// time of a device
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DailyMinRuntime {
|
||||
/// Minimum time, in seconds, that this relay should run each day
|
||||
pub min_runtime: usize,
|
||||
/// The seconds in the days (from 00:00) where the counter is reset
|
||||
pub reset_time: usize,
|
||||
/// The hours during which the relay should be turned on to reach expected runtime
|
||||
pub catch_up_hours: Vec<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)]
|
||||
pub struct DeviceRelayID(uuid::Uuid);
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DeviceRelay {
|
||||
id: DeviceRelayID,
|
||||
name: String,
|
||||
enabled: bool,
|
||||
priority: usize,
|
||||
consumption: usize,
|
||||
minimal_uptime: usize,
|
||||
minimal_downtime: usize,
|
||||
daily_runtime: Option<DailyMinRuntime>,
|
||||
depends_on: Vec<DeviceRelay>,
|
||||
conflicts_with: Vec<DeviceRelay>,
|
||||
impl Default for DeviceRelayID {
|
||||
fn default() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
/// Single device relay information
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct DeviceRelay {
|
||||
/// Device relay id. Should be unique across the whole application
|
||||
#[serde(default)]
|
||||
id: DeviceRelayID,
|
||||
/// Human-readable name for the relay
|
||||
name: String,
|
||||
/// Whether this relay can be turned on or not
|
||||
enabled: bool,
|
||||
/// Relay priority when selecting relays to turn on. 0 = lowest priority
|
||||
priority: usize,
|
||||
/// Estimated consumption of the electrical equipment triggered by the relay
|
||||
consumption: usize,
|
||||
/// Minimal time this relay shall be left on before it can be turned off (in seconds)
|
||||
minimal_uptime: usize,
|
||||
/// Minimal time this relay shall be left off before it can be turned on again (in seconds)
|
||||
minimal_downtime: usize,
|
||||
/// Optional minimal runtime requirements for this relay
|
||||
daily_runtime: Option<DailyMinRuntime>,
|
||||
/// Specify relay that must be turned on before this relay can be started
|
||||
depends_on: Vec<DeviceRelayID>,
|
||||
/// Specify relays that must be turned off before this relay can be started
|
||||
conflicts_with: Vec<DeviceRelayID>,
|
||||
}
|
||||
|
||||
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::<HashMap<_, _>>();
|
||||
|
||||
if self.depends_on.iter().any(|d| !relays_map.contains_key(d)) {
|
||||
return Some("A specified dependent relay does not exists!");
|
||||
}
|
||||
|
||||
if self
|
||||
.conflicts_with
|
||||
.iter()
|
||||
.any(|d| !relays_map.contains_key(d))
|
||||
{
|
||||
return Some("A specified conflicting relay does not exists!");
|
||||
}
|
||||
|
||||
// TODO : check for loops
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Device general information
|
||||
///
|
||||
/// This structure is used to update device information
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct DeviceGeneralInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl DeviceGeneralInfo {
|
||||
/// Check for errors in the structure
|
||||
pub fn error(&self) -> Option<&'static str> {
|
||||
let constraints = StaticConstraints::default();
|
||||
if !constraints.dev_name_len.validate(&self.name) {
|
||||
return Some("Invalid device name length!");
|
||||
}
|
||||
|
||||
if !constraints.dev_description_len.validate(&self.description) {
|
||||
return Some("Invalid device description length!");
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::devices::device::DeviceRelay;
|
||||
|
||||
#[test]
|
||||
fn check_device_relay_error() {
|
||||
let unitary = DeviceRelay {
|
||||
name: "unitary".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bad_name = DeviceRelay {
|
||||
name: "".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let dep_on_unitary = DeviceRelay {
|
||||
name: "dep_on_unitary".to_string(),
|
||||
depends_on: vec![unitary.id],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(unitary.error(&[]), None);
|
||||
assert_eq!(unitary.error(&[unitary.clone(), bad_name.clone()]), None);
|
||||
assert!(bad_name.error(&[]).is_some());
|
||||
assert_eq!(dep_on_unitary.error(&[unitary.clone()]), None);
|
||||
assert!(dep_on_unitary.error(&[]).is_some());
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::crypto::pki;
|
||||
use crate::devices::device::{Device, DeviceId, DeviceInfo};
|
||||
use crate::devices::device::{Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay};
|
||||
use crate::utils::time_utils::time_secs;
|
||||
use openssl::x509::X509Req;
|
||||
use openssl::x509::{X509Req, X509};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
@ -15,6 +15,12 @@ pub enum DevicesListError {
|
||||
ValidateDeviceFailedDeviceNotFound,
|
||||
#[error("Validated device failed: the device is already validated!")]
|
||||
ValidateDeviceFailedDeviceAlreadyValidated,
|
||||
#[error("Update device failed: the device does not exists!")]
|
||||
UpdateDeviceFailedDeviceNotFound,
|
||||
#[error("Requested device was not found")]
|
||||
DeviceNotFound,
|
||||
#[error("Requested device is not validated")]
|
||||
DeviceNotValidated,
|
||||
}
|
||||
|
||||
pub struct DevicesList(HashMap<DeviceId, Device>);
|
||||
@ -129,12 +135,47 @@ impl DevicesList {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a device general information
|
||||
pub fn update_general_info(
|
||||
&mut self,
|
||||
id: &DeviceId,
|
||||
general_info: DeviceGeneralInfo,
|
||||
) -> anyhow::Result<()> {
|
||||
let dev = self
|
||||
.0
|
||||
.get_mut(id)
|
||||
.ok_or(DevicesListError::UpdateDeviceFailedDeviceNotFound)?;
|
||||
|
||||
dev.name = general_info.name;
|
||||
dev.description = general_info.description;
|
||||
dev.enabled = general_info.enabled;
|
||||
dev.time_update = time_secs();
|
||||
|
||||
self.persist_dev_config(id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get single certificate information
|
||||
fn get_cert(&self, id: &DeviceId) -> anyhow::Result<X509> {
|
||||
let dev = self
|
||||
.get_single(id)
|
||||
.ok_or(DevicesListError::DeviceNotFound)?;
|
||||
if !dev.validated {
|
||||
return Err(DevicesListError::DeviceNotValidated.into());
|
||||
}
|
||||
|
||||
Ok(X509::from_pem(&std::fs::read(
|
||||
AppConfig::get().device_cert_path(id),
|
||||
)?)?)
|
||||
}
|
||||
|
||||
/// Delete a device
|
||||
pub fn delete(&mut self, id: &DeviceId) -> anyhow::Result<()> {
|
||||
let crt_path = AppConfig::get().device_cert_path(id);
|
||||
if crt_path.is_file() {
|
||||
// TODO : implement
|
||||
unimplemented!("Certificate revocation not implemented yet!");
|
||||
let cert = self.get_cert(id)?;
|
||||
pki::revoke_device_cert(&cert)?;
|
||||
}
|
||||
|
||||
let csr_path = AppConfig::get().device_csr_path(id);
|
||||
@ -151,4 +192,12 @@ impl DevicesList {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the full list of relays
|
||||
pub fn relays_list(&mut self) -> Vec<DeviceRelay> {
|
||||
self.0
|
||||
.iter()
|
||||
.flat_map(|(_id, d)| d.relays.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
use crate::constants;
|
||||
use crate::devices::device::{Device, DeviceId, DeviceInfo};
|
||||
use crate::devices::device::{Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay};
|
||||
use crate::devices::devices_list::DevicesList;
|
||||
use crate::energy::consumption;
|
||||
use crate::energy::consumption::EnergyConsumption;
|
||||
@ -109,6 +109,27 @@ impl Handler<ValidateDevice> for EnergyActor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a device general information
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
pub struct UpdateDeviceGeneralInfo(pub DeviceId, pub DeviceGeneralInfo);
|
||||
|
||||
impl Handler<UpdateDeviceGeneralInfo> for EnergyActor {
|
||||
type Result = anyhow::Result<()>;
|
||||
|
||||
fn handle(&mut self, msg: UpdateDeviceGeneralInfo, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
log::info!(
|
||||
"Requested to update device general info {:?}... {:#?}",
|
||||
&msg.0,
|
||||
&msg.1
|
||||
);
|
||||
|
||||
self.devices.update_general_info(&msg.0, msg.1)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a device
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
@ -150,3 +171,16 @@ impl Handler<GetSingleDevice> for EnergyActor {
|
||||
self.devices.get_single(&msg.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the full list of relays
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "Vec<DeviceRelay>")]
|
||||
pub struct GetRelaysList;
|
||||
|
||||
impl Handler<GetRelaysList> for EnergyActor {
|
||||
type Result = Vec<DeviceRelay>;
|
||||
|
||||
fn handle(&mut self, _msg: GetRelaysList, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices.relays_list()
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ use central_backend::energy::energy_actor::EnergyActor;
|
||||
use central_backend::server::servers;
|
||||
use central_backend::utils::files_utils::create_directory_if_missing;
|
||||
use futures::future;
|
||||
use tokio_schedule::{every, Job};
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
@ -23,7 +24,15 @@ async fn main() -> std::io::Result<()> {
|
||||
pki::initialize_devices_ca().expect("Failed to initialize devices CA!");
|
||||
pki::initialize_server_ca().expect("Failed to initialize server certificate!");
|
||||
|
||||
// Initialize CRL
|
||||
pki::refresh_crls().expect("Failed to initialize Root CA!");
|
||||
let refresh_crl = every(1).hour().perform(|| async {
|
||||
log::info!("Periodic refresh of CRLs...");
|
||||
if let Err(e) = pki::refresh_crls() {
|
||||
log::error!("Failed to perform auto refresh of CRLs! {e}");
|
||||
}
|
||||
});
|
||||
tokio::spawn(refresh_crl);
|
||||
|
||||
// Initialize energy actor
|
||||
let actor = EnergyActor::new()
|
||||
|
@ -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<EnergyActorAddr>;
|
||||
|
@ -6,6 +6,7 @@ use crate::server::auth_middleware::AuthChecker;
|
||||
use crate::server::devices_api::{mgmt_controller, utils_controller};
|
||||
use crate::server::unsecure_server::*;
|
||||
use crate::server::web_api::*;
|
||||
use crate::server::web_app_controller;
|
||||
use actix_cors::Cors;
|
||||
use actix_identity::config::LogoutBehaviour;
|
||||
use actix_identity::IdentityMiddleware;
|
||||
@ -105,12 +106,14 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
||||
.app_data(web::Data::new(RemoteIPConfig {
|
||||
proxy: AppConfig::get().proxy_ip.clone(),
|
||||
}))
|
||||
.route("/", web::get().to(server_controller::secure_home))
|
||||
//.route("/", web::get().to(server_controller::secure_home))
|
||||
// Web API
|
||||
// Server controller
|
||||
.route(
|
||||
"/web_api/server/config",
|
||||
web::get().to(server_controller::config),
|
||||
)
|
||||
// Auth controller
|
||||
.route(
|
||||
"/web_api/auth/password_auth",
|
||||
web::post().to(auth_controller::password_auth),
|
||||
@ -123,6 +126,7 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
||||
"/web_api/auth/sign_out",
|
||||
web::get().to(auth_controller::sign_out),
|
||||
)
|
||||
// Energy controller
|
||||
.route(
|
||||
"/web_api/energy/curr_consumption",
|
||||
web::get().to(energy_controller::curr_consumption),
|
||||
@ -131,6 +135,7 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
||||
"/web_api/energy/cached_consumption",
|
||||
web::get().to(energy_controller::cached_consumption),
|
||||
)
|
||||
// Devices controller
|
||||
.route(
|
||||
"/web_api/devices/list_pending",
|
||||
web::get().to(devices_controller::list_pending),
|
||||
@ -139,14 +144,27 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
||||
"/web_api/devices/list_validated",
|
||||
web::get().to(devices_controller::list_validated),
|
||||
)
|
||||
.route(
|
||||
"/web_api/device/{id}",
|
||||
web::get().to(devices_controller::get_single),
|
||||
)
|
||||
.route(
|
||||
"/web_api/device/{id}/validate",
|
||||
web::post().to(devices_controller::validate_device),
|
||||
)
|
||||
.route(
|
||||
"/web_api/device/{id}",
|
||||
web::patch().to(devices_controller::update_device),
|
||||
)
|
||||
.route(
|
||||
"/web_api/device/{id}",
|
||||
web::delete().to(devices_controller::delete_device),
|
||||
)
|
||||
// Relays API
|
||||
.route(
|
||||
"/web_api/relays/list",
|
||||
web::get().to(relays_controller::get_list),
|
||||
)
|
||||
// Devices API
|
||||
.route(
|
||||
"/devices_api/utils/time",
|
||||
@ -164,6 +182,13 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
||||
"/devices_api/mgmt/get_certificate",
|
||||
web::get().to(mgmt_controller::get_certificate),
|
||||
)
|
||||
// Web app
|
||||
.route("/", web::get().to(web_app_controller::root_index))
|
||||
.route(
|
||||
"/assets/{tail:.*}",
|
||||
web::get().to(web_app_controller::serve_assets_content),
|
||||
)
|
||||
.route("/{tail:.*}", web::get().to(web_app_controller::root_index))
|
||||
})
|
||||
.bind_openssl(&AppConfig::get().listen_address, builder)?
|
||||
.run()
|
||||
|
@ -1,4 +1,4 @@
|
||||
use crate::devices::device::DeviceId;
|
||||
use crate::devices::device::{DeviceGeneralInfo, DeviceId};
|
||||
use crate::energy::energy_actor;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::server::WebEnergyActor;
|
||||
@ -33,6 +33,18 @@ pub struct DeviceInPath {
|
||||
id: DeviceId,
|
||||
}
|
||||
|
||||
/// Get a single device information
|
||||
pub async fn get_single(actor: WebEnergyActor, id: web::Path<DeviceInPath>) -> 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<DeviceInPath>) -> HttpResult {
|
||||
actor
|
||||
@ -42,6 +54,26 @@ pub async fn validate_device(actor: WebEnergyActor, id: web::Path<DeviceInPath>)
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
|
||||
/// Update a device information
|
||||
pub async fn update_device(
|
||||
actor: WebEnergyActor,
|
||||
id: web::Path<DeviceInPath>,
|
||||
update: web::Json<DeviceGeneralInfo>,
|
||||
) -> 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<DeviceInPath>) -> HttpResult {
|
||||
actor
|
||||
|
@ -1,4 +1,5 @@
|
||||
pub mod auth_controller;
|
||||
pub mod devices_controller;
|
||||
pub mod energy_controller;
|
||||
pub mod relays_controller;
|
||||
pub mod server_controller;
|
||||
|
10
central_backend/src/server/web_api/relays_controller.rs
Normal file
10
central_backend/src/server/web_api/relays_controller.rs
Normal file
@ -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))
|
||||
}
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
46
central_backend/src/server/web_app_controller.rs
Normal file
46
central_backend/src/server/web_app_controller.rs
Normal file
@ -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<String>) -> impl Responder {
|
||||
handle_embedded_file(&format!("assets/{}", path.as_ref()), false)
|
||||
}
|
||||
}
|
124
central_frontend/package-lock.json
generated
124
central_frontend/package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -12,6 +12,7 @@ import { HomeRoute } from "./routes/HomeRoute";
|
||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
||||
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
|
||||
import { DevicesRoute } from "./routes/DevicesRoute";
|
||||
import { DeviceRoute } from "./routes/DeviceRoute/DeviceRoute";
|
||||
|
||||
export function App() {
|
||||
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
||||
@ -21,8 +22,9 @@ export function App() {
|
||||
createRoutesFromElements(
|
||||
<Route path="*" element={<BaseAuthenticatedPage />}>
|
||||
<Route path="" element={<HomeRoute />} />
|
||||
<Route path="devices" element={<DevicesRoute />} />
|
||||
<Route path="pending_devices" element={<PendingDevicesRoute />} />
|
||||
<Route path="devices" element={<DevicesRoute />} />
|
||||
<Route path="dev/:id" element={<DeviceRoute />} />
|
||||
<Route path="*" element={<NotFoundRoute />} />
|
||||
</Route>
|
||||
)
|
||||
|
@ -12,8 +12,10 @@ export interface DailyMinRuntime {
|
||||
catch_up_hours: number[];
|
||||
}
|
||||
|
||||
export type RelayID = string;
|
||||
|
||||
export interface DeviceRelay {
|
||||
id: string;
|
||||
id: RelayID;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
@ -21,8 +23,8 @@ export interface DeviceRelay {
|
||||
minimal_uptime: number;
|
||||
minimal_downtime: number;
|
||||
daily_runtime?: DailyMinRuntime;
|
||||
depends_on: DeviceRelay[];
|
||||
conflicts_with: DeviceRelay[];
|
||||
depends_on: RelayID[];
|
||||
conflicts_with: RelayID[];
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
@ -37,6 +39,16 @@ export interface Device {
|
||||
relays: DeviceRelay[];
|
||||
}
|
||||
|
||||
export interface UpdatedInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function DeviceURL(d: Device): string {
|
||||
return `/dev/${encodeURIComponent(d.id)}`;
|
||||
}
|
||||
|
||||
export class DeviceApi {
|
||||
/**
|
||||
* Get the list of pending devices
|
||||
@ -72,6 +84,29 @@ export class DeviceApi {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the information about a single device
|
||||
*/
|
||||
static async GetSingle(id: string): Promise<Device> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
uri: `/device/${encodeURIComponent(id)}`,
|
||||
method: "GET",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a device general information
|
||||
*/
|
||||
static async Update(d: Device, info: UpdatedInfo): Promise<void> {
|
||||
await APIClient.exec({
|
||||
uri: `/device/${encodeURIComponent(d.id)}`,
|
||||
method: "PATCH",
|
||||
jsonData: info,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a device
|
||||
*/
|
||||
|
41
central_frontend/src/api/RelayApi.ts
Normal file
41
central_frontend/src/api/RelayApi.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
import { Device, DeviceRelay } from "./DeviceApi";
|
||||
|
||||
export class RelayApi {
|
||||
/**
|
||||
* Get the full list of relays
|
||||
*/
|
||||
static async GetList(): Promise<DeviceRelay[]> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
method: "GET",
|
||||
uri: "/relays/list",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new relay
|
||||
*/
|
||||
static async Create(device: Device, relay: DeviceRelay): Promise<void> {
|
||||
await APIClient.exec({
|
||||
method: "POST",
|
||||
uri: "/relay/create",
|
||||
jsonData: {
|
||||
...relay,
|
||||
device_id: device.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a relay information
|
||||
*/
|
||||
static async Update(relay: DeviceRelay): Promise<void> {
|
||||
await APIClient.exec({
|
||||
method: "PUT",
|
||||
uri: `/relay/${relay.id}`,
|
||||
jsonData: relay,
|
||||
});
|
||||
}
|
||||
}
|
@ -2,6 +2,23 @@ import { APIClient } from "./ApiClient";
|
||||
|
||||
export interface ServerConfig {
|
||||
auth_disabled: boolean;
|
||||
constraints: ServerConstraint;
|
||||
}
|
||||
|
||||
export interface ServerConstraint {
|
||||
dev_name_len: LenConstraint;
|
||||
dev_description_len: LenConstraint;
|
||||
relay_name_len: LenConstraint;
|
||||
relay_priority: LenConstraint;
|
||||
relay_consumption: LenConstraint;
|
||||
relay_minimal_uptime: LenConstraint;
|
||||
relay_minimal_downtime: LenConstraint;
|
||||
relay_daily_minimal_runtime: LenConstraint;
|
||||
}
|
||||
|
||||
export interface LenConstraint {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
let config: ServerConfig | null = null;
|
||||
|
87
central_frontend/src/dialogs/EditDeviceMetadataDialog.tsx
Normal file
87
central_frontend/src/dialogs/EditDeviceMetadataDialog.tsx
Normal file
@ -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 (
|
||||
<Dialog open>
|
||||
<DialogTitle>Edit device general information</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextInput
|
||||
editable
|
||||
label="Device name"
|
||||
value={name}
|
||||
onValueChange={(s) => setName(s ?? "")}
|
||||
size={ServerApi.Config.constraints.dev_name_len}
|
||||
/>
|
||||
<TextInput
|
||||
editable
|
||||
label="Device description"
|
||||
value={description}
|
||||
onValueChange={(s) => setDescription(s ?? "")}
|
||||
size={ServerApi.Config.constraints.dev_description_len}
|
||||
/>
|
||||
<CheckboxInput
|
||||
editable
|
||||
label="Enable device"
|
||||
checked={enabled}
|
||||
onValueChange={setEnabled}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onClose}>Cancel</Button>
|
||||
<Button onClick={onSubmit} autoFocus disabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
332
central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx
Normal file
332
central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { TimePicker } from "@mui/x-date-pickers";
|
||||
import React from "react";
|
||||
import { Device, DeviceRelay } from "../api/DeviceApi";
|
||||
import { RelayApi } from "../api/RelayApi";
|
||||
import { ServerApi } from "../api/ServerApi";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
import { dayjsToTimeOfDay, timeOfDay } from "../utils/DateUtils";
|
||||
import { lenValid } from "../utils/StringsUtils";
|
||||
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
|
||||
import { MultipleSelectInput } from "../widgets/forms/MultipleSelectInput";
|
||||
import { SelectMultipleRelaysInput } from "../widgets/forms/SelectMultipleRelaysInput";
|
||||
import { TextInput } from "../widgets/forms/TextInput";
|
||||
|
||||
export function EditDeviceRelaysDialog(p: {
|
||||
onClose: () => void;
|
||||
relay?: DeviceRelay;
|
||||
device: Device;
|
||||
onUpdated: () => void;
|
||||
}): React.ReactElement {
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [relay, setRelay] = React.useState<DeviceRelay>(
|
||||
p.relay ?? {
|
||||
id: "",
|
||||
name: "relay",
|
||||
enabled: false,
|
||||
priority: 1,
|
||||
consumption: 500,
|
||||
minimal_downtime: 60 * 5,
|
||||
minimal_uptime: 60 * 5,
|
||||
depends_on: [],
|
||||
conflicts_with: [],
|
||||
}
|
||||
);
|
||||
|
||||
const creating = !p.relay;
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
loadingMessage.show(
|
||||
`${creating ? "Creating" : "Updating"} relay information`
|
||||
);
|
||||
|
||||
if (creating) await RelayApi.Create(p.device, relay);
|
||||
else await RelayApi.Update(relay);
|
||||
|
||||
snackbar(
|
||||
`The relay have been successfully ${creating ? "created" : "updated"}!`
|
||||
);
|
||||
p.onUpdated();
|
||||
} catch (e) {
|
||||
console.error("Failed to update device relay information!" + e);
|
||||
alert(`Failed to ${creating ? "create" : "update"} relay! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
lenValid(relay.name, ServerApi.Config.constraints.relay_name_len) &&
|
||||
relay.priority >= 0;
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogTitle>
|
||||
{creating ? "Create a new relay" : "Edit relay information"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogFormTitle>General info</DialogFormTitle>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Relay name"
|
||||
value={relay.name}
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
name: v ?? "",
|
||||
};
|
||||
})
|
||||
}
|
||||
size={ServerApi.Config.constraints.dev_name_len}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<CheckboxInput
|
||||
editable
|
||||
label="Enable relay"
|
||||
checked={relay.enabled}
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
enabled: v,
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Priority"
|
||||
value={relay.priority.toString()}
|
||||
type="number"
|
||||
onValueChange={(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"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Consumption"
|
||||
value={relay.consumption.toString()}
|
||||
type="number"
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
consumption: Number(v) ?? 0,
|
||||
};
|
||||
})
|
||||
}
|
||||
size={ServerApi.Config.constraints.relay_consumption}
|
||||
helperText="Estimated consumption of device powered by relay"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Minimal uptime"
|
||||
value={relay.minimal_uptime.toString()}
|
||||
type="number"
|
||||
onValueChange={(v) =>
|
||||
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)"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Minimal downtime"
|
||||
value={relay.minimal_downtime.toString()}
|
||||
type="number"
|
||||
onValueChange={(v) =>
|
||||
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)"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<DialogFormTitle>Daily runtime</DialogFormTitle>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<CheckboxInput
|
||||
editable
|
||||
label="Enable minimal runtime"
|
||||
checked={!!relay.daily_runtime}
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
daily_runtime: v
|
||||
? { reset_time: 0, min_runtime: 3600, catch_up_hours: [] }
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{!!relay.daily_runtime && (
|
||||
<>
|
||||
<Grid item xs={6}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Minimal daily runtime"
|
||||
value={relay.daily_runtime!.min_runtime.toString()}
|
||||
type="number"
|
||||
onValueChange={(v) =>
|
||||
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"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TimePicker
|
||||
label="Reset time"
|
||||
value={timeOfDay(relay.daily_runtime!.reset_time)}
|
||||
onChange={(d) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
daily_runtime: {
|
||||
...r.daily_runtime!,
|
||||
reset_time: d ? dayjsToTimeOfDay(d) : 0,
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<MultipleSelectInput
|
||||
label="Catchup hours"
|
||||
helperText="The hours during which the relay should be turned on to reach expected runtime"
|
||||
values={Array.apply(null, Array(24)).map((_y, i) => {
|
||||
return {
|
||||
label: `${i.toString().padStart(2, "0")}:00`,
|
||||
value: i,
|
||||
};
|
||||
})}
|
||||
selected={relay.daily_runtime!.catch_up_hours}
|
||||
onChange={(d) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
daily_runtime: {
|
||||
...r.daily_runtime!,
|
||||
catch_up_hours: d,
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<DialogFormTitle>Constraints</DialogFormTitle>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<SelectMultipleRelaysInput
|
||||
label="Required relays"
|
||||
exclude={[relay.id]}
|
||||
value={relay.depends_on}
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
depends_on: v,
|
||||
};
|
||||
})
|
||||
}
|
||||
helperText="Relays that must be already up for this relay to be started"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<SelectMultipleRelaysInput
|
||||
label="Conflicting relays"
|
||||
exclude={[relay.id]}
|
||||
value={relay.conflicts_with}
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
conflicts_with: v,
|
||||
};
|
||||
})
|
||||
}
|
||||
helperText="Relays that must be off before this relay can be started"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onClose}>Cancel</Button>
|
||||
<Button onClick={onSubmit} autoFocus disabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFormTitle(p: React.PropsWithChildren): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<span style={{ height: "2px" }}></span>
|
||||
<Typography variant="h6">{p.children}</Typography>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<DarkThemeProvider>
|
||||
<AlertDialogProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<SnackbarProvider>
|
||||
<LoadingMessageProvider>
|
||||
<AsyncWidget
|
||||
loadKey={1}
|
||||
load={async () => await ServerApi.LoadConfig()}
|
||||
errMsg="Failed to connect to backend to retrieve static config!"
|
||||
build={() => <App />}
|
||||
/>
|
||||
</LoadingMessageProvider>
|
||||
</SnackbarProvider>
|
||||
</ConfirmDialogProvider>
|
||||
</AlertDialogProvider>
|
||||
</DarkThemeProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<DarkThemeProvider>
|
||||
<AlertDialogProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<SnackbarProvider>
|
||||
<LoadingMessageProvider>
|
||||
<AsyncWidget
|
||||
loadKey={1}
|
||||
load={async () => await ServerApi.LoadConfig()}
|
||||
errMsg="Failed to connect to backend to retrieve static config!"
|
||||
build={() => <App />}
|
||||
/>
|
||||
</LoadingMessageProvider>
|
||||
</SnackbarProvider>
|
||||
</ConfirmDialogProvider>
|
||||
</AlertDialogProvider>
|
||||
</DarkThemeProvider>
|
||||
</LocalizationProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
50
central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx
Normal file
50
central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx
Normal file
@ -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<DeviceRelay | undefined>();
|
||||
|
||||
const createNewRelay = () => {
|
||||
setDialogOpen(true);
|
||||
setCurrRelay(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogOpen && (
|
||||
<EditDeviceRelaysDialog
|
||||
device={p.device}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
relay={currRelay}
|
||||
onUpdated={() => {
|
||||
setDialogOpen(false);
|
||||
p.onReload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DeviceRouteCard
|
||||
title="Device relays"
|
||||
actions={
|
||||
<Tooltip title="Create new relay">
|
||||
<IconButton
|
||||
onClick={createNewRelay}
|
||||
disabled={p.device.relays.length >= p.device.info.max_relays}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
TODO : relays list ({p.device.relays.length}) relays now)
|
||||
</DeviceRouteCard>
|
||||
</>
|
||||
);
|
||||
}
|
93
central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx
Normal file
93
central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { Grid, IconButton, Tooltip } from "@mui/material";
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Device, DeviceApi } from "../../api/DeviceApi";
|
||||
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||
import { AsyncWidget } from "../../widgets/AsyncWidget";
|
||||
import { SolarEnergyRouteContainer } from "../../widgets/SolarEnergyRouteContainer";
|
||||
import { GeneralDeviceInfo } from "./GeneralDeviceInfo";
|
||||
import { DeviceRelays } from "./DeviceRelays";
|
||||
|
||||
export function DeviceRoute(): React.ReactElement {
|
||||
const { id } = useParams();
|
||||
const [device, setDevice] = React.useState<Device | undefined>();
|
||||
|
||||
const loadKey = React.useRef(1);
|
||||
|
||||
const load = async () => {
|
||||
setDevice(await DeviceApi.GetSingle(id!));
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
loadKey.current += 1;
|
||||
setDevice(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={loadKey.current}
|
||||
errMsg="Failed to load device information"
|
||||
load={load}
|
||||
ready={!!device}
|
||||
build={() => <DeviceRouteInner device={device!} onReload={reload} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SolarEnergyRouteContainer
|
||||
label={`Device ${p.device.name}`}
|
||||
actions={
|
||||
<Tooltip title="Delete device">
|
||||
<IconButton onClick={() => deleteDevice(p.device)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<GeneralDeviceInfo {...p} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<DeviceRelays {...p} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SolarEnergyRouteContainer>
|
||||
);
|
||||
}
|
24
central_frontend/src/routes/DeviceRoute/DeviceRouteCard.tsx
Normal file
24
central_frontend/src/routes/DeviceRoute/DeviceRouteCard.tsx
Normal file
@ -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 (
|
||||
<Card component={Paper}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" style={{ padding: "6px" }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
{p.actions}
|
||||
</div>
|
||||
{p.children}
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import {
|
||||
IconButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { Device } from "../../api/DeviceApi";
|
||||
import { EditDeviceMetadataDialog } from "../../dialogs/EditDeviceMetadataDialog";
|
||||
import { formatDate } from "../../widgets/TimeWidget";
|
||||
import { DeviceRouteCard } from "./DeviceRouteCard";
|
||||
|
||||
export function GeneralDeviceInfo(p: {
|
||||
device: Device;
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogOpen && (
|
||||
<EditDeviceMetadataDialog
|
||||
device={p.device}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onUpdated={p.onReload}
|
||||
/>
|
||||
)}
|
||||
<DeviceRouteCard
|
||||
title="General device information"
|
||||
actions={
|
||||
<Tooltip title="Edit device information">
|
||||
<IconButton onClick={() => setDialogOpen(true)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<DeviceInfoProperty label="ID" value={p.device.id} />
|
||||
<DeviceInfoProperty
|
||||
label="Reference"
|
||||
value={p.device.info.reference}
|
||||
/>
|
||||
<DeviceInfoProperty label="Version" value={p.device.info.version} />
|
||||
<DeviceInfoProperty label="Name" value={p.device.name} />
|
||||
<DeviceInfoProperty
|
||||
label="Description"
|
||||
value={p.device.description}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Created"
|
||||
value={formatDate(p.device.time_create)}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Updated"
|
||||
value={formatDate(p.device.time_update)}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Enabled"
|
||||
value={p.device.enabled ? "YES" : "NO"}
|
||||
color={p.device.enabled ? "green" : "red"}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Maximum number of relays"
|
||||
value={p.device.info.max_relays.toString()}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Number of configured relays"
|
||||
value={p.device.relays.length.toString()}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DeviceRouteCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceInfoProperty(p: {
|
||||
icon?: React.ReactElement;
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<TableRow hover sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<TableCell>{p.label}</TableCell>
|
||||
<TableCell style={{ color: p.color }}>{p.value}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import {
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
@ -8,18 +9,14 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { Device, DeviceApi } from "../api/DeviceApi";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Device, DeviceApi, DeviceURL } from "../api/DeviceApi";
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import { TimeWidget } from "../widgets/TimeWidget";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
|
||||
export function DevicesRoute(): React.ReactElement {
|
||||
const loadKey = React.useRef(1);
|
||||
@ -61,32 +58,7 @@ function ValidatedDevicesList(p: {
|
||||
list: Device[];
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
const deleteDevice = async (d: Device) => {
|
||||
try {
|
||||
if (
|
||||
!(await confirm(
|
||||
`Do you really want to delete the device ${d.id}? The operation cannot be reverted!`
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
loadingMessage.show("Deleting device...");
|
||||
await DeviceApi.Delete(d);
|
||||
|
||||
snackbar("The device has been successfully deleted!");
|
||||
p.onReload();
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete device! ${e})`);
|
||||
alert("Failed to delete device!");
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (p.list.length === 0) {
|
||||
return <p>There is no device validated yet.</p>;
|
||||
@ -108,7 +80,11 @@ function ValidatedDevicesList(p: {
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{p.list.map((dev) => (
|
||||
<TableRow key={dev.id}>
|
||||
<TableRow
|
||||
hover
|
||||
key={dev.id}
|
||||
onDoubleClick={() => navigate(DeviceURL(dev))}
|
||||
>
|
||||
<TableCell component="th" scope="row">
|
||||
{dev.id}
|
||||
</TableCell>
|
||||
@ -122,10 +98,12 @@ function ValidatedDevicesList(p: {
|
||||
<TimeWidget time={dev.time_update} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title="Delete device">
|
||||
<IconButton onClick={() => deleteDevice(dev)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<Tooltip title="Open device page">
|
||||
<Link to={DeviceURL(dev)}>
|
||||
<IconButton>
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
8
central_frontend/src/utils/StringsUtils.ts
Normal file
8
central_frontend/src/utils/StringsUtils.ts
Normal file
@ -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;
|
||||
}
|
21
central_frontend/src/widgets/forms/CheckboxInput.tsx
Normal file
21
central_frontend/src/widgets/forms/CheckboxInput.tsx
Normal file
@ -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 (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
disabled={!p.editable}
|
||||
checked={p.checked}
|
||||
onChange={(e) => p.onValueChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={p.label}
|
||||
/>
|
||||
);
|
||||
}
|
113
central_frontend/src/widgets/forms/MultipleSelectInput.tsx
Normal file
113
central_frontend/src/widgets/forms/MultipleSelectInput.tsx
Normal file
@ -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<E> {
|
||||
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<E>(v: Value<E>, selected: readonly E[], theme: Theme) {
|
||||
return {
|
||||
fontWeight:
|
||||
selected.find((e) => e === v.value) === undefined
|
||||
? theme.typography.fontWeightRegular
|
||||
: theme.typography.fontWeightMedium,
|
||||
};
|
||||
}
|
||||
|
||||
export function MultipleSelectInput<E>(p: {
|
||||
values: Value<E>[];
|
||||
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<E>) => {
|
||||
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<E>();
|
||||
for (const el of newVals) {
|
||||
if (!setVal.has(el)) setVal.add(el);
|
||||
else setVal.delete(el);
|
||||
}
|
||||
|
||||
p.onChange([...setVal]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id={labelId}>{p.label}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
labelId={labelId}
|
||||
id="bad"
|
||||
label={p.label}
|
||||
value={p.selected as any}
|
||||
onChange={handleChange}
|
||||
input={<OutlinedInput id="select-multiple-chip" label="Chip" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{(selected as Array<E>).map((value) => (
|
||||
<Chip
|
||||
key={String(value)}
|
||||
label={p.values.find((e) => e.value === value)!.label}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
MenuProps={MenuProps}
|
||||
>
|
||||
{p.values.map((v) => (
|
||||
<MenuItem
|
||||
key={v.label + String(v)}
|
||||
value={String(v.value)}
|
||||
style={getStyles(v, p.selected, theme)}
|
||||
>
|
||||
{v.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{p.helperText && <FormHelperText>{p.helperText}</FormHelperText>}
|
||||
</FormControl>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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<DeviceRelay[]>();
|
||||
|
||||
const load = async () => {
|
||||
setList(await RelayApi.GetList());
|
||||
};
|
||||
|
||||
const values =
|
||||
list?.map((r) => {
|
||||
return {
|
||||
label: r.name,
|
||||
value: r.id,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={1}
|
||||
load={load}
|
||||
errMsg="Failed to load the list of relays!"
|
||||
build={() => (
|
||||
<MultipleSelectInput
|
||||
label={p.label}
|
||||
onChange={p.onValueChange}
|
||||
selected={p.value}
|
||||
helperText={p.helperText}
|
||||
values={values}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
61
central_frontend/src/widgets/forms/TextInput.tsx
Normal file
61
central_frontend/src/widgets/forms/TextInput.tsx
Normal file
@ -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 (
|
||||
<TextField
|
||||
label={p.label}
|
||||
value={p.value ?? ""}
|
||||
onChange={(e) =>
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user