Display the list of pending devices in the UI

This commit is contained in:
Pierre HUBERT 2024-07-03 19:17:47 +02:00
parent 01ffe085d7
commit 716af6219a
14 changed files with 301 additions and 17 deletions

View File

@ -81,7 +81,7 @@ impl DevicesList {
let dev = self let dev = self
.0 .0
.get(id) .get(id)
.ok_or_else(|| DevicesListError::PersistFailedDeviceNotFound)?; .ok_or(DevicesListError::PersistFailedDeviceNotFound)?;
std::fs::write( std::fs::write(
AppConfig::get().device_config_path(id), AppConfig::get().device_config_path(id),
@ -90,4 +90,9 @@ impl DevicesList {
Ok(()) Ok(())
} }
/// Get a copy of the full list of devices
pub fn full_list(&self) -> Vec<Device> {
self.0.clone().into_values().collect()
}
} }

View File

@ -1,5 +1,5 @@
use crate::constants; use crate::constants;
use crate::devices::device::{DeviceId, DeviceInfo}; use crate::devices::device::{Device, 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;
@ -93,3 +93,16 @@ impl Handler<EnrollDevice> for EnergyActor {
self.devices.enroll(&msg.0, &msg.1, &msg.2) self.devices.enroll(&msg.0, &msg.1, &msg.2)
} }
} }
/// Get the list of devices
#[derive(Message)]
#[rtype(result = "Vec<Device>")]
pub struct GetDeviceLists;
impl Handler<GetDeviceLists> for EnergyActor {
type Result = Vec<Device>;
fn handle(&mut self, _msg: GetDeviceLists, _ctx: &mut Context<Self>) -> Self::Result {
self.devices.full_list()
}
}

View File

@ -131,6 +131,14 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
"/web_api/energy/cached_consumption", "/web_api/energy/cached_consumption",
web::get().to(energy_controller::cached_consumption), web::get().to(energy_controller::cached_consumption),
) )
.route(
"/web_api/devices/list_pending",
web::get().to(devices_controller::list_pending),
)
.route(
"/web_api/devices/list_validated",
web::get().to(devices_controller::list_validated),
)
// Devices API // Devices API
.route( .route(
"/devices_api/utils/time", "/devices_api/utils/time",

View File

@ -0,0 +1,28 @@
use crate::energy::energy_actor;
use crate::server::custom_error::HttpResult;
use crate::server::WebEnergyActor;
use actix_web::HttpResponse;
/// Get the list of pending (not accepted yet) devices
pub async fn list_pending(actor: WebEnergyActor) -> HttpResult {
let list = actor
.send(energy_actor::GetDeviceLists)
.await?
.into_iter()
.filter(|d| !d.validated)
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(list))
}
/// Get the list of validated (not accepted yet) devices
pub async fn list_validated(actor: WebEnergyActor) -> HttpResult {
let list = actor
.send(energy_actor::GetDeviceLists)
.await?
.into_iter()
.filter(|d| d.validated)
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(list))
}

View File

@ -1,3 +1,4 @@
pub mod auth_controller; pub mod auth_controller;
pub mod devices_controller;
pub mod energy_controller; pub mod energy_controller;
pub mod server_controller; pub mod server_controller;

View File

@ -15,6 +15,7 @@
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@mui/icons-material": "^5.15.21", "@mui/icons-material": "^5.15.21",
"@mui/material": "^5.15.21", "@mui/material": "^5.15.21",
"date-and-time": "^3.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.24.0" "react-router-dom": "^6.24.0"
@ -2205,6 +2206,11 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
}, },
"node_modules/date-and-time": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.3.0.tgz",
"integrity": "sha512-UguWfh9LkUecVrGSE0B7SpAnGRMPATmpwSoSij24/lDnwET3A641abfDBD/TdL0T+E04f8NWlbMkD9BscVvIZg=="
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.5", "version": "4.3.5",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",

View File

@ -17,6 +17,7 @@
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@mui/icons-material": "^5.15.21", "@mui/icons-material": "^5.15.21",
"@mui/material": "^5.15.21", "@mui/material": "^5.15.21",
"date-and-time": "^3.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.24.0" "react-router-dom": "^6.24.0"

View File

@ -10,6 +10,7 @@ import { LoginRoute } from "./routes/LoginRoute";
import { NotFoundRoute } from "./routes/NotFoundRoute"; import { NotFoundRoute } from "./routes/NotFoundRoute";
import { HomeRoute } from "./routes/HomeRoute"; import { HomeRoute } from "./routes/HomeRoute";
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
export function App() { export function App() {
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled) if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
@ -19,7 +20,7 @@ export function App() {
createRoutesFromElements( createRoutesFromElements(
<Route path="*" element={<BaseAuthenticatedPage />}> <Route path="*" element={<BaseAuthenticatedPage />}>
<Route path="" element={<HomeRoute />} /> <Route path="" element={<HomeRoute />} />
<Route path="pending_devices" element={<PendingDevicesRoute />} />
<Route path="*" element={<NotFoundRoute />} /> <Route path="*" element={<NotFoundRoute />} />
</Route> </Route>
) )

View File

@ -0,0 +1,52 @@
import { APIClient } from "./ApiClient";
export interface DeviceInfo {
reference: string;
version: string;
max_relays: number;
}
export interface DailyMinRuntime {
min_runtime: number;
reset_time: number;
catch_up_hours: number[];
}
export interface DeviceRelay {
id: string;
name: string;
enabled: boolean;
priority: number;
consumption: number;
minimal_uptime: number;
minimal_downtime: number;
daily_runtime?: DailyMinRuntime;
depends_on: DeviceRelay[];
conflicts_with: DeviceRelay[];
}
export interface Device {
id: string;
info: DeviceInfo;
time_create: number;
time_update: number;
name: string;
description: string;
validated: boolean;
enabled: boolean;
relays: DeviceRelay[];
}
export class DeviceApi {
/**
* Get the list of pending devices
*/
static async PendingList(): Promise<Device[]> {
return (
await APIClient.exec({
uri: "/devices/list_pending",
method: "GET",
})
).data;
}
}

View File

@ -0,0 +1,79 @@
import React from "react";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
import { Device, DeviceApi } from "../api/DeviceApi";
import {
TableContainer,
Paper,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
} from "@mui/material";
import { TimeWidget } from "../widgets/TimeWidget";
export function PendingDevicesRoute(): React.ReactElement {
const loadKey = React.useRef(1);
const [pending, setPending] = React.useState<Device[] | undefined>();
const load = async () => {
setPending(await DeviceApi.PendingList());
};
const reload = () => {
loadKey.current += 1;
setPending(undefined);
};
return (
<SolarEnergyRouteContainer label="Pending devices">
<AsyncWidget
loadKey={loadKey.current}
ready={!!pending}
errMsg="Failed to load the list of pending devices!"
load={load}
build={() => (
<PendingDevicesList onReload={reload} pending={pending!} />
)}
/>
</SolarEnergyRouteContainer>
);
}
function PendingDevicesList(p: {
pending: Device[];
onReload: () => void;
}): React.ReactElement {
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>#</TableCell>
<TableCell>Model</TableCell>
<TableCell>Version</TableCell>
<TableCell>Maximum number of relays</TableCell>
<TableCell>Created</TableCell>
</TableRow>
</TableHead>
<TableBody>
{p.pending.map((dev) => (
<TableRow key={dev.id}>
<TableCell component="th" scope="row">
{dev.id}
</TableCell>
<TableCell>{dev.info.reference}</TableCell>
<TableCell>{dev.info.version}</TableCell>
<TableCell>{dev.info.max_relays}</TableCell>
<TableCell>
<TimeWidget time={dev.time_create} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

View File

@ -0,0 +1,6 @@
/**
* Get current UNIX time, in seconds
*/
export function time(): number {
return Math.floor(new Date().getTime() / 1000);
}

View File

@ -1,14 +1,4 @@
import { import { mdiHome, mdiNewBox } from "@mdi/js";
mdiAccountMultiple,
mdiAccountMusic,
mdiAlbum,
mdiApi,
mdiChartLine,
mdiCog,
mdiHome,
mdiInbox,
mdiMusic,
} from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import { import {
List, List,
@ -16,14 +6,11 @@ import {
ListItemIcon, ListItemIcon,
ListItemSecondaryAction, ListItemSecondaryAction,
ListItemText, ListItemText,
ListSubheader,
} from "@mui/material"; } from "@mui/material";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useAuthInfo } from "./BaseAuthenticatedPage";
import { RouterLink } from "./RouterLink"; import { RouterLink } from "./RouterLink";
export function SolarEnergyNavList(): React.ReactElement { export function SolarEnergyNavList(): React.ReactElement {
const user = useAuthInfo().info;
return ( return (
<List <List
dense dense
@ -34,6 +21,11 @@ export function SolarEnergyNavList(): React.ReactElement {
}} }}
> >
<NavLink label="Home" uri="/" icon={<Icon path={mdiHome} size={1} />} /> <NavLink label="Home" uri="/" icon={<Icon path={mdiHome} size={1} />} />
<NavLink
label="Pending devices"
uri="/pending_devices"
icon={<Icon path={mdiNewBox} size={1} />}
/>
</List> </List>
); );
} }

View File

@ -0,0 +1,27 @@
import { Typography } from "@mui/material";
import React, { PropsWithChildren } from "react";
export function SolarEnergyRouteContainer(
p: {
label: string;
actions?: React.ReactElement;
} & PropsWithChildren
): React.ReactElement {
return (
<div style={{ margin: "50px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
}}
>
<Typography variant="h4">{p.label}</Typography>
{p.actions ?? <></>}
</div>
{p.children}
</div>
);
}

View File

@ -0,0 +1,65 @@
import { Tooltip } from "@mui/material";
import date from "date-and-time";
import { time } from "../utils/DateUtils";
export function formatDate(time: number): string {
const t = new Date();
t.setTime(1000 * time);
return date.format(t, "DD/MM/YYYY HH:mm:ss");
}
export function timeDiff(a: number, b: number): string {
let diff = b - a;
if (diff === 0) return "now";
if (diff === 1) return "1 second";
if (diff < 60) {
return `${diff} seconds`;
}
diff = Math.floor(diff / 60);
if (diff === 1) return "1 minute";
if (diff < 24) {
return `${diff} minutes`;
}
diff = Math.floor(diff / 60);
if (diff === 1) return "1 hour";
if (diff < 24) {
return `${diff} hours`;
}
const diffDays = Math.floor(diff / 24);
if (diffDays === 1) return "1 day";
if (diffDays < 31) {
return `${diffDays} days`;
}
diff = Math.floor(diffDays / 31);
if (diff < 12) {
return `${diff} month`;
}
const diffYears = Math.floor(diffDays / 365);
if (diffYears === 1) return "1 year";
return `${diffYears} years`;
}
export function timeDiffFromNow(t: number): string {
return timeDiff(t, time());
}
export function TimeWidget(p: { time?: number }): React.ReactElement {
if (!p.time) return <></>;
return (
<Tooltip title={formatDate(p.time)} arrow>
<span>{timeDiffFromNow(p.time)}</span>
</Tooltip>
);
}