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} + /> + ); +}