Can update device general information
This commit is contained in:
parent
baf341d505
commit
4d5ba939d1
@ -18,3 +18,40 @@ pub const MAX_SESSION_DURATION: u64 = 3600 * 24;
|
|||||||
/// List of routes that do not require authentication
|
/// List of routes that do not require authentication
|
||||||
pub const ROUTES_WITHOUT_AUTH: [&str; 2] =
|
pub const ROUTES_WITHOUT_AUTH: [&str; 2] =
|
||||||
["/web_api/server/config", "/web_api/auth/password_auth"];
|
["/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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
//! # Devices entities definition
|
//! # Devices entities definition
|
||||||
|
|
||||||
|
use crate::constants::StaticConstraints;
|
||||||
|
|
||||||
/// Device information provided directly by the device during syncrhonisation.
|
/// Device information provided directly by the device during syncrhonisation.
|
||||||
///
|
///
|
||||||
/// It should not be editable fro the Web UI
|
/// 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
|
/// Specify relays that must be turned off before this relay can be started
|
||||||
conflicts_with: Vec<DeviceRelayID>,
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
use crate::app_config::AppConfig;
|
use crate::app_config::AppConfig;
|
||||||
use crate::crypto::pki;
|
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 crate::utils::time_utils::time_secs;
|
||||||
use openssl::x509::{X509Req, X509};
|
use openssl::x509::{X509Req, X509};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@ -15,6 +15,8 @@ pub enum DevicesListError {
|
|||||||
ValidateDeviceFailedDeviceNotFound,
|
ValidateDeviceFailedDeviceNotFound,
|
||||||
#[error("Validated device failed: the device is already validated!")]
|
#[error("Validated device failed: the device is already validated!")]
|
||||||
ValidateDeviceFailedDeviceAlreadyValidated,
|
ValidateDeviceFailedDeviceAlreadyValidated,
|
||||||
|
#[error("Update device failed: the device does not exists!")]
|
||||||
|
UpdateDeviceFailedDeviceNotFound,
|
||||||
#[error("Requested device was not found")]
|
#[error("Requested device was not found")]
|
||||||
DeviceNotFound,
|
DeviceNotFound,
|
||||||
#[error("Requested device is not validated")]
|
#[error("Requested device is not validated")]
|
||||||
@ -133,6 +135,27 @@ impl DevicesList {
|
|||||||
Ok(())
|
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
|
/// Get single certificate information
|
||||||
fn get_cert(&self, id: &DeviceId) -> anyhow::Result<X509> {
|
fn get_cert(&self, id: &DeviceId) -> anyhow::Result<X509> {
|
||||||
let dev = self
|
let dev = self
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
use crate::constants;
|
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::devices::devices_list::DevicesList;
|
||||||
use crate::energy::consumption;
|
use crate::energy::consumption;
|
||||||
use crate::energy::consumption::EnergyConsumption;
|
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
|
/// Delete a device
|
||||||
#[derive(Message)]
|
#[derive(Message)]
|
||||||
#[rtype(result = "anyhow::Result<()>")]
|
#[rtype(result = "anyhow::Result<()>")]
|
||||||
|
@ -147,6 +147,10 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
|||||||
"/web_api/device/{id}/validate",
|
"/web_api/device/{id}/validate",
|
||||||
web::post().to(devices_controller::validate_device),
|
web::post().to(devices_controller::validate_device),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/web_api/device/{id}",
|
||||||
|
web::patch().to(devices_controller::update_device),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/web_api/device/{id}",
|
"/web_api/device/{id}",
|
||||||
web::delete().to(devices_controller::delete_device),
|
web::delete().to(devices_controller::delete_device),
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use crate::devices::device::DeviceId;
|
use crate::devices::device::{DeviceGeneralInfo, DeviceId};
|
||||||
use crate::energy::energy_actor;
|
use crate::energy::energy_actor;
|
||||||
use crate::server::custom_error::HttpResult;
|
use crate::server::custom_error::HttpResult;
|
||||||
use crate::server::WebEnergyActor;
|
use crate::server::WebEnergyActor;
|
||||||
@ -54,6 +54,26 @@ pub async fn validate_device(actor: WebEnergyActor, id: web::Path<DeviceInPath>)
|
|||||||
Ok(HttpResponse::Accepted().finish())
|
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
|
/// Delete a device
|
||||||
pub async fn delete_device(actor: WebEnergyActor, id: web::Path<DeviceInPath>) -> HttpResult {
|
pub async fn delete_device(actor: WebEnergyActor, id: web::Path<DeviceInPath>) -> HttpResult {
|
||||||
actor
|
actor
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
use crate::app_config::AppConfig;
|
use crate::app_config::AppConfig;
|
||||||
|
use crate::constants::StaticConstraints;
|
||||||
use actix_web::HttpResponse;
|
use actix_web::HttpResponse;
|
||||||
|
|
||||||
pub async fn secure_home() -> HttpResponse {
|
pub async fn secure_home() -> HttpResponse {
|
||||||
@ -10,12 +11,14 @@ pub async fn secure_home() -> HttpResponse {
|
|||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct ServerConfig {
|
struct ServerConfig {
|
||||||
auth_disabled: bool,
|
auth_disabled: bool,
|
||||||
|
constraints: StaticConstraints,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
auth_disabled: AppConfig::get().unsecure_disable_login,
|
auth_disabled: AppConfig::get().unsecure_disable_login,
|
||||||
|
constraints: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ import { HomeRoute } from "./routes/HomeRoute";
|
|||||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
||||||
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
|
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
|
||||||
import { DevicesRoute } from "./routes/DevicesRoute";
|
import { DevicesRoute } from "./routes/DevicesRoute";
|
||||||
import { DeviceRoute } from "./routes/DeviceRoute";
|
import { DeviceRoute } from "./routes/DeviceRoute/DeviceRoute";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
||||||
|
@ -37,6 +37,12 @@ export interface Device {
|
|||||||
relays: DeviceRelay[];
|
relays: DeviceRelay[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdatedInfo {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function DeviceURL(d: Device): string {
|
export function DeviceURL(d: Device): string {
|
||||||
return `/dev/${encodeURIComponent(d.id)}`;
|
return `/dev/${encodeURIComponent(d.id)}`;
|
||||||
}
|
}
|
||||||
@ -88,6 +94,17 @@ export class DeviceApi {
|
|||||||
).data;
|
).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
|
* Delete a device
|
||||||
*/
|
*/
|
||||||
|
@ -2,6 +2,17 @@ import { APIClient } from "./ApiClient";
|
|||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
auth_disabled: boolean;
|
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;
|
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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
85
central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx
Normal file
85
central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
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,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>
|
||||||
|
);
|
||||||
|
}
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
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