Skip to content

Commit ab1b109

Browse files
committed
feat(frontend): manual OTA with base image from collection
Introduce UI in a Device page's Software Updates tab that allows to start a manual OTA update by selecting a base image already uploaded to a base image collection. Signed-off-by: Damiano Mason <damiano.mason@secomind.com>
1 parent c4bb427 commit ab1b109

8 files changed

Lines changed: 581 additions & 114 deletions

File tree

frontend/src/components/BaseImageSelect.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
* This file is part of Edgehog.
33
*
4-
* Copyright 2023-2025 SECO Mind Srl
4+
* Copyright 2023-2026 SECO Mind Srl
55
*
66
* Licensed under the Apache License, Version 2.0 (the "License");
77
* you may not use this file except in compliance with the License.
@@ -74,15 +74,18 @@ const BASE_IMAGE_SELECT_OPTIONS_FRAGMENT = graphql`
7474
id
7575
name
7676
version
77+
url
7778
}
7879
}
7980
}
8081
}
8182
`;
8283

83-
export type BaseImageRecord = NonNullable<
84+
type BaseImageNode = NonNullable<
8485
NonNullable<BaseImageSelect_BaseImagesFragment$data["baseImages"]>["edges"]
8586
>[number]["node"];
87+
type OmitUrl = Omit<BaseImageNode, "url">;
88+
export type BaseImageRecord = OmitUrl & { readonly url?: string };
8689

8790
type BaseImageSelectProps = {
8891
updateCampaignBaseImageOptionsRef: BaseImageSelect_BaseImagesFragment$key | null;
@@ -147,7 +150,7 @@ const BaseImageSelect = ({
147150
return (
148151
paginationData?.baseImages?.edges
149152
?.map((edge) => edge?.node)
150-
.filter((node): node is BaseImageRecord => node != null) ?? []
153+
.filter((node): node is BaseImageNode => node != null) ?? []
151154
);
152155
}, [paginationData]);
153156

frontend/src/components/DeviceTabs/SoftwareUpdateTab.tsx

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
* This file is part of Edgehog.
33
*
4-
* Copyright 2025 SECO Mind Srl
4+
* Copyright 2025-2026 SECO Mind Srl
55
*
66
* Licensed under the Apache License, Version 2.0 (the "License");
77
* you may not use this file except in compliance with the License.
@@ -18,7 +18,7 @@
1818
* SPDX-License-Identifier: Apache-2.0
1919
*/
2020

21-
import React, { useEffect, useState, useRef } from "react";
21+
import React, { useEffect, useState, useRef, Suspense } from "react";
2222
import type { Subscription } from "relay-runtime";
2323
import { FormattedMessage, useIntl } from "react-intl";
2424
import {
@@ -27,17 +27,23 @@ import {
2727
fetchQuery,
2828
useRelayEnvironment,
2929
usePaginationFragment,
30+
usePreloadedQuery,
31+
PreloadedQuery,
3032
} from "react-relay/hooks";
33+
import { Form, Stack } from "react-bootstrap";
3134

35+
import type { Device_getBaseImageCollections_Query } from "@/api/__generated__/Device_getBaseImageCollections_Query.graphql";
36+
import { SoftwareUpdateTab_createManualOtaOperation_Mutation } from "@/api/__generated__/SoftwareUpdateTab_createManualOtaOperation_Mutation.graphql";
3237
import type { SoftwareUpdateTab_PaginationQuery } from "@/api/__generated__/SoftwareUpdateTab_PaginationQuery.graphql";
3338
import type { SoftwareUpdateTab_otaOperations$key } from "@/api/__generated__/SoftwareUpdateTab_otaOperations.graphql";
34-
import type { SoftwareUpdateTab_createManualOtaOperation_Mutation } from "@/api/__generated__/SoftwareUpdateTab_createManualOtaOperation_Mutation.graphql";
3539

3640
import Alert from "@/components/Alert";
3741
import OperationTable from "@/components/OperationTable";
3842
import Spinner from "@/components/Spinner";
3943
import { Tab } from "@/components/Tabs";
40-
import BaseImageForm from "@/forms/BaseImageForm";
44+
import ManualOtaFromCollectionForm from "@/forms/ManualOtaFromCollectionForm";
45+
import ManualOtaFromFileForm from "@/forms/ManualOtaFromFileForm";
46+
import { GET_BASE_IMAGE_COLL_QUERY } from "@/pages/Device";
4147

4248
const DEVICE_OTA_OPERATIONS_FRAGMENT = graphql`
4349
fragment SoftwareUpdateTab_otaOperations on Device
@@ -90,18 +96,38 @@ const DEVICE_CREATE_MANUAL_OTA_OPERATION_MUTATION = graphql`
9096
}
9197
`;
9298

99+
export type GetBaseImageCollsQueryType = PreloadedQuery<
100+
Device_getBaseImageCollections_Query,
101+
Record<string, unknown>
102+
>;
103+
104+
type OtaOperationInput = {
105+
imageFile?: File;
106+
imageUrl?: string;
107+
};
108+
93109
type DeviceSoftwareUpdateTabProps = {
94110
deviceRef: SoftwareUpdateTab_otaOperations$key;
111+
getBaseImageCollsQuery: GetBaseImageCollsQueryType;
95112
};
96113

97114
const DeviceSoftwareUpdateTab = ({
98115
deviceRef,
116+
getBaseImageCollsQuery,
99117
}: DeviceSoftwareUpdateTabProps) => {
100118
const [isRefreshing, setIsRefreshing] = useState(false);
101119
const [errorFeedback, setErrorFeedback] = useState<React.ReactNode>(null);
102120
const intl = useIntl();
103121
const relayEnvironment = useRelayEnvironment();
104122

123+
const [updateMode, setUpdateMode] = useState<"collection" | "file">(
124+
"collection",
125+
);
126+
const modeOnChange =
127+
(mode: "collection" | "file") =>
128+
(_event: React.ChangeEvent<HTMLInputElement>) =>
129+
setUpdateMode(mode);
130+
105131
const { data } = usePaginationFragment<
106132
SoftwareUpdateTab_PaginationQuery,
107133
SoftwareUpdateTab_otaOperations$key
@@ -182,19 +208,28 @@ const DeviceSoftwareUpdateTab = ({
182208
deviceId,
183209
]);
184210

211+
const baseImageCollections = usePreloadedQuery(
212+
GET_BASE_IMAGE_COLL_QUERY,
213+
getBaseImageCollsQuery,
214+
);
215+
185216
if (!data.capabilities.includes("SOFTWARE_UPDATES")) {
186217
return null;
187218
}
188219

189-
const launchManualOTAUpdate = (file: File) => {
220+
const launchManualOTAUpdate = ({
221+
imageFile,
222+
imageUrl,
223+
}: OtaOperationInput) => {
190224
createOtaOperation({
191225
variables: {
192226
input: {
193227
deviceId,
194-
baseImageFile: file,
228+
baseImageFile: imageFile,
229+
baseImageUrl: imageUrl,
195230
},
196231
},
197-
onCompleted(data, errors) {
232+
onCompleted(_data, errors) {
198233
if (errors) {
199234
const errorFeedback = errors
200235
.map(({ fields, message }) =>
@@ -252,11 +287,44 @@ const DeviceSoftwareUpdateTab = ({
252287
>
253288
{errorFeedback}
254289
</Alert>
255-
<BaseImageForm
256-
className="mt-3"
257-
onSubmit={launchManualOTAUpdate}
258-
isLoading={isCreatingOtaOperation}
259-
/>
290+
<Suspense fallback={<Spinner />}>
291+
<Stack direction="vertical" className="mt-3">
292+
<Form.Group key="updateMode">
293+
<Form.Check
294+
name="updateMode"
295+
inline
296+
type="radio"
297+
label="Collection"
298+
id="Collection"
299+
onChange={modeOnChange("collection")}
300+
checked={updateMode === "collection"}
301+
/>
302+
<Form.Check
303+
name="updateMode"
304+
inline
305+
type="radio"
306+
label="File"
307+
id="File"
308+
onChange={modeOnChange("file")}
309+
checked={updateMode === "file"}
310+
/>
311+
</Form.Group>
312+
{updateMode === "collection" ? (
313+
<ManualOtaFromCollectionForm
314+
baseImageCollectionsData={baseImageCollections}
315+
className="mt-3 w-75"
316+
isLoading={isCreatingOtaOperation}
317+
onManualOTAImageSubmit={launchManualOTAUpdate}
318+
/>
319+
) : (
320+
<ManualOtaFromFileForm
321+
className="mt-3 w-75"
322+
isLoading={isCreatingOtaOperation}
323+
onManualOTAImageSubmit={launchManualOTAUpdate}
324+
/>
325+
)}
326+
</Stack>
327+
</Suspense>
260328
{currentOperation && (
261329
<div className="mt-3">
262330
<FormattedMessage

frontend/src/forms/BaseImageForm.tsx

Lines changed: 0 additions & 82 deletions
This file was deleted.

0 commit comments

Comments
 (0)