Merge branch 'master' of http://10.0.0.10/pierre/SolarEnergy
This commit is contained in:
commit
d90d033e74
@ -31,6 +31,13 @@ pub fn get_ota_update(platform: OTAPlatform, version: &semver::Version) -> anyho
|
||||
Ok(std::fs::read(path)?)
|
||||
}
|
||||
|
||||
/// Delete an OTA update
|
||||
pub fn delete_update(platform: OTAPlatform, version: &semver::Version) -> anyhow::Result<()> {
|
||||
let path = AppConfig::get().path_ota_update(platform, version);
|
||||
std::fs::remove_file(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the list of OTA software updates for a platform
|
||||
pub fn get_ota_updates_for_platform(platform: OTAPlatform) -> anyhow::Result<Vec<OTAUpdate>> {
|
||||
let ota_path = AppConfig::get().ota_platform_dir(platform);
|
||||
@ -49,3 +56,14 @@ pub fn get_ota_updates_for_platform(platform: OTAPlatform) -> anyhow::Result<Vec
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Get all the available OTA updates
|
||||
pub fn get_all_ota_updates() -> anyhow::Result<Vec<OTAUpdate>> {
|
||||
let mut out = vec![];
|
||||
|
||||
for p in OTAPlatform::supported_platforms() {
|
||||
out.append(&mut get_ota_updates_for_platform(*p)?)
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
@ -7,6 +7,13 @@ pub enum OTAPlatform {
|
||||
Wt32Eth01,
|
||||
}
|
||||
|
||||
impl OTAPlatform {
|
||||
/// Get the list of supported platforms
|
||||
pub fn supported_platforms() -> &'static [Self] {
|
||||
&[OTAPlatform::Wt32Eth01]
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for OTAPlatform {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let s = serde_json::to_string(&self).unwrap().replace('"', "");
|
||||
|
@ -191,13 +191,19 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
||||
"/web_api/ota/{platform}/{version}",
|
||||
web::post().to(ota_controller::upload_firmware),
|
||||
)
|
||||
// TODO : list all ota software updates
|
||||
.route(
|
||||
"/web_api/ota/{platform}/{version}",
|
||||
web::get().to(ota_controller::download_firmware),
|
||||
)
|
||||
.route(
|
||||
"/web_api/ota/{platform}/{version}",
|
||||
web::delete().to(ota_controller::delete_update),
|
||||
)
|
||||
.route("/web_api/ota", web::get().to(ota_controller::list_all_ota))
|
||||
.route(
|
||||
"/web_api/ota/{platform}",
|
||||
web::get().to(ota_controller::list_updates_platform),
|
||||
)
|
||||
// TODO : download a OTA file
|
||||
// TODO : delete an OTA file
|
||||
.route(
|
||||
"/web_api/ota/set_desired_version",
|
||||
web::post().to(ota_controller::set_desired_version),
|
||||
|
@ -10,7 +10,7 @@ use actix_multipart::form::MultipartForm;
|
||||
use actix_web::{web, HttpResponse};
|
||||
|
||||
pub async fn supported_platforms() -> HttpResult {
|
||||
Ok(HttpResponse::Ok().json(vec![OTAPlatform::Wt32Eth01]))
|
||||
Ok(HttpResponse::Ok().json(OTAPlatform::supported_platforms()))
|
||||
}
|
||||
|
||||
#[derive(Debug, MultipartForm)]
|
||||
@ -20,14 +20,15 @@ pub struct UploadForm {
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct UploadPath {
|
||||
pub struct SpecificOTAVersionPath {
|
||||
platform: OTAPlatform,
|
||||
version: semver::Version,
|
||||
}
|
||||
|
||||
/// Upload a new firmware update
|
||||
pub async fn upload_firmware(
|
||||
MultipartForm(form): MultipartForm<UploadForm>,
|
||||
path: web::Path<UploadPath>,
|
||||
path: web::Path<SpecificOTAVersionPath>,
|
||||
) -> HttpResult {
|
||||
if ota_manager::update_exists(path.platform, &path.version)? {
|
||||
return Ok(HttpResponse::Conflict()
|
||||
@ -53,6 +54,42 @@ pub async fn upload_firmware(
|
||||
Ok(HttpResponse::Accepted().body("OTA update successfully saved."))
|
||||
}
|
||||
|
||||
/// Download a firmware update
|
||||
pub async fn download_firmware(path: web::Path<SpecificOTAVersionPath>) -> HttpResult {
|
||||
if !ota_manager::update_exists(path.platform, &path.version)? {
|
||||
return Ok(HttpResponse::NotFound().json("The requested firmware update was not found!"));
|
||||
}
|
||||
|
||||
let firmware = ota_manager::get_ota_update(path.platform, &path.version)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/octet-stream")
|
||||
.append_header((
|
||||
"content-disposition",
|
||||
format!(
|
||||
"attachment; filename=\"{}-{}.bin\"",
|
||||
path.platform, path.version
|
||||
),
|
||||
))
|
||||
.body(firmware))
|
||||
}
|
||||
|
||||
/// Delete an uploaded firmware update
|
||||
pub async fn delete_update(path: web::Path<SpecificOTAVersionPath>) -> HttpResult {
|
||||
if !ota_manager::update_exists(path.platform, &path.version)? {
|
||||
return Ok(HttpResponse::NotFound().json("The requested firmware update was not found!"));
|
||||
}
|
||||
|
||||
ota_manager::delete_update(path.platform, &path.version)?;
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
|
||||
/// Get the list of all OTA updates
|
||||
pub async fn list_all_ota() -> HttpResult {
|
||||
Ok(HttpResponse::Ok().json(ota_manager::get_all_ota_updates()?))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ListOTAPath {
|
||||
platform: OTAPlatform,
|
||||
|
628
central_frontend/package-lock.json
generated
628
central_frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -19,11 +19,14 @@
|
||||
"@mui/material": "^6.1.2",
|
||||
"@mui/x-charts": "^7.19.0",
|
||||
"@mui/x-date-pickers": "^7.19.0",
|
||||
"@types/semver": "^7.5.8",
|
||||
"date-and-time": "^3.6.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"filesize": "^10.1.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
"react-router-dom": "^6.26.2",
|
||||
"semver": "^7.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
|
@ -15,6 +15,7 @@ import { NotFoundRoute } from "./routes/NotFoundRoute";
|
||||
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
|
||||
import { RelaysListRoute } from "./routes/RelaysListRoute";
|
||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
||||
import { OTARoute } from "./routes/OTARoute";
|
||||
|
||||
export function App() {
|
||||
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
||||
@ -28,6 +29,7 @@ export function App() {
|
||||
<Route path="devices" element={<DevicesRoute />} />
|
||||
<Route path="dev/:id" element={<DeviceRoute />} />
|
||||
<Route path="relays" element={<RelaysListRoute />} />
|
||||
<Route path="ota" element={<OTARoute />} />
|
||||
<Route path="logs" element={<LogsRoute />} />
|
||||
<Route path="*" element={<NotFoundRoute />} />
|
||||
</Route>
|
||||
|
87
central_frontend/src/api/OTAApi.ts
Normal file
87
central_frontend/src/api/OTAApi.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
|
||||
export interface OTAUpdate {
|
||||
platform: string;
|
||||
version: string;
|
||||
file_size: number;
|
||||
}
|
||||
|
||||
export class OTAAPI {
|
||||
/**
|
||||
* Get the list of supported OTA platforms
|
||||
*/
|
||||
static async SupportedPlatforms(): Promise<Array<string>> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
method: "GET",
|
||||
uri: "/ota/supported_platforms",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload new OTA firwmare
|
||||
*/
|
||||
static async UploadFirmware(
|
||||
platform: string,
|
||||
version: string,
|
||||
firmware: File
|
||||
): Promise<void> {
|
||||
const fd = new FormData();
|
||||
fd.append("firmware", firmware);
|
||||
|
||||
await APIClient.exec({
|
||||
method: "POST",
|
||||
uri: `/ota/${platform}/${version}`,
|
||||
formData: fd,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the link to download an OTA update
|
||||
*/
|
||||
static DownloadOTAUpdateURL(update: OTAUpdate): string {
|
||||
return APIClient.backendURL() + `/ota/${update.platform}/${update.version}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an update
|
||||
*/
|
||||
static async DeleteUpdate(update: OTAUpdate): Promise<void> {
|
||||
await APIClient.exec({
|
||||
method: "DELETE",
|
||||
uri: `/ota/${update.platform}/${update.version}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of OTA updates
|
||||
*/
|
||||
static async ListOTAUpdates(): Promise<OTAUpdate[]> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
method: "GET",
|
||||
uri: "/ota",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set desired version for one or mor devices
|
||||
*/
|
||||
static async SetDesiredVersion(
|
||||
update: OTAUpdate,
|
||||
all_devices: boolean,
|
||||
devices?: string[]
|
||||
): Promise<void> {
|
||||
await APIClient.exec({
|
||||
method: "POST",
|
||||
uri: "/ota/set_desired_version",
|
||||
jsonData: {
|
||||
version: update.version,
|
||||
platform: update.platform,
|
||||
devices: all_devices ? undefined : devices!,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
148
central_frontend/src/dialogs/DeployOTAUpdateDialogProvider.tsx
Normal file
148
central_frontend/src/dialogs/DeployOTAUpdateDialogProvider.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
FormLabel,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { Device, DeviceApi } from "../api/DeviceApi";
|
||||
import { OTAAPI, OTAUpdate } from "../api/OTAApi";
|
||||
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";
|
||||
|
||||
export function DeployOTAUpdateDialogProvider(p: {
|
||||
update: OTAUpdate;
|
||||
onClose: () => void;
|
||||
}): React.ReactElement {
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [devicesList, setDevicesList] = React.useState<Device[] | undefined>();
|
||||
|
||||
const loadDevicesList = async () => {
|
||||
let list = await DeviceApi.ValidatedList();
|
||||
list = list.filter((e) => e.info.reference == p.update.platform);
|
||||
setDevicesList(list);
|
||||
};
|
||||
|
||||
const [allDevices, setAllDevices] = React.useState(false);
|
||||
const [selectedDevices, setSelectedDevices] = React.useState<string[]>([]);
|
||||
|
||||
const startDeployment = async () => {
|
||||
if (
|
||||
allDevices &&
|
||||
!(await confirm(
|
||||
"Do you really want to deploy the update to all devices?"
|
||||
))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
loadingMessage.show("Applying OTA update...");
|
||||
|
||||
await OTAAPI.SetDesiredVersion(p.update, allDevices, selectedDevices);
|
||||
|
||||
snackbar("The update was successfully applied!");
|
||||
p.onClose();
|
||||
} catch (e) {
|
||||
console.error("Failed to deploy the udpate!", e);
|
||||
alert(`Failed to deploy the udpate! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onClose={p.onClose}>
|
||||
<DialogTitle>
|
||||
Deploy update <i>{p.update.version}</i> for platform{" "}
|
||||
<i>{p.update.platform}</i>
|
||||
</DialogTitle>
|
||||
<AsyncWidget
|
||||
loadKey={1}
|
||||
load={loadDevicesList}
|
||||
errMsg="Failed to load the list of devices!"
|
||||
build={() => (
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
You can choose to deploy update to all device or to target only a
|
||||
part of devices:
|
||||
</DialogContentText>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Gender</FormLabel>
|
||||
<RadioGroup
|
||||
name="radio-buttons-group"
|
||||
value={allDevices}
|
||||
onChange={(v) => setAllDevices(v.target.value == "true")}
|
||||
>
|
||||
<FormControlLabel
|
||||
value={true}
|
||||
control={<Radio />}
|
||||
label="Deploy the update to all the devices of the platform"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value={false}
|
||||
control={<Radio />}
|
||||
label="Deploy the update to a limited range of devices"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
{!allDevices && (
|
||||
<Typography>
|
||||
There are no devices to which the update can be deployed.
|
||||
</Typography>
|
||||
)}
|
||||
{!allDevices && (
|
||||
<FormGroup>
|
||||
{devicesList?.map((d) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(d.id)}
|
||||
onChange={(_e, v) => {
|
||||
if (v) {
|
||||
selectedDevices.push(d.id);
|
||||
setSelectedDevices([...selectedDevices]);
|
||||
} else
|
||||
setSelectedDevices(
|
||||
selectedDevices.filter((e) => e != d.id)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={d.name}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
)}
|
||||
</DialogContent>
|
||||
)}
|
||||
/>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={startDeployment}
|
||||
autoFocus
|
||||
disabled={!allDevices && selectedDevices.length == 0}
|
||||
>
|
||||
Start deployment
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
132
central_frontend/src/dialogs/UploadUpdateDialog.tsx
Normal file
132
central_frontend/src/dialogs/UploadUpdateDialog.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
styled,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { OTAAPI } from "../api/OTAApi";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
import { checkVersion } from "../utils/StringsUtils";
|
||||
import { TextInput } from "../widgets/forms/TextInput";
|
||||
|
||||
const VisuallyHiddenInput = styled("input")({
|
||||
clip: "rect(0 0 0 0)",
|
||||
clipPath: "inset(50%)",
|
||||
height: 1,
|
||||
overflow: "hidden",
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
whiteSpace: "nowrap",
|
||||
width: 1,
|
||||
});
|
||||
|
||||
export function UploadUpdateDialog(p: {
|
||||
platforms: string[];
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
const [platform, setPlatform] = React.useState<string | undefined>();
|
||||
const [version, setVersion] = React.useState<string | undefined>();
|
||||
const [file, setFile] = React.useState<File | undefined>();
|
||||
|
||||
const canSubmit = platform && version && checkVersion(version) && file;
|
||||
|
||||
const upload = async () => {
|
||||
try {
|
||||
loadingMessage.show("Uploading firmware...");
|
||||
await OTAAPI.UploadFirmware(platform!, version!, file!);
|
||||
|
||||
snackbar("Successfully uploaded new firmware!");
|
||||
|
||||
p.onCreated();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(`Failed to upload firmware: ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onClose={p.onClose}>
|
||||
<DialogTitle>Submit a new update</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
You can upload a new firmware using this form.
|
||||
</DialogContentText>
|
||||
<br />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Platform</InputLabel>
|
||||
<Select
|
||||
label="Platform"
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
variant="standard"
|
||||
>
|
||||
{p.platforms.map((p) => (
|
||||
<MenuItem key={p} value={p}>
|
||||
{p}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<br />
|
||||
<br />
|
||||
<TextInput
|
||||
editable
|
||||
label="Version"
|
||||
helperText="The version shall follow semantics requirements"
|
||||
value={version}
|
||||
onValueChange={setVersion}
|
||||
checkValue={checkVersion}
|
||||
/>
|
||||
|
||||
<br />
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
component="label"
|
||||
role={undefined}
|
||||
variant={file ? "contained" : "outlined"}
|
||||
tabIndex={-1}
|
||||
startIcon={<CloudUploadIcon />}
|
||||
>
|
||||
Upload file
|
||||
<VisuallyHiddenInput
|
||||
type="file"
|
||||
onChange={(event) =>
|
||||
setFile(
|
||||
(event.target.files?.length ?? 0) > 0
|
||||
? event.target.files![0]
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
multiple
|
||||
/>
|
||||
</Button>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={!canSubmit} onClick={upload}>
|
||||
Upload
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
194
central_frontend/src/routes/OTARoute.tsx
Normal file
194
central_frontend/src/routes/OTARoute.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import { mdiFolderUploadOutline } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import FileUploadIcon from "@mui/icons-material/FileUpload";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import {
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import { filesize } from "filesize";
|
||||
import React from "react";
|
||||
import { OTAAPI, OTAUpdate } from "../api/OTAApi";
|
||||
import { DeployOTAUpdateDialogProvider } from "../dialogs/DeployOTAUpdateDialogProvider";
|
||||
import { UploadUpdateDialog } from "../dialogs/UploadUpdateDialog";
|
||||
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 { RouterLink } from "../widgets/RouterLink";
|
||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||
|
||||
export function OTARoute(): React.ReactElement {
|
||||
const [list, setList] = React.useState<string[] | undefined>();
|
||||
const load = async () => {
|
||||
setList(await OTAAPI.SupportedPlatforms());
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={1}
|
||||
ready={!!list}
|
||||
load={load}
|
||||
errMsg="Failed to load OTA screen!"
|
||||
build={() => <_OTARoute platforms={list!} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function _OTARoute(p: { platforms: Array<string> }): React.ReactElement {
|
||||
const key = React.useRef(1);
|
||||
const [showUploadDialog, setShowUploadDialog] = React.useState(false);
|
||||
|
||||
const [list, setList] = React.useState<undefined | OTAUpdate[]>();
|
||||
|
||||
const load = async () => {
|
||||
const list = await OTAAPI.ListOTAUpdates();
|
||||
list.sort((a, b) =>
|
||||
`${a.platform}#${a.version}`.localeCompare(`${b.platform}#${b.version}`)
|
||||
);
|
||||
list.reverse();
|
||||
setList(list);
|
||||
};
|
||||
|
||||
const reload = async () => {
|
||||
key.current += 1;
|
||||
setList(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<SolarEnergyRouteContainer
|
||||
label="OTA"
|
||||
actions={
|
||||
<span>
|
||||
<Tooltip title="Refresh the list of updates">
|
||||
<IconButton onClick={reload}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Upload a new update">
|
||||
<IconButton onClick={() => setShowUploadDialog(true)}>
|
||||
<FileUploadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{showUploadDialog && (
|
||||
<UploadUpdateDialog
|
||||
platforms={p.platforms}
|
||||
onClose={() => setShowUploadDialog(false)}
|
||||
onCreated={() => {
|
||||
setShowUploadDialog(false);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AsyncWidget
|
||||
loadKey={key.current}
|
||||
ready={!!list}
|
||||
errMsg="Failed to load the list of OTA updates!"
|
||||
load={load}
|
||||
build={() => <_OTAList list={list!} onReload={reload} />}
|
||||
/>
|
||||
</SolarEnergyRouteContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function _OTAList(p: {
|
||||
list: OTAUpdate[];
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [deployUpdate, setDeployUpdate] = React.useState<
|
||||
OTAUpdate | undefined
|
||||
>();
|
||||
|
||||
const deleteUpdate = async (update: OTAUpdate) => {
|
||||
if (
|
||||
!(await confirm(
|
||||
`Do you really want to delete the update for platform ${update.platform} version ${update.version}?`
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
loadingMessage.show("Deleting update...");
|
||||
|
||||
await OTAAPI.DeleteUpdate(update);
|
||||
|
||||
snackbar("The update was successfully deleted!");
|
||||
|
||||
p.onReload();
|
||||
} catch (e) {
|
||||
console.error("Failed to delete update!", e);
|
||||
alert(`Failed to delete the update! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{deployUpdate && (
|
||||
<DeployOTAUpdateDialogProvider
|
||||
update={deployUpdate!}
|
||||
onClose={() => setDeployUpdate(undefined)}
|
||||
/>
|
||||
)}
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center">Platform</TableCell>
|
||||
<TableCell align="center">Version</TableCell>
|
||||
<TableCell align="center">File size</TableCell>
|
||||
<TableCell align="center"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{p.list.map((row, num) => (
|
||||
<TableRow hover key={num}>
|
||||
<TableCell align="center">{row.platform}</TableCell>
|
||||
<TableCell align="center">{row.version}</TableCell>
|
||||
<TableCell align="center">{filesize(row.file_size)}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="Deploy the update to devices">
|
||||
<IconButton onClick={() => setDeployUpdate(row)}>
|
||||
<Icon path={mdiFolderUploadOutline} size={1} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Download a copy of the firmware">
|
||||
<RouterLink to={OTAAPI.DownloadOTAUpdateURL(row)}>
|
||||
<IconButton>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</RouterLink>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete firmware update">
|
||||
<IconButton onClick={() => deleteUpdate(row)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { SemVer } from "semver";
|
||||
import { LenConstraint } from "../api/ServerApi";
|
||||
|
||||
/**
|
||||
@ -6,3 +7,16 @@ import { LenConstraint } from "../api/ServerApi";
|
||||
export function lenValid(s: string, c: LenConstraint): boolean {
|
||||
return s.length >= c.min && s.length <= c.max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out whether a given version number respect semantics requirements or not
|
||||
*/
|
||||
export function checkVersion(v: string): boolean {
|
||||
try {
|
||||
new SemVer(v, { loose: false });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import {
|
||||
mdiChip,
|
||||
mdiElectricSwitch,
|
||||
mdiHome,
|
||||
mdiMonitorArrowDown,
|
||||
mdiNewBox,
|
||||
mdiNotebookMultiple,
|
||||
} from "@mdi/js";
|
||||
@ -41,6 +42,11 @@ export function SolarEnergyNavList(): React.ReactElement {
|
||||
uri="/relays"
|
||||
icon={<Icon path={mdiElectricSwitch} size={1} />}
|
||||
/>
|
||||
<NavLink
|
||||
label="OTA"
|
||||
uri="/ota"
|
||||
icon={<Icon path={mdiMonitorArrowDown} size={1} />}
|
||||
/>
|
||||
<NavLink
|
||||
label="Logging"
|
||||
uri="/logs"
|
||||
|
Loading…
Reference in New Issue
Block a user