Can update device general information

This commit is contained in:
Pierre HUBERT 2024-07-22 22:19:48 +02:00
parent baf341d505
commit 4d5ba939d1
18 changed files with 546 additions and 147 deletions

View File

@ -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),
}
}
}

View File

@ -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<DeviceRelayID>,
}
/// 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
}
}

View File

@ -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<X509> {
let dev = self

View File

@ -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<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<()>")]

View File

@ -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),

View File

@ -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<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

View File

@ -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(),
}
}
}

View File

@ -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)

View File

@ -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<void> {
await APIClient.exec({
uri: `/device/${encodeURIComponent(d.id)}`,
method: "PATCH",
jsonData: info,
});
}
/**
* Delete a device
*/

View File

@ -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;

View 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>
);
}

View File

@ -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<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 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 (
<SolarEnergyRouteContainer
label={`Device ${p.device.name}`}
actions={
<Tooltip title="Delete device">
<IconButton onClick={() => deleteDevice(p.device)}>
<DeleteIcon />
</IconButton>
</Tooltip>
}
>
<GeneralDeviceInfo {...p} />
</SolarEnergyRouteContainer>
);
}
function GeneralDeviceInfo(p: { device: Device }): React.ReactElement {
return (
<TableContainer component={Paper}>
<Typography variant="h6" style={{ padding: "6px" }}>
General device information
</Typography>
<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="Enabled"
value={p.device.enabled ? "YES" : "NO"}
/>
<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>
</TableContainer>
);
}
function DeviceInfoProperty(p: {
icon?: React.ReactElement;
label: string;
value: string;
}): React.ReactElement {
return (
<TableRow hover sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
<TableCell>{p.label}</TableCell>
<TableCell>{p.value}</TableCell>
</TableRow>
);
}

View File

@ -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<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>
}
>
<GeneralDeviceInfo {...p} />
</SolarEnergyRouteContainer>
);
}

View 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>
);
}

View File

@ -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 && (
<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"}
/>
<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;
}): React.ReactElement {
return (
<TableRow hover sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
<TableCell>{p.label}</TableCell>
<TableCell>{p.value}</TableCell>
</TableRow>
);
}

View 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;
}

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

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