diff --git a/backend/lib/edgehog/devices/device/device.ex b/backend/lib/edgehog/devices/device/device.ex index 7b4fc9683..bee7cd02f 100644 --- a/backend/lib/edgehog/devices/device/device.ex +++ b/backend/lib/edgehog/devices/device/device.ex @@ -62,7 +62,8 @@ defmodule Edgehog.Devices.Device do paginate_relationship_with application_deployments: :relay, ota_operations: :relay, tags: :relay, - file_download_requests: :relay + file_download_requests: :relay, + file_upload_requests: :relay subscriptions do pubsub EdgehogWeb.Endpoint @@ -508,6 +509,12 @@ defmodule Edgehog.Devices.Device do writable? false end + has_many :file_upload_requests, Edgehog.Files.FileUploadRequest do + public? true + description "The existing file upload requests for this device" + writable? false + end + has_many :application_deployments, Deployment do public? true end diff --git a/backend/lib/edgehog/files/file_upload_request/calculations/get_presigned_url.ex b/backend/lib/edgehog/files/file_upload_request/calculations/get_presigned_url.ex new file mode 100644 index 000000000..b7b45b4e0 --- /dev/null +++ b/backend/lib/edgehog/files/file_upload_request/calculations/get_presigned_url.ex @@ -0,0 +1,58 @@ +# +# This file is part of Edgehog. +# +# Copyright 2026 SECO Mind Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +defmodule Edgehog.Files.FileUploadRequest.Calculations.GetPresignedUrl do + @moduledoc false + use Ash.Resource.Calculation + + alias Ash.Resource.Calculation + + @files_storage_module Application.compile_env( + :edgehog, + :files_storage_module, + Edgehog.Storage + ) + + @impl Calculation + def load(_query, _opts, _context) do + [:id] + end + + @impl Calculation + def calculate(records, _opts, context) do + tenant_id = extract_tenant_id(context) + + Enum.map(records, fn file_upload_request -> + file_path = + "uploads/tenants/#{tenant_id}/file_upload_requests/#{file_upload_request.id}" + + case @files_storage_module.read_presigned_url(file_path) do + {:ok, %{get_url: get_url}} -> get_url + {:error, _reason} -> nil + end + end) + end + + # Ash context can carry tenant as a struct in request flows or as a raw id in + # background executor flows, so we support both shapes to avoid crashes. + defp extract_tenant_id(%{tenant: %{tenant_id: tenant_id}}), do: tenant_id + defp extract_tenant_id(%{tenant: tenant_id}), do: tenant_id + defp extract_tenant_id(_), do: nil +end diff --git a/backend/lib/edgehog/files/file_upload_request/file_upload_request.ex b/backend/lib/edgehog/files/file_upload_request/file_upload_request.ex index aac1bd262..3616fa87f 100644 --- a/backend/lib/edgehog/files/file_upload_request/file_upload_request.ex +++ b/backend/lib/edgehog/files/file_upload_request/file_upload_request.ex @@ -22,8 +22,10 @@ defmodule Edgehog.Files.FileUploadRequest do @moduledoc false use Edgehog.MultitenantResource, domain: Edgehog.Files, - extensions: [AshGraphql.Resource] + extensions: [AshGraphql.Resource], + notifiers: [Ash.Notifier.PubSub] + alias Edgehog.Files.FileUploadRequest.Calculations alias Edgehog.Files.FileUploadRequest.Changes alias Edgehog.Files.FileUploadRequest.FileSource alias Edgehog.Files.FileUploadRequest.ManualActions @@ -31,11 +33,31 @@ defmodule Edgehog.Files.FileUploadRequest do graphql do type :file_upload_request + + subscriptions do + pubsub EdgehogWeb.Endpoint + + subscribe :file_upload_requests do + action_types [:create, :update] + end + + subscribe :file_upload_requests_by_device do + action_types [:create, :update] + read_action :read_by_device + relay_id_translations device_id: :device + end + end end actions do defaults [:read, :destroy] + read :read_by_device do + argument :device_id, :id, allow_nil?: false + + get_by :device_id + end + create :send_request do accept [:source, :source_type, :compression, :progress_tracked, :http_headers] @@ -160,6 +182,16 @@ defmodule Edgehog.Files.FileUploadRequest do end end + calculations do + calculate :get_presigned_url, :string do + public? true + + description "Get a presigned URL for downloading the uploaded file. The URL is valid for a limited time." + + calculation Calculations.GetPresignedUrl + end + end + postgres do table "file_upload_requests" repo Edgehog.Repo diff --git a/frontend/src/api/schema.graphql b/frontend/src/api/schema.graphql index 8e95501e6..f57019521 100644 --- a/frontend/src/api/schema.graphql +++ b/frontend/src/api/schema.graphql @@ -5119,6 +5119,9 @@ input DeviceFilterInput { "The existing file download requests for this device" fileDownloadRequests: FileDownloadRequestFilterInput + "The existing file upload requests for this device" + fileUploadRequests: FileUploadRequestFilterInput + applicationDeployments: DeploymentFilterInput } @@ -5241,6 +5244,27 @@ type Device implements Node { last: Int ): FileDownloadRequestConnection! + "The existing file upload requests for this device" + fileUploadRequests( + "How to sort the records in the response" + sort: [FileUploadRequestSortInput] + + "A filter to limit the results" + filter: FileUploadRequestFilterInput + + "The number of records to return from the beginning. Maximum 250" + first: Int + + "Show records before the specified keyset." + before: String + + "Show records after the specified keyset." + after: String + + "The number of records to return to the end. Maximum 250" + last: Int + ): FileUploadRequestConnection! + applicationDeployments( "How to sort the records in the response" sort: [DeploymentSortInput] @@ -5315,6 +5339,16 @@ type Device implements Node { wifiScanResults: [WifiScanResult!] } +type file_upload_requests_by_device_result { + created: FileUploadRequest + updated: FileUploadRequest +} + +type file_upload_requests_result { + created: FileUploadRequest + updated: FileUploadRequest +} + "The result of the :create_file_upload_request mutation" type CreateFileUploadRequestResult { "The successful result of the mutation" @@ -5333,19 +5367,246 @@ input CreateFileUploadRequestInput { deviceId: ID! } +enum FileUploadRequestSortField { + ID + URL + SOURCE + SOURCE_TYPE + COMPRESSION + PROGRESS_TRACKED + STATUS + PROGRESS_PERCENTAGE + RESPONSE_CODE + RESPONSE_MESSAGE + HTTP_HEADERS +} + +":file_upload_request connection" +type FileUploadRequestConnection { + "Total count on all pages" + count: Int + + "Page information" + pageInfo: PageInfo! + + ":file_upload_request edges" + edges: [FileUploadRequestEdge!] +} + +":file_upload_request edge" +type FileUploadRequestEdge { + "Cursor" + cursor: String! + + ":file_upload_request node" + node: FileUploadRequest! +} + +input FileUploadRequestFilterHttpHeaders { + isNil: Boolean + eq: JsonString + notEq: JsonString + in: [JsonString] + lessThan: JsonString + greaterThan: JsonString + lessThanOrEqual: JsonString + greaterThanOrEqual: JsonString + isDistinctFrom: JsonString + isNotDistinctFrom: JsonString +} + +input FileUploadRequestFilterResponseMessage { + isNil: Boolean + eq: String + notEq: String + in: [String] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + contains: String + isDistinctFrom: String + isNotDistinctFrom: String + like: String + ilike: String +} + +input FileUploadRequestFilterResponseCode { + isNil: Boolean + eq: Int + notEq: Int + in: [Int] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int + isDistinctFrom: Int + isNotDistinctFrom: Int +} + +input FileUploadRequestFilterProgressPercentage { + isNil: Boolean + eq: Int + notEq: Int + in: [Int] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int + isDistinctFrom: Int + isNotDistinctFrom: Int +} + +input FileUploadRequestFilterStatus { + isNil: Boolean + eq: FileUploadRequestStatus + notEq: FileUploadRequestStatus + in: [FileUploadRequestStatus] + lessThan: FileUploadRequestStatus + greaterThan: FileUploadRequestStatus + lessThanOrEqual: FileUploadRequestStatus + greaterThanOrEqual: FileUploadRequestStatus + isDistinctFrom: FileUploadRequestStatus + isNotDistinctFrom: FileUploadRequestStatus +} + +input FileUploadRequestFilterProgressTracked { + isNil: Boolean + eq: Boolean + notEq: Boolean + in: [Boolean] + lessThan: Boolean + greaterThan: Boolean + lessThanOrEqual: Boolean + greaterThanOrEqual: Boolean + isDistinctFrom: Boolean + isNotDistinctFrom: Boolean +} + +input FileUploadRequestFilterCompression { + isNil: Boolean + eq: String + notEq: String + in: [String] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + contains: String + isDistinctFrom: String + isNotDistinctFrom: String + like: String + ilike: String +} + +input FileUploadRequestFilterSourceType { + isNil: Boolean + eq: FileSource + notEq: FileSource + in: [FileSource!] + lessThan: FileSource + greaterThan: FileSource + lessThanOrEqual: FileSource + greaterThanOrEqual: FileSource + isDistinctFrom: FileSource + isNotDistinctFrom: FileSource +} + +input FileUploadRequestFilterSource { + isNil: Boolean + eq: String + notEq: String + in: [String] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + contains: String + isDistinctFrom: String + isNotDistinctFrom: String + like: String + ilike: String +} + +input FileUploadRequestFilterUrl { + isNil: Boolean + eq: String + notEq: String + in: [String!] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + contains: String + isDistinctFrom: String + isNotDistinctFrom: String + like: String + ilike: String +} + +input FileUploadRequestFilterId { + isNil: Boolean + eq: ID + notEq: ID + in: [ID!] + lessThan: ID + greaterThan: ID + lessThanOrEqual: ID + greaterThanOrEqual: ID + isDistinctFrom: ID + isNotDistinctFrom: ID +} + +input FileUploadRequestFilterInput { + and: [FileUploadRequestFilterInput!] + or: [FileUploadRequestFilterInput!] + not: [FileUploadRequestFilterInput!] + id: FileUploadRequestFilterId + url: FileUploadRequestFilterUrl + source: FileUploadRequestFilterSource + sourceType: FileUploadRequestFilterSourceType + compression: FileUploadRequestFilterCompression + progressTracked: FileUploadRequestFilterProgressTracked + status: FileUploadRequestFilterStatus + progressPercentage: FileUploadRequestFilterProgressPercentage + responseCode: FileUploadRequestFilterResponseCode + responseMessage: FileUploadRequestFilterResponseMessage + httpHeaders: FileUploadRequestFilterHttpHeaders + device: DeviceFilterInput +} + +input FileUploadRequestSortInput { + order: SortOrder + field: FileUploadRequestSortField! +} + type FileUploadRequest { id: ID! + url: String! + source: String + sourceType: FileSource! + compression: String + progressTracked: Boolean + status: FileUploadRequestStatus + progressPercentage: Int + responseCode: Int + responseMessage: String + httpHeaders: JsonString + device: Device! + + "Get a presigned URL for downloading the uploaded file. The URL is valid for a limited time." + getPresignedUrl: String } type file_download_requests_by_device_result { @@ -8317,6 +8578,16 @@ type RootSubscriptionType { deviceId: ID! ): file_download_requests_by_device_result + fileUploadRequests( + "A filter to limit the results" + filter: FileUploadRequestFilterInput + ): file_upload_requests_result + fileUploadRequestsByDevice( + "A filter to limit the results" + filter: FileUploadRequestFilterInput + + deviceId: ID! + ): file_upload_requests_by_device_result deviceChanged( "A filter to limit the results" filter: DeviceFilterInput diff --git a/frontend/src/components/DeviceTabs/FileManagementTab.tsx b/frontend/src/components/DeviceTabs/FileManagementTab.tsx new file mode 100644 index 000000000..757709297 --- /dev/null +++ b/frontend/src/components/DeviceTabs/FileManagementTab.tsx @@ -0,0 +1,160 @@ +/* + * This file is part of Edgehog. + * + * Copyright 2026 SECO Mind Srl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useMemo, useState } from "react"; +import { graphql, useFragment } from "react-relay/hooks"; +import { FormattedMessage, useIntl } from "react-intl"; +import Select from "react-select"; + +import type { FileManagementTab_fileManagement$key } from "@/api/__generated__/FileManagementTab_fileManagement.graphql"; + +import FilesDownloadTab from "@/components/DeviceTabs/FilesDownloadTab"; +import FilesUploadTab from "@/components/DeviceTabs/FilesUploadTab"; +import Form from "@/components/Form"; +import { Tab } from "@/components/Tabs"; + +type FileManagementTabProps = { + deviceRef: FileManagementTab_fileManagement$key; +}; + +type FileManagementMode = + | "to-device-file" + | "to-device-repository" + | "from-device"; + +type FileManagementModeOption = { + value: FileManagementMode; + label: string; +}; + +const FILE_MANAGEMENT_FRAGMENT = graphql` + fragment FileManagementTab_fileManagement on Device { + capabilities + ...FilesUploadTab_fileDownloadRequests + ...FilesDownloadTab_fileUploadRequests + } +`; + +const FileManagementTab = ({ deviceRef }: FileManagementTabProps) => { + const intl = useIntl(); + const data = useFragment(FILE_MANAGEMENT_FRAGMENT, deviceRef); + + const supportsServerToDevice = + data.capabilities.includes("POSIX_FILE_TRANSFER_STORAGE") || + data.capabilities.includes("WINDOWS_FILE_TRANSFER_STORAGE") || + data.capabilities.includes("POSIX_FILE_TRANSFER_STREAM") || + data.capabilities.includes("WINDOWS_FILE_TRANSFER_STREAM"); + + const supportsDeviceToServer = + data.capabilities.includes("FILE_TRANSFER_READ"); + + const modeOptions = useMemo>(() => { + const options: Array = []; + + if (supportsServerToDevice) { + options.push( + { + value: "to-device-file", + label: intl.formatMessage({ + id: "components.DeviceTabs.FileManagementTab.toDeviceDirect", + defaultMessage: "To Device - Direct File", + }), + }, + { + value: "to-device-repository", + label: intl.formatMessage({ + id: "components.DeviceTabs.FileManagementTab.toDeviceRepository", + defaultMessage: "To Device - Repository", + }), + }, + ); + } + + if (supportsDeviceToServer) { + options.push({ + value: "from-device", + label: intl.formatMessage({ + id: "components.DeviceTabs.FileManagementTab.fromDevice", + defaultMessage: "From Device", + }), + }); + } + + return options; + }, [intl, supportsDeviceToServer, supportsServerToDevice]); + + const [selectedMode, setSelectedMode] = + useState("to-device-file"); + + if (modeOptions.length === 0) { + return null; + } + + const fallbackMode = modeOptions[0]?.value ?? "to-device-file"; + + const effectiveMode = modeOptions.some((m) => m.value === selectedMode) + ? selectedMode + : fallbackMode; + + const selectedModeOption = + modeOptions.find((option) => option.value === effectiveMode) ?? null; + + return ( + + {modeOptions.length > 1 && ( +
+ +
+ +
+ + value={selectedModeOption} + onChange={(option) => { + setSelectedMode(option?.value ?? fallbackMode); + }} + options={modeOptions} + isSearchable={false} + styles={{ + container: (base) => ({ + ...base, + maxWidth: "20rem", + minWidth: "16rem", + }), + }} + /> +
+
+ )} + + {effectiveMode === "from-device" ? ( + + ) : effectiveMode === "to-device-repository" ? ( + + ) : ( + + )} +
+ ); +}; + +export default FileManagementTab; diff --git a/frontend/src/components/DeviceTabs/FilesDownloadTab.tsx b/frontend/src/components/DeviceTabs/FilesDownloadTab.tsx new file mode 100644 index 000000000..752290b45 --- /dev/null +++ b/frontend/src/components/DeviceTabs/FilesDownloadTab.tsx @@ -0,0 +1,377 @@ +/* + * This file is part of Edgehog. + * + * Copyright 2026 SECO Mind Srl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { + ConnectionHandler, + graphql, + useMutation, + usePaginationFragment, + useSubscription, +} from "react-relay/hooks"; +import { useParams } from "react-router-dom"; + +import type { FilesDownloadTab_PaginationQuery } from "@/api/__generated__/FilesDownloadTab_PaginationQuery.graphql"; +import type { FilesDownloadTab_createFileUploadRequest_Mutation } from "@/api/__generated__/FilesDownloadTab_createFileUploadRequest_Mutation.graphql"; +import type { FilesDownloadTab_fileUploadRequest_updated_Subscription } from "@/api/__generated__/FilesDownloadTab_fileUploadRequest_updated_Subscription.graphql"; +import type { FilesDownloadTab_fileUploadRequests$key } from "@/api/__generated__/FilesDownloadTab_fileUploadRequests.graphql"; + +import Alert from "@/components/Alert"; +import FileUploadRequestsTable from "@/components/FileUploadRequestsTable"; +import Stack from "@/components/Stack"; +import { Tab } from "@/components/Tabs"; +import ManualFileUploadRequestForm, { + type StorageSourceOption, + type SourceTypeOption, +} from "@/forms/ManualFileUploadRequestForm"; +import type { ManualFileUploadRequestData } from "@/forms/validation"; + +// We use graphql fields below in table columns configuration +/* eslint-disable relay/unused-fields */ +const DEVICE_FILE_UPLOAD_REQUESTS_FRAGMENT = graphql` + fragment FilesDownloadTab_fileUploadRequests on Device + @refetchable(queryName: "FilesDownloadTab_PaginationQuery") { + id + capabilities + fileUploadRequests(first: $first, after: $after) + @connection(key: "FilesDownloadTab_fileUploadRequests") { + edges { + node { + id + url + getPresignedUrl + source + sourceType + compression + progressTracked + status + progressPercentage + responseCode + responseMessage + httpHeaders + } + } + } + storageFileDownloadRequests: fileDownloadRequests( + first: $first + after: $after + filter: { destinationType: { eq: STORAGE } } + ) { + edges { + node { + pathOnDevice + fileName + } + } + } + } +`; + +const DEVICE_CREATE_FILE_UPLOAD_REQUEST_MUTATION = graphql` + mutation FilesDownloadTab_createFileUploadRequest_Mutation( + $input: CreateFileUploadRequestInput! + ) { + createFileUploadRequest(input: $input) { + result { + id + url + getPresignedUrl + source + sourceType + compression + progressTracked + status + progressPercentage + responseCode + responseMessage + httpHeaders + } + } + } +`; + +const FILE_UPLOAD_REQUEST_UPDATED_SUBSCRIPTION = graphql` + subscription FilesDownloadTab_fileUploadRequest_updated_Subscription( + $deviceId: ID! + ) { + fileUploadRequestsByDevice(deviceId: $deviceId) { + updated { + id + status + progressPercentage + responseCode + responseMessage + } + } + } +`; + +type ManualFileUploadRequestFormWrapperProps = { + setErrorFeedback: (feedback: React.ReactNode) => void; + deviceId: string; + sourceTypeOptions: SourceTypeOption[]; + storageSourceOptions: StorageSourceOption[]; +}; + +const ManualFileUploadRequestFormWrapper = ({ + setErrorFeedback, + deviceId, + sourceTypeOptions, + storageSourceOptions, +}: ManualFileUploadRequestFormWrapperProps) => { + const intl = useIntl(); + const [createFileUploadRequest, isCreating] = + useMutation( + DEVICE_CREATE_FILE_UPLOAD_REQUEST_MUTATION, + ); + + const handleSubmit = useCallback( + async (values: ManualFileUploadRequestData) => { + setErrorFeedback(null); + + try { + const { sourceType, source, compression, progressTracked } = values; + + await new Promise((resolve, reject) => { + createFileUploadRequest({ + variables: { + input: { + deviceId, + sourceType, + source, + compression, + progressTracked, + }, + }, + onCompleted(_responseData, errors) { + if (errors && errors.length > 0) { + reject( + new Error( + errors + .map(({ fields, message }) => + fields?.length + ? `${fields.join(" ")} ${message}` + : message, + ) + .join(". \n"), + ), + ); + return; + } + + resolve(); + }, + updater(store, data) { + const newRequestId = data?.createFileUploadRequest?.result?.id; + if (!newRequestId) return; + const newRequest = store.get(newRequestId); + const storedDevice = store.get(deviceId); + if (!storedDevice || !newRequest) return; + + const connection = ConnectionHandler.getConnection( + storedDevice, + "FilesDownloadTab_fileUploadRequests", + ); + if (!connection) return; + + const edges = connection.getLinkedRecords("edges") ?? []; + const alreadyPresent = edges.some( + (edge) => + edge.getLinkedRecord("node")?.getDataID() === newRequestId, + ); + if (alreadyPresent) return; + + const edge = ConnectionHandler.createEdge( + store, + connection, + newRequest, + "FileUploadRequestEdge", + ); + ConnectionHandler.insertEdgeBefore(connection, edge); + }, + onError(error) { + reject(error); + }, + }); + }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : intl.formatMessage({ + id: "components.DeviceTabs.FilesDownloadTab.error.unknownError", + defaultMessage: "An unknown error occurred.", + }); + + setErrorFeedback(message); + } + }, + [createFileUploadRequest, deviceId, intl, setErrorFeedback], + ); + + return ( + + ); +}; + +type FilesDownloadTabProps = { + deviceRef: FilesDownloadTab_fileUploadRequests$key; + embedded?: boolean; +}; + +const FilesDownloadTab = ({ + deviceRef, + embedded = false, +}: FilesDownloadTabProps) => { + const intl = useIntl(); + const { deviceId = "" } = useParams(); + + const [errorFeedback, setErrorFeedback] = useState(null); + + const { data } = usePaginationFragment< + FilesDownloadTab_PaginationQuery, + FilesDownloadTab_fileUploadRequests$key + >(DEVICE_FILE_UPLOAD_REQUESTS_FRAGMENT, deviceRef); + + useSubscription( + useMemo( + () => ({ + subscription: FILE_UPLOAD_REQUEST_UPDATED_SUBSCRIPTION, + variables: { deviceId }, + }), + [deviceId], + ), + ); + + const fileUploadRequests = useMemo( + () => data.fileUploadRequests?.edges?.map((edge) => edge.node) ?? [], + [data.fileUploadRequests], + ); + + const sourceTypeOptions: SourceTypeOption[] = [ + { value: "STORAGE", label: "Storage" }, + { value: "FILESYSTEM", label: "File System" }, + ]; + + const storageSourceOptions: StorageSourceOption[] = useMemo(() => { + const uniqueStorageIds = new Map(); + + data.storageFileDownloadRequests?.edges?.forEach((edge) => { + const request = edge?.node; + + if (!request) { + return; + } + + const storageId = request.pathOnDevice?.trim(); + + if (!storageId || uniqueStorageIds.has(storageId)) { + return; + } + + const label = request.fileName + ? `${request.fileName} (${storageId})` + : storageId; + + uniqueStorageIds.set(storageId, label); + }); + + return Array.from(uniqueStorageIds.entries()).map(([value, label]) => ({ + value, + label, + })); + }, [data.storageFileDownloadRequests]); + + if (!data.capabilities.includes("FILE_TRANSFER_READ")) { + return null; + } + + const content = ( + <> +
+ setErrorFeedback(null)} + dismissible + > + {errorFeedback} + + + + + +
+ +
+ +
+
+ +
+ + +
+ + ); + + if (embedded) { + return content; + } + + return ( + +
+
+ +
+ {content} +
+
+ ); +}; + +export default FilesDownloadTab; diff --git a/frontend/src/components/DeviceTabs/FilesUploadTab.tsx b/frontend/src/components/DeviceTabs/FilesUploadTab.tsx index deffa3fef..c4bb4c47f 100644 --- a/frontend/src/components/DeviceTabs/FilesUploadTab.tsx +++ b/frontend/src/components/DeviceTabs/FilesUploadTab.tsx @@ -608,13 +608,20 @@ const ManualFileDownloadRequestFromRepositoryFormWrapper = ({ type FilesUploadTabProps = { deviceRef: FilesUploadTab_fileDownloadRequests$key; + embedded?: boolean; + embeddedMode?: "file" | "repository"; }; -const FilesUploadTab = ({ deviceRef }: FilesUploadTabProps) => { +const FilesUploadTab = ({ + deviceRef, + embedded = false, + embeddedMode, +}: FilesUploadTabProps) => { const intl = useIntl(); const { deviceId = "" } = useParams(); const [updateMode, setUpdateMode] = useState<"repository" | "file">("file"); + const effectiveUpdateMode = embeddedMode ?? updateMode; const [errorFeedback, setErrorFeedback] = useState(null); @@ -689,21 +696,9 @@ const FilesUploadTab = ({ deviceRef }: FilesUploadTabProps) => { return null; } - return ( - + const content = ( + <>
-
- -
{ }> -
- - + - - - - - - - -
- - {updateMode === "file" ? ( + + + + + + + + +
+ )} + + {effectiveUpdateMode === "file" ? ( { + + ); + + if (embedded) { + return content; + } + + return ( + +
+
+ +
+ {content} +
); }; diff --git a/frontend/src/components/FileUploadRequestsTable.tsx b/frontend/src/components/FileUploadRequestsTable.tsx new file mode 100644 index 000000000..77e7c7b1c --- /dev/null +++ b/frontend/src/components/FileUploadRequestsTable.tsx @@ -0,0 +1,247 @@ +/* + * This file is part of Edgehog. + * + * Copyright 2026 SECO Mind Srl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useMemo, type ReactNode } from "react"; +import { FormattedMessage } from "react-intl"; + +import type { FilesDownloadTab_fileUploadRequests$data } from "@/api/__generated__/FilesDownloadTab_fileUploadRequests.graphql"; + +import Button from "@/components/Button"; +import Icon from "@/components/Icon"; +import RequestStatus from "@/components/RequestStatus"; +import Table, { createColumnHelper } from "@/components/Table"; + +type FileUploadRequestNode = NonNullable< + NonNullable< + FilesDownloadTab_fileUploadRequests$data["fileUploadRequests"] + >["edges"] +>[number]["node"]; + +const columnHelper = createColumnHelper(); +const getColumnsDefinition = ( + setErrorFeedback: (feedback: ReactNode) => void, +) => [ + columnHelper.accessor("status", { + header: () => ( + + ), + cell: ({ getValue }) => { + const status = getValue(); + return ; + }, + }), + columnHelper.accessor("progressPercentage", { + header: () => ( + + ), + cell: ({ getValue, row }) => { + const progressTracked = row.original.progressTracked; + + if (!progressTracked) { + return ( + + + + ); + } + + const progress = getValue(); + return progress != null ? `${progress}%` : null; + }, + }), + columnHelper.accessor("sourceType", { + header: () => ( + + ), + cell: ({ getValue, row }) => { + const sourceType = getValue(); + + if (sourceType === "FILESYSTEM") { + return ( + + ); + } + + if (sourceType === "STORAGE") { + return ( + + ); + } + + return sourceType; + }, + }), + columnHelper.accessor("compression", { + header: () => ( + + ), + cell: ({ getValue }) => { + const compression = getValue(); + + if (!compression) { + return ( + + + + ); + } + + return compression; + }, + }), + columnHelper.accessor("responseMessage", { + header: () => ( + + ), + cell: ({ getValue, row }) => { + const statusCode = row.original.responseCode; + const message = getValue(); + + if (!statusCode && !message) return null; + + if (!statusCode) return message ?? null; + if (!message) return String(statusCode); + + return `${statusCode}: ${message}`; + }, + }), + columnHelper.display({ + id: "actions", + header: () => ( + + ), + cell: ({ row }) => { + const downloadUrl = row.original.getPresignedUrl; + + const isDownloadReady = + row.original.status === "COMPLETED" && !!downloadUrl; + + if (!isDownloadReady) { + return ( + + + + ); + } + + return ( + + ); + }, + }), +]; + +type FileUploadRequestsTableProps = { + requests: FileUploadRequestNode[]; + setErrorFeedback: (feedback: ReactNode) => void; +}; + +const FileUploadRequestsTable = ({ + requests, + setErrorFeedback, +}: FileUploadRequestsTableProps) => { + const columns = useMemo( + () => getColumnsDefinition(setErrorFeedback), + [setErrorFeedback], + ); + + if (requests.length === 0) { + return ( +

+ +

+ ); + } + + return ; +}; + +export default FileUploadRequestsTable; diff --git a/frontend/src/forms/ManualFileUploadRequestForm.tsx b/frontend/src/forms/ManualFileUploadRequestForm.tsx new file mode 100644 index 000000000..5edd677fa --- /dev/null +++ b/frontend/src/forms/ManualFileUploadRequestForm.tsx @@ -0,0 +1,325 @@ +/* + * This file is part of Edgehog. + * + * Copyright 2026 SECO Mind Srl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Controller, useForm, useWatch } from "react-hook-form"; +import { FormattedMessage, useIntl } from "react-intl"; +import Select from "react-select"; +import CreatableSelect from "react-select/creatable"; + +import Button from "@/components/Button"; +import Col from "@/components/Col"; +import Form from "@/components/Form"; +import { FormRowWithMargin as FormRow } from "@/components/FormRow"; +import Row from "@/components/Row"; +import Spinner from "@/components/Spinner"; +import FormFeedback from "@/forms/FormFeedback"; +import { + fileUploadRequestFormSchema, + type FileSourceType, + type ManualFileUploadRequestData, +} from "@/forms/validation"; + +type SourceTypeOption = { + value: FileSourceType; + label: string; +}; + +type CompressionOption = { + value: string; + label: string; +}; + +type StorageSourceOption = { + value: string; + label: string; +}; + +type ManualFileUploadRequestFormProps = { + className?: string; + isLoading: boolean; + onSubmit: (values: ManualFileUploadRequestData) => void; + sourceTypeOptions: SourceTypeOption[]; + storageSourceOptions: StorageSourceOption[]; +}; + +const ManualFileUploadRequestForm = ({ + className, + isLoading, + onSubmit, + sourceTypeOptions, + storageSourceOptions, +}: ManualFileUploadRequestFormProps) => { + const intl = useIntl(); + + const { + control, + formState: { errors }, + handleSubmit, + register, + reset, + } = useForm({ + mode: "onTouched", + defaultValues: { + sourceType: "STORAGE", + source: null, + compression: "", + progressTracked: false, + }, + resolver: zodResolver(fileUploadRequestFormSchema), + }); + + const selectedSourceType = useWatch({ + control, + name: "sourceType", + }); + + const effectiveSourceType = selectedSourceType ?? "STORAGE"; + + const sourcePlaceholderByType: Record = { + STORAGE: intl.formatMessage({ + id: "forms.ManualFileUploadRequestForm.sourceStoragePlaceholder", + defaultMessage: "Select or enter a storage file ID", + }), + FILESYSTEM: "/tmp/file.bin", + }; + + const compressionOptions: CompressionOption[] = [ + { + value: "", + label: intl.formatMessage({ + id: "forms.ManualFileUploadRequestForm.compressionNone", + defaultMessage: "None", + }), + }, + { + value: "tar.gz", + label: intl.formatMessage({ + id: "forms.ManualFileUploadRequestForm.compressionTarGz", + defaultMessage: "tar.gz", + }), + }, + ]; + + const onFormSubmit = handleSubmit((data) => { + onSubmit(data); + reset({ + sourceType: data.sourceType, + source: data.source, + compression: data.compression ?? "", + progressTracked: data.progressTracked, + }); + }); + + return ( +
+ + } + > + { + const selectedOption = + sourceTypeOptions.find((opt) => opt.value === field.value) || + null; + + return ( + { + field.onChange(option?.value ?? ""); + }} + options={compressionOptions} + /> + ); + }} + /> + + {errors.compression ? ( + + ) : ( + + + + )} + + + + } + > + + + + + +
+ + + + + ); +}; + +export type { SourceTypeOption }; +export type { StorageSourceOption }; + +export default ManualFileUploadRequestForm; diff --git a/frontend/src/forms/validation.ts b/frontend/src/forms/validation.ts index 3daaee81f..6a794d6ae 100644 --- a/frontend/src/forms/validation.ts +++ b/frontend/src/forms/validation.ts @@ -259,6 +259,10 @@ const fileDestinationTypeSchema = z.enum([ type FileDestinationType = z.infer; +const fileSourceTypeSchema = z.enum(["STORAGE", "FILESYSTEM"]); + +type FileSourceType = z.infer; + const nullableDestinationSchema = z.preprocess((value) => { if (typeof value !== "string") { return value ?? null; @@ -269,6 +273,16 @@ const nullableDestinationSchema = z.preprocess((value) => { return trimmed === "" ? null : trimmed; }, z.string().nullable()); +const nullableSourceSchema = z.preprocess((value) => { + if (typeof value !== "string") { + return value ?? null; + } + + const trimmed = value.trim(); + + return trimmed === "" ? null : trimmed; +}, z.string().nullable()); + /* ----------------------------- Schemas ----------------------------- */ const handleSchema = z @@ -633,6 +647,29 @@ type ManualFileDownloadRequestFromRepositoryData = z.infer< typeof manualFileDownloadRequestFromRepositorySchema >; +const fileUploadRequestFormSchema = z + .object({ + sourceType: fileSourceTypeSchema, + source: nullableSourceSchema, + compression: z.string().optional(), + progressTracked: z.boolean(), + }) + .superRefine((data, ctx) => { + if (data.source === null) { + ctx.addIssue({ + code: "custom", + message: messages.required.id, + path: ["source"], + }); + } + }) + .transform((data) => ({ + ...data, + compression: data.compression?.trim() ? data.compression.trim() : undefined, + })); + +type ManualFileUploadRequestData = z.infer; + /* ----------------------------- Campaigns Schemas ----------------------------- */ const deploymentCampaignSchema = z @@ -1065,6 +1102,8 @@ export type { FileFormData, ManualFileDownloadRequestFromRepositoryData, FileDestinationType, + FileSourceType, + ManualFileUploadRequestData, }; export { @@ -1098,4 +1137,5 @@ export { repositoryUpdateSchema, fileSchema, manualFileDownloadRequestFromRepositorySchema, + fileUploadRequestFormSchema, }; diff --git a/frontend/src/i18n/langs/en.json b/frontend/src/i18n/langs/en.json index 3258453de..e6d6fa2fb 100644 --- a/frontend/src/i18n/langs/en.json +++ b/frontend/src/i18n/langs/en.json @@ -751,6 +751,30 @@ "components.DeviceTabs.CellularConnectionTab": { "defaultMessage": "Cellular Connection" }, + "components.DeviceTabs.FileManagementTab.fileManagementLabel": { + "defaultMessage": "File Management Mode" + }, + "components.DeviceTabs.FileManagementTab.fromDevice": { + "defaultMessage": "From Device" + }, + "components.DeviceTabs.FileManagementTab.toDeviceDirect": { + "defaultMessage": "To Device - Direct File" + }, + "components.DeviceTabs.FileManagementTab.toDeviceRepository": { + "defaultMessage": "To Device - Repository" + }, + "components.DeviceTabs.FilesDownloadTab": { + "defaultMessage": "Files Download" + }, + "components.DeviceTabs.FilesDownloadTab.error.unknownError": { + "defaultMessage": "An unknown error occurred." + }, + "components.DeviceTabs.FilesDownloadTab.requestHistory": { + "defaultMessage": "Request History" + }, + "components.DeviceTabs.FilesDownloadTab.uploadSource": { + "defaultMessage": "Upload Source" + }, "components.DeviceTabs.FilesUploadTab": { "defaultMessage": "Files Upload" }, @@ -1049,6 +1073,45 @@ "components.FileSelect.noFilesFoundMatching": { "defaultMessage": "No files found matching \"{inputValue}\"" }, + "components.FileUploadRequestsTable.actions": { + "defaultMessage": "Actions" + }, + "components.FileUploadRequestsTable.actions.notAvailableYet": { + "defaultMessage": "Not available yet" + }, + "components.FileUploadRequestsTable.compression": { + "defaultMessage": "Compression" + }, + "components.FileUploadRequestsTable.compression.none": { + "defaultMessage": "None" + }, + "components.FileUploadRequestsTable.downloadError": { + "defaultMessage": "Failed to download file" + }, + "components.FileUploadRequestsTable.noRequests": { + "defaultMessage": "No file upload requests have been sent yet." + }, + "components.FileUploadRequestsTable.progress": { + "defaultMessage": "Progress" + }, + "components.FileUploadRequestsTable.responseMessage": { + "defaultMessage": "Response Message" + }, + "components.FileUploadRequestsTable.source": { + "defaultMessage": "Source" + }, + "components.FileUploadRequestsTable.source.filesystem": { + "defaultMessage": "FILESYSTEM: {source}" + }, + "components.FileUploadRequestsTable.source.storage": { + "defaultMessage": "STORAGE: {source}" + }, + "components.FileUploadRequestsTable.status": { + "defaultMessage": "Status" + }, + "components.FileUploadRequestsTable.status.notTracked": { + "defaultMessage": "Not Tracked" + }, "components.FilesTable.action": { "defaultMessage": "Action" }, @@ -2475,6 +2538,45 @@ "forms.ManualFileDownloadRequestFromRepositoryForm.userIdLabel": { "defaultMessage": "User ID" }, + "forms.ManualFileUploadRequestForm.compressionHint": { + "defaultMessage": "Optional compression format. Leave empty for no compression." + }, + "forms.ManualFileUploadRequestForm.compressionLabel": { + "defaultMessage": "Compression" + }, + "forms.ManualFileUploadRequestForm.compressionNone": { + "defaultMessage": "None" + }, + "forms.ManualFileUploadRequestForm.compressionTarGz": { + "defaultMessage": "tar.gz" + }, + "forms.ManualFileUploadRequestForm.progressLabel": { + "defaultMessage": "Report Progress" + }, + "forms.ManualFileUploadRequestForm.sourceLabel": { + "defaultMessage": "Source" + }, + "forms.ManualFileUploadRequestForm.sourcePathHint": { + "defaultMessage": "Absolute path to the file on the device that should be uploaded." + }, + "forms.ManualFileUploadRequestForm.sourceStorageCreateOption": { + "defaultMessage": "Use \"{value}\"" + }, + "forms.ManualFileUploadRequestForm.sourceStorageHint": { + "defaultMessage": "Select a known storage file ID from previous storage downloads, or type one manually." + }, + "forms.ManualFileUploadRequestForm.sourceStorageNoOptions": { + "defaultMessage": "No known storage file IDs for this device yet." + }, + "forms.ManualFileUploadRequestForm.sourceStoragePlaceholder": { + "defaultMessage": "Select or enter a storage file ID" + }, + "forms.ManualFileUploadRequestForm.sourceTypeLabel": { + "defaultMessage": "Source Type" + }, + "forms.ManualFileUploadRequestForm.uploadButton": { + "defaultMessage": "Request Upload" + }, "forms.ManualOtaFromCollectionForm.baseImageCollectionOption": { "defaultMessage": "Search or select a base image collection..." }, diff --git a/frontend/src/pages/Device.tsx b/frontend/src/pages/Device.tsx index bd5d118d7..d919b1303 100644 --- a/frontend/src/pages/Device.tsx +++ b/frontend/src/pages/Device.tsx @@ -80,7 +80,7 @@ import DeviceNetworkInterfacesTab from "@/components/DeviceTabs/NetworkInterface import DeviceLocationTab from "@/components/DeviceTabs/LocationTab"; import DeviceWiFiScanResultsTab from "@/components/DeviceTabs/WiFiScanResultsTab"; import DeviceSoftwareUpdateTab from "@/components/DeviceTabs/SoftwareUpdateTab"; -import DeviceFilesUploadTab from "@/components/DeviceTabs/FilesUploadTab"; +import DeviceFileManagementTab from "@/components/DeviceTabs/FileManagementTab"; import DeviceApplicationsTab from "@/components/DeviceTabs/ApplicationsTab"; const DEVICE_CONNECTION_STATUS_FRAGMENT = graphql` @@ -135,7 +135,7 @@ const GET_DEVICE_QUERY = graphql` ...SoftwareUpdateTab_otaOperations ...CellularConnectionTab_cellularConnection ...NetworkInterfacesTab_networkInterfaces - ...FilesUploadTab_fileDownloadRequests + ...FileManagementTab_fileManagement ...Device_connectionStatus } ...ApplicationsTab_deployedApplications @@ -926,7 +926,7 @@ const DeviceContent = ({ "device-network-interfaces-tab", "device-wifi-scan-results-tab", "device-software-update-tab", - "device-files-upload-tab", + "device-file-management-tab", "applications-tab", ]} > @@ -942,7 +942,7 @@ const DeviceContent = ({ - +