From e3c0eaad45593602f312137f4cb85248fd09401d Mon Sep 17 00:00:00 2001 From: Omar Date: Wed, 4 Mar 2026 15:37:20 +0100 Subject: [PATCH 1/2] feat: paginate file_download_requests with relay Add `file_download_requests: :relay` to `paginate_relationship_with` in `Device`, enabling cursor-based pagination over a device's file download requests consistent with other paginated relationships. Signed-off-by: Omar --- backend/lib/edgehog/devices/device/device.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/lib/edgehog/devices/device/device.ex b/backend/lib/edgehog/devices/device/device.ex index e8a483f7b..9c4cda7db 100644 --- a/backend/lib/edgehog/devices/device/device.ex +++ b/backend/lib/edgehog/devices/device/device.ex @@ -61,7 +61,8 @@ defmodule Edgehog.Devices.Device do # datalayer subqueries so Ash can compose and support the functionality. paginate_relationship_with application_deployments: :relay, ota_operations: :relay, - tags: :relay + tags: :relay, + file_download_requests: :relay subscriptions do pubsub EdgehogWeb.Endpoint From dea454189a66c64c2260d452700856b0649042f5 Mon Sep 17 00:00:00 2001 From: Omar Date: Wed, 4 Mar 2026 15:38:12 +0100 Subject: [PATCH 2/2] feat: add Files Upload tab with manual file download request flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FilesUploadTab with a 4-step upload flow: `compute SHA-256 digest` → `fetch presigned PUT URL` → `upload to S3` → `create FileDownloadRequest via GraphQL mutation` - Add ManualFileDownloadRequestForm with destination, TTL, and progress fields validated via zod - Add FileDownloadRequestsTable showing request history Signed-off-by: Omar --- frontend/package-lock.json | 31 + frontend/package.json | 2 + frontend/src/api/schema.graphql | 1290 +++++++++++++++-- .../components/DeviceTabs/FilesUploadTab.tsx | 411 ++++++ .../components/FileDownloadRequestsTable.tsx | 183 +++ frontend/src/components/FileDropzone.tsx | 319 ++++ frontend/src/components/RequestStatus.tsx | 89 ++ .../forms/ManualFileDownloadRequestForm.tsx | 257 ++++ frontend/src/forms/validation.ts | 11 + frontend/src/i18n/langs/en.json | 117 ++ frontend/src/lib/files.ts | 194 +++ frontend/src/pages/Device.tsx | 4 + 12 files changed, 2751 insertions(+), 157 deletions(-) create mode 100644 frontend/src/components/DeviceTabs/FilesUploadTab.tsx create mode 100644 frontend/src/components/FileDownloadRequestsTable.tsx create mode 100644 frontend/src/components/FileDropzone.tsx create mode 100644 frontend/src/components/RequestStatus.tsx create mode 100644 frontend/src/forms/ManualFileDownloadRequestForm.tsx create mode 100644 frontend/src/lib/files.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 53a296494..799731355 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", + "fflate": "^0.8.2", "graphql": "^16.9.0", "history": "^5.1.0", "js-cookie": "^3.0.5", @@ -57,6 +58,7 @@ "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", "typescript": "^5.5.3", + "uuid": "^13.0.0", "vite": "^7.3.1", "vite-plugin-eslint": "^1.8.1", "vite-plugin-relay-lite": "^0.12.0", @@ -5667,6 +5669,12 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -9754,6 +9762,19 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -13726,6 +13747,11 @@ "web-streams-polyfill": "^3.0.3" } }, + "fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, "figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -16288,6 +16314,11 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==" + }, "validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8afff51b0..e9aac64f4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", + "fflate": "^0.8.2", "graphql": "^16.9.0", "history": "^5.1.0", "js-cookie": "^3.0.5", @@ -59,6 +60,7 @@ "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", "typescript": "^5.5.3", + "uuid": "^13.0.0", "vite": "^7.3.1", "vite-plugin-eslint": "^1.8.1", "vite-plugin-relay-lite": "^0.12.0", diff --git a/frontend/src/api/schema.graphql b/frontend/src/api/schema.graphql index c8e33eaf3..ab1eaa35f 100644 --- a/frontend/src/api/schema.graphql +++ b/frontend/src/api/schema.graphql @@ -26,11 +26,7 @@ input ContainerCreateWithNestedDeviceMappingsInput { } input CampaignCampaignMechanismFirmwareUpgradeInput { - """ - This boolean flag determines if the Base Image will be pushed to the - Device even if it already has a greater version of the Base Image. - """ - forceDowngrade: Boolean + baseImageId: ID """ The maximum percentage of failures allowed over the number of total targets. @@ -60,7 +56,11 @@ input CampaignCampaignMechanismFirmwareUpgradeInput { """ requestTimeoutSeconds: Int - baseImageId: ID + """ + This boolean flag determines if the Base Image will be pushed to the + Device even if it already has a greater version of the Base Image. + """ + forceDowngrade: Boolean } "An object representing the properties of a Firmware Upgrade campaign mechanism." @@ -106,6 +106,10 @@ type FirmwareUpgrade { } input CampaignCampaignMechanismDeploymentUpgradeInput { + releaseId: ID + + targetReleaseId: ID + """ The maximum percentage of failures allowed over the number of total targets. If the failures exceed this threshold, the Campaign terminates with @@ -133,10 +137,6 @@ input CampaignCampaignMechanismDeploymentUpgradeInput { Deployment lost (and possibly retry). It must be at least 30 seconds. """ requestTimeoutSeconds: Int - - releaseId: ID - - targetReleaseId: ID } "An object representing the properties of a Upgrade deployment campaign mechanism." @@ -181,6 +181,8 @@ type DeploymentUpgrade { } input CampaignCampaignMechanismDeploymentDeleteInput { + releaseId: ID + """ The maximum percentage of failures allowed over the number of total targets. If the failures exceed this threshold, the Campaign terminates with @@ -208,8 +210,6 @@ input CampaignCampaignMechanismDeploymentDeleteInput { Deployment lost (and possibly retry). It must be at least 30 seconds. """ requestTimeoutSeconds: Int - - releaseId: ID } "An object representing the properties of a Delete deployment campaign mechanism." @@ -249,6 +249,8 @@ type DeploymentDelete { } input CampaignCampaignMechanismDeploymentStopInput { + releaseId: ID + """ The maximum percentage of failures allowed over the number of total targets. If the failures exceed this threshold, the Campaign terminates with @@ -276,8 +278,6 @@ input CampaignCampaignMechanismDeploymentStopInput { Deployment lost (and possibly retry). It must be at least 30 seconds. """ requestTimeoutSeconds: Int - - releaseId: ID } "An object representing the properties of a Stop deployment campaign mechanism." @@ -317,6 +317,8 @@ type DeploymentStop { } input CampaignCampaignMechanismDeploymentStartInput { + releaseId: ID + """ The maximum percentage of failures allowed over the number of total targets. If the failures exceed this threshold, the Campaign terminates with @@ -344,8 +346,6 @@ input CampaignCampaignMechanismDeploymentStartInput { Deployment lost (and possibly retry). It must be at least 30 seconds. """ requestTimeoutSeconds: Int - - releaseId: ID } "An object representing the properties of a Start deployment campaign mechanism." @@ -385,6 +385,8 @@ type DeploymentStart { } input CampaignCampaignMechanismDeploymentDeployInput { + releaseId: ID + """ The maximum percentage of failures allowed over the number of total targets. If the failures exceed this threshold, the Deployment Campaign terminates with @@ -412,8 +414,6 @@ input CampaignCampaignMechanismDeploymentDeployInput { Deployment lost (and possibly retry). It must be at least 30 seconds. """ requestTimeoutSeconds: Int - - releaseId: ID } "An object representing the properties of a Deploy deployment campaign mechanism." @@ -694,6 +694,31 @@ enum ForwarderSessionStatus { CONNECTING } +enum FileDownloadRequestStatus { + "Transfer completed successfully (response_code = 0)" + COMPLETED + + "Transfer failed (response_code != 0 or timeout)" + FAILED + + "Device is actively downloading/uploading (progress updates received)" + IN_PROGRESS + + "Request created in database, not yet sent to device" + PENDING + + "Request sent to device via Astarte, awaiting response" + SENT +} + +enum FileDestination { + "Persist file on device storage" + STORAGE + + "Process file without storing (e.g., pipe to another process)" + STREAMING +} + enum NetworkInterfaceTechnology { "Bluetooth." BLUETOOTH @@ -4588,6 +4613,9 @@ input DeviceFilterInput { "The existing OTA operations for this device" otaOperations: OtaOperationFilterInput + "The existing file download requests for this device" + fileDownloadRequests: FileDownloadRequestFilterInput + applicationDeployments: DeploymentFilterInput } @@ -4689,6 +4717,27 @@ type Device implements Node { last: Int ): OtaOperationConnection! + "The existing file download requests for this device" + fileDownloadRequests( + "How to sort the records in the response" + sort: [FileDownloadRequestSortInput] + + "A filter to limit the results" + filter: FileDownloadRequestFilterInput + + "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 + ): FileDownloadRequestConnection! + applicationDeployments( "How to sort the records in the response" sort: [DeploymentSortInput] @@ -4763,163 +4812,111 @@ type Device implements Node { wifiScanResults: [WifiScanResult!] } -input RequestForwarderSessionInput { - deviceId: ID! -} - -"The details of a forwarder session." -type ForwarderSession { - "The token that identifies the session." - id: ID! - - "The token that identifies the session." - token: String! - - "The status of the session." - status: ForwarderSessionStatus! - - "The hostname of the forwarder instance." - forwarderHostname: String! - - "The port of the forwarder instance." - forwarderPort: Int! +"The result of the :create_file_download_request mutation" +type CreateFileDownloadRequestResult { + "The successful result of the mutation" + result: FileDownloadRequest - "Indicates if TLS is used when the device connects to the forwarder." - secure: Boolean! + "Any errors generated, if the mutation failed" + errors: [MutationError!]! } -"The details of a forwarder instance." -type ForwarderConfig { - "A unique identifier" - id: ID! - - "The hostname of the forwarder instance." - hostname: String! +input CreateFileDownloadRequestInput { + "The URL from which the file can be downloaded." + url: String! - "The port of the forwarder instance." - port: Int! + "The name of the file being downloaded." + fileName: String - "Indicates if TLS should used when connecting to the forwarder." - secureSessions: Boolean! -} + "The size of the file being downloaded, in bytes, before compression." + uncompressedFileSizeBytes: Int -type device_group_result { - created: DeviceGroup - updated: DeviceGroup - destroyed: ID -} + "The digest of the file being downloaded, used for integrity verification." + digest: String -"The result of the :delete_device_group mutation" -type DeleteDeviceGroupResult { - "The record that was successfully deleted" - result: DeviceGroup + "Optional enum string for the file compression with default value empty, other values are: ['tar.gz']" + compression: String - "Any errors generated, if the mutation failed" - errors: [MutationError!]! -} + "Optional ttl for how long to keep the file fore, if 0 is forever, default value is 0." + ttlSeconds: Int -"The result of the :update_device_group mutation" -type UpdateDeviceGroupResult { - "The successful result of the mutation" - result: DeviceGroup + "Optional unix mode for the file, set to default if 0. All files are immutable, so setting it to writable has no effect." + fileMode: Int - "Any errors generated, if the mutation failed" - errors: [MutationError!]! -} + "Optional unix uid of the user owning the file, set to default if -1." + userId: Int -input UpdateDeviceGroupInput { - "The display name of the device group." - name: String + "Optional unix gid of the group owning the file, set to default if -1." + groupId: Int - """ - The identifier of the device group. + "Device-specific field, some default values are storage and streaming." + destination: FileDestination - It should start with a lower case ASCII letter and only contain lower case ASCII letters, digits and the hyphen - symbol. - """ - handle: String + "Flag to enable the progress reporting of the download." + progress: Boolean - """ - The Selector that will determine which devices belong to the device group. + "The ID identifying the File Download Request, generated by the client and used to track the request across the system." + fileDownloadRequestId: ID! - This must be a valid selector expression, consult the Selector section of the Edgehog documentation for more information about Selectors. - """ - selector: String + "The ID identifying the Device the File Download Request will be sent to" + deviceId: ID! } -"The result of the :create_device_group mutation" -type CreateDeviceGroupResult { - "The successful result of the mutation" - result: DeviceGroup - - "Any errors generated, if the mutation failed" - errors: [MutationError!]! +input ReadFileDownloadRequestPresignedUrlInput { + filename: String! + fileDownloadRequestId: ID! } -input CreateDeviceGroupInput { - "The display name of the device group." - name: String! - - """ - The identifier of the device group. - - It should start with a lower case ASCII letter and only contain lower case ASCII letters, digits and the hyphen - symbol. - """ - handle: String! - - """ - The Selector that will determine which devices belong to the device group. - - This must be a valid selector expression, consult the Selector section of the Edgehog documentation for more information about Selectors. - """ - selector: String! +input CreateFileDownloadRequestPresignedUrlInput { + filename: String! + fileDownloadRequestId: ID! } -enum DeviceGroupSortField { +enum FileDownloadRequestSortField { ID - NAME - HANDLE - SELECTOR + URL + FILE_NAME + UNCOMPRESSED_FILE_SIZE_BYTES + DIGEST + COMPRESSION + TTL_SECONDS + FILE_MODE + USER_ID + GROUP_ID + DESTINATION + PROGRESS + STATUS + STATUS_PROGRESS + STATUS_CODE + MESSAGE } -":device_group connection" -type DeviceGroupConnection { +":file_download_request connection" +type FileDownloadRequestConnection { "Total count on all pages" count: Int "Page information" pageInfo: PageInfo! - ":device_group edges" - edges: [DeviceGroupEdge!] + ":file_download_request edges" + edges: [FileDownloadRequestEdge!] } -":device_group edge" -type DeviceGroupEdge { +":file_download_request edge" +type FileDownloadRequestEdge { "Cursor" cursor: String! - ":device_group node" - node: DeviceGroup! -} - -input DeviceGroupFilterSelector { - isNil: Boolean - eq: String - notEq: String - in: [String!] - lessThan: String - greaterThan: String - lessThanOrEqual: String - greaterThanOrEqual: String - like: String - ilike: String + ":file_download_request node" + node: FileDownloadRequest! } -input DeviceGroupFilterHandle { +input FileDownloadRequestFilterMessage { isNil: Boolean eq: String notEq: String - in: [String!] + in: [String] lessThan: String greaterThan: String lessThanOrEqual: String @@ -4928,44 +4925,960 @@ input DeviceGroupFilterHandle { ilike: String } -input DeviceGroupFilterName { +input FileDownloadRequestFilterStatusCode { isNil: Boolean - eq: String - notEq: String - in: [String!] - lessThan: String - greaterThan: String - lessThanOrEqual: String - greaterThanOrEqual: String - like: String - ilike: String + eq: Int + notEq: Int + in: [Int] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int } -input DeviceGroupFilterId { +input FileDownloadRequestFilterStatusProgress { isNil: Boolean eq: Int notEq: Int - in: [Int!] + in: [Int] lessThan: Int greaterThan: Int lessThanOrEqual: Int greaterThanOrEqual: Int } -input DeviceGroupFilterInput { - and: [DeviceGroupFilterInput!] - - or: [DeviceGroupFilterInput!] +input FileDownloadRequestFilterStatus { + isNil: Boolean + eq: FileDownloadRequestStatus + notEq: FileDownloadRequestStatus + in: [FileDownloadRequestStatus] + lessThan: FileDownloadRequestStatus + greaterThan: FileDownloadRequestStatus + lessThanOrEqual: FileDownloadRequestStatus + greaterThanOrEqual: FileDownloadRequestStatus +} - not: [DeviceGroupFilterInput!] +input FileDownloadRequestFilterProgress { + isNil: Boolean + eq: Boolean + notEq: Boolean + in: [Boolean] + lessThan: Boolean + greaterThan: Boolean + lessThanOrEqual: Boolean + greaterThanOrEqual: Boolean +} - id: DeviceGroupFilterId +input FileDownloadRequestFilterDestination { + isNil: Boolean + eq: FileDestination + notEq: FileDestination + in: [FileDestination] + lessThan: FileDestination + greaterThan: FileDestination + lessThanOrEqual: FileDestination + greaterThanOrEqual: FileDestination +} - "The display name of the device group." - name: DeviceGroupFilterName +input FileDownloadRequestFilterGroupId { + isNil: Boolean + eq: Int + notEq: Int + in: [Int] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int +} - """ - The identifier of the device group. +input FileDownloadRequestFilterUserId { + isNil: Boolean + eq: Int + notEq: Int + in: [Int] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int +} + +input FileDownloadRequestFilterFileMode { + isNil: Boolean + eq: Int + notEq: Int + in: [Int] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int +} + +input FileDownloadRequestFilterTtlSeconds { + isNil: Boolean + eq: Int + notEq: Int + in: [Int] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int +} + +input FileDownloadRequestFilterCompression { + isNil: Boolean + eq: String + notEq: String + in: [String] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input FileDownloadRequestFilterDigest { + isNil: Boolean + eq: String + notEq: String + in: [String] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input FileDownloadRequestFilterUncompressedFileSizeBytes { + isNil: Boolean + eq: Int + notEq: Int + in: [Int] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int +} + +input FileDownloadRequestFilterFileName { + isNil: Boolean + eq: String + notEq: String + in: [String] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input FileDownloadRequestFilterUrl { + isNil: Boolean + eq: String + notEq: String + in: [String!] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input FileDownloadRequestFilterId { + isNil: Boolean + eq: ID + notEq: ID + in: [ID!] + lessThan: ID + greaterThan: ID + lessThanOrEqual: ID + greaterThanOrEqual: ID +} + +input FileDownloadRequestFilterInput { + and: [FileDownloadRequestFilterInput!] + + or: [FileDownloadRequestFilterInput!] + + not: [FileDownloadRequestFilterInput!] + + id: FileDownloadRequestFilterId + + "The URL from which the file can be downloaded." + url: FileDownloadRequestFilterUrl + + "The name of the file being downloaded." + fileName: FileDownloadRequestFilterFileName + + "The size of the file being downloaded, in bytes, before compression." + uncompressedFileSizeBytes: FileDownloadRequestFilterUncompressedFileSizeBytes + + "The digest of the file being downloaded, used for integrity verification." + digest: FileDownloadRequestFilterDigest + + "Optional enum string for the file compression with default value empty, other values are: ['tar.gz']" + compression: FileDownloadRequestFilterCompression + + "Optional ttl for how long to keep the file fore, if 0 is forever, default value is 0." + ttlSeconds: FileDownloadRequestFilterTtlSeconds + + "Optional unix mode for the file, set to default if 0. All files are immutable, so setting it to writable has no effect." + fileMode: FileDownloadRequestFilterFileMode + + "Optional unix uid of the user owning the file, set to default if -1." + userId: FileDownloadRequestFilterUserId + + "Optional unix gid of the group owning the file, set to default if -1." + groupId: FileDownloadRequestFilterGroupId + + "Device-specific field, some default values are storage and streaming." + destination: FileDownloadRequestFilterDestination + + "Flag to enable the progress reporting of the download." + progress: FileDownloadRequestFilterProgress + + "The status of the file download (e.g., 'pending', 'sent', 'in_progress', 'completed', 'failed')." + status: FileDownloadRequestFilterStatus + + "The progress of the file download as a percentage (0-100)." + statusProgress: FileDownloadRequestFilterStatusProgress + + "A 0 code is a success, errors are POSIX error numbers." + statusCode: FileDownloadRequestFilterStatusCode + + "Optional message for the response." + message: FileDownloadRequestFilterMessage + + "The device associated with this file download request." + device: DeviceFilterInput +} + +input FileDownloadRequestSortInput { + order: SortOrder + field: FileDownloadRequestSortField! +} + +""" +Represents a request to download a file to a device. + +This resource is used to track the progress and status of file download operations initiated by the system. +""" +type FileDownloadRequest { + id: ID! + + "The URL from which the file can be downloaded." + url: String! + + "The name of the file being downloaded." + fileName: String + + "The size of the file being downloaded, in bytes, before compression." + uncompressedFileSizeBytes: Int + + "The digest of the file being downloaded, used for integrity verification." + digest: String + + "Optional enum string for the file compression with default value empty, other values are: ['tar.gz']" + compression: String + + "Optional ttl for how long to keep the file fore, if 0 is forever, default value is 0." + ttlSeconds: Int + + "Optional unix mode for the file, set to default if 0. All files are immutable, so setting it to writable has no effect." + fileMode: Int + + "Optional unix uid of the user owning the file, set to default if -1." + userId: Int + + "Optional unix gid of the group owning the file, set to default if -1." + groupId: Int + + "Device-specific field, some default values are storage and streaming." + destination: FileDestination + + "Flag to enable the progress reporting of the download." + progress: Boolean + + "The status of the file download (e.g., 'pending', 'sent', 'in_progress', 'completed', 'failed')." + status: FileDownloadRequestStatus + + "The progress of the file download as a percentage (0-100)." + statusProgress: Int + + "A 0 code is a success, errors are POSIX error numbers." + statusCode: Int + + "Optional message for the response." + message: String + + "The device associated with this file download request." + device: Device! +} + +"The result of the :delete_repository mutation" +type DeleteRepositoryResult { + "The record that was successfully deleted" + result: Repository + + "Any errors generated, if the mutation failed" + errors: [MutationError!]! +} + +"The result of the :update_repository mutation" +type UpdateRepositoryResult { + "The successful result of the mutation" + result: Repository + + "Any errors generated, if the mutation failed" + errors: [MutationError!]! +} + +input UpdateRepositoryInput { + "The display name of the repository." + name: String + + """ + The identifier of the repository. + + It should start with a lower case ASCII letter and only contain lower case ASCII letters, digits and the hyphen - symbol. + """ + handle: String + + "An optional description of the repository." + description: String +} + +"The result of the :create_repository mutation" +type CreateRepositoryResult { + "The successful result of the mutation" + result: Repository + + "Any errors generated, if the mutation failed" + errors: [MutationError!]! +} + +input CreateRepositoryInput { + "The display name of the repository." + name: String! + + """ + The identifier of the repository. + + It should start with a lower case ASCII letter and only contain lower case ASCII letters, digits and the hyphen - symbol. + """ + handle: String! + + "An optional description of the repository." + description: String +} + +enum RepositorySortField { + ID + NAME + HANDLE + DESCRIPTION +} + +":repository connection" +type RepositoryConnection { + "Total count on all pages" + count: Int + + "Page information" + pageInfo: PageInfo! + + ":repository edges" + edges: [RepositoryEdge!] +} + +":repository edge" +type RepositoryEdge { + "Cursor" + cursor: String! + + ":repository node" + node: Repository! +} + +input RepositoryFilterDescription { + isNil: Boolean + eq: String + notEq: String + in: [String] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input RepositoryFilterHandle { + isNil: Boolean + eq: String + notEq: String + in: [String!] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input RepositoryFilterName { + isNil: Boolean + eq: String + notEq: String + in: [String!] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input RepositoryFilterId { + isNil: Boolean + eq: ID + notEq: ID + in: [ID!] + lessThan: ID + greaterThan: ID + lessThanOrEqual: ID + greaterThanOrEqual: ID +} + +input RepositoryFilterInput { + and: [RepositoryFilterInput!] + + or: [RepositoryFilterInput!] + + not: [RepositoryFilterInput!] + + id: RepositoryFilterId + + "The display name of the repository." + name: RepositoryFilterName + + """ + The identifier of the repository. + + It should start with a lower case ASCII letter and only contain lower case ASCII letters, digits and the hyphen - symbol. + """ + handle: RepositoryFilterHandle + + "An optional description of the repository." + description: RepositoryFilterDescription + + "The files associated with the repository." + files: FileFilterInput +} + +input RepositorySortInput { + order: SortOrder + field: RepositorySortField! +} + +""" +A logical collection of files. + +Repositories provide organization for files, similar to how +BaseImageCollections group BaseImages. Storage location is +configured at the application level, not per-repository. +""" +type Repository implements Node { + id: ID! + + "The display name of the repository." + name: String! + + """ + The identifier of the repository. + + It should start with a lower case ASCII letter and only contain lower case ASCII letters, digits and the hyphen - symbol. + """ + handle: String! + + "An optional description of the repository." + description: String + + "The files associated with the repository." + files( + "How to sort the records in the response" + sort: [FileSortInput] + + "A filter to limit the results" + filter: FileFilterInput + + "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 + ): FileConnection! +} + +"The result of the :delete_file mutation" +type DeleteFileResult { + "The record that was successfully deleted" + result: File + + "Any errors generated, if the mutation failed" + errors: [MutationError!]! +} + +"The result of the :create_file mutation" +type CreateFileResult { + "The successful result of the mutation" + result: File + + "Any errors generated, if the mutation failed" + errors: [MutationError!]! +} + +input CreateFileInput { + "Filename for display and as default name on the device." + name: String! + + "File size in bytes. Sent to device for space reservation." + size: Int! + + "File digest in format algorithm:hash (e.g., sha256:abc123) for integrity checks." + digest: String! + + "Unix file mode (e.g., 0o644). Applied when file is stored on device." + mode: Int + + "Unix UID for file ownership on the device." + userId: Int + + "Unix GID for file ownership on the device." + groupId: Int + + "Full URL where file is stored. Set by the uploader." + url: String + + "The ID of the repository this file will belong to." + repositoryId: ID! +} + +input ReadFilePresignedUrlInput { + filename: String! + repositoryId: ID! +} + +input CreateFilePresignedUrlInput { + filename: String! + repositoryId: ID! +} + +enum FileSortField { + ID + NAME + SIZE + DIGEST + MODE + USER_ID + GROUP_ID + URL +} + +":file connection" +type FileConnection { + "Total count on all pages" + count: Int + + "Page information" + pageInfo: PageInfo! + + ":file edges" + edges: [FileEdge!] +} + +":file edge" +type FileEdge { + "Cursor" + cursor: String! + + ":file node" + node: File! +} + +input FileFilterUrl { + isNil: Boolean + eq: String + notEq: String + in: [String] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input FileFilterGroupId { + isNil: Boolean + eq: Int + notEq: Int + in: [Int] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int +} + +input FileFilterUserId { + isNil: Boolean + eq: Int + notEq: Int + in: [Int] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int +} + +input FileFilterMode { + isNil: Boolean + eq: Int + notEq: Int + in: [Int] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int +} + +input FileFilterDigest { + isNil: Boolean + eq: String + notEq: String + in: [String!] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input FileFilterSize { + isNil: Boolean + eq: Int + notEq: Int + in: [Int!] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int +} + +input FileFilterName { + isNil: Boolean + eq: String + notEq: String + in: [String!] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input FileFilterId { + isNil: Boolean + eq: ID + notEq: ID + in: [ID!] + lessThan: ID + greaterThan: ID + lessThanOrEqual: ID + greaterThanOrEqual: ID +} + +input FileFilterInput { + and: [FileFilterInput!] + + or: [FileFilterInput!] + + not: [FileFilterInput!] + + id: FileFilterId + + "Filename for display and as default name on the device." + name: FileFilterName + + "File size in bytes. Sent to device for space reservation." + size: FileFilterSize + + "File digest in format algorithm:hash (e.g., sha256:abc123) for integrity checks." + digest: FileFilterDigest + + "Unix file mode (e.g., 0o644). Applied when file is stored on device." + mode: FileFilterMode + + "Unix UID for file ownership on the device." + userId: FileFilterUserId + + "Unix GID for file ownership on the device." + groupId: FileFilterGroupId + + "Full URL where file is stored. Set by the uploader." + url: FileFilterUrl + + "The repository this file belongs to." + repository: RepositoryFilterInput +} + +input FileSortInput { + order: SortOrder + field: FileSortField! +} + +type File { + id: ID! + + "Filename for display and as default name on the device." + name: String! + + "File size in bytes. Sent to device for space reservation." + size: Int! + + "File digest in format algorithm:hash (e.g., sha256:abc123) for integrity checks." + digest: String! + + "Unix file mode (e.g., 0o644). Applied when file is stored on device." + mode: Int + + "Unix UID for file ownership on the device." + userId: Int + + "Unix GID for file ownership on the device." + groupId: Int + + "Full URL where file is stored. Set by the uploader." + url: String + + "The repository this file belongs to." + repository: Repository! +} + +input RequestForwarderSessionInput { + deviceId: ID! +} + +"The details of a forwarder session." +type ForwarderSession { + "The token that identifies the session." + id: ID! + + "The token that identifies the session." + token: String! + + "The status of the session." + status: ForwarderSessionStatus! + + "The hostname of the forwarder instance." + forwarderHostname: String! + + "The port of the forwarder instance." + forwarderPort: Int! + + "Indicates if TLS is used when the device connects to the forwarder." + secure: Boolean! +} + +"The details of a forwarder instance." +type ForwarderConfig { + "A unique identifier" + id: ID! + + "The hostname of the forwarder instance." + hostname: String! + + "The port of the forwarder instance." + port: Int! + + "Indicates if TLS should used when connecting to the forwarder." + secureSessions: Boolean! +} + +type device_group_result { + created: DeviceGroup + updated: DeviceGroup + destroyed: ID +} + +"The result of the :delete_device_group mutation" +type DeleteDeviceGroupResult { + "The record that was successfully deleted" + result: DeviceGroup + + "Any errors generated, if the mutation failed" + errors: [MutationError!]! +} + +"The result of the :update_device_group mutation" +type UpdateDeviceGroupResult { + "The successful result of the mutation" + result: DeviceGroup + + "Any errors generated, if the mutation failed" + errors: [MutationError!]! +} + +input UpdateDeviceGroupInput { + "The display name of the device group." + name: String + + """ + The identifier of the device group. + + It should start with a lower case ASCII letter and only contain lower case ASCII letters, digits and the hyphen - symbol. + """ + handle: String + + """ + The Selector that will determine which devices belong to the device group. + + This must be a valid selector expression, consult the Selector section of the Edgehog documentation for more information about Selectors. + """ + selector: String +} + +"The result of the :create_device_group mutation" +type CreateDeviceGroupResult { + "The successful result of the mutation" + result: DeviceGroup + + "Any errors generated, if the mutation failed" + errors: [MutationError!]! +} + +input CreateDeviceGroupInput { + "The display name of the device group." + name: String! + + """ + The identifier of the device group. + + It should start with a lower case ASCII letter and only contain lower case ASCII letters, digits and the hyphen - symbol. + """ + handle: String! + + """ + The Selector that will determine which devices belong to the device group. + + This must be a valid selector expression, consult the Selector section of the Edgehog documentation for more information about Selectors. + """ + selector: String! +} + +enum DeviceGroupSortField { + ID + NAME + HANDLE + SELECTOR +} + +":device_group connection" +type DeviceGroupConnection { + "Total count on all pages" + count: Int + + "Page information" + pageInfo: PageInfo! + + ":device_group edges" + edges: [DeviceGroupEdge!] +} + +":device_group edge" +type DeviceGroupEdge { + "Cursor" + cursor: String! + + ":device_group node" + node: DeviceGroup! +} + +input DeviceGroupFilterSelector { + isNil: Boolean + eq: String + notEq: String + in: [String!] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input DeviceGroupFilterHandle { + isNil: Boolean + eq: String + notEq: String + in: [String!] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input DeviceGroupFilterName { + isNil: Boolean + eq: String + notEq: String + in: [String!] + lessThan: String + greaterThan: String + lessThanOrEqual: String + greaterThanOrEqual: String + like: String + ilike: String +} + +input DeviceGroupFilterId { + isNil: Boolean + eq: Int + notEq: Int + in: [Int!] + lessThan: Int + greaterThan: Int + lessThanOrEqual: Int + greaterThanOrEqual: Int +} + +input DeviceGroupFilterInput { + and: [DeviceGroupFilterInput!] + + or: [DeviceGroupFilterInput!] + + not: [DeviceGroupFilterInput!] + + id: DeviceGroupFilterId + + "The display name of the device group." + name: DeviceGroupFilterName + + """ + The identifier of the device group. It should start with a lower case ASCII letter and only contain lower case ASCII letters, digits and the hyphen - symbol. """ @@ -6171,6 +7084,30 @@ type RootQueryType { "Fetches a forwarder session by its token and the device ID." forwarderSession(token: String!, deviceId: ID!): ForwarderSession + "Returns a single repository." + repository("The id of the record" id: ID!): Repository + + "Returns a list of repositories." + repositories( + "How to sort the records in the response" + sort: [RepositorySortInput] + + "A filter to limit the results" + filter: RepositoryFilterInput + + "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 + ): RepositoryConnection + "Returns a single device." device("The id of the record" id: ID!): Device @@ -6445,6 +7382,45 @@ type RootMutationType { """ requestForwarderSession(input: RequestForwarderSessionInput!): String! + "Generates presigned URLs to upload and download a file via HTTP requests." + createFilePresignedUrl(input: CreateFilePresignedUrlInput!): JsonString! + + "Reads presigned URLs to download a file via HTTP requests." + readFilePresignedUrl(input: ReadFilePresignedUrlInput!): JsonString! + + "Create a new file by uploading it to the storage." + createFile(input: CreateFileInput!): CreateFileResult + + "Deletes a file" + deleteFile(id: ID!): DeleteFileResult + + "Creates a new repository." + createRepository(input: CreateRepositoryInput!): CreateRepositoryResult + + "Updates a repository." + updateRepository( + id: ID! + input: UpdateRepositoryInput + ): UpdateRepositoryResult + + "Deletes a repository" + deleteRepository(id: ID!): DeleteRepositoryResult + + "Generates presigned URLs to upload and download a file via HTTP requests." + createFileDownloadRequestPresignedUrl( + input: CreateFileDownloadRequestPresignedUrlInput! + ): JsonString! + + "Reads presigned URLs to download a file via HTTP requests." + readFileDownloadRequestPresignedUrl( + input: ReadFileDownloadRequestPresignedUrlInput! + ): JsonString! + + "Initiates an file download request, with a user provided file." + createFileDownloadRequest( + input: CreateFileDownloadRequestInput! + ): CreateFileDownloadRequestResult + "Updates a device." updateDevice(id: ID!, input: UpdateDeviceInput): UpdateDeviceResult diff --git a/frontend/src/components/DeviceTabs/FilesUploadTab.tsx b/frontend/src/components/DeviceTabs/FilesUploadTab.tsx new file mode 100644 index 000000000..ebc8abeaf --- /dev/null +++ b/frontend/src/components/DeviceTabs/FilesUploadTab.tsx @@ -0,0 +1,411 @@ +/* + * 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, useEffect, useMemo, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { + ConnectionHandler, + graphql, + useMutation, + usePaginationFragment, +} from "react-relay/hooks"; +import { v7 as uuidv7 } from "uuid"; + +import type { FilesUploadTab_PaginationQuery } from "@/api/__generated__/FilesUploadTab_PaginationQuery.graphql"; +import type { FilesUploadTab_createFileDownloadRequestPresignedUrl_Mutation } from "@/api/__generated__/FilesUploadTab_createFileDownloadRequestPresignedUrl_Mutation.graphql"; +import type { FilesUploadTab_createFileDownloadRequest_Mutation } from "@/api/__generated__/FilesUploadTab_createFileDownloadRequest_Mutation.graphql"; +import type { FilesUploadTab_fileDownloadRequests$key } from "@/api/__generated__/FilesUploadTab_fileDownloadRequests.graphql"; + +import Alert from "@/components/Alert"; +import FileDownloadRequestsTable from "@/components/FileDownloadRequestsTable"; +import { Tab } from "@/components/Tabs"; +import type { FileDownloadRequestFormValues } from "@/forms/ManualFileDownloadRequestForm"; +import ManualFileDownloadRequestForm from "@/forms/ManualFileDownloadRequestForm"; +import { computeDigest, createTarGzArchive } from "@/lib/files"; + +// We use graphql fields below in columns configuration +/* eslint-disable relay/unused-fields */ +const DEVICE_FILE_DOWNLOAD_REQUESTS_FRAGMENT = graphql` + fragment FilesUploadTab_fileDownloadRequests on Device + @refetchable(queryName: "FilesUploadTab_PaginationQuery") { + id + capabilities + fileDownloadRequests(first: $first, after: $after) + @connection(key: "FilesUploadTab_fileDownloadRequests") { + edges { + node { + id + url + fileName + status + statusProgress + statusCode + message + destination + progress + ttlSeconds + digest + fileMode + userId + groupId + uncompressedFileSizeBytes + } + } + } + } +`; + +const DEVICE_GET_PRESIGNED_URL_MUTATION = graphql` + mutation FilesUploadTab_createFileDownloadRequestPresignedUrl_Mutation( + $input: CreateFileDownloadRequestPresignedUrlInput! + ) { + createFileDownloadRequestPresignedUrl(input: $input) + } +`; + +const DEVICE_CREATE_FILE_DOWNLOAD_REQUEST_MUTATION = graphql` + mutation FilesUploadTab_createFileDownloadRequest_Mutation( + $input: CreateFileDownloadRequestInput! + ) { + createFileDownloadRequest(input: $input) { + result { + id + url + fileName + status + statusProgress + statusCode + message + destination + progress + ttlSeconds + digest + fileMode + userId + groupId + uncompressedFileSizeBytes + } + } + } +`; + +type FilesUploadTabProps = { + deviceRef: FilesUploadTab_fileDownloadRequests$key; +}; + +const FilesUploadTab = ({ deviceRef }: FilesUploadTabProps) => { + const intl = useIntl(); + + const [isUploading, setIsUploading] = useState(false); + const [errorFeedback, setErrorFeedback] = useState(null); + + const { data } = usePaginationFragment< + FilesUploadTab_PaginationQuery, + FilesUploadTab_fileDownloadRequests$key + >(DEVICE_FILE_DOWNLOAD_REQUESTS_FRAGMENT, deviceRef); + + const [getPresignedUrl] = + useMutation( + DEVICE_GET_PRESIGNED_URL_MUTATION, + ); + + const [createFileDownloadRequest] = + useMutation( + DEVICE_CREATE_FILE_DOWNLOAD_REQUEST_MUTATION, + ); + + const deviceId = data.id; + + // Warn user before leaving page during upload + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (isUploading) { + event.preventDefault(); + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [isUploading]); + + const handleFileUpload = useCallback( + async (values: FileDownloadRequestFormValues) => { + setErrorFeedback(null); + setIsUploading(true); + + try { + const { files, archiveName, destination, ttlSeconds, progress } = + values; + + let uploadBlob: Blob; + let fileName: string; + let uncompressedSize: number; + let compression: string | null = null; + + // Files from folder selection have webkitRelativePath set. + // These need archiving even if there's only one file, to preserve + // the directory structure. + const hasRelativePaths = files.some((f) => f.webkitRelativePath); + const needsArchive = files.length > 1 || hasRelativePaths; + + if (needsArchive) { + // Multiple files or folder contents: create tar.gz archive + uploadBlob = await createTarGzArchive(files); + const baseName = archiveName?.trim() || "files-archive"; + fileName = baseName.endsWith(".tar.gz") + ? baseName + : `${baseName}.tar.gz`; + uncompressedSize = files.reduce((sum, f) => sum + f.size, 0); + compression = "tar.gz"; + } else { + uploadBlob = files[0]; + fileName = files[0].name; + uncompressedSize = files[0].size; + } + + if (files.length === 1 && /\.(tar\.gz|tgz)$/i.test(files[0].name)) { + compression = "tar.gz"; + } + + const archiveData = new Uint8Array(await uploadBlob.arrayBuffer()); + const fileDownloadRequestId = uuidv7(); + const digest = await computeDigest(archiveData); + + // Note: The browser File API does not expose Unix file permissions (fileMode), + // userId, or groupId. These are OS-level metadata not available in web browsers. + // We use sensible defaults: fileMode 0644 (rw-r--r--), userId -1, groupId -1. + const fileMode = 0o644; + const userId = -1; + const groupId = -1; + + // Get presigned URL from the backend + const presignedUrls = await new Promise<{ + get_url: string; + put_url: string; + }>((resolve, reject) => { + getPresignedUrl({ + variables: { + input: { + fileDownloadRequestId, + filename: fileName, + }, + }, + onCompleted(responseData, errors) { + if (errors && errors.length > 0) { + const errorMessage = errors + .map(({ fields, message }) => + fields && fields.length + ? `${fields.join(" ")} ${message}` + : message, + ) + .join(". \n"); + reject(new Error(errorMessage)); + return; + } + try { + const raw = responseData.createFileDownloadRequestPresignedUrl; + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + if (!parsed?.put_url || !parsed?.get_url) { + reject( + new Error( + intl.formatMessage({ + id: "components.DeviceTabs.FilesUploadTab.error.presignedUrlMissingFields", + defaultMessage: + "Presigned URL response is missing put_url or get_url.", + }), + ), + ); + return; + } + resolve(parsed); + } catch { + reject( + new Error( + intl.formatMessage({ + id: "components.DeviceTabs.FilesUploadTab.error.presignedUrlParseFailed", + defaultMessage: + "Failed to parse the presigned URL response.", + }), + ), + ); + } + }, + onError(error) { + reject(error); + }, + }); + }); + + // Upload the file to the presigned PUT URL + const uploadResponse = await fetch(presignedUrls.put_url, { + method: "PUT", + headers: { "x-ms-blob-type": "BlockBlob" }, + body: uploadBlob, + }); + + if (!uploadResponse.ok) { + const responseBody = await uploadResponse.text().catch(() => ""); + throw new Error( + intl.formatMessage( + { + id: "components.DeviceTabs.FilesUploadTab.error.uploadFailed", + defaultMessage: + "File upload failed with status {status}: {statusText}{body}.", + }, + { + status: uploadResponse.status, + statusText: uploadResponse.statusText, + body: responseBody ? ` - ${responseBody}` : "", + }, + ), + ); + } + + // Create the file download request with all metadata + await new Promise((resolve, reject) => { + createFileDownloadRequest({ + variables: { + input: { + deviceId, + fileDownloadRequestId, + url: presignedUrls.get_url, + fileName, + uncompressedFileSizeBytes: uncompressedSize, + digest, + compression, + fileMode, + userId, + groupId, + destination, + progress, + ttlSeconds, + }, + }, + onCompleted(_responseData, errors) { + if (errors && errors.length > 0) { + const errorMessage = errors + .map(({ fields, message }) => + fields && fields.length + ? `${fields.join(" ")} ${message}` + : message, + ) + .join(". \n"); + reject(new Error(errorMessage)); + return; + } + resolve(); + }, + updater(store, data) { + const newRequestId = data?.createFileDownloadRequest?.result?.id; + if (!newRequestId) return; + const newRequest = store.get(newRequestId); + const storedDevice = store.get(deviceId); + if (!storedDevice || !newRequest) return; + const connection = ConnectionHandler.getConnection( + storedDevice, + "FilesUploadTab_fileDownloadRequests", + ); + 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, + "FileDownloadRequestEdge", + ); + ConnectionHandler.insertEdgeBefore(connection, edge); + }, + onError(error) { + reject(error); + }, + }); + }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : intl.formatMessage({ + id: "components.DeviceTabs.FilesUploadTab.error.unknownError", + defaultMessage: "An unknown error occurred.", + }); + setErrorFeedback(message); + } finally { + setIsUploading(false); + } + }, + [deviceId, getPresignedUrl, createFileDownloadRequest], + ); + + const fileDownloadRequests = useMemo( + () => data.fileDownloadRequests?.edges?.map((edge) => edge.node) ?? [], + [data.fileDownloadRequests], + ); + + return ( + +
+
+ +
+ setErrorFeedback(null)} + dismissible + > + {errorFeedback} + + +
+
+
+
+ +
+ +
+
+ ); +}; + +export default FilesUploadTab; diff --git a/frontend/src/components/FileDownloadRequestsTable.tsx b/frontend/src/components/FileDownloadRequestsTable.tsx new file mode 100644 index 000000000..0cda5fa95 --- /dev/null +++ b/frontend/src/components/FileDownloadRequestsTable.tsx @@ -0,0 +1,183 @@ +/* + * 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 { FormattedMessage } from "react-intl"; + +import type { FilesUploadTab_fileDownloadRequests$data } from "@/api/__generated__/FilesUploadTab_fileDownloadRequests.graphql"; + +import RequestStatus from "@/components/RequestStatus"; +import Table, { createColumnHelper } from "@/components/Table"; +import { formatFileSize } from "@/lib/files"; + +type FileDownloadRequestNode = NonNullable< + NonNullable< + FilesUploadTab_fileDownloadRequests$data["fileDownloadRequests"] + >["edges"] +>[number]["node"]; + +const columnHelper = createColumnHelper(); +const columns = [ + columnHelper.accessor("fileName", { + header: () => ( + + ), + cell: ({ getValue }) => getValue(), + }), + columnHelper.accessor("status", { + header: () => ( + + ), + cell: ({ getValue, row }) => { + const progressTracked = row.original.progress; + + if (!progressTracked) { + return ( + + + + ); + } + + const status = getValue(); + return ; + }, + }), + columnHelper.accessor("statusProgress", { + header: () => ( + + ), + cell: ({ getValue, row }) => { + const progressTracked = row.original.progress; + + if (!progressTracked) { + return ( + + + + ); + } + + const progress = getValue(); + return progress != null ? `${progress}%` : null; + }, + }), + columnHelper.accessor("destination", { + header: () => ( + + ), + cell: ({ getValue }) => getValue(), + }), + columnHelper.accessor("uncompressedFileSizeBytes", { + header: () => ( + + ), + cell: ({ getValue }) => { + const size = getValue(); + return size != null ? formatFileSize(size) : null; + }, + }), + columnHelper.accessor("ttlSeconds", { + header: () => ( + + ), + cell: ({ getValue }) => { + const ttl = getValue(); + + if (ttl === 0) { + return ( +

+ +

+ ); + } + + return ttl; + }, + }), + columnHelper.accessor("message", { + header: () => ( + + ), + cell: ({ getValue, row }) => { + const statusCode = row.original.statusCode; + const message = getValue(); + + if (!statusCode && !message) return null; + + if (!statusCode) return message ?? null; + if (!message) return String(statusCode); + + return `${statusCode}: ${message}`; + }, + }), +]; + +type FileDownloadRequestsTableProps = { + requests: FileDownloadRequestNode[]; +}; + +const FileDownloadRequestsTable = ({ + requests, +}: FileDownloadRequestsTableProps) => { + if (requests.length === 0) { + return ( +

+ +

+ ); + } + + return ; +}; + +export default FileDownloadRequestsTable; diff --git a/frontend/src/components/FileDropzone.tsx b/frontend/src/components/FileDropzone.tsx new file mode 100644 index 000000000..3d5dbcbdf --- /dev/null +++ b/frontend/src/components/FileDropzone.tsx @@ -0,0 +1,319 @@ +/* + * 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 { useCallback, useMemo, useRef, useState } from "react"; +import { FormattedMessage } from "react-intl"; + +import CloseButton from "@/components/CloseButton"; +import Tag from "@/components/Tag"; +import { formatFileSize } from "@/lib/files"; + +const readFileEntry = (entry: FileSystemFileEntry): Promise => + new Promise((resolve, reject) => entry.file(resolve, reject)); + +const readDirectoryBatch = ( + reader: FileSystemDirectoryReader, +): Promise => + new Promise((resolve, reject) => reader.readEntries(resolve, reject)); + +const readAllDirectoryEntries = async ( + reader: FileSystemDirectoryReader, +): Promise => { + const all: FileSystemEntry[] = []; + let batch: FileSystemEntry[]; + do { + batch = await readDirectoryBatch(reader); + all.push(...batch); + } while (batch.length > 0); + return all; +}; + +const collectFilesFromEntry = async ( + entry: FileSystemEntry, + basePath: string, +): Promise => { + if (entry.isFile) { + const file = await readFileEntry(entry as FileSystemFileEntry); + const relativePath = basePath ? `${basePath}/${entry.name}` : ""; + if (relativePath) { + Object.defineProperty(file, "webkitRelativePath", { + value: relativePath, + configurable: true, + }); + } + return [file]; + } + if (entry.isDirectory) { + const reader = (entry as FileSystemDirectoryEntry).createReader(); + const children = await readAllDirectoryEntries(reader); + const dirPath = basePath ? `${basePath}/${entry.name}` : entry.name; + const results: File[] = []; + for (const child of children) { + results.push(...(await collectFilesFromEntry(child, dirPath))); + } + return results; + } + return []; +}; + +const collectFilesFromDrop = async ( + dataTransfer: DataTransfer, +): Promise => { + const files: File[] = []; + const items = Array.from(dataTransfer.items); + for (const item of items) { + const entry = item.webkitGetAsEntry?.(); + if (entry) { + files.push(...(await collectFilesFromEntry(entry, ""))); + } + } + return files; +}; + +const getFileKey = (file: File): string => file.webkitRelativePath || file.name; + +type FileDropzoneProps = { + files: File[]; + onChange: (files: File[]) => void; + isInvalid?: boolean; +}; + +const FileDropzone = ({ files, onChange, isInvalid }: FileDropzoneProps) => { + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + const folderInputRef = useRef(null); + const dragCounter = useRef(0); + + const merge = useCallback( + (newFiles: File[]) => { + const existingKeys = new Set(files.map(getFileKey)); + const deduplicated = newFiles.filter( + (f) => !existingKeys.has(getFileKey(f)), + ); + onChange([...files, ...deduplicated]); + }, + [files, onChange], + ); + + const removeFile = useCallback( + (index: number) => { + onChange(files.filter((_, i) => i !== index)); + }, + [files, onChange], + ); + + const handleFilesChange = (e: React.ChangeEvent) => { + const newFiles = e.target.files ? Array.from(e.target.files) : []; + merge(newFiles); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleFolderChange = (e: React.ChangeEvent) => { + const newFiles = e.target.files ? Array.from(e.target.files) : []; + merge(newFiles); + if (folderInputRef.current) folderInputRef.current.value = ""; + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current += 1; + if (dragCounter.current === 1) setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current -= 1; + if (dragCounter.current === 0) setIsDragOver(false); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current = 0; + setIsDragOver(false); + const droppedFiles = await collectFilesFromDrop(e.dataTransfer); + merge(droppedFiles); + }; + + const totalSize = useMemo( + () => files.reduce((sum, f) => sum + f.size, 0), + [files], + ); + + return ( + <> + + +
+ {files.length === 0 ? ( +
+

+ +

+

+ { + e.preventDefault(); + fileInputRef.current?.click(); + }} + > + + + ), + browseFolder: ( + { + e.preventDefault(); + folderInputRef.current?.click(); + }} + > + + + ), + }} + /> +

+
+ ) : ( + <> +
+ {files.map((file, index) => ( + + {getFileKey(file)} + { + e.stopPropagation(); + removeFile(index); + }} + /> + + ))} +
+

+ { + e.preventDefault(); + fileInputRef.current?.click(); + }} + > + + + ), + addFolder: ( + { + e.preventDefault(); + folderInputRef.current?.click(); + }} + > + + + ), + clearAll: ( + { + e.preventDefault(); + onChange([]); + }} + > + + + ), + }} + /> +

+ + )} +
+ + ); +}; + +export default FileDropzone; diff --git a/frontend/src/components/RequestStatus.tsx b/frontend/src/components/RequestStatus.tsx new file mode 100644 index 000000000..43b04fbcc --- /dev/null +++ b/frontend/src/components/RequestStatus.tsx @@ -0,0 +1,89 @@ +/* + * 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 { defineMessages, FormattedMessage } from "react-intl"; + +import Icon from "@/components/Icon"; + +export type FileDownloadRequestStatus = + | "COMPLETED" + | "FAILED" + | "IN_PROGRESS" + | "PENDING" + | "SENT"; + +const statusColors: Record = { + COMPLETED: "text-success", + FAILED: "text-danger", + IN_PROGRESS: "text-info", + PENDING: "text-warning", + SENT: "text-primary", +}; + +const statusMessages = defineMessages({ + COMPLETED: { + id: "components.RequestStatus.completed", + defaultMessage: "Completed", + }, + FAILED: { + id: "components.RequestStatus.failed", + defaultMessage: "Failed", + }, + IN_PROGRESS: { + id: "components.RequestStatus.inProgress", + defaultMessage: "In Progress", + }, + PENDING: { + id: "components.RequestStatus.pending", + defaultMessage: "Pending", + }, + SENT: { + id: "components.RequestStatus.sent", + defaultMessage: "Sent", + }, +}); + +const RequestStatus = ({ + status, +}: { + status: FileDownloadRequestStatus | null; +}) => { + if (status === null) { + return null; + } + + const color = statusColors[status] ?? "text-secondary"; + const message = statusMessages[status as keyof typeof statusMessages]; + + const iconName = status === "IN_PROGRESS" ? "spinner" : "circle"; + const iconClass = + status === "IN_PROGRESS" + ? `me-2 ${color} spinner-border spinner-border-sm` + : `me-2 ${color}`; + + return ( +
+ + {message ? : status} +
+ ); +}; + +export default RequestStatus; diff --git a/frontend/src/forms/ManualFileDownloadRequestForm.tsx b/frontend/src/forms/ManualFileDownloadRequestForm.tsx new file mode 100644 index 000000000..5ae159c8a --- /dev/null +++ b/frontend/src/forms/ManualFileDownloadRequestForm.tsx @@ -0,0 +1,257 @@ +/* + * 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 { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { FormattedMessage } from "react-intl"; +import Select from "react-select"; + +import Button from "@/components/Button"; +import Col from "@/components/Col"; +import FileDropzone from "@/components/FileDropzone"; +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 { fileDownloadRequestFormSchema } from "@/forms/validation"; + +type FileDestination = "STORAGE" | "STREAMING"; + +type FileDownloadRequestFormValues = { + files: File[]; + archiveName?: string; + destination: FileDestination; + ttlSeconds: number; + progress: boolean; +}; + +type ManualFileDownloadRequestFormProps = { + className?: string; + isLoading: boolean; + onFileSubmit: (values: FileDownloadRequestFormValues) => void; +}; + +const destinationOptions = [ + { value: "STORAGE", label: "Storage" }, + { value: "STREAMING", label: "Streaming" }, +]; + +const ManualFileDownloadRequestForm = ({ + className, + isLoading, + onFileSubmit, +}: ManualFileDownloadRequestFormProps) => { + const [selectedFiles, setSelectedFiles] = useState([]); + + const { + formState: { errors }, + handleSubmit, + register, + control, + setValue, + reset, + } = useForm({ + mode: "onTouched", + defaultValues: { + file: undefined, + archiveName: "", + destination: "STORAGE" as FileDestination, + ttlSeconds: 0, + progress: false, + }, + resolver: zodResolver(fileDownloadRequestFormSchema), + }); + + const handleFilesChanged = (files: File[]) => { + setSelectedFiles(files); + const dt = new DataTransfer(); + files.forEach((f) => dt.items.add(f)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setValue("file", dt.files as any, { shouldValidate: true }); + }; + + const onSubmit = handleSubmit((data) => { + if (selectedFiles.length > 0) { + onFileSubmit({ + files: selectedFiles, + archiveName: + selectedFiles.length > 1 && data.archiveName + ? data.archiveName + : undefined, + destination: data.destination as FileDestination, + ttlSeconds: data.ttlSeconds, + progress: data.progress, + }); + setSelectedFiles([]); + reset(); + } + }); + + const hasRelativePaths = selectedFiles.some((f) => f.webkitRelativePath); + const showArchiveName = selectedFiles.length > 1 || hasRelativePaths; + + return ( +
+ + } + > + + {errors.file ? ( + + ) : ( + + + + )} + + + {showArchiveName && ( + + } + > + + + + + + )} + + + } + > + { + const selectedOption = + destinationOptions.find((opt) => opt.value === field.value) || + null; + + return ( +