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
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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<()>")]
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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;
|
||||
|
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