Skip to content

Commit d82f208

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 e704fda commit d82f208

6 files changed

Lines changed: 623 additions & 77 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: 172 additions & 33 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,14 @@
1818
* SPDX-License-Identifier: Apache-2.0
1919
*/
2020

21-
import React, { useEffect, useState, useRef } from "react";
21+
import React, {
22+
useEffect,
23+
useState,
24+
useRef,
25+
useCallback,
26+
Suspense,
27+
useMemo,
28+
} from "react";
2229
import type { Subscription } from "relay-runtime";
2330
import { FormattedMessage, useIntl } from "react-intl";
2431
import {
@@ -27,17 +34,29 @@ import {
2734
fetchQuery,
2835
useRelayEnvironment,
2936
usePaginationFragment,
37+
usePreloadedQuery,
38+
PreloadedQuery,
39+
UseMutationConfig,
3040
} from "react-relay/hooks";
3141

42+
import type { Device_getBaseImageCollections_Query } from "@/api/__generated__/Device_getBaseImageCollections_Query.graphql";
43+
import type {
44+
SoftwareUpdateTab_createManualOtaOperationNoExistingBaseImage_Mutation,
45+
SoftwareUpdateTab_createManualOtaOperationNoExistingBaseImage_Mutation$data,
46+
} from "@/api/__generated__/SoftwareUpdateTab_createManualOtaOperationNoExistingBaseImage_Mutation.graphql";
47+
import type {
48+
SoftwareUpdateTab_createManualOtaOperationExistingBaseImage_Mutation,
49+
SoftwareUpdateTab_createManualOtaOperationExistingBaseImage_Mutation$data,
50+
} from "@/api/__generated__/SoftwareUpdateTab_createManualOtaOperationExistingBaseImage_Mutation.graphql";
3251
import type { SoftwareUpdateTab_PaginationQuery } from "@/api/__generated__/SoftwareUpdateTab_PaginationQuery.graphql";
3352
import type { SoftwareUpdateTab_otaOperations$key } from "@/api/__generated__/SoftwareUpdateTab_otaOperations.graphql";
34-
import type { SoftwareUpdateTab_createManualOtaOperation_Mutation } from "@/api/__generated__/SoftwareUpdateTab_createManualOtaOperation_Mutation.graphql";
3553

3654
import Alert from "@/components/Alert";
3755
import OperationTable from "@/components/OperationTable";
3856
import Spinner from "@/components/Spinner";
3957
import { Tab } from "@/components/Tabs";
4058
import BaseImageForm from "@/forms/BaseImageForm";
59+
import { GET_BASE_IMAGE_COLL_QUERY } from "@/pages/Device";
4160

4261
const DEVICE_OTA_OPERATIONS_FRAGMENT = graphql`
4362
fragment SoftwareUpdateTab_otaOperations on Device
@@ -73,11 +92,11 @@ const GET_DEVICE_OTA_OPERATIONS_QUERY = graphql`
7392
}
7493
`;
7594

76-
const DEVICE_CREATE_MANUAL_OTA_OPERATION_MUTATION = graphql`
77-
mutation SoftwareUpdateTab_createManualOtaOperation_Mutation(
78-
$input: CreateManualOtaOperationInput!
95+
const DEVICE_CREATE_MANUAL_OTA_OPERATION_NO_EXISTING_IMAGE_MUTATION = graphql`
96+
mutation SoftwareUpdateTab_createManualOtaOperationNoExistingBaseImage_Mutation(
97+
$input: CreateManualOtaOperationNoExistingBaseImageInput!
7998
) {
80-
createManualOtaOperation(input: $input) {
99+
createManualOtaOperationNoExistingBaseImage(input: $input) {
81100
result {
82101
id
83102
baseImageUrl
@@ -90,12 +109,48 @@ const DEVICE_CREATE_MANUAL_OTA_OPERATION_MUTATION = graphql`
90109
}
91110
`;
92111

112+
const DEVICE_CREATE_MANUAL_OTA_OPERATION_EXISTING_IMAGE_MUTATION = graphql`
113+
mutation SoftwareUpdateTab_createManualOtaOperationExistingBaseImage_Mutation(
114+
$input: CreateManualOtaOperationExistingBaseImageInput!
115+
) {
116+
createManualOtaOperationExistingBaseImage(input: $input) {
117+
result {
118+
id
119+
baseImageUrl
120+
createdAt
121+
status
122+
statusCode
123+
updatedAt
124+
}
125+
}
126+
}
127+
`;
128+
129+
export type GetBaseImageCollsQueryType = PreloadedQuery<
130+
Device_getBaseImageCollections_Query,
131+
Record<string, unknown>
132+
>;
133+
134+
type OTAOperationFunctionIncompleteVariables = {
135+
input: {
136+
deviceId: string;
137+
baseImageFile?: File;
138+
baseImageUrl?: string;
139+
};
140+
};
141+
142+
type OTAOperationFunctionVariables =
143+
UseMutationConfig<SoftwareUpdateTab_createManualOtaOperationNoExistingBaseImage_Mutation>["variables"] &
144+
UseMutationConfig<SoftwareUpdateTab_createManualOtaOperationExistingBaseImage_Mutation>["variables"];
145+
93146
type DeviceSoftwareUpdateTabProps = {
94147
deviceRef: SoftwareUpdateTab_otaOperations$key;
148+
getBaseImageCollsQuery: GetBaseImageCollsQueryType;
95149
};
96150

97151
const DeviceSoftwareUpdateTab = ({
98152
deviceRef,
153+
getBaseImageCollsQuery,
99154
}: DeviceSoftwareUpdateTabProps) => {
100155
const [isRefreshing, setIsRefreshing] = useState(false);
101156
const [errorFeedback, setErrorFeedback] = useState<React.ReactNode>(null);
@@ -109,10 +164,60 @@ const DeviceSoftwareUpdateTab = ({
109164

110165
const deviceId = data.id;
111166

112-
const [createOtaOperation, isCreatingOtaOperation] =
113-
useMutation<SoftwareUpdateTab_createManualOtaOperation_Mutation>(
114-
DEVICE_CREATE_MANUAL_OTA_OPERATION_MUTATION,
167+
const [
168+
createOtaOperationNoExistingImage,
169+
isCreatingOtaOperationNoExistingImage,
170+
] =
171+
useMutation<SoftwareUpdateTab_createManualOtaOperationNoExistingBaseImage_Mutation>(
172+
DEVICE_CREATE_MANUAL_OTA_OPERATION_NO_EXISTING_IMAGE_MUTATION,
115173
);
174+
const [createOtaOperationExistingImage, isCreatingOtaOperationExistingImage] =
175+
useMutation<SoftwareUpdateTab_createManualOtaOperationExistingBaseImage_Mutation>(
176+
DEVICE_CREATE_MANUAL_OTA_OPERATION_EXISTING_IMAGE_MUTATION,
177+
);
178+
const pickOTAOperationFunction = useCallback(
179+
(...input: Array<File | string>) => {
180+
const variables: OTAOperationFunctionIncompleteVariables = {
181+
input: {
182+
deviceId,
183+
},
184+
};
185+
if (input.length === 1) {
186+
const [baseImage] = input;
187+
if (baseImage instanceof File) {
188+
variables.input.baseImageFile = baseImage;
189+
return {
190+
func: createOtaOperationNoExistingImage,
191+
vars: variables as UseMutationConfig<SoftwareUpdateTab_createManualOtaOperationNoExistingBaseImage_Mutation>["variables"],
192+
};
193+
} else if (typeof baseImage === "string") {
194+
variables.input.baseImageUrl = baseImage;
195+
return {
196+
func: createOtaOperationExistingImage,
197+
vars: variables as UseMutationConfig<SoftwareUpdateTab_createManualOtaOperationExistingBaseImage_Mutation>["variables"],
198+
};
199+
}
200+
}
201+
throw new TypeError(
202+
"Only one Base Image can be submitted for an update.",
203+
);
204+
},
205+
[
206+
createOtaOperationNoExistingImage,
207+
createOtaOperationExistingImage,
208+
deviceId,
209+
],
210+
);
211+
212+
const isCreatingOtaOperation = useMemo(
213+
() =>
214+
isCreatingOtaOperationNoExistingImage ||
215+
isCreatingOtaOperationExistingImage,
216+
[
217+
isCreatingOtaOperationNoExistingImage,
218+
isCreatingOtaOperationExistingImage,
219+
],
220+
);
116221

117222
const otaOperations = (
118223
data.otaOperations?.edges?.map(({ node }) => node) || []
@@ -182,19 +287,21 @@ const DeviceSoftwareUpdateTab = ({
182287
deviceId,
183288
]);
184289

290+
const baseImageCollections = usePreloadedQuery(
291+
GET_BASE_IMAGE_COLL_QUERY,
292+
getBaseImageCollsQuery,
293+
);
294+
185295
if (!data.capabilities.includes("SOFTWARE_UPDATES")) {
186296
return null;
187297
}
188298

189-
const launchManualOTAUpdate = (file: File) => {
299+
const launchManualOTAUpdate = (...input: Array<File | string>) => {
300+
const { func: createOtaOperation, vars: variables } =
301+
pickOTAOperationFunction(...input);
190302
createOtaOperation({
191-
variables: {
192-
input: {
193-
deviceId,
194-
baseImageFile: file,
195-
},
196-
},
197-
onCompleted(data, errors) {
303+
variables: variables as OTAOperationFunctionVariables,
304+
onCompleted(_data, errors) {
198305
if (errors) {
199306
const errorFeedback = errors
200307
.map(({ fields, message }) =>
@@ -213,16 +320,31 @@ const DeviceSoftwareUpdateTab = ({
213320
);
214321
},
215322
updater(store, data) {
216-
const otaOperationId = data?.createManualOtaOperation?.result?.id;
217-
if (otaOperationId) {
218-
const otaOperation = store.get(otaOperationId);
219-
const storedDevice = store.get(deviceId);
220-
const otaOperations = storedDevice?.getLinkedRecords("otaOperations");
221-
if (storedDevice && otaOperation && otaOperations) {
222-
storedDevice.setLinkedRecords(
223-
[otaOperation, ...otaOperations],
224-
"otaOperations",
225-
);
323+
if (data) {
324+
let mutData:
325+
| SoftwareUpdateTab_createManualOtaOperationNoExistingBaseImage_Mutation$data["createManualOtaOperationNoExistingBaseImage"]
326+
| SoftwareUpdateTab_createManualOtaOperationExistingBaseImage_Mutation$data["createManualOtaOperationExistingBaseImage"]
327+
| undefined;
328+
if ("createManualOtaOperationNoExistingBaseImage" in data) {
329+
mutData = data.createManualOtaOperationNoExistingBaseImage;
330+
} else if (
331+
data &&
332+
"createManualOtaOperationExistingBaseImage" in data
333+
) {
334+
mutData = data.createManualOtaOperationExistingBaseImage;
335+
}
336+
const otaOperationId = mutData?.result?.id;
337+
if (otaOperationId) {
338+
const otaOperation = store.get(otaOperationId);
339+
const storedDevice = store.get(deviceId);
340+
const otaOperations =
341+
storedDevice?.getLinkedRecords("otaOperations");
342+
if (storedDevice && otaOperation && otaOperations) {
343+
storedDevice.setLinkedRecords(
344+
[otaOperation, ...otaOperations],
345+
"otaOperations",
346+
);
347+
}
226348
}
227349
}
228350
},
@@ -252,11 +374,14 @@ const DeviceSoftwareUpdateTab = ({
252374
>
253375
{errorFeedback}
254376
</Alert>
255-
<BaseImageForm
256-
className="mt-3"
257-
onSubmit={launchManualOTAUpdate}
258-
isLoading={isCreatingOtaOperation}
259-
/>
377+
<Suspense fallback={<Spinner />}>
378+
<BaseImageForm
379+
className="mt-3"
380+
onManualOTAImageSubmit={launchManualOTAUpdate}
381+
isLoading={isCreatingOtaOperation}
382+
baseImageCollectionsData={baseImageCollections}
383+
/>
384+
</Suspense>
260385
{currentOperation && (
261386
<div className="mt-3">
262387
<FormattedMessage
@@ -290,4 +415,18 @@ const DeviceSoftwareUpdateTab = ({
290415
);
291416
};
292417

418+
export const DisabledDeviceSoftwareTab = () => {
419+
const intl = useIntl();
420+
return (
421+
<Tab
422+
className="disabled"
423+
eventKey="device-software-update-tab"
424+
title={intl.formatMessage({
425+
id: "components.DeviceTabs.SoftwareUpdateTab",
426+
defaultMessage: "Software Updates",
427+
})}
428+
/>
429+
);
430+
};
431+
293432
export default DeviceSoftwareUpdateTab;

0 commit comments

Comments
 (0)