From 09cb93469e30614481189160d9fd57823e72f3b0 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 19 Jun 2025 15:44:07 +0100 Subject: [PATCH 1/3] fix(fileimport service): health check for next gen importer (#4963) --- .../src/nextGen/healthcheck.ts | 21 +++++++++++++++++++ .../fileimport-service/src/nextGen/main.ts | 12 +++++++++++ .../fileimport_service/deployment.yml | 6 ++++++ 3 files changed, 39 insertions(+) create mode 100644 packages/fileimport-service/src/nextGen/healthcheck.ts diff --git a/packages/fileimport-service/src/nextGen/healthcheck.ts b/packages/fileimport-service/src/nextGen/healthcheck.ts new file mode 100644 index 000000000..5d9208584 --- /dev/null +++ b/packages/fileimport-service/src/nextGen/healthcheck.ts @@ -0,0 +1,21 @@ +import http from 'node:http' +import { Logger } from 'pino' + +export const startHealthCheckServer = (params: { logger: Logger }) => { + const { logger } = params + const server = http.createServer((req, res) => { + if (req.url === '/healthz' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('OK') + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not Found') + } + }) + + server.listen(9080, 'localhost', () => { + logger.info('Server running at http://localhost with endpoint /healthz') + }) + + return server +} diff --git a/packages/fileimport-service/src/nextGen/main.ts b/packages/fileimport-service/src/nextGen/main.ts index 3cfa7da66..a695008f6 100644 --- a/packages/fileimport-service/src/nextGen/main.ts +++ b/packages/fileimport-service/src/nextGen/main.ts @@ -10,10 +10,12 @@ import { logger } from '@/observability/logging.js' import { Logger } from 'pino' import { ensureError, TIME_MS } from '@speckle/shared' import { jobProcessor } from './jobProcessor.js' +import { startHealthCheckServer } from './healthcheck.js' let jobQueue: Bull.Queue | undefined = undefined let appState: AppState = AppState.STARTING let currentJob: { logger: Logger; done: Bull.DoneCallback } | undefined = undefined +let healthCheckServer: ReturnType | undefined export const main = async () => { logger.info('Starting FileUploads Service (nextGen 🚀)...') @@ -37,6 +39,9 @@ export const main = async () => { process.exit(1) } appState = AppState.RUNNING + + healthCheckServer = startHealthCheckServer({ logger }) + logger.debug(`Starting processing of "${QUEUE_NAME}" message queue`) await jobQueue.process(async (payload, done) => { @@ -143,6 +148,13 @@ const beforeShutdown = async () => { currentJob.done(new Error('Job cancelled due to fileimport-service shutdown')) } // no need to close the job queue and redis client, when the process exits they will be closed automatically + + if (healthCheckServer) { + logger.info('Stopping health check server') + healthCheckServer.close(() => { + logger.info('Health check server stopped') + }) + } } const onShutdown = () => { diff --git a/utils/helm/speckle-server/templates/fileimport_service/deployment.yml b/utils/helm/speckle-server/templates/fileimport_service/deployment.yml index f8b071eb3..61bc50859 100644 --- a/utils/helm/speckle-server/templates/fileimport_service/deployment.yml +++ b/utils/helm/speckle-server/templates/fileimport_service/deployment.yml @@ -40,11 +40,17 @@ spec: livenessProbe: initialDelaySeconds: 60 periodSeconds: 60 + {{- if .Values.featureFlags.nextGenFileImporterEnabled }} + httpGet: + path: /healthz + port: 9080 + {{- else }} exec: command: - /usr/bin/node - -e - "process.exit((Date.now() - require('fs').readFileSync('/tmp/last_successful_query', 'utf8') > 25 * 60 * 1000) ? 1 : 0)" + {{- end }} resources: {{- with .Values.fileimport_service.requests }} From 3c1a10bff113d52dc67a0e746cf827a0aada4f54 Mon Sep 17 00:00:00 2001 From: Alexandru Popovici Date: Fri, 20 Jun 2025 09:46:04 +0300 Subject: [PATCH 2/3] Support for duplicate objects (#4959) * feat(viewer-lib): Implemented support for duplicate speckle objects. The world tree now accepts duplicates by appending a unique identifier to the node ids. The speckle object ids remain the same. Searching for a duplicated node id will also yield all nodes * feat(viewer-lib): Changed the way duplication handling works. It's more simple, cleaner and it works better. NodeMap now registers duplicates softly, and only renderable objects are actually duplicated * fix(viewer-lib): Fixed typo --- packages/viewer-sandbox/src/main.ts | 3 + .../loaders/Speckle/SpeckleConverter.ts | 74 +++++++++++++++---- .../modules/loaders/Speckle/SpeckleLoader.ts | 1 + packages/viewer/src/modules/tree/NodeMap.ts | 48 ++++++++++-- .../viewer/src/modules/tree/RenderTree.ts | 4 + packages/viewer/src/modules/tree/WorldTree.ts | 16 ++++ 6 files changed, 126 insertions(+), 20 deletions(-) diff --git a/packages/viewer-sandbox/src/main.ts b/packages/viewer-sandbox/src/main.ts index 8eaf575de..8d73951ee 100644 --- a/packages/viewer-sandbox/src/main.ts +++ b/packages/viewer-sandbox/src/main.ts @@ -560,6 +560,9 @@ const getStream = () => { // Instances with far away transform // 'https://app.speckle.systems/projects/9d0ce16ba8/models/3c079572ea' + + // Duplicate display values + // 'https://app.speckle.systems/projects/1466fe31c6/models/2eaf0f0571' ) } diff --git a/packages/viewer/src/modules/loaders/Speckle/SpeckleConverter.ts b/packages/viewer/src/modules/loaders/Speckle/SpeckleConverter.ts index 74ca5ada9..fe51fa4a7 100644 --- a/packages/viewer/src/modules/loaders/Speckle/SpeckleConverter.ts +++ b/packages/viewer/src/modules/loaders/Speckle/SpeckleConverter.ts @@ -5,6 +5,7 @@ import { NodeMap } from '../../tree/NodeMap.js' import { SpeckleType, type SpeckleObject } from '../../../index.js' import Logger from '../../utils/Logger.js' import { ObjectLoader2 } from '@speckle/objectloader2' +import { SpeckleTypeAllRenderables } from '../GeometryConverter.js' export type ConverterResultDelegate = (count: number) => void export type SpeckleConverterNodeDelegate = @@ -29,6 +30,7 @@ export default class SpeckleConverter { protected renderMaterialMap: { [id: string]: SpeckleObject } = {} protected colorMap: { [id: string]: SpeckleObject } = {} protected instanceCounter = 0 + protected duplicateCounter = 0 private traverseCount = 0 protected readonly NodeConverterMapping: { @@ -140,9 +142,9 @@ export default class SpeckleConverter { children: [] }) this.tree.addSubtree(this.subtree) - this.tree.addNode(childNode, this.subtree) + this.addNode(childNode, this.subtree) } else { - this.tree.addNode(childNode, node) + this.addNode(childNode, node) } // If we can convert it, we should invoke the respective conversion routine. @@ -183,7 +185,7 @@ export default class SpeckleConverter { atomic: false, children: [] }) - this.tree.addNode(nestedNode, childNode) + this.addNode(nestedNode, childNode) await this.convertToNode(displayValue, nestedNode) } catch (e) { Logger.warn( @@ -202,7 +204,7 @@ export default class SpeckleConverter { atomic: false, children: [] }) - this.tree.addNode(nestedNode, childNode) + this.addNode(nestedNode, childNode) await this.convertToNode(val, nestedNode) } } @@ -269,9 +271,17 @@ export default class SpeckleConverter { private getNodeId(obj: SpeckleObject): string { if (this.spoofIDs) return MathUtils.generateUUID() + return obj.id } + private addNode(node: TreeNode, parent: TreeNode) { + if (this.tree.hasNodeId(node.model.id, parent.model.subtreeId)) { + node.model.id = this.getDuplicateId(node.model.id, ++this.duplicateCounter) + } + this.tree.addNode(node, parent) + } + /** * Takes an array composed of chunked references and dechunks it. * @param {[type]} arr [description] @@ -410,6 +420,14 @@ export default class SpeckleConverter { return baseId.substring(0, index) + NodeMap.COMPOUND_ID_CHAR + counter } + private getDuplicateId(baseId: string, counter: number) { + const index = baseId.indexOf(NodeMap.DUPLICATE_ID_CHAR) + if (index === -1) { + return baseId + NodeMap.DUPLICATE_ID_CHAR + counter + } + return baseId.substring(0, index) + NodeMap.DUPLICATE_ID_CHAR + counter + } + private getEmptyTransformData(id: string) { // eslint-disable-next-line camelcase return { id, speckle_type: 'Transform', units: 'm', matrix: new Array(16) } @@ -456,7 +474,7 @@ export default class SpeckleConverter { instanced }) - this.tree.addNode(valueNode, node) + this.addNode(valueNode, node) await this.displayableLookup(value, valueNode, instanced) } } @@ -485,7 +503,7 @@ export default class SpeckleConverter { atomic: false, children: [] }) - this.tree.addNode(transformNode, instanceNode) + this.addNode(transformNode, instanceNode) const childNode: TreeNode = this.tree.parse({ id: this.getCompoundId(defGeometry.id, this.instanceCounter++), @@ -494,7 +512,7 @@ export default class SpeckleConverter { children: [], instanced: true }) - this.tree.addNode(childNode, transformNode) + this.addNode(childNode, transformNode) await this.displayableLookup(defGeometry, childNode, true) } @@ -510,7 +528,7 @@ export default class SpeckleConverter { atomic: false, children: [] }) - this.tree.addNode(childNode, instanceNode) + this.addNode(childNode, instanceNode) await this.displayableLookup(elementObj, childNode, false) } @@ -637,7 +655,7 @@ export default class SpeckleConverter { const definition = this.instanceDefinitionLookupTable[definitionId] const transformNode = this.createTransformNode(obj) - this.tree.addNode(transformNode, node) + this.addNode(transformNode, node) const objectApplicationIds = this.getInstanceProxyDefinitionObjects( definition.model.raw ) @@ -658,7 +676,7 @@ export default class SpeckleConverter { children: [], instanced: true }) - this.tree.addNode(instancedNode, transformNode) + this.addNode(instancedNode, transformNode) await this.convertToNode(speckleData, instancedNode) } } @@ -875,7 +893,7 @@ export default class SpeckleConverter { ...(node.model.instanced && { instanced: node.model.instanced }) }) await this.convertToNode(ref, nestedNode) - this.tree.addNode(nestedNode, node) + this.addNode(nestedNode, node) // deletes known unneeded fields delete obj.Edges @@ -940,7 +958,7 @@ export default class SpeckleConverter { ...(node.model.instanced && { instanced: node.model.instanced }) }) await this.convertToNode(ref, nestedNode) - this.tree.addNode(nestedNode, node) + this.addNode(nestedNode, node) } catch (e) { Logger.warn(`Failed to convert Region id: ${obj.id}`) throw e @@ -960,7 +978,7 @@ export default class SpeckleConverter { atomic: false, children: [] }) - this.tree.addNode(childNode, node) + this.addNode(childNode, node) await this.convertToNode(displayValue, childNode) } /** @@ -990,7 +1008,7 @@ export default class SpeckleConverter { atomic: false, children: [] }) - this.tree.addNode(textNode, node) + this.addNode(textNode, node) await this.convertToNode(textObj, textNode) } @@ -1071,4 +1089,32 @@ export default class SpeckleConverter { private async EllipseToNode(_obj: SpeckleObject, _node: TreeNode) { return } + + /** We shouldn't need to work with duplicates */ + public handleDuplicates(): Promise { + /** We're generally interested in handling renderable duplicates. Otherwise we're overbloat everything with millions of parameters and such */ + const SpeckleTypeDuplicableRenderables: SpeckleType[] = + SpeckleTypeAllRenderables.slice() + /** We remove Point because speckle data contains tons of points that are not really renderable */ + SpeckleTypeDuplicableRenderables.splice( + SpeckleTypeAllRenderables.indexOf(SpeckleType.Point), + 1 + ) + const duplicates = this.tree.getRenderTree(this.subtree.model.id)?.getDuplicates() + for (const k in duplicates) { + const baseObject = this.tree.findId(k) + if (!baseObject) { + Logger.warn(`Base duplicated object ${k} not found!`) + continue + } + const speckleType = this.getSpeckleType(baseObject[0].model.raw) as SpeckleType + if (!SpeckleTypeDuplicableRenderables.includes(speckleType)) continue + + for (const m in duplicates[k]) { + /** Normally we'd only need the geometry related data cloned, but this covers 100% */ + duplicates[k][m].model.raw = structuredClone(duplicates[k][m].model.raw) + } + } + return Promise.resolve() + } } diff --git a/packages/viewer/src/modules/loaders/Speckle/SpeckleLoader.ts b/packages/viewer/src/modules/loaders/Speckle/SpeckleLoader.ts index 9554f6866..e1ab94b13 100644 --- a/packages/viewer/src/modules/loaders/Speckle/SpeckleLoader.ts +++ b/packages/viewer/src/modules/loaders/Speckle/SpeckleLoader.ts @@ -140,6 +140,7 @@ export class SpeckleLoader extends Loader { await this.converter.convertInstances() await this.converter.applyMaterials() + await this.converter.handleDuplicates() await this.loader.disposeAsync() const t0 = performance.now() diff --git a/packages/viewer/src/modules/tree/NodeMap.ts b/packages/viewer/src/modules/tree/NodeMap.ts index 63aa53101..418aa6c07 100644 --- a/packages/viewer/src/modules/tree/NodeMap.ts +++ b/packages/viewer/src/modules/tree/NodeMap.ts @@ -3,9 +3,11 @@ import { type TreeNode } from './WorldTree.js' export class NodeMap { public static readonly COMPOUND_ID_CHAR = '~' + public static readonly DUPLICATE_ID_CHAR = '#' private all: { [id: string]: TreeNode } = {} public instances: { [id: string]: { [id: string]: TreeNode } } = {} + public duplicates: { [id: string]: { [id: string]: TreeNode } } = {} public get nodeCount() { return Object.keys(this.all).length @@ -23,7 +25,12 @@ export class NodeMap { // console.warn(`Duplicate id ${node.model.id}, skipping!`) return false } + this.registerNode(node) + + if (node.model.id.includes(NodeMap.DUPLICATE_ID_CHAR)) { + this.registerDuplicate(node) + } } return true } @@ -53,12 +60,28 @@ export class NodeMap { return null } } + if (id.includes(NodeMap.DUPLICATE_ID_CHAR)) { + const baseId = id.substring(0, id.indexOf(NodeMap.DUPLICATE_ID_CHAR)) + if (this.duplicates[baseId]) { + if (this.duplicates[baseId][id]) { + return [this.duplicates[baseId][id]] + } + } else { + Logger.warn('Could not find duplicate with baseID: ', baseId) + return null + } + } + if (this.all[id]) { + if (this.duplicates[id]) { + return [this.all[id], ...Object.values(this.duplicates[id])] + } return [this.all[id]] } if (this.instances[id]) { return Object.values(this.instances[id]) } + return null } @@ -67,6 +90,14 @@ export class NodeMap { } public hasId(id: string): boolean { + return this.hasNodeId(id) || this.hasInstanceId(id) + } + + public hasNodeId(id: string): boolean { + return this.all[id] !== undefined + } + + public hasInstanceId(id: string): boolean { if (id.includes(NodeMap.COMPOUND_ID_CHAR)) { const baseId = id.substring(0, id.indexOf(NodeMap.COMPOUND_ID_CHAR)) if (this.instances[baseId]) { @@ -75,12 +106,6 @@ export class NodeMap { return false } } - if (this.all[id]) { - return true - } - if (this.instances[id]) { - return true - } return false } @@ -95,6 +120,17 @@ export class NodeMap { this.instances[baseId][node.model.id] = node } + private registerDuplicate(node: TreeNode): void { + const baseId = node.model.id.substring( + 0, + node.model.id.indexOf(NodeMap.DUPLICATE_ID_CHAR) + ) + if (!this.duplicates[baseId]) { + this.duplicates[baseId] = {} + } + this.duplicates[baseId][node.model.id] = node + } + private registerNode(node: TreeNode) { this.all[node.model.id] = node } diff --git a/packages/viewer/src/modules/tree/RenderTree.ts b/packages/viewer/src/modules/tree/RenderTree.ts index d2110a7c4..00fff70e5 100644 --- a/packages/viewer/src/modules/tree/RenderTree.ts +++ b/packages/viewer/src/modules/tree/RenderTree.ts @@ -174,6 +174,10 @@ export class RenderTree { return this.tree.getInstances(this.root.model.subtreeId) } + public getDuplicates() { + return this.tree.getDuplicates(this.root.model.subtreeId) + } + public getRenderableRenderViews(...types: SpeckleType[]): NodeRenderView[] { return this.getRenderableNodes(...types).map( (val: TreeNode) => val.model.renderView diff --git a/packages/viewer/src/modules/tree/WorldTree.ts b/packages/viewer/src/modules/tree/WorldTree.ts index bd0209785..ab87fc3d8 100644 --- a/packages/viewer/src/modules/tree/WorldTree.ts +++ b/packages/viewer/src/modules/tree/WorldTree.ts @@ -119,6 +119,18 @@ export class WorldTree { } } + public hasNodeId(id: string, subtreeId: number = 1) { + return this.nodeMaps[subtreeId] && this.nodeMaps[subtreeId].hasNodeId(id) + } + + public hasInstanceId(id: string, subtreeId: number = 1) { + return this.nodeMaps[subtreeId] && this.nodeMaps[subtreeId].hasInstanceId(id) + } + + public hasId(id: string, subtreeId: number = 1) { + return this.nodeMaps[subtreeId] && this.nodeMaps[subtreeId].hasId(id) + } + public findAll(predicate: SearchPredicate, node?: TreeNode): Array { if (!node && !this.supressWarnings) { Logger.warn(`Root will be used for searching. You might not want that`) @@ -158,6 +170,10 @@ export class WorldTree { return this.nodeMaps[subtreeId].instances } + public getDuplicates(subtreeId: string): { [id: string]: Record } { + return this.nodeMaps[subtreeId].duplicates + } + /** TO DO: We might want to add boolean as return type here too */ public walk(predicate: SearchPredicate, node?: TreeNode): void { if (!node && !this.supressWarnings) { From 3b641024ccff3f9982822b510820ac61a7c68722 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Fri, 20 Jun 2025 10:50:16 +0300 Subject: [PATCH 3/3] feat(fe2): enable large file uploads (#4965) --- .../components/project/CardImportFileArea.vue | 13 +- .../components/project/page/models/Card.vue | 2 +- .../project/page/models/CardView.vue | 2 +- .../project/page/models/ListView.vue | 2 +- .../project/page/models/StructureItem.vue | 2 +- .../projects/ProjectDashboardCard.vue | 2 +- .../lib/common/generated/gql/gql.ts | 12 ++ .../lib/common/generated/gql/graphql.ts | 16 ++ .../frontend-2/lib/core/api/fileImport.ts | 13 +- .../lib/core/composables/fileImport.ts | 184 +++++++++++++++--- .../frontend-2/lib/form/helpers/fileUpload.ts | 6 +- .../src/composables/form/fileUpload.ts | 3 +- 12 files changed, 218 insertions(+), 39 deletions(-) diff --git a/packages/frontend-2/components/project/CardImportFileArea.vue b/packages/frontend-2/components/project/CardImportFileArea.vue index 432cf2548..9fc636d42 100644 --- a/packages/frontend-2/components/project/CardImportFileArea.vue +++ b/packages/frontend-2/components/project/CardImportFileArea.vue @@ -277,7 +277,7 @@ const onModelCreate = (params: { model: ProjectPageLatestItemsModelItemFragment if (!isFileUploadUploadable.value) return uploadSelected({ - modelName: params.model.name + model: params.model }) } @@ -296,11 +296,18 @@ watch(showNewModelDialog, (newVal, oldVal) => { watch(isUploading, (newVal, oldVal) => { // fileUpload is always gonna be non-null when isUploading changes - emit('uploading', { isUploading: newVal, upload: fileUpload.value! }) + emit('uploading', { + isUploading: newVal, + upload: fileUpload.value!, + error: errorMessage.value + }) if (!newVal && oldVal) { // Reset file upload state when upload finishes - resetSelected() + // but only if it was successful! otherwise we wanna show the error + if (!errorMessage.value) { + resetSelected() + } } }) diff --git a/packages/frontend-2/components/project/page/models/Card.vue b/packages/frontend-2/components/project/page/models/Card.vue index 6d990cb8d..c204dfea7 100644 --- a/packages/frontend-2/components/project/page/models/Card.vue +++ b/packages/frontend-2/components/project/page/models/Card.vue @@ -238,7 +238,7 @@ const onCardClick = (event: KeyboardEvent | MouseEvent) => { } const onVersionUploading = (payload: FileAreaUploadingPayload) => { - isVersionUploading.value = payload.isUploading + isVersionUploading.value = !!(payload.isUploading || payload.error) } const triggerVersionUpload = () => { diff --git a/packages/frontend-2/components/project/page/models/CardView.vue b/packages/frontend-2/components/project/page/models/CardView.vue index 41822ff3a..439906b5b 100644 --- a/packages/frontend-2/components/project/page/models/CardView.vue +++ b/packages/frontend-2/components/project/page/models/CardView.vue @@ -204,7 +204,7 @@ const calculateLoaderId = () => { } const onModelUploading = (payload: FileAreaUploadingPayload) => { - isModelUploading.value = payload.isUploading + isModelUploading.value = !!(payload.isUploading || payload.error) } watch(areQueriesLoading, (newVal) => { diff --git a/packages/frontend-2/components/project/page/models/ListView.vue b/packages/frontend-2/components/project/page/models/ListView.vue index a736e6631..aa65d9c25 100644 --- a/packages/frontend-2/components/project/page/models/ListView.vue +++ b/packages/frontend-2/components/project/page/models/ListView.vue @@ -216,7 +216,7 @@ const calculateLoaderId = () => { } const onModelUploading = (payload: FileAreaUploadingPayload) => { - isModelUploading.value = payload.isUploading + isModelUploading.value = !!(payload.isUploading || payload.error) } watch(areQueriesLoading, (newVal) => { diff --git a/packages/frontend-2/components/project/page/models/StructureItem.vue b/packages/frontend-2/components/project/page/models/StructureItem.vue index 7e85eb6a4..87847e28c 100644 --- a/packages/frontend-2/components/project/page/models/StructureItem.vue +++ b/packages/frontend-2/components/project/page/models/StructureItem.vue @@ -436,7 +436,7 @@ const triggerVersionUpload = () => { } const onVersionUploading = (payload: FileAreaUploadingPayload) => { - isVersionUploading.value = payload.isUploading + isVersionUploading.value = !!(payload.isUploading || payload.error) } const onVersionsClick = () => { diff --git a/packages/frontend-2/components/projects/ProjectDashboardCard.vue b/packages/frontend-2/components/projects/ProjectDashboardCard.vue index b3a668f65..d7a7ed3b1 100644 --- a/packages/frontend-2/components/projects/ProjectDashboardCard.vue +++ b/packages/frontend-2/components/projects/ProjectDashboardCard.vue @@ -202,6 +202,6 @@ const gridClasses = computed(() => [ ]) const onModelUploading = (payload: FileAreaUploadingPayload) => { - isModelUploading.value = payload.isUploading + isModelUploading.value = !!(payload.isUploading || payload.error) } diff --git a/packages/frontend-2/lib/common/generated/gql/gql.ts b/packages/frontend-2/lib/common/generated/gql/gql.ts index 840368b4f..fbce55302 100644 --- a/packages/frontend-2/lib/common/generated/gql/gql.ts +++ b/packages/frontend-2/lib/common/generated/gql/gql.ts @@ -211,6 +211,8 @@ type Documents = { "\n query ServerInfoBlobSizeLimit {\n serverInfo {\n configuration {\n blobSizeLimitBytes\n }\n }\n }\n": typeof types.ServerInfoBlobSizeLimitDocument, "\n query ServerInfoAllScopes {\n serverInfo {\n scopes {\n name\n description\n }\n }\n }\n": typeof types.ServerInfoAllScopesDocument, "\n query ProjectModelsSelectorValues($projectId: String!, $cursor: String) {\n project(id: $projectId) {\n id\n models(limit: 100, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...CommonModelSelectorModel\n }\n }\n }\n }\n": typeof types.ProjectModelsSelectorValuesDocument, + "\n mutation GenerateUploadUrl($input: GenerateFileUploadUrlInput!) {\n fileUploadMutations {\n generateUploadUrl(input: $input) {\n url\n fileId\n }\n }\n }\n": typeof types.GenerateUploadUrlDocument, + "\n mutation StartFileImport($input: StartFileImportInput!) {\n fileUploadMutations {\n startFileImport(input: $input) {\n id\n }\n }\n }\n": typeof types.StartFileImportDocument, "\n fragment UseFileImport_Project on Project {\n id\n }\n": typeof types.UseFileImport_ProjectFragmentDoc, "\n fragment UseFileImport_Model on Model {\n id\n name\n }\n": typeof types.UseFileImport_ModelFragmentDoc, "\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n configuration {\n isEmailEnabled\n }\n }\n }\n": typeof types.MainServerInfoDataDocument, @@ -662,6 +664,8 @@ const documents: Documents = { "\n query ServerInfoBlobSizeLimit {\n serverInfo {\n configuration {\n blobSizeLimitBytes\n }\n }\n }\n": types.ServerInfoBlobSizeLimitDocument, "\n query ServerInfoAllScopes {\n serverInfo {\n scopes {\n name\n description\n }\n }\n }\n": types.ServerInfoAllScopesDocument, "\n query ProjectModelsSelectorValues($projectId: String!, $cursor: String) {\n project(id: $projectId) {\n id\n models(limit: 100, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...CommonModelSelectorModel\n }\n }\n }\n }\n": types.ProjectModelsSelectorValuesDocument, + "\n mutation GenerateUploadUrl($input: GenerateFileUploadUrlInput!) {\n fileUploadMutations {\n generateUploadUrl(input: $input) {\n url\n fileId\n }\n }\n }\n": types.GenerateUploadUrlDocument, + "\n mutation StartFileImport($input: StartFileImportInput!) {\n fileUploadMutations {\n startFileImport(input: $input) {\n id\n }\n }\n }\n": types.StartFileImportDocument, "\n fragment UseFileImport_Project on Project {\n id\n }\n": types.UseFileImport_ProjectFragmentDoc, "\n fragment UseFileImport_Model on Model {\n id\n name\n }\n": types.UseFileImport_ModelFragmentDoc, "\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n configuration {\n isEmailEnabled\n }\n }\n }\n": types.MainServerInfoDataDocument, @@ -1718,6 +1722,14 @@ export function graphql(source: "\n query ServerInfoAllScopes {\n serverInfo * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n query ProjectModelsSelectorValues($projectId: String!, $cursor: String) {\n project(id: $projectId) {\n id\n models(limit: 100, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...CommonModelSelectorModel\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectModelsSelectorValues($projectId: String!, $cursor: String) {\n project(id: $projectId) {\n id\n models(limit: 100, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...CommonModelSelectorModel\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation GenerateUploadUrl($input: GenerateFileUploadUrlInput!) {\n fileUploadMutations {\n generateUploadUrl(input: $input) {\n url\n fileId\n }\n }\n }\n"): (typeof documents)["\n mutation GenerateUploadUrl($input: GenerateFileUploadUrlInput!) {\n fileUploadMutations {\n generateUploadUrl(input: $input) {\n url\n fileId\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation StartFileImport($input: StartFileImportInput!) {\n fileUploadMutations {\n startFileImport(input: $input) {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation StartFileImport($input: StartFileImportInput!) {\n fileUploadMutations {\n startFileImport(input: $input) {\n id\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 699842d14..0671afcd7 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -5800,6 +5800,20 @@ export type ProjectModelsSelectorValuesQueryVariables = Exact<{ export type ProjectModelsSelectorValuesQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, models: { __typename?: 'ModelCollection', cursor?: string | null, totalCount: number, items: Array<{ __typename?: 'Model', id: string, name: string }> } } }; +export type GenerateUploadUrlMutationVariables = Exact<{ + input: GenerateFileUploadUrlInput; +}>; + + +export type GenerateUploadUrlMutation = { __typename?: 'Mutation', fileUploadMutations: { __typename?: 'FileUploadMutations', generateUploadUrl: { __typename?: 'GenerateFileUploadUrlOutput', url: string, fileId: string } } }; + +export type StartFileImportMutationVariables = Exact<{ + input: StartFileImportInput; +}>; + + +export type StartFileImportMutation = { __typename?: 'Mutation', fileUploadMutations: { __typename?: 'FileUploadMutations', startFileImport: { __typename?: 'FileUpload', id: string } } }; + export type UseFileImport_ProjectFragment = { __typename?: 'Project', id: string }; export type UseFileImport_ModelFragment = { __typename?: 'Model', id: string, name: string }; @@ -7609,6 +7623,8 @@ export const MentionsUserSearchDocument = {"kind":"Document","definitions":[{"ki export const ServerInfoBlobSizeLimitDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ServerInfoBlobSizeLimit"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configuration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"blobSizeLimitBytes"}}]}}]}}]}}]} as unknown as DocumentNode; export const ServerInfoAllScopesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ServerInfoAllScopes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"scopes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]}}]} as unknown as DocumentNode; export const ProjectModelsSelectorValuesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ProjectModelsSelectorValues"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"models"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"100"}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CommonModelSelectorModel"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CommonModelSelectorModel"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode; +export const GenerateUploadUrlDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GenerateUploadUrl"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GenerateFileUploadUrlInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fileUploadMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generateUploadUrl"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"fileId"}}]}}]}}]}}]} as unknown as DocumentNode; +export const StartFileImportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StartFileImport"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"StartFileImportInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fileUploadMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startFileImport"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const MainServerInfoDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MainServerInfoData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminContact"}},{"kind":"Field","name":{"kind":"Name","value":"canonicalUrl"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"guestModeEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"inviteOnly"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfService"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"automateUrl"}},{"kind":"Field","name":{"kind":"Name","value":"configuration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isEmailEnabled"}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteAccessTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteAccessToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiTokenRevoke"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}]}]}}]} as unknown as DocumentNode; export const CreateAccessTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateAccessToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApiTokenCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiTokenCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}]}]}}]} as unknown as DocumentNode; diff --git a/packages/frontend-2/lib/core/api/fileImport.ts b/packages/frontend-2/lib/core/api/fileImport.ts index 806259807..5584f43ea 100644 --- a/packages/frontend-2/lib/core/api/fileImport.ts +++ b/packages/frontend-2/lib/core/api/fileImport.ts @@ -8,18 +8,25 @@ export enum FileUploadConvertedStatus { Error = 3 } -export function importFile( +export type ImportFile = ( params: { file: File projectId: string apiOrigin: string authToken: string - modelName?: string + modelName: string + modelId: string }, callbacks?: Partial<{ onProgress: (percentage: number) => void }> -) { +) => Promise + +/** + * Old upload mechanism that streams uploads through the server + * @deprecated Use useFileImportApi() instead + */ +export const importFileLegacy: ImportFile = (params, callbacks) => { const { file, projectId, modelName, apiOrigin, authToken } = params const { onProgress } = callbacks || {} diff --git a/packages/frontend-2/lib/core/composables/fileImport.ts b/packages/frontend-2/lib/core/composables/fileImport.ts index 86a175e29..d6c7356c3 100644 --- a/packages/frontend-2/lib/core/composables/fileImport.ts +++ b/packages/frontend-2/lib/core/composables/fileImport.ts @@ -1,20 +1,149 @@ import type { MaybeRef } from '@vueuse/core' -import { ensureError } from '@speckle/shared' +import { buildManualPromise, ensureError } from '@speckle/shared' import type { MaybeNullOrUndefined, Nullable, Optional } from '@speckle/shared' import { useServerFileUploadLimit } from '~~/lib/common/composables/serverInfo' import type { UploadableFileItem, UploadFileItem } from '~~/lib/form/composables/fileUpload' -import { importFile } from '~~/lib/core/api/fileImport' +import { importFileLegacy, type ImportFile } from '~~/lib/core/api/fileImport' import { useAuthCookie } from '~~/lib/auth/composables/auth' -import { BlobUploadStatus } from '~~/lib/core/api/blobStorage' +import { BlobUploadStatus, type BlobPostResultItem } from '~~/lib/core/api/blobStorage' import { useMixpanel } from '~~/lib/core/composables/mp' import { graphql } from '~/lib/common/generated/gql' import type { UseFileImport_ModelFragment, UseFileImport_ProjectFragment } from '~/lib/common/generated/gql/graphql' +import { useApolloClient } from '@vue/apollo-composable' + +const generateUploadUrlMutation = graphql(` + mutation GenerateUploadUrl($input: GenerateFileUploadUrlInput!) { + fileUploadMutations { + generateUploadUrl(input: $input) { + url + fileId + } + } + } +`) + +const startFileImportMutation = graphql(` + mutation StartFileImport($input: StartFileImportInput!) { + fileUploadMutations { + startFileImport(input: $input) { + id + } + } + } +`) + +export const useFileImportApi = () => { + const { + public: { FF_LARGE_FILE_IMPORTS_ENABLED } + } = useRuntimeConfig() + const apollo = useApolloClient().client + + const importFileV2: ImportFile = async (params, callbacks) => { + const { file, projectId, modelId } = params + const { onProgress } = callbacks || {} + + // Generate upload URL + const generateUploadUrlResponse = await apollo.mutate({ + mutation: generateUploadUrlMutation, + variables: { + input: { + projectId, + fileName: file.name + } + } + }) + + const generateUploadUrl = + generateUploadUrlResponse.data?.fileUploadMutations.generateUploadUrl + if (!generateUploadUrl) { + const errMsg = getFirstGqlErrorMessage( + generateUploadUrlResponse.errors, + "Couldn't generate upload URL" + ) + throw new Error(errMsg) + } + + const { url: uploadUrl, fileId } = generateUploadUrl + + // Upload to S3 compatible endpoint + const request = new XMLHttpRequest() + const uploadPromise = buildManualPromise<{ etag: string }>() + request.open('PUT', uploadUrl) + request.setRequestHeader('Content-Type', file.type) + + request.upload.addEventListener('progress', (e) => { + const percentage = (e.loaded / e.total) * 100 + onProgress?.(percentage) + }) + + const handleResponse = () => { + const statusCode = request.status + if (statusCode >= 200 && statusCode < 300) { + // Collect etag + const etag = request.getResponseHeader('ETag') + if (!etag) { + return uploadPromise.reject(new Error('No ETag in upload response')) + } + return uploadPromise.resolve({ etag }) + } else { + // Try to resolve error message from XML response w/ regex (dont want to parse XML) + const errorMessage = request.responseText.match( + /(.*?)<\/Message>/ + )?.[1] + return uploadPromise.reject( + new Error(errorMessage || `Upload failed with status ${statusCode}`) + ) + } + } + + request.addEventListener('load', () => handleResponse()) + request.addEventListener('error', () => handleResponse()) + request.send(file) + const { etag } = await uploadPromise.promise + + // Now lets start the file import + const startFileImportResponse = await apollo.mutate({ + mutation: startFileImportMutation, + variables: { + input: { + projectId, + fileId, + etag, + modelId + } + } + }) + const fileImportStarted = + startFileImportResponse.data?.fileUploadMutations.startFileImport.id + if (!fileImportStarted) { + const errMsg = getFirstGqlErrorMessage( + startFileImportResponse.errors, + "Couldn't start file import" + ) + throw new Error(errMsg) + } + + const res: BlobPostResultItem = { + fileName: file.name, + fileSize: file.size, + formKey: 'file', + uploadStatus: BlobUploadStatus.Completed, + uploadError: '' + } + + return res + } + + return { + importFile: FF_LARGE_FILE_IMPORTS_ENABLED ? importFileV2 : importFileLegacy + } +} graphql(` fragment UseFileImport_Project on Project { @@ -31,12 +160,11 @@ graphql(` export function useFileImport(params: { project: MaybeRef - model?: MaybeRef> /** - * Sometimes we don't have a model, but we still want to specify a target model name (e.g. for - * model list view uploads, where list items don't necessarily represent real models) + * Model should exist if upload is automatically triggered. Otherwise you must still feed it in, but + * at the point when you call uploadSelected(). */ - modelName?: MaybeRef> + model?: MaybeRef> /** * If true, the upload will be prepared and validated, but for it to start you must invoke uploadSelected() manually */ @@ -58,18 +186,19 @@ export function useFileImport(params: { fileSelectedCallback } = params + const { importFile } = useFileImportApi() const { maxSizeInBytes } = useServerFileUploadLimit() const authToken = useAuthCookie() const apiOrigin = useApiOrigin() const accept = ref('.ifc,.stl,.obj') - const upload = ref(null as Nullable }>) + const upload = ref(null as Nullable) const isUploading = ref(false) - const modelName = computed(() => unref(params.modelName) || unref(model)?.name) const isUploadable = computed(() => { if (!upload.value) return false if (upload.value.error) return false + if (upload.value.result) return false if (isUploading.value) return false if (!authToken.value) return false if (!upload.value.file) return false @@ -80,20 +209,32 @@ export function useFileImport(params: { const uploadSelected = async (params?: { /** - * Optionally override model name to target for the upload + * Optionally override model target for the upload */ - modelName?: string + model: UseFileImport_ModelFragment }) => { if (!isUploadable.value || !upload.value || !authToken.value) return - const finalModelName = params?.modelName || upload.value.modelName + + const baseModel = unref(model) + const overridenModel = params?.model isUploading.value = true try { + let finalModel: UseFileImport_ModelFragment + if (overridenModel) { + finalModel = overridenModel + } else if (baseModel) { + finalModel = baseModel + } else { + throw new Error('No model provided for file import') + } + const res = await importFile( { file: upload.value.file, projectId: unref(project).id, - modelName: finalModelName, + modelName: finalModel.name, + modelId: finalModel.id, authToken: authToken.value, apiOrigin }, @@ -104,13 +245,11 @@ export function useFileImport(params: { } ) upload.value.result = res - // TODO: add file extension - // const extension = res.fileName?.split('.').reverse()[0] + mp.track('Upload Action', { type: 'action', name: 'create', - source: finalModelName ? 'model card' : 'empty card' - // extension + source: 'model card' }) fileUploadedCallback?.(upload.value) @@ -131,13 +270,7 @@ export function useFileImport(params: { upload.value = null } - const onFilesSelected = async (params: { - files: UploadableFileItem[] - /** - * Optionally override model name to target for the upload - */ - modelName?: string - }) => { + const onFilesSelected = async (params: { files: UploadableFileItem[] }) => { if (isUploading.value || !authToken.value) return const file = params.files[0] @@ -146,8 +279,7 @@ export function useFileImport(params: { upload.value = { ...file, result: undefined, - progress: 0, - modelName: params.modelName || modelName.value || undefined + progress: 0 } if (file.error) { diff --git a/packages/frontend-2/lib/form/helpers/fileUpload.ts b/packages/frontend-2/lib/form/helpers/fileUpload.ts index 862bb7994..2578a1084 100644 --- a/packages/frontend-2/lib/form/helpers/fileUpload.ts +++ b/packages/frontend-2/lib/form/helpers/fileUpload.ts @@ -1,3 +1,7 @@ import type { UploadFileItem } from '@speckle/ui-components' -export type FileAreaUploadingPayload = { isUploading: boolean; upload: UploadFileItem } +export type FileAreaUploadingPayload = { + isUploading: boolean + upload: UploadFileItem + error: string | null +} diff --git a/packages/ui-components/src/composables/form/fileUpload.ts b/packages/ui-components/src/composables/form/fileUpload.ts index 74a36d698..1a392fb10 100644 --- a/packages/ui-components/src/composables/form/fileUpload.ts +++ b/packages/ui-components/src/composables/form/fileUpload.ts @@ -10,6 +10,7 @@ import type { FileTypeSpecifier } from '~~/src/helpers/form/file' import { computed, unref } from 'vue' import type { CSSProperties } from 'vue' import { BaseError } from '~~/src/lib' +import type { BlobUploadStatus } from '@speckle/shared/blobs' /** * A file, as emitted out from FileUploadZone @@ -31,7 +32,7 @@ export type BlobPostResultItem = { /** * Success = 1, Failure = 2 */ - uploadStatus: number + uploadStatus: BlobUploadStatus uploadError: string }