Merge branch 'main' into iain/task-id-to-request-context

This commit is contained in:
Iain Sproat
2025-03-14 17:25:38 +00:00
203 changed files with 6088 additions and 2257 deletions
+2
View File
@@ -71,6 +71,8 @@ redis-data/
.tshy-build
obj/
bin/
!packages/fileimport-service/src/obj
!packages/fileimport-service/bin
!packages/monitor-deployment/bin
!packages/preview-service/bin
!packages/server/bin
+7
View File
@@ -0,0 +1,7 @@
FILE_IMPORT_TIME_LIMIT_MIN='10'
MAX_OBJECT_SIZE_MB='10'
POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE='1'
POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS='16000'
POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS='5000'
FF_WORKSPACES_MULTI_REGION_ENABLED=false
# IFC_DOTNET_DLL_PATH='packages/fileimport-service/src/ifc-dotnet/ifc-converter.dll'
-18
View File
@@ -1,18 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch via Yarn",
"request": "launch",
"console": "integratedTerminal",
"runtimeArgs": ["dev"],
"runtimeExecutable": "yarn",
"skipFiles": ["<node_internals>/**"],
"envFile": "${workspaceFolder}/.env",
"type": "node"
}
]
}
+4 -3
View File
@@ -2,7 +2,7 @@ ARG NODE_ENV=production
FROM mcr.microsoft.com/dotnet/sdk:8.0-noble AS dotnet-build-stage
WORKDIR /app
COPY packages/fileimport-service/ifc-dotnet .
COPY packages/fileimport-service/src/ifc-dotnet .
RUN dotnet publish ifc-converter.csproj -c Release -o output/
@@ -69,8 +69,9 @@ ENV PYTHON_BINARY_PATH=${PYTHON_BINARY_PATH}
ARG DOTNET_BINARY_PATH=/usr/bin/dotnet
ENV DOTNET_BINARY_PATH=${DOTNET_BINARY_PATH}
COPY --link --from=dotnet-build-stage /app/output packages/fileimport-service/ifc-dotnet
COPY --link --from=dotnet-build-stage /app/output packages/fileimport-service/src/ifc-dotnet
ENV IFC_DOTNET_DLL_PATH='/speckle-server/packages/fileimport-service/src/ifc-dotnet/ifc-converter.dll'
WORKDIR /speckle-server/packages/fileimport-service
ENTRYPOINT [ "tini", "--", "node", "--no-experimental-fetch", "src/daemon.js"]
ENTRYPOINT [ "tini", "--", "node", "--loader=./dist/src/aliasLoader.js", "bin/www.js" ]
+9 -7
View File
@@ -1,11 +1,13 @@
# `ifc-parser`
# File Import Service
> TODO: description
## Description of how this works
## Usage
A micro-service which polls a Postgres database table `file_uploads` for new records and processes them.
```
const ifcParser = require('ifc-parser');
It retrieves a referenced file from an S3 bucket and stores it in a local directory for parsing.
// TODO: DEMONSTRATE API
```
The File Import service can parse either STL, OBJ, or IFC files using external packages, written in either .Net or Python (_note_, there is a legacy IFC parser written in Node.js). These external packages are controlled via shell commands.
The parsers are responsible for extracting the necessary data from the files and storing it in the database. They are also responsible for creating a new Speckle model if necessary.
The service is then responsible for updating the status of the `file_uploads` table, and for posting a Postgres notification.
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/src/bin.js'
+47 -6
View File
@@ -1,20 +1,61 @@
import { baseConfigs, globals } from '../../eslint.config.mjs'
import tseslint from 'typescript-eslint'
import {
baseConfigs,
getESMDirname,
globals,
prettierConfig
} from '../../eslint.config.mjs'
/**
* @type {Array<import('eslint').Linter.FlatConfig>}
*/
const configs = [
...baseConfigs,
{
ignores: ['**/ifc/**', '**/obj/**', '**/stl/**']
ignores: ['dist', 'public', 'docs']
},
{
files: ['**/*.js'],
ignores: ['**/*.mjs'],
languageOptions: {
sourceType: 'module',
globals: {
...globals.node
}
}
},
{
files: ['bin/www'],
languageOptions: {
sourceType: 'module',
globals: {
...globals.node
}
}
},
...tseslint.configs.recommendedTypeChecked.map((c) => ({
...c,
files: [...(c.files || []), '**/*.ts', '**/*.d.ts']
})),
{
files: ['**/*.ts', '**/*.d.ts'],
languageOptions: {
parserOptions: {
tsconfigRootDir: getESMDirname(import.meta.url),
project: './tsconfig.json'
}
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unsafe-return': 'error'
}
},
{
files: ['**/*.spec.{js,ts}'],
languageOptions: {
globals: {
...globals.node
}
}
}
},
prettierConfig
]
export default configs
@@ -1,4 +0,0 @@
{
"extends": "../../jsconfig.base.json",
"include": ["src", "ifc"]
}
@@ -0,0 +1,34 @@
{
"main": {
"postgres": {
"connectionUri": "postgresql://speckle:speckle@127.0.0.1:5432/speckle",
"privateConnectionUri": "postgresql://speckle:speckle@postgres:5432/speckle",
"databaseName": "speckle"
},
"blobStorage": {
"accessKey": "minioadmin",
"secretKey": "minioadmin",
"bucket": "speckle-server",
"createBucketIfNotExists": true,
"endpoint": "http://127.0.0.1:9000",
"s3Region": "us-east-1"
}
},
"regions": {
"region1": {
"postgres": {
"connectionUri": "postgresql://speckle:speckle@127.0.0.1:5401/speckle",
"privateConnectionUri": "postgresql://speckle:speckle@postgres-region1:5432/speckle",
"databaseName": "speckle"
},
"blobStorage": {
"accessKey": "minioadmin",
"secretKey": "minioadmin",
"bucket": "speckle-server",
"createBucketIfNotExists": true,
"endpoint": "http://127.0.0.1:9020",
"s3Region": "us-east-1"
}
}
}
}
+35 -16
View File
@@ -6,7 +6,8 @@
"author": "Speckle Systems <hello@speckle.systems>",
"homepage": "https://github.com/specklesystems/speckle-server#readme",
"license": "SEE LICENSE IN readme.md",
"main": "daemon.js",
"main": "./bin/www.js",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/specklesystems/speckle-server.git"
@@ -15,34 +16,52 @@
"node": "^18.19.0"
},
"scripts": {
"dev": "cross-env POSTGRES_URL=postgres://speckle:speckle@127.0.0.1/speckle NODE_ENV=development LOG_PRETTY=true SPECKLE_SERVER_URL=http://127.0.0.1:3000 nodemon --no-experimental-fetch ./src/daemon.js",
"parse:ifc": "node --no-experimental-fetch ./ifc/import_file.js ./ifc/ifcs/steelplates.ifc 33763848d6 2e4bfb467a main File upload: steelplates.ifc",
"lint": "eslint ."
},
"bugs": {
"url": "https://github.com/specklesystems/speckle-server/issues"
"build:tsc:watch": "tsc -p ./tsconfig.build.json --watch",
"run:watch": "NODE_ENV=development LOG_PRETTY=true LOG_LEVEL=debug nodemon --exec \"yarn start\" --trace-deprecation --watch ./bin/www.js --watch ./dist",
"dev": "concurrently \"npm:build:tsc:watch\" \"npm:run:watch\"",
"dev:headed": "yarn dev",
"build:tsc": "rimraf ./dist/src && tsc -p ./tsconfig.build.json",
"build": "yarn build:tsc",
"lint": "yarn lint:tsc && yarn lint:eslint",
"lint:ci": "yarn lint:tsc",
"lint:tsc": "tsc --noEmit",
"lint:eslint": "eslint .",
"start": "node --loader=./dist/src/aliasLoader.js ./bin/www.js",
"test": "NODE_ENV=test LOG_LEVEL=silent LOG_PRETTY=true vitest run --sequence.shuffle",
"downloadBlob": "node scripts/downloadBlob.js"
},
"dependencies": {
"@speckle/shared": "workspace:^",
"bcrypt": "^5.0.1",
"crypto-random-string": "^3.3.1",
"bcrypt": "^5.0.0",
"crypto": "^1.0.1",
"crypto-random-string": "^3.2.0",
"dotenv": "^16.4.5",
"esm-module-alias": "^2.2.0",
"knex": "^2.5.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"pg": "^8.7.3",
"pino": "^8.7.0",
"pino-http": "^8.0.0",
"pino-pretty": "^9.1.1",
"prom-client": "^14.0.1",
"undici": "^5.28.4",
"tarn": "^3.0.2",
"valid-filename": "^3.1.0",
"web-ifc": "^0.0.36",
"znv": "^0.4.0",
"zod": "^3.22.4"
"web-ifc": "^0.0.36"
},
"devDependencies": {
"cross-env": "^7.0.3",
"@types/bcrypt": "^5.0.0",
"@types/lodash-es": "^4.17.6",
"@types/node": "^18.19.38",
"@vitest/coverage-istanbul": "^1.6.0",
"concurrently": "^8.2.2",
"eslint": "^9.4.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vitest": "^0.5.4",
"nodemon": "^2.0.20",
"prettier": "^2.5.1"
"prettier": "^2.5.1",
"rimraf": "^5.0.7",
"typescript": "^4.6.4",
"typescript-eslint": "^7.12.0",
"vitest": "^1.6.0"
}
}
@@ -0,0 +1,16 @@
const { downloadFile } = require('../src/controller/filesApi.js')
const { logger } = require('../src/observability/logging')
//https://latest.speckle.systems/api/stream/c83a5b2d1f/blob/29fe85cffb
const speckleServerUrl = 'https://latest.speckle.systems'
const fileId = '29fe85cffb'
const streamId = 'c83a5b2d1f'
downloadFile({
speckleServerUrl,
fileId,
streamId,
destination: '/var/folders/p2/fczcvzfd62x5jcfdlw6ghf640000gn/T/tmp.U8MlF9KIxH',
token: process.env.SPECKLE_TOKEN || '',
logger: logger.child({ streamId, fileId })
})
@@ -0,0 +1,6 @@
import generateAliasesResolver from 'esm-module-alias'
import { srcRoot } from './root.js'
export const resolve = generateAliasesResolver({
'@': srcRoot
})
+9
View File
@@ -0,0 +1,9 @@
import '@/bootstrap.js' // This has side-effects and has to be imported first
import { main } from '@/controller/daemon.js'
const start = () => {
void main()
}
start()
@@ -0,0 +1,2 @@
import dotenv from 'dotenv'
dotenv.config()
@@ -1,22 +1,59 @@
'use strict'
const crypto = require('crypto')
const crs = require('crypto-random-string')
const bcrypt = require('bcrypt')
const { chunk } = require('lodash')
const { logger: parentLogger } = require('../observability/logging')
import crypto from 'crypto'
import crs from 'crypto-random-string'
import bcrypt from 'bcrypt'
import { chunk } from 'lodash-es'
import { logger as parentLogger } from '@/observability/logging.js'
import Observability from '@speckle/shared/dist/commonjs/observability/index.js'
import type { Knex } from 'knex'
import type { Logger } from 'pino'
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const tables = (db) => ({
const tables = (db: Knex) => ({
objects: db('objects'),
branches: db('branches'),
branches: db<{
id: string
streamId: string
authorId: string
name: string
description: string
}>('branches'),
streams: db('streams'),
apiTokens: db('api_tokens'),
tokenScopes: db('token_scopes')
})
module.exports = class ServerAPI {
constructor({ db, streamId, logger }) {
type SpeckleObject = {
id?: string
hash?: string
streamId: string
__closure?: Record<string, number>
__tree?: unknown
speckleType: string
totalChildrenCount?: number
totalChildrenCountByDepth?: string
data: unknown
}
type SpeckleObjectWithId = SpeckleObject & {
id: string
}
export class ServerAPI {
tables: ReturnType<typeof tables>
db: Knex
streamId: string
isSending: boolean
buffer: unknown[]
logger: Logger
constructor({
db,
streamId,
logger
}: {
db: Knex
streamId: string
logger: Logger
}) {
this.tables = tables(db)
this.db = db
this.streamId = streamId
@@ -27,7 +64,7 @@ module.exports = class ServerAPI {
Observability.extendLoggerComponent(parentLogger.child({ streamId }), 'ifc')
}
async saveObject(obj) {
async saveObject(obj: SpeckleObject) {
if (!obj) throw new Error('Null object')
if (!obj.id) {
@@ -39,14 +76,20 @@ module.exports = class ServerAPI {
return obj.id
}
async saveObjectBatch(objs) {
async saveObjectBatch(objs: SpeckleObject[]) {
return await this.createObjectsBatched(this.streamId, objs)
}
async createObject({ streamId, object }) {
async createObject({
streamId,
object
}: {
streamId: string
object: SpeckleObject
}) {
const insertionObject = this.prepInsertionObject(streamId, object)
const totalChildrenCountByDepth = {}
const totalChildrenCountByDepth: Record<string, number> = {}
if (object.__closure !== null) {
for (const prop in object.__closure) {
if (totalChildrenCountByDepth[object.__closure[prop].toString()])
@@ -58,7 +101,7 @@ module.exports = class ServerAPI {
delete insertionObject.__tree
delete insertionObject.__closure
insertionObject.totalChildrenCount = object.__closures.length
insertionObject.totalChildrenCount = object.__closure?.length
insertionObject.totalChildrenCountByDepth = JSON.stringify(
totalChildrenCountByDepth
)
@@ -68,15 +111,15 @@ module.exports = class ServerAPI {
return insertionObject.id
}
async createObjectsBatched(streamId, objects) {
const objsToInsert = []
const ids = []
async createObjectsBatched(streamId: string, objects: SpeckleObject[]) {
const objsToInsert: SpeckleObjectWithId[] = []
const ids: string[] = []
// Prep objects up
objects.forEach((obj) => {
const insertionObject = this.prepInsertionObject(streamId, obj)
let totalChildrenCountGlobal = 0
const totalChildrenCountByDepth = {}
const totalChildrenCountByDepth: Record<string, number> = {}
if (obj.__closure !== null) {
for (const prop in obj.__closure) {
@@ -121,7 +164,7 @@ module.exports = class ServerAPI {
return ids
}
prepInsertionObject(streamId, obj) {
prepInsertionObject(streamId: string, obj: SpeckleObject): SpeckleObjectWithId {
const maximumObjectSizeMB = parseInt(process.env['MAX_OBJECT_SIZE_MB'] || '10')
const MAX_OBJECT_SIZE = maximumObjectSizeMB * 1024 * 1024
@@ -145,23 +188,49 @@ module.exports = class ServerAPI {
}
}
prepInsertionObjectBatch(batch) {
prepInsertionObjectBatch(batch: Array<{ id: string }>) {
batch.sort((a, b) => (a.id > b.id ? 1 : -1))
}
prepInsertionClosureBatch(batch) {
batch.sort((a, b) =>
a.parent > b.parent
prepInsertionClosureBatch(
batch: Array<{ parent: string | undefined; child: string | undefined }>
) {
batch.sort((a, b) => {
return this.hasParent(a) && this.hasParent(b) && a.parent > b.parent
? 1
: a.parent === b.parent
? a.child > b.child
? this.hasChild(a) && this.hasChild(b) && a.child > b.child
? 1
: -1
: -1
})
}
hasParent(maybeHasParent: unknown): maybeHasParent is { parent: string } {
return (
!!maybeHasParent &&
typeof maybeHasParent === 'object' &&
'parent' in maybeHasParent &&
typeof maybeHasParent.parent === 'string'
)
}
async getBranchByNameAndStreamId({ streamId, name }) {
hasChild(maybeHasChild: unknown): maybeHasChild is { child: string } {
return (
!!maybeHasChild &&
typeof maybeHasChild === 'object' &&
'child' in maybeHasChild &&
typeof maybeHasChild.child === 'string'
)
}
async getBranchByNameAndStreamId({
streamId,
name
}: {
streamId: string
name: string
}) {
const query = this.tables.branches
.select('*')
.where({ streamId })
@@ -170,13 +239,24 @@ module.exports = class ServerAPI {
return await query
}
async createBranch({ name, description, streamId, authorId }) {
const branch = {}
branch.id = crs({ length: 10 })
branch.streamId = streamId
branch.authorId = authorId
branch.name = name.toLowerCase()
branch.description = description
async createBranch({
name,
description,
streamId,
authorId
}: {
name: string
description: string
streamId: string
authorId: string
}) {
const branch = {
id: crs({ length: 10 }),
streamId,
authorId,
name: name.toLowerCase(),
description
}
await this.tables.branches.returning('id').insert(branch)
@@ -197,7 +277,17 @@ module.exports = class ServerAPI {
return { tokenId, tokenString, tokenHash, lastChars }
}
async createToken({ userId, name, scopes, lifespan }) {
async createToken({
userId,
name,
scopes,
lifespan
}: {
userId: string
name: string
scopes: string[]
lifespan: number
}) {
const { tokenId, tokenString, tokenHash, lastChars } = await this.createBareToken()
if (scopes.length === 0) throw new Error('No scopes provided')
@@ -218,7 +308,7 @@ module.exports = class ServerAPI {
return { id: tokenId, token: tokenId + tokenString }
}
async revokeTokenById(tokenId) {
async revokeTokenById(tokenId: string) {
const delCount = await this.tables.apiTokens
.where({ id: tokenId.slice(0, 10) })
.del()
@@ -1,22 +1,23 @@
'use strict'
const Environment = require('@speckle/shared/dist/commonjs/environment/index.js')
const {
import Environment from '@speckle/shared/dist/commonjs/environment/index.js'
import {
initPrometheusMetrics,
metricDuration,
metricInputFileSize,
metricOperationErrors
} = require('./prometheusMetrics')
const getDbClients = require('../knex')
} from '@/controller/prometheusMetrics.js'
import { getDbClients } from '@/knex.js'
const { downloadFile } = require('./filesApi')
const fs = require('fs')
const { spawn } = require('child_process')
import { downloadFile } from '@/controller/filesApi.js'
import fs from 'fs'
import { spawn } from 'child_process'
import { ServerAPI } from '@/controller/api.js'
import { downloadDependencies } from '@/controller/objDependencies.js'
import { logger } from '@/observability/logging.js'
import { Nullable, Scopes, wait } from '@speckle/shared'
import { Knex } from 'knex'
import { Logger } from 'pino'
const ServerAPI = require('./api')
const objDependencies = require('./objDependencies')
const { logger } = require('../observability/logging')
const { Scopes, wait } = require('@speckle/shared')
const { FF_FILEIMPORT_IFC_DOTNET_ENABLED } = Environment.getFeatureFlags()
const HEALTHCHECK_FILE_PATH = '/tmp/last_successful_query'
@@ -29,11 +30,11 @@ let shouldExit = false
let TIME_LIMIT = 10 * 60 * 1000
const providedTimeLimit = parseInt(process.env.FILE_IMPORT_TIME_LIMIT_MIN)
const providedTimeLimit = parseInt(process.env['FILE_IMPORT_TIME_LIMIT_MIN'] || '10')
if (providedTimeLimit) TIME_LIMIT = providedTimeLimit * 60 * 1000
async function startTask(knex) {
const { rows } = await knex.raw(`
async function startTask(knex: Knex) {
const { rows } = (await knex.raw(`
UPDATE file_uploads
SET
"convertedStatus" = 1,
@@ -46,18 +47,23 @@ async function startTask(knex) {
) as task
WHERE file_uploads."id" = task."id"
RETURNING file_uploads."id"
`)
`)) satisfies { rows: { id: string }[] }
return rows[0]
}
async function doTask(mainDb, regionName, taskDb, task) {
async function doTask(
mainDb: Knex,
regionName: string,
taskDb: Knex,
task: { id: string }
) {
const taskId = task.id
// Mark task as started
await mainDb.raw(`NOTIFY file_import_started, '${task.id}'`)
let taskLogger = logger.child({ taskId })
let tempUserToken = null
let tempUserToken: Nullable<string> = null
let mainServerApi = null
let taskServerApi = null
let fileTypeForMetric = 'unknown'
@@ -65,11 +71,24 @@ async function doTask(mainDb, regionName, taskDb, task) {
const metricDurationEnd = metricDuration.startTimer()
let newBranchCreated = false
let branchMetadata = { streamId: null, branchName: null }
let branchMetadata: { streamId: Nullable<string>; branchName: Nullable<string> } = {
streamId: null,
branchName: null
}
try {
taskLogger.info("Doing task '{taskId}'.")
const info = await taskDb('file_uploads').where({ id: taskId }).first()
const info = await taskDb<{
id: string
fileType: string
fileSize: string
fileName: string
userId: string
streamId: string
branchName: string
}>('file_uploads')
.where({ id: taskId })
.first()
if (!info) {
throw new Error('Internal error: DB inconsistent')
}
@@ -116,30 +135,40 @@ async function doTask(mainDb, regionName, taskDb, task) {
const { token } = await mainServerApi.createToken({
userId: info.userId,
name: 'temp upload token',
scopes: [Scopes.Streams.Write, Scopes.Streams.Read],
scopes: [Scopes.Streams.Write, Scopes.Streams.Read, Scopes.Profile.Read],
lifespan: 1000000
})
tempUserToken = token
taskLogger.info('Downloading file {fileId}')
const speckleServerUrl = process.env.SPECKLE_SERVER_URL || 'http://127.0.0.1:3000'
await downloadFile({
speckleServerUrl,
fileId: info.id,
streamId: info.streamId,
token,
destination: TMP_FILE_PATH
destination: TMP_FILE_PATH,
logger: taskLogger
})
taskLogger.info('Triggering importer for {fileType}')
if (info.fileType.toLowerCase() === 'ifc') {
if (FF_FILEIMPORT_IFC_DOTNET_ENABLED) {
await runProcessWithTimeout(
taskLogger,
process.env['DOTNET_BINARY_PATH'] || 'dotnet',
[
'/speckle-server/packages/fileimport-service/ifc-dotnet/ifc-converter.dll',
process.env['IFC_DOTNET_DLL_PATH'] ||
'/speckle-server/packages/fileimport-service/src/ifc-dotnet/ifc-converter.dll',
TMP_FILE_PATH,
TMP_RESULTS_PATH,
info.streamId,
`File upload: ${info.fileName}`,
existingBranch?.id || '',
info.branchName,
regionName
],
{
@@ -153,7 +182,8 @@ async function doTask(mainDb, regionName, taskDb, task) {
process.env['NODE_BINARY_PATH'] || 'node',
[
'--no-experimental-fetch',
'./ifc/import_file.js',
'--loader=./dist/src/aliasLoader.js',
'./src/ifc/import_file.js',
TMP_FILE_PATH,
TMP_RESULTS_PATH,
info.userId,
@@ -175,7 +205,7 @@ async function doTask(mainDb, regionName, taskDb, task) {
taskLogger,
process.env['PYTHON_BINARY_PATH'] || 'python3',
[
'./stl/import_file.py',
'./src/stl/import_file.py',
TMP_FILE_PATH,
TMP_RESULTS_PATH,
info.userId,
@@ -192,7 +222,8 @@ async function doTask(mainDb, regionName, taskDb, task) {
TIME_LIMIT
)
} else if (info.fileType.toLowerCase() === 'obj') {
await objDependencies.downloadDependencies({
await downloadDependencies({
speckleServerUrl,
objFilePath: TMP_FILE_PATH,
streamId: info.streamId,
destinationDir: TMP_INPUT_DIR,
@@ -204,7 +235,7 @@ async function doTask(mainDb, regionName, taskDb, task) {
process.env['PYTHON_BINARY_PATH'] || 'python3',
[
'-u',
'./obj/import_file.py',
'./src/obj/import_file.py',
TMP_FILE_PATH,
TMP_RESULTS_PATH,
info.userId,
@@ -224,9 +255,11 @@ async function doTask(mainDb, regionName, taskDb, task) {
throw new Error(`File type ${info.fileType} is not supported`)
}
const output = JSON.parse(fs.readFileSync(TMP_RESULTS_PATH))
const output: unknown = JSON.parse(fs.readFileSync(TMP_RESULTS_PATH, 'utf8'))
if (!output.success) throw new Error(output.error)
if (!isSuccessOutput(output)) {
throw new Error(isErrorOutput(output) ? output.error : 'Unknown error')
}
const commitId = output.commitId
@@ -243,7 +276,8 @@ async function doTask(mainDb, regionName, taskDb, task) {
[commitId, task.id]
)
} catch (err) {
taskLogger.error(err)
taskLogger.error(err, 'Error processing task')
const errorForDatabase = maybeErrorToString(err)
await taskDb.raw(
`
UPDATE file_uploads
@@ -254,7 +288,7 @@ async function doTask(mainDb, regionName, taskDb, task) {
WHERE "id" = ?
`,
// DB only accepts a varchar 255
[err.toString().substring(0, 254), task.id]
[errorForDatabase.substring(0, 254), task.id]
)
metricOperationErrors.labels(fileTypeForMetric).inc()
} finally {
@@ -271,12 +305,56 @@ async function doTask(mainDb, regionName, taskDb, task) {
fs.rmSync(TMP_INPUT_DIR, { force: true, recursive: true })
if (fs.existsSync(TMP_RESULTS_PATH)) fs.unlinkSync(TMP_RESULTS_PATH)
if (tempUserToken) {
if (mainServerApi && tempUserToken) {
await mainServerApi.revokeTokenById(tempUserToken)
}
}
function runProcessWithTimeout(processLogger, cmd, cmdArgs, extraEnv, timeoutMs) {
function maybeErrorToString(error: unknown): string {
const unknownError = 'Unknown error'
if (!error) return unknownError
if (typeof error === 'string') return error
if (error instanceof Error) return error.message
try {
return JSON.stringify(error)
} catch {
return unknownError
}
}
function isSuccessOutput(
maybeSuccessOutput: unknown
): maybeSuccessOutput is { success: true; commitId: string } {
return (
!!maybeSuccessOutput &&
typeof maybeSuccessOutput === 'object' &&
'success' in maybeSuccessOutput &&
typeof maybeSuccessOutput.success === 'boolean' &&
maybeSuccessOutput.success &&
'commitId' in maybeSuccessOutput &&
typeof maybeSuccessOutput.commitId === 'string'
)
}
function isErrorOutput(
maybeErrorOutput: unknown
): maybeErrorOutput is { success: false; error: string } {
return (
!!maybeErrorOutput &&
typeof maybeErrorOutput === 'object' &&
'error' in maybeErrorOutput &&
typeof maybeErrorOutput.error === 'string' &&
!!maybeErrorOutput.error
)
}
function runProcessWithTimeout(
processLogger: Logger,
cmd: string,
cmdArgs: string[],
extraEnv: Record<string, string>,
timeoutMs: number
): Promise<void> {
return new Promise((resolve, reject) => {
let boundLogger = processLogger.child({ cmd, args: cmdArgs })
boundLogger.info('Starting process.')
@@ -304,7 +382,7 @@ function runProcessWithTimeout(processLogger, cmd, cmdArgs, extraEnv, timeoutMs)
error: rejectionReason
}
fs.writeFileSync(TMP_RESULTS_PATH, JSON.stringify(output))
reject(rejectionReason)
reject(new Error(rejectionReason))
}, timeoutMs)
childProc.on('close', (code) => {
@@ -319,20 +397,20 @@ function runProcessWithTimeout(processLogger, cmd, cmdArgs, extraEnv, timeoutMs)
if (code === 0) {
resolve()
} else {
reject(`Parser exited with code ${code}`)
reject(new Error(`Parser exited with code ${code}`))
}
})
})
}
function handleData(data, isErr, logger) {
function handleData(data: unknown, isErr: boolean, logger: Logger) {
try {
Buffer.isBuffer(data) && (data = data.toString())
data.split('\n').forEach((line) => {
if (!Buffer.isBuffer(data)) return
const dataAsString = data.toString()
dataAsString.split('\n').forEach((line) => {
if (!line) return
try {
JSON.parse(line) // verify if the data is already in JSON format
process.stdout.write(line)
process.stdout.write('\n')
} catch {
wrapLogLine(line, isErr, logger)
@@ -343,7 +421,7 @@ function handleData(data, isErr, logger) {
}
}
function wrapLogLine(line, isErr, logger) {
function wrapLogLine(line: string, isErr: boolean, logger: Logger) {
if (isErr) {
logger.error({ parserLogLine: line }, 'ParserLog: {parserLogLine}')
return
@@ -356,7 +434,7 @@ const doStuff = async () => {
const mainDb = dbClients.main.public
const dbClientsIterator = infiniteDbClientsIterator(dbClients)
while (!shouldExit) {
const [regionName, taskDb] = dbClientsIterator.next().value
const [regionName, taskDb]: [string, Knex] = dbClientsIterator.next().value
try {
const task = await startTask(taskDb)
fs.writeFile(HEALTHCHECK_FILE_PATH, '' + Date.now(), () => {})
@@ -374,7 +452,7 @@ const doStuff = async () => {
}
}
async function main() {
export async function main() {
logger.info('Starting FileUploads Service...')
await initPrometheusMetrics()
@@ -387,7 +465,9 @@ async function main() {
process.exit(0)
}
function* infiniteDbClientsIterator(dbClients) {
function* infiniteDbClientsIterator(dbClients: {
[key: string]: { public: Knex }
}): Generator<[string, Knex], [string, Knex], [string, Knex]> {
let index = 0
const dbClientEntries = [...Object.entries(dbClients)]
const clientCount = dbClientEntries.length
@@ -399,5 +479,3 @@ function* infiniteDbClientsIterator(dbClients) {
yield [regionName, dbConnection.public]
}
}
main()
@@ -0,0 +1,95 @@
import { ensureError } from '@speckle/shared/dist/esm/index.js'
import fs from 'fs'
import path from 'node:path'
import { pipeline } from 'node:stream/promises'
import { Logger } from 'pino'
export async function downloadFile({
speckleServerUrl,
fileId,
streamId,
token,
destination,
logger
}: {
speckleServerUrl: string
fileId: string
streamId: string
token: string
destination: string
logger: Logger
}) {
try {
fs.mkdirSync(path.dirname(destination), { recursive: true })
} catch (e) {
throw ensureError(e, 'Unknown error while creating directory')
}
logger.info(
{ destinationFile: destination },
'Downloading file {fileId} from {streamId} to {destinationFile}'
)
let response
try {
response = await fetch(
`${speckleServerUrl}/api/stream/${streamId}/blob/${fileId}`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
} catch (e) {
throw ensureError(e, 'Unknown error while fetching file')
}
if (response === undefined || !response.ok) {
logger.error(
{ status: response?.status, statusText: response?.statusText },
'Failed to download file {fileId}. HTTP {status}: {statusText}'
)
throw new Error(
`Failed to download file ${fileId}. HTTP ${response?.status}: ${response?.statusText}`
)
}
if (!response.body) {
throw new Error('Response body is undefined')
}
const writer = fs.createWriteStream(destination)
//handle errors
writer.on('error', (err) => {
logger.error(ensureError(err), `Error writing file ${destination}`)
throw err
})
//handle completion
writer.on('finish', () => {
logger.info(`File written to ${destination}`)
})
await pipeline(response.body, writer, { end: true })
}
export async function getFileInfoByName({
speckleServerUrl,
fileName,
streamId,
token
}: {
speckleServerUrl: string
fileName: string
streamId: string
token: string
}) {
const response = await fetch(
`${speckleServerUrl}/api/stream/${streamId}/blobs?fileName=${fileName}`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
return response.json() as Promise<{ blobs: { id: string }[] }>
}
@@ -0,0 +1,71 @@
import events from 'events'
import fs from 'fs'
import readline from 'readline'
import path from 'path'
import isValidFilename from 'valid-filename'
import { downloadFile, getFileInfoByName } from '@/controller/filesApi.js'
import { logger } from '@/observability/logging.js'
const getReferencedMtlFiles = async ({ objFilePath }: { objFilePath: string }) => {
const mtlFiles: string[] = []
try {
const rl = readline.createInterface({
input: fs.createReadStream(objFilePath),
crlfDelay: Infinity
})
rl.on('line', (line) => {
if (line.startsWith('mtllib ')) {
const mtlFile = line.slice('mtllib '.length).trim()
mtlFiles.push(mtlFile)
}
})
await events.once(rl, 'close')
} catch (err) {
logger.error(err, `Error getting dependencies for file ${objFilePath}`)
}
return mtlFiles
}
export async function downloadDependencies({
speckleServerUrl,
objFilePath,
streamId,
destinationDir,
token
}: {
speckleServerUrl: string
objFilePath: string
streamId: string
destinationDir: string
token: string
}) {
const dependencies = await getReferencedMtlFiles({ objFilePath })
logger.info(`Obj file depends on ${dependencies.join(', ')}`)
for (const mtlFile of dependencies) {
// there might be multiple files named with the same name, take the first...
const [file] = (
await getFileInfoByName({ speckleServerUrl, fileName: mtlFile, streamId, token })
).blobs
if (!file) {
logger.info(`OBJ dependency file not found in stream: ${mtlFile}`)
continue
}
if (!isValidFilename(mtlFile)) {
logger.warn(`Invalid filename reference in OBJ dependencies: ${mtlFile}`)
continue
}
await downloadFile({
speckleServerUrl,
fileId: file.id,
streamId,
token,
destination: path.join(destinationDir, mtlFile),
logger
})
}
}
@@ -0,0 +1,171 @@
import http from 'http'
import prometheusClient, { Counter, Summary } from 'prom-client'
import { getDbClients } from '@/knex.js'
import { Knex } from 'knex'
import { Pool } from 'tarn'
import { isObject } from 'lodash-es'
import { IncomingMessage } from 'http'
let metricQueryDuration: Summary<string> | null = null
let metricQueryErrors: Counter<string> | null = null
const queryStartTime: Record<string, number> = {}
prometheusClient.register.clear()
prometheusClient.register.setDefaultLabels({
project: 'speckle-server',
app: 'fileimport-service'
})
prometheusClient.collectDefaultMetrics()
let prometheusInitialized = false
const initDBPrometheusMetricsFactory =
({ db }: { db: Knex }) =>
() => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const dbConnectionPool = db.client.pool as Pool<unknown>
new prometheusClient.Gauge({
name: 'speckle_server_knex_free',
help: 'Number of free DB connections',
collect() {
this.set(dbConnectionPool.numFree())
}
})
new prometheusClient.Gauge({
name: 'speckle_server_knex_used',
help: 'Number of used DB connections',
collect() {
this.set(dbConnectionPool.numUsed())
}
})
new prometheusClient.Gauge({
name: 'speckle_server_knex_pending',
help: 'Number of pending DB connection aquires',
collect() {
this.set(dbConnectionPool.numPendingAcquires())
}
})
new prometheusClient.Gauge({
name: 'speckle_server_knex_pending_creates',
help: 'Number of pending DB connection creates',
collect() {
this.set(dbConnectionPool.numPendingCreates())
}
})
new prometheusClient.Gauge({
name: 'speckle_server_knex_pending_validations',
help: 'Number of pending DB connection validations. This is a state between pending acquisition and acquiring a connection.',
collect() {
this.set(dbConnectionPool.numPendingValidations())
}
})
new prometheusClient.Gauge({
name: 'speckle_server_knex_remaining_capacity',
help: 'Remaining capacity of the DB connection pool',
collect() {
const postgresMaxConnections = parseInt(
process.env['POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE'] || '1'
)
const demand =
dbConnectionPool.numUsed() +
dbConnectionPool.numPendingCreates() +
dbConnectionPool.numPendingValidations() +
dbConnectionPool.numPendingAcquires()
this.set(Math.max(postgresMaxConnections - demand, 0))
}
})
metricQueryDuration = new prometheusClient.Summary({
name: 'speckle_server_knex_query_duration',
help: 'Summary of the DB query durations in seconds'
})
metricQueryErrors = new prometheusClient.Counter({
name: 'speckle_server_knex_query_errors',
help: 'Number of DB queries with errors'
})
db.on('query', (data) => {
if (!isObject(data) || !('__knexQueryUid' in data)) return
const queryId = String(data.__knexQueryUid)
queryStartTime[queryId] = Date.now()
})
db.on('query-response', (_data, obj) => {
if (!isObject(obj) || !('__knexQueryUid' in obj)) return
const queryId = String(obj.__knexQueryUid)
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
if (metricQueryDuration && !isNaN(durationSec))
metricQueryDuration.observe(durationSec)
})
db.on('query-error', (_err, querySpec) => {
if (!isObject(querySpec) || !('__knexQueryUid' in querySpec)) return
const queryId = String(querySpec.__knexQueryUid)
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
if (metricQueryDuration && !isNaN(durationSec))
metricQueryDuration.observe(durationSec)
if (metricQueryErrors) metricQueryErrors.inc()
})
}
export async function initPrometheusMetrics() {
if (prometheusInitialized) return
prometheusInitialized = true
const db = (await getDbClients()).main.public
initDBPrometheusMetricsFactory({ db })()
const requestHandler = async (req: IncomingMessage, res: http.OutgoingMessage) => {
if (req.url === '/metrics') {
res.setHeader('Content-Type', prometheusClient.register.contentType)
const metrics = await prometheusClient.register.metrics()
res.end(metrics)
} else {
res.end('Speckle FileImport Service - prometheus metrics')
}
}
// Define the HTTP server
// eslint-disable-next-line @typescript-eslint/no-misused-promises
const server = http.createServer(requestHandler)
server.listen(Number(process.env.PROMETHEUS_METRICS_PORT) || 9093)
}
export const metricDuration = new prometheusClient.Histogram({
name: 'speckle_server_operation_duration',
help: 'Summary of the operation durations in seconds',
buckets: [0.5, 1, 5, 10, 30, 60, 300, 600, 900, 1200],
labelNames: ['op']
})
export const metricOperationErrors = new prometheusClient.Counter({
name: 'speckle_server_operation_errors',
help: 'Number of operations with errors',
labelNames: ['op']
})
export const metricInputFileSize = new prometheusClient.Histogram({
name: 'speckle_server_operation_file_size',
help: 'Size of the operation input file size',
buckets: [
1000,
100 * 1000,
500 * 1000,
1000 * 1000,
5 * 1000 * 1000,
10 * 1000 * 1000,
100 * 1000 * 1000
],
labelNames: ['op']
})
@@ -1,32 +0,0 @@
/* istanbul ignore file */
'use strict'
const fs = require('fs')
const path = require('node:path')
const { stream, fetch } = require('undici')
module.exports = {
async downloadFile({ fileId, streamId, token, destination }) {
fs.mkdirSync(path.dirname(destination), { recursive: true })
await stream(
`${process.env.SPECKLE_SERVER_URL}/api/stream/${streamId}/blob/${fileId}`,
{
opaque: fs.createWriteStream(destination),
headers: {
Authorization: `Bearer ${token}`
}
},
({ opaque }) => opaque
)
},
async getFileInfoByName({ fileName, streamId, token }) {
const response = await fetch(
`${process.env.SPECKLE_SERVER_URL}/api/stream/${streamId}/blobs?fileName=${fileName}`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
return response.json()
}
}
@@ -4,9 +4,7 @@
"tools": {
"csharpier": {
"version": "0.30.1",
"commands": [
"dotnet-csharpier"
]
"commands": ["dotnet-csharpier"]
}
}
}
}
@@ -2,46 +2,64 @@
using System.Text.Json;
using Speckle.Importers.Ifc;
using Speckle.Sdk.Common;
using Speckle.Sdk.Models.Extensions;
var filePathArgument = new Argument<string>(name: "filePath");
var filePathArgument = new Argument<string>("filePath");
var outputPathArgument = new Argument<string>("outputPath");
var streamIdArgument = new Argument<string>("streamId");
var commitMessageArgument = new Argument<string>("commitMessage");
var projectIdArgument = new Argument<string>("projectId");
var versionMessageArgument = new Argument<string>("versionMessage");
var modelIdArgument = new Argument<string>("modelId");
var modelNameArgument = new Argument<string>("modelName");
var regionNameArgument = new Argument<string>("regionName");
var rootCommand = new RootCommand
{
filePathArgument,
outputPathArgument,
streamIdArgument,
commitMessageArgument,
projectIdArgument,
versionMessageArgument,
modelIdArgument,
modelNameArgument,
regionNameArgument,
};
rootCommand.SetHandler(
async (filePath, outputPath, streamId, commitMessage, modelId, _) =>
async (filePath, outputPath, projectId, versionMessage, modelId, modelName, _) =>
{
try
{
var token = Environment.GetEnvironmentVariable("USER_TOKEN").NotNull("USER_TOKEN is missing");
var url = Environment.GetEnvironmentVariable("SPECKLE_SERVER_URL") ?? "http://127.0.0.1:3000";
var commitId = await Import.Ifc(url, filePath, streamId, modelId, commitMessage, token);
File.WriteAllText(outputPath, JsonSerializer.Serialize(new { success = true, commitId }));
ImporterArgs args = new()
{
ServerUrl = new(url),
FilePath = filePath,
ProjectId = projectId,
ModelId = modelId,
ModelName = modelName,
VersionMessage = versionMessage,
Token = token
};
var version = await Import.Ifc(args);
File.WriteAllText(outputPath, JsonSerializer.Serialize(new { success = true, commitId = version.id }));
}
catch (Exception e)
{
Console.WriteLine($"IFC Importer failed with exception {e.ToFormattedString()}");
File.WriteAllText(
outputPath,
JsonSerializer.Serialize(new { success = false, error = e.ToString() })
JsonSerializer.Serialize(new { success = false, error = e.ToFormattedString() })
);
}
},
filePathArgument,
outputPathArgument,
streamIdArgument,
commitMessageArgument,
projectIdArgument,
versionMessageArgument,
modelIdArgument,
modelNameArgument,
regionNameArgument
);
await rootCommand.InvokeAsync(args);
@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Speckle.Importers.Ifc" Version="3.1.0-nuget-wip.1" />
<PackageReference Include="Speckle.Importers.Ifc" Version="3.0.0-jedd-test.2" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
@@ -1,9 +1,8 @@
const fs = require('fs')
const { logger: parentLogger } = require('../observability/logging')
const { parseAndCreateCommitFactory } = require('./index')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const getDbClients = require('../knex')
import fs from 'fs'
import Observability from '@speckle/shared/dist/commonjs/observability/index.js'
import { logger as parentLogger } from '@/observability/logging.js'
import { getDbClients } from '@/knex.js'
import { parseAndCreateCommitFactory } from '@/ifc/index.js'
async function main() {
const cmdArgs = process.argv.slice(2)
@@ -1,11 +1,11 @@
const { performance } = require('perf_hooks')
const { fetch } = require('undici')
const Parser = require('./parser')
const ServerAPI = require('../src/api.js')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const { logger: parentLogger } = require('../observability/logging')
import { performance } from 'perf_hooks'
import { fetch } from 'undici'
import Observability from '@speckle/shared/dist/commonjs/observability/index.js'
import { ServerAPI } from '@/controller/api.js'
import { logger as parentLogger } from '@/observability/logging.js'
import { IFCParser } from '@/ifc/parser.js'
const parseAndCreateCommitFactory =
export const parseAndCreateCommitFactory =
({ db }) =>
async ({
data,
@@ -24,7 +24,7 @@ const parseAndCreateCommitFactory =
)
}
const serverApi = new ServerAPI({ db, streamId, logger })
const myParser = new Parser({ serverApi, fileId, logger })
const myParser = new IFCParser({ serverApi, fileId, logger })
const start = performance.now()
const { id, tCount } = await myParser.parse(data)
@@ -80,5 +80,3 @@ const parseAndCreateCommitFactory =
return json.data.commitCreate
}
module.exports = { parseAndCreateCommitFactory }
@@ -1,16 +1,16 @@
const { performance } = require('perf_hooks')
const WebIFC = require('web-ifc/web-ifc-api-node')
const {
import { performance } from 'perf_hooks'
import WebIFC from 'web-ifc/web-ifc-api-node.js'
import Observability from '@speckle/shared/dist/commonjs/observability/index.js'
import { logger as parentLogger } from '@/observability/logging.js'
import {
getHash,
IfcElements,
PropNames,
GeometryTypes,
IfcTypesMap
} = require('./utils')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const { logger: parentLogger } = require('../observability/logging')
} from '@/ifc/utils.js'
module.exports = class IFCParser {
export class IFCParser {
constructor({ serverApi, fileId, logger }) {
this.ifcapi = new WebIFC.IfcAPI()
this.ifcapi.SetWasmPath('./', false)
@@ -1,7 +1,7 @@
const crypto = require('crypto')
const WebIFC = require('web-ifc/web-ifc-api-node')
import crypto from 'crypto'
import WebIFC from 'web-ifc/web-ifc-api-node.js'
const IfcElements = {
export const IfcElements = {
103090709: 'IFCPROJECT',
4097777520: 'IFCSITE',
4031249490: 'IFCBUILDING',
@@ -141,7 +141,7 @@ const IfcElements = {
3009204131: 'IFCGRID'
}
const GeometryTypes = new Set([
export const GeometryTypes = new Set([
1123145078, 574549367, 1675464909, 2059837836, 3798115385, 32440307, 3125803723,
3207858831, 2740243338, 2624227202, 4240577450, 3615266464, 3724593414, 220341763,
477187591, 1878645084, 1300840506, 3303107099, 1607154358, 1878645084, 846575682,
@@ -163,7 +163,7 @@ const GeometryTypes = new Set([
1682466193, 2519244187, 2839578677, 3958567839, 2513912981, 2830218821, 427810014
])
const IfcTypesMap = {
export const IfcTypesMap = {
3821786052: 'IFCACTIONREQUEST',
2296667514: 'IFCACTOR',
3630933823: 'IFCACTORROLE',
@@ -982,7 +982,7 @@ const IfcTypesMap = {
1033361043: 'IFCZONE'
}
const PropNames = {
export const PropNames = {
aggregates: {
name: WebIFC.IFCRELAGGREGATES,
relating: 'RelatingObject',
@@ -1015,14 +1015,6 @@ const PropNames = {
}
}
const getHash = (obj) => {
export const getHash = (obj) => {
return crypto.createHash('md5').update(JSON.stringify(obj)).digest('hex')
}
module.exports = {
IfcElements,
IfcTypesMap,
PropNames,
GeometryTypes,
getHash
}
@@ -1,18 +1,18 @@
'use strict'
const Environment = require('@speckle/shared/dist/commonjs/environment/index.js')
const {
import Environment from '@speckle/shared/dist/commonjs/environment/index.js'
import {
loadMultiRegionsConfig,
configureKnexClient
} = require('@speckle/shared/dist/commonjs/environment/multiRegionConfig.js')
const { logger } = require('./observability/logging')
} from '@speckle/shared/dist/commonjs/environment/multiRegionConfig.js'
import { logger } from '@/observability/logging.js'
import { Knex } from 'knex'
const { FF_WORKSPACES_MULTI_REGION_ENABLED } = Environment.getFeatureFlags()
const isDevEnv = process.env.NODE_ENV !== 'production'
let dbClients
const getDbClients = async () => {
type DbClient = { public: Knex; private?: Knex }
let dbClients: { [key: string]: DbClient }
export const getDbClients = async () => {
if (dbClients) return dbClients
const maxConnections = parseInt(
process.env['POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE'] || '1'
@@ -49,7 +49,10 @@ const getDbClients = async () => {
} else {
const configPath = process.env.MULTI_REGION_CONFIG_PATH || 'multiregion.json'
const config = await loadMultiRegionsConfig({ path: configPath })
const clients = [['main', configureKnexClient(config.main, configArgs)]]
const clients: [string, DbClient][] = [
['main', configureKnexClient(config.main, configArgs)]
]
Object.entries(config.regions).map(([key, config]) => {
clients.push([key, configureKnexClient(config, configArgs)])
})
@@ -57,5 +60,3 @@ const getDbClients = async () => {
}
return dbClients
}
module.exports = getDbClients
@@ -1,59 +0,0 @@
'use strict'
const events = require('events')
const fs = require('fs')
const readline = require('readline')
const path = require('path')
const { downloadFile, getFileInfoByName } = require('./filesApi')
const isValidFilename = require('valid-filename')
const { logger } = require('../observability/logging')
const getReferencedMtlFiles = async ({ objFilePath }) => {
const mtlFiles = []
try {
const rl = readline.createInterface({
input: fs.createReadStream(objFilePath),
crlfDelay: Infinity
})
rl.on('line', (line) => {
if (line.startsWith('mtllib ')) {
const mtlFile = line.slice('mtllib '.length).trim()
mtlFiles.push(mtlFile)
}
})
await events.once(rl, 'close')
} catch (err) {
logger.error(err, `Error getting dependencies for file ${objFilePath}`)
}
return mtlFiles
}
module.exports = {
async downloadDependencies({ objFilePath, streamId, destinationDir, token }) {
const dependencies = await getReferencedMtlFiles({ objFilePath })
logger.info(`Obj file depends on ${dependencies}`)
for (const mtlFile of dependencies) {
// there might be multiple files named with the same name, take the first...
const [file] = (await getFileInfoByName({ fileName: mtlFile, streamId, token }))
.blobs
if (!file) {
logger.info(`OBJ dependency file not found in stream: ${mtlFile}`)
continue
}
if (!isValidFilename(mtlFile)) {
logger.warn(`Invalid filename reference in OBJ dependencies: ${mtlFile}`)
continue
}
await downloadFile({
fileId: file.id,
streamId,
token,
destination: path.join(destinationDir, mtlFile)
})
}
}
}
@@ -1,14 +1,10 @@
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
import Observability from '@speckle/shared/dist/commonjs/observability/index.js'
// loggers for specific components within normal operation
const logger = Observability.extendLoggerComponent(
export const logger = Observability.extendLoggerComponent(
Observability.getLogger(
process.env.LOG_LEVEL || 'info',
process.env.LOG_PRETTY === 'true'
),
'fileimport-service'
)
module.exports = {
logger
}
@@ -1,166 +0,0 @@
/* eslint-disable no-unused-vars */
'use strict'
const http = require('http')
const prometheusClient = require('prom-client')
const getDbClients = require('../knex')
let metricFree = null
let metricUsed = null
let metricPendingAquires = null
let metricPendingCreates = null
let metricPendingValidations = null
let metricRemainingCapacity = null
let metricQueryDuration = null
let metricQueryErrors = null
const queryStartTime = {}
prometheusClient.register.clear()
prometheusClient.register.setDefaultLabels({
project: 'speckle-server',
app: 'fileimport-service'
})
prometheusClient.collectDefaultMetrics()
let prometheusInitialized = false
const initDBPrometheusMetricsFactory =
({ db }) =>
() => {
metricFree = new prometheusClient.Gauge({
name: 'speckle_server_knex_free',
help: 'Number of free DB connections',
collect() {
this.set(db.client.pool.numFree())
}
})
metricUsed = new prometheusClient.Gauge({
name: 'speckle_server_knex_used',
help: 'Number of used DB connections',
collect() {
this.set(db.client.pool.numUsed())
}
})
metricPendingAquires = new prometheusClient.Gauge({
name: 'speckle_server_knex_pending',
help: 'Number of pending DB connection aquires',
collect() {
this.set(db.client.pool.numPendingAcquires())
}
})
metricPendingCreates = new prometheusClient.Gauge({
name: 'speckle_server_knex_pending_creates',
help: 'Number of pending DB connection creates',
collect() {
this.set(db.client.pool.numPendingCreates())
}
})
metricPendingValidations = new prometheusClient.Gauge({
name: 'speckle_server_knex_pending_validations',
help: 'Number of pending DB connection validations. This is a state between pending acquisition and acquiring a connection.',
collect() {
this.set(db.client.pool.numPendingValidations())
}
})
metricRemainingCapacity = new prometheusClient.Gauge({
name: 'speckle_server_knex_remaining_capacity',
help: 'Remaining capacity of the DB connection pool',
collect() {
const postgresMaxConnections =
parseInt(process.env.POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE) || 1
const demand =
db.client.pool.numUsed() +
db.client.pool.numPendingCreates() +
db.client.pool.numPendingValidations() +
db.client.pool.numPendingAcquires()
this.set(Math.max(postgresMaxConnections - demand, 0))
}
})
metricQueryDuration = new prometheusClient.Summary({
name: 'speckle_server_knex_query_duration',
help: 'Summary of the DB query durations in seconds'
})
metricQueryErrors = new prometheusClient.Counter({
name: 'speckle_server_knex_query_errors',
help: 'Number of DB queries with errors'
})
db.on('query', (data) => {
const queryId = data.__knexQueryUid + ''
queryStartTime[queryId] = Date.now()
})
db.on('query-response', (data, obj, builder) => {
const queryId = obj.__knexQueryUid + ''
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
})
db.on('query-error', (err, querySpec) => {
const queryId = querySpec.__knexQueryUid + ''
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
metricQueryErrors.inc()
})
}
module.exports = {
async initPrometheusMetrics() {
if (prometheusInitialized) return
prometheusInitialized = true
const db = (await getDbClients()).main.public
initDBPrometheusMetricsFactory({ db })()
// Define the HTTP server
const server = http.createServer(async (req, res) => {
if (req.url === '/metrics') {
res.setHeader('Content-Type', prometheusClient.register.contentType)
res.end(await prometheusClient.register.metrics())
} else {
res.end('Speckle FileImport Service - prometheus metrics')
}
})
server.listen(Number(process.env.PROMETHEUS_METRICS_PORT) || 9093)
},
metricDuration: new prometheusClient.Histogram({
name: 'speckle_server_operation_duration',
help: 'Summary of the operation durations in seconds',
buckets: [0.5, 1, 5, 10, 30, 60, 300, 600, 900, 1200],
labelNames: ['op']
}),
metricOperationErrors: new prometheusClient.Counter({
name: 'speckle_server_operation_errors',
help: 'Number of operations with errors',
labelNames: ['op']
}),
metricInputFileSize: new prometheusClient.Histogram({
name: 'speckle_server_operation_file_size',
help: 'Size of the operation input file size',
buckets: [
1000,
100 * 1000,
500 * 1000,
1000 * 1000,
5 * 1000 * 1000,
10 * 1000 * 1000,
100 * 1000 * 1000
],
labelNames: ['op']
})
}
+21
View File
@@ -0,0 +1,21 @@
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'url'
/**
* Singleton module for src root and package root directory resolution
*/
const __filename = fileURLToPath(import.meta.url)
const srcRoot = path.dirname(__filename)
// Recursively walk back from __dirname till we find our package.json
let packageRoot = srcRoot
while (packageRoot !== '/') {
if (fs.readdirSync(packageRoot).includes('package.json')) {
break
}
packageRoot = path.resolve(packageRoot, '..')
}
export { srcRoot, packageRoot }
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*"],
"exclude": ["**/*.spec.js", "**/*.spec.ts"]
}
+108
View File
@@ -0,0 +1,108 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "node16" /* Specify what module code is generated. */,
"rootDir": "./" /* Specify the root folder within your source files. */,
"moduleResolution": "node16" /* Specify how TypeScript looks up a file from a given module specifier. */,
"baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */,
"paths": {
"@/*": ["./src/*"]
},
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true /* Create source map files for emitted JavaScript files. */,
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"ts-node": {
"swc": true
},
"include": ["src/**/*", "vitest.config.ts"],
"exclude": ["node_modules", "coverage", "reports"]
}
@@ -0,0 +1,18 @@
import path from 'path'
import { configDefaults, defineConfig } from 'vitest/config'
export default defineConfig({
test: {
exclude: [...configDefaults.exclude],
// reporters: ['verbose', 'hanging-process'] //uncomment to debug hanging processes etc.
sequence: {
shuffle: true,
concurrent: true
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
@@ -8,7 +8,7 @@
]"
@click="($event) => $emit('click', $event)"
>
<span class="truncate">{{ item.company ? item.company : item.name }}</span>
<span class="truncate">{{ item.name }}</span>
</a>
</template>
<script setup lang="ts">
@@ -1,8 +1,6 @@
<template>
<div>
<CommonCard
class="flex flex-1 flex-col gap-1 !p-4 !pt-2 !pb-3 hover:border-outline-2"
>
<CommonCard class="flex flex-1 flex-col gap-1 !p-4 !pt-2 !pb-3 h-full">
<div class="flex gap-2 items-center">
<img
v-if="connector.image"
@@ -11,12 +9,6 @@
class="w-[48px] -ml-1"
/>
<div class="flex flex-col gap-y-1.5">
<p
v-if="connector.isComingSoon"
class="text-body-3xs text-foreground-2 leading-none"
>
Coming soon
</p>
<h2 class="text-body-xs text-foreground font-medium leading-none">
{{ connector.title }}
</h2>
@@ -25,11 +17,11 @@
<p class="text-body-2xs text-foreground-2 line-clamp-2 leading-5">
{{ connector.description }}
</p>
<div class="space-x-2 mt-2">
<div class="space-x-1 mt-2">
<FormButton
color="outline"
size="sm"
:disabled="isLoadingVersions"
:disabled="enableButton"
external
:to="latestAvailableVersion?.Url"
@click="
@@ -38,13 +30,12 @@
})
"
>
{{ connector.isComingSoon ? 'Coming soon' : 'Install' }}
{{ connector.isComingSoon ? 'Coming soon' : 'Install for Windows' }}
</FormButton>
<FormButton
v-if="connector.url"
color="outline"
color="subtle"
size="sm"
text
target="_blank"
external
:to="connector.url"
@@ -70,32 +61,23 @@ const props = defineProps<{
}>()
const mixpanel = useMixpanel()
const versions = ref<Version[]>([])
const latestAvailableVersion = ref<Version | null>(null)
const isLoadingVersions = ref(true)
const getVersions = async () => {
const response = await fetch(
`https://releases.speckle.dev/manager2/feeds/${props.connector.slug}-v3.json`,
{
method: 'GET'
}
)
if (!response.ok) {
throw new Error('Failed to fetch versions')
const { data: versionData, status } = useFetch(
`https://releases.speckle.dev/manager2/feeds/${props.connector.slug}-v3.json`,
{
immediate: !props.connector.isComingSoon
}
)
const data = (await response.json()) as unknown as Versions
const sortedVersions = data.Versions.sort(function (a: Version, b: Version) {
return new Date(b.Date).getTime() - new Date(a.Date).getTime()
})
versions.value = sortedVersions
latestAvailableVersion.value = sortedVersions[0]
const enableButton = computed(() => status.value !== 'success')
isLoadingVersions.value = false
}
void getVersions()
const latestAvailableVersion = computed<Version | null>(() => {
if (versionData.value) {
const typedData = versionData.value as Versions
const sortedVersions = [...typedData.Versions].sort(
(a, b) => new Date(b.Date).getTime() - new Date(a.Date).getTime()
)
return sortedVersions.length > 0 ? sortedVersions[0] : null
}
return null
})
</script>
@@ -10,15 +10,15 @@
<p class="text-body-sm text-foreground-2">
Extract and exchange data between the most popular AEC applications using
our tailored connectors.
<span class="italic">
Looking for V2 connectors? Get them
<NuxtLink
class="text-foreground hover:text-primary"
to="https://releases.speckle.systems"
>
here.
</NuxtLink>
</span>
</p>
<p class="text-body-xs text-foreground-3 leading-none">
Looking for V2 connectors? Get them
<NuxtLink
class="text-foreground-3 hover:text-foreground-2 underline"
to="https://releases.speckle.systems/legacy-connectors"
>
here.
</NuxtLink>
</p>
</div>
</section>
@@ -0,0 +1,188 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
<template>
<div class="group h-full">
<template v-if="isLoggedIn">
<Portal to="mobile-navigation">
<div class="lg:hidden">
<FormButton
:color="isOpenMobile ? 'outline' : 'subtle'"
size="sm"
class="mt-px"
@click="isOpenMobile = !isOpenMobile"
>
<IconSidebar v-if="!isOpenMobile" class="h-4 w-4 -ml-1 -mr-1" />
<IconSidebarClose v-else class="h-4 w-4 -ml-1 -mr-1" />
</FormButton>
</div>
</Portal>
<div
v-keyboard-clickable
class="lg:hidden absolute inset-0 backdrop-blur-sm z-40 transition-all"
:class="isOpenMobile ? 'opacity-100' : 'opacity-0 pointer-events-none'"
@click="isOpenMobile = false"
/>
<div
class="absolute z-40 lg:static h-full flex w-[17rem] shrink-0 transition-all"
:class="isOpenMobile ? '' : '-translate-x-[17rem] lg:translate-x-0'"
>
<LayoutSidebar
class="border-r border-outline-3 px-2 pt-3 pb-2 bg-foundation-page"
>
<LayoutSidebarMenu>
<LayoutSidebarMenuGroup>
<template v-if="!isWorkspacesEnabled">
<NuxtLink :to="projectsRoute" @click="isOpenMobile = false">
<LayoutSidebarMenuGroupItem
label="Projects"
:active="isActive(projectsRoute)"
>
<template #icon>
<IconProjects class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
</template>
<NuxtLink
v-if="activeWorkspaceSlug"
:to="workspaceRoute(activeWorkspaceSlug)"
@click="isOpenMobile = false"
>
<LayoutSidebarMenuGroupItem
label="Home"
:active="route.name === 'workspaces-slug'"
>
<template #icon>
<HomeIcon class="size-4 stroke-[1.5px]" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<NuxtLink
v-else-if="isProjectsActive"
:to="projectsRoute"
@click="isOpenMobile = false"
>
<LayoutSidebarMenuGroupItem
label="Projects"
:active="isActive(projectsRoute)"
>
<template #icon>
<IconProjects class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<NuxtLink :to="connectorsRoute" @click="isOpenMobile = false">
<LayoutSidebarMenuGroupItem
label="Connectors"
:active="isActive(connectorsRoute)"
>
<template #icon>
<IconConnectors class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<NuxtLink :to="tutorialsRoute" @click="isOpenMobile = false">
<LayoutSidebarMenuGroupItem
label="Tutorials"
:active="isActive(tutorialsRoute)"
>
<template #icon>
<IconTutorials class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
</LayoutSidebarMenuGroup>
<LayoutSidebarMenuGroup title="Resources" collapsible>
<NuxtLink
to="https://speckle.community/"
target="_blank"
@click="isOpenMobile = false"
>
<LayoutSidebarMenuGroupItem label="Community forum" external>
<template #icon>
<IconCommunity class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<div @click="openFeedbackDialog">
<LayoutSidebarMenuGroupItem label="Give us feedback">
<template #icon>
<IconFeedback class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</div>
<NuxtLink
to="https://speckle.guide/"
target="_blank"
@click="isOpenMobile = false"
>
<LayoutSidebarMenuGroupItem label="Documentation" external>
<template #icon>
<IconDocumentation class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<NuxtLink
to="https://speckle.community/c/making-speckle/changelog"
target="_blank"
@click="isOpenMobile = false"
>
<LayoutSidebarMenuGroupItem label="Changelog" external>
<template #icon>
<IconChangelog class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
</LayoutSidebarMenuGroup>
</LayoutSidebarMenu>
</LayoutSidebar>
</div>
</template>
<FeedbackDialog v-model:open="showFeedbackDialog" />
</div>
</template>
<script setup lang="ts">
import {
FormButton,
LayoutSidebar,
LayoutSidebarMenu,
LayoutSidebarMenuGroup,
LayoutSidebarMenuGroupItem
} from '@speckle/ui-components'
import {
projectsRoute,
connectorsRoute,
workspaceRoute,
tutorialsRoute
} from '~/lib/common/helpers/route'
import { useRoute } from 'vue-router'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { HomeIcon } from '@heroicons/vue/24/outline'
import { useNavigation } from '~~/lib/navigation/composables/navigation'
const { isLoggedIn } = useActiveUser()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const route = useRoute()
const { activeWorkspaceSlug, isProjectsActive } = useNavigation()
const isOpenMobile = ref(false)
const showFeedbackDialog = ref(false)
const isActive = (...routes: string[]): boolean => {
return routes.some((routeTo) => route.path === routeTo)
}
const openFeedbackDialog = () => {
showFeedbackDialog.value = true
isOpenMobile.value = false
}
</script>
@@ -0,0 +1,39 @@
<template>
<div class="flex items-center">
<div class="w-6 flex-shrink-0">
<IconCheck v-if="isActive" class="w-4 h-4 mx-1 text-foreground" />
</div>
<MenuItem class="min-w-0 w-full">
<NuxtLink class="flex-1 min-w-0" @click="$emit('onClick')">
<LayoutSidebarMenuGroupItem
:label="name"
:tag="tag"
color-classes="bg-foundation-2 text-foreground-2"
>
<template #icon>
<WorkspaceAvatar
:name="name"
:logo="logo"
size="sm"
class="flex-shrink-0"
/>
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
</MenuItem>
</div>
</template>
<script setup lang="ts">
import { MenuItem } from '@headlessui/vue'
import type { MaybeNullOrUndefined } from '@speckle/shared'
defineEmits(['onClick'])
defineProps<{
isActive: boolean
name: string
logo?: MaybeNullOrUndefined<string>
tag?: string
}>()
</script>
@@ -0,0 +1,11 @@
<template>
<div class="h-full">
<DashboardSidebarNew v-if="isWorkspaceNewPlansEnabled" />
<DashboardSidebar v-else />
</div>
</template>
<script setup lang="ts">
// Temporary wrapper to hold both the old and new sidebars
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
</script>
@@ -4,7 +4,15 @@
<div
class="flex gap-4 items-center justify-between h-full w-screen py-4 px-3 sm:px-4"
>
<HeaderLogoBlock :active="false" to="/" class="hidden lg:flex lg:min-w-40" />
<div class="hidden lg:block">
<HeaderWorkspaceSwitcher v-if="showWorkspaceSwitcher" />
<HeaderLogoBlock
v-else
:active="false"
to="/"
class="hidden lg:flex lg:min-w-40"
/>
</div>
<div class="flex items-center truncate">
<ClientOnly>
<PortalTarget name="mobile-navigation"></PortalTarget>
@@ -18,16 +26,18 @@
<PortalTarget name="secondary-actions"></PortalTarget>
<PortalTarget name="primary-actions"></PortalTarget>
</ClientOnly>
<FormButton
v-if="!activeUser"
:to="loginUrl.fullPath"
color="outline"
class="hidden md:flex"
>
Sign in
</FormButton>
<!-- Profile dropdown -->
<HeaderNavUserMenu :login-url="loginUrl" />
<div class="flex justify-end">
<FormButton
v-if="!activeUser"
:to="loginUrl.fullPath"
color="outline"
class="hidden md:flex"
>
Sign in
</FormButton>
<!-- Profile dropdown -->
<HeaderNavUserMenu :login-url="loginUrl" />
</div>
</div>
</div>
</nav>
@@ -39,6 +49,8 @@ import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { loginRoute } from '~~/lib/common/helpers/route'
import type { Optional } from '@speckle/shared'
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
const { activeUser } = useActiveUser()
const route = useRoute()
const router = useRouter()
@@ -53,4 +65,9 @@ const loginUrl = computed(() =>
}
})
)
const showWorkspaceSwitcher = computed(
() =>
isWorkspacesEnabled.value && isWorkspaceNewPlansEnabled.value && activeUser.value
)
</script>
@@ -19,77 +19,64 @@
<MenuItems
class="absolute right-4 top-14 w-56 origin-top-right bg-foundation outline outline-1 outline-primary-muted rounded-md shadow-lg overflow-hidden"
>
<div class="border-b border-outline-3 py-1 mb-1">
<div class="pt-1">
<MenuItem v-if="activeUser" v-slot="{ active }">
<NuxtLink
:to="settingsUserRoutes.profile"
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
Settings
</NuxtLink>
</MenuItem>
<MenuItem v-if="isAdmin" v-slot="{ active }">
<NuxtLink
:to="settingsServerRoutes.general"
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
Server settings
</NuxtLink>
</MenuItem>
<MenuItem v-slot="{ active }">
<NuxtLink
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-primary cursor-pointer transition mx-1 rounded'
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
target="_blank"
external
:href="downloadManagerUrl"
@click="toggleTheme"
>
Connector downloads
{{ isDarkTheme ? 'Light mode' : 'Dark mode' }}
</NuxtLink>
</MenuItem>
<MenuItem v-if="activeUser && !isGuest" v-slot="{ active }">
<NuxtLink
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
@click="toggleInviteDialog"
>
Invite to Speckle
</NuxtLink>
</MenuItem>
<MenuItem v-slot="{ active }">
<NuxtLink
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
class="text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded"
@click="openFeedbackDialog"
>
Feedback
</NuxtLink>
</MenuItem>
</div>
<MenuItem v-if="activeUser" v-slot="{ active }">
<NuxtLink
:to="settingsUserRoutes.profile"
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
Settings
</NuxtLink>
</MenuItem>
<MenuItem v-if="isAdmin" v-slot="{ active }">
<NuxtLink
:to="settingsServerRoutes.general"
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
Server settings
</NuxtLink>
</MenuItem>
<MenuItem v-slot="{ active }">
<NuxtLink
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
@click="toggleTheme"
>
{{ isDarkTheme ? 'Light mode' : 'Dark mode' }}
</NuxtLink>
</MenuItem>
<MenuItem v-if="activeUser && !isGuest" v-slot="{ active }">
<NuxtLink
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
@click="toggleInviteDialog"
>
Invite to Speckle
</NuxtLink>
</MenuItem>
<MenuItem v-slot="{ active }">
<NuxtLink
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
class="text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded"
@click="openFeedbackDialog"
>
Feedback
</NuxtLink>
</MenuItem>
<div class="border-t border-outline-3 py-1 mt-1">
<MenuItem v-if="activeUser" v-slot="{ active }">
<NuxtLink
@@ -135,11 +122,7 @@ import { Roles } from '@speckle/shared'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import { useTheme } from '~~/lib/core/composables/theme'
import {
downloadManagerUrl,
settingsUserRoutes,
settingsServerRoutes
} from '~/lib/common/helpers/route'
import { settingsUserRoutes, settingsServerRoutes } from '~/lib/common/helpers/route'
import type { RouteLocationRaw } from 'vue-router'
import { useServerInfo } from '~/lib/core/composables/server'
@@ -0,0 +1,238 @@
<template>
<div>
<Menu as="div" class="flex items-center">
<MenuButton :id="menuButtonId" v-slot="{ open: userOpen }">
<span class="sr-only">Open workspace menu</span>
<div class="flex items-center gap-2 p-0.5 pr-1.5 hover:bg-highlight-2 rounded">
<template v-if="activeWorkspaceSlug || isProjectsActive">
<div class="relative">
<WorkspaceAvatar :name="displayName" :logo="displayLogo" />
<div
v-if="hasDiscoverableWorkspaces"
class="absolute -top-[4px] -right-[4px] size-3 border-[2px] border-foundation-page bg-primary rounded-full"
/>
</div>
<p class="text-body-xs text-foreground truncate max-w-40">
{{ displayName }}
</p>
</template>
<HeaderLogoBlock v-else no-link />
<ChevronDownIcon
:class="userOpen ? 'rotate-180' : ''"
class="h-3 w-3 flex-shrink-0"
/>
</div>
</MenuButton>
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute left-4 top-14 w-64 origin-top-right bg-foundation outline outline-1 outline-primary-muted rounded-md shadow-lg overflow-hidden divide-y divide-outline-2"
>
<div
v-if="activeWorkspaceSlug || isProjectsActive"
class="p-2 pb-3 flex flex-col gap-y-4"
>
<div class="flex gap-x-2 items-center">
<MenuItem>
<NuxtLink
:to="
activeWorkspaceSlug
? workspaceRoute(activeWorkspaceSlug)
: projectsRoute
"
>
<WorkspaceAvatar
:name="displayName"
:logo="displayLogo"
size="lg"
class="flex-shrink-0"
/>
</NuxtLink>
</MenuItem>
<div class="flex flex-col space-between min-w-0">
<p class="text-body-xs text-foreground truncate">
{{ displayName }}
</p>
<p
v-if="activeWorkspace"
class="text-body-2xs text-foreground-2 capitalize truncate"
>
{{ activeWorkspace?.plan?.name }} ·
{{ activeWorkspace?.team?.totalCount }} member{{
activeWorkspace?.team?.totalCount > 1 ? 's' : ''
}}
</p>
<p v-else class="text-body-2xs text-foreground-2 truncate">
2 projects to move
</p>
</div>
</div>
<div v-if="activeWorkspaceSlug" class="flex gap-x-2">
<MenuItem>
<FormButton
color="outline"
full-width
size="sm"
@click="goToSettingsRoute"
>
Settings
</FormButton>
</MenuItem>
<MenuItem>
<FormButton
full-width
color="outline"
size="sm"
:disabled="activeWorkspace?.role !== Roles.Workspace.Admin"
@click="showInviteDialog = true"
>
Invite members
</FormButton>
</MenuItem>
</div>
</div>
<div class="p-2 pt-1 max-h-96 overflow-y-auto simple-scrollbar">
<LayoutSidebarMenuGroup
title="Workspaces"
:icon-click="isGuest ? undefined : handlePlusClick"
icon-text="Create workspace"
>
<div v-if="hasWorkspaces" class="w-full">
<template v-for="item in workspaces" :key="`menu-item-${item.id}`">
<DashboardSidebarWorkspaceItem
:is-active="item.slug === activeWorkspaceSlug"
:name="item.name"
:logo="item.logo"
@on-click="onWorkspaceSelect(item.slug)"
/>
</template>
<DashboardSidebarWorkspaceItem
:is-active="route.path === projectsRoute"
name="Personal projects"
tag="LEGACY"
@on-click="onProjectsSelect"
/>
</div>
</LayoutSidebarMenuGroup>
</div>
<MenuItem v-if="hasDiscoverableWorkspacesOrJoinRequests">
<div class="p-2">
<NuxtLink
class="flex justify-between items-center cursor-pointer hover:bg-highlight-1 py-1 px-2 rounded"
@click="showDiscoverableWorkspacesModal = true"
>
<p class="text-body-xs text-foreground">Join existing workspaces</p>
<CommonBadge v-if="hasDiscoverableWorkspaces" rounded>
{{ discoverableWorkspacesCount }}
</CommonBadge>
</NuxtLink>
</div>
</MenuItem>
</MenuItems>
</Transition>
</Menu>
<InviteDialogWorkspace
v-model:open="showInviteDialog"
:workspace="activeWorkspace"
/>
<WorkspaceDiscoverableWorkspacesModal
v-model:open="showDiscoverableWorkspacesModal"
/>
</div>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import {
workspaceCreateRoute,
workspaceRoute,
settingsWorkspaceRoutes,
projectsRoute
} from '~/lib/common/helpers/route'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useUserWorkspaces } from '~/lib/user/composables/workspaces'
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
import { graphql } from '~/lib/common/generated/gql'
import { useNavigation } from '~~/lib/navigation/composables/navigation'
import { Roles } from '@speckle/shared'
graphql(`
fragment HeaderWorkspaceSwitcher_Workspace on Workspace {
...InviteDialogWorkspace_Workspace
id
name
logo
role
plan {
name
}
team {
totalCount
}
}
`)
const { isGuest } = useActiveUser()
const menuButtonId = useId()
const mixpanel = useMixpanel()
const {
activeWorkspaceSlug,
isProjectsActive,
mutateActiveWorkspaceSlug,
mutateIsProjectsActive,
workspaceData
} = useNavigation()
const showInviteDialog = ref(false)
const showDiscoverableWorkspacesModal = ref(false)
const activeWorkspace = computed(() => {
return workspaceData.value
})
const displayName = computed(() => activeWorkspace.value?.name || 'Personal projects')
const displayLogo = computed(() => {
if (isProjectsActive.value) return null
return activeWorkspace.value?.logo
})
const route = useRoute()
const { workspaces, hasWorkspaces } = useUserWorkspaces()
const {
hasDiscoverableWorkspaces,
discoverableWorkspacesCount,
hasDiscoverableWorkspacesOrJoinRequests
} = useDiscoverableWorkspaces()
const onWorkspaceSelect = (slug: string) => {
navigateTo(workspaceRoute(slug))
mutateActiveWorkspaceSlug(slug)
}
const onProjectsSelect = () => {
mutateIsProjectsActive(true)
navigateTo(projectsRoute)
}
const goToSettingsRoute = () => {
if (!activeWorkspaceSlug.value) return
navigateTo(settingsWorkspaceRoutes.general.route(activeWorkspaceSlug.value))
}
const handlePlusClick = () => {
navigateTo(workspaceCreateRoute())
mixpanel.track('Create Workspace Button Clicked', {
source: 'navigation'
})
}
</script>
@@ -29,7 +29,7 @@ import {
} from '~/lib/common/generated/gql/graphql'
import type { InviteGenericItem } from '~~/lib/invites/helpers/types'
import { emptyInviteGenericItem } from '~~/lib/invites/helpers/constants'
import { Roles } from '@speckle/shared'
import { Roles, type MaybeNullOrUndefined } from '@speckle/shared'
import { useMixpanel } from '~/lib/core/composables/mp'
import { mapMainRoleToGqlWorkspaceRole } from '~/lib/workspaces/helpers/roles'
import { mapServerRoleToGqlServerRole } from '~/lib/common/helpers/roles'
@@ -58,7 +58,7 @@ graphql(`
`)
const props = defineProps<{
workspace: InviteDialogWorkspace_WorkspaceFragment
workspace?: MaybeNullOrUndefined<InviteDialogWorkspace_WorkspaceFragment>
}>()
const isOpen = defineModel<boolean>('open', { required: true })
@@ -75,7 +75,7 @@ const invites = ref<InviteGenericItem[]>([
])
const allowedDomains = computed(() =>
props.workspace.domainBasedMembershipProtectionEnabled
props.workspace?.domainBasedMembershipProtectionEnabled
? props.workspace.domains?.map((d) => d.domain)
: null
)
@@ -117,7 +117,7 @@ const onSelectUsersSubmit = async (updatedInvites: InviteGenericItem[]) => {
: undefined
}))
if (!inputs.length) return
if (!inputs.length || !props.workspace?.id) return
await inviteToWorkspace({ workspaceId: props.workspace.id, inputs })
isOpen.value = false
@@ -29,10 +29,11 @@ import { useForm } from 'vee-validate'
import type { OnboardingRole, OnboardingPlan, OnboardingSource } from '@speckle/shared'
import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding'
import { homeRoute, workspaceJoinRoute } from '~/lib/common/helpers/route'
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
const isOnboardingForced = useIsOnboardingForced()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
const { hasDiscoverableWorkspaces } = useDiscoverableWorkspaces()
const { setUserOnboardingComplete, setMixpanelSegments } = useProcessOnboarding()
@@ -53,7 +54,7 @@ const onSubmit = handleSubmit(async () => {
plans: values.plan,
source: values.source
})
if (!isWorkspaceNewPlansEnabled.value && isWorkspacesEnabled.value) {
if (isWorkspacesEnabled.value && hasDiscoverableWorkspaces.value) {
navigateTo(workspaceJoinRoute)
} else {
navigateTo(homeRoute)
@@ -37,12 +37,7 @@
</div>
<span v-else class="text-body-xs text-foreground-2 text-center select-none">
Use our
<NuxtLink
target="_blank"
:to="downloadManagerUrl"
class="font-medium"
@click.stop
>
<NuxtLink target="_blank" :to="connectorsRoute" class="font-medium" @click.stop>
<span class="underline">connectors</span>
</NuxtLink>
to publish a {{ modelName ? '' : 'new model' }} version to
@@ -56,7 +51,7 @@
import { useFileImport } from '~~/lib/core/composables/fileImport'
import { useFileUploadProgressCore } from '~~/lib/form/composables/fileUpload'
import { ExclamationTriangleIcon } from '@heroicons/vue/24/solid'
import { downloadManagerUrl } from '~/lib/common/helpers/route'
import { connectorsRoute } from '~/lib/common/helpers/route'
import type { Nullable } from '@speckle/shared'
const props = defineProps<{
@@ -2,6 +2,7 @@
<div>
<Portal to="primary-actions"></Portal>
<ProjectsDashboardHeader
v-if="!isWorkspaceNewPlansEnabled"
:projects-invites="projectsPanelResult?.activeUser"
:workspaces-invites="workspacesResult?.activeUser"
/>
@@ -92,6 +93,7 @@ const showLoadingBar = ref(false)
const areQueriesLoading = useQueryLoading()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { isGuest } = useActiveUser()
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
useUserProjectsUpdatedTracking()
const {
@@ -110,7 +112,8 @@ const {
} = useQuery(projectsDashboardQuery, () => ({
filter: {
search: (search.value || '').trim() || null,
onlyWithRoles: selectedRoles.value?.length ? selectedRoles.value : null
onlyWithRoles: selectedRoles.value?.length ? selectedRoles.value : null,
workspaceId: isWorkspaceNewPlansEnabled ? (null as Nullable<string>) : undefined
},
cursor: null as Nullable<string>
}))
@@ -68,61 +68,103 @@
/>
</NuxtLink>
</LayoutSidebarMenuGroup>
<LayoutSidebarMenuGroup v-if="isWorkspacesEnabled" title="Workspace settings">
<LayoutSidebarMenuGroup
v-for="workspaceItem in workspaceItems"
:key="`workspace-item-${workspaceItem.slug}`"
:title="workspaceItem.name"
collapsible
:collapsed="slug !== workspaceItem.slug"
:tag="
workspaceItem.plan?.status === WorkspacePlanStatuses.Trial ||
!workspaceItem.plan?.status
? 'TRIAL'
: undefined
"
nested
>
<template #title-icon>
<WorkspaceAvatar
:logo="workspaceItem.logo"
:name="workspaceItem.name"
size="sm"
/>
</template>
<LayoutSidebarMenuGroup
v-if="showWorkspaceSettings"
title="Workspace settings"
>
<template v-if="isWorkspaceNewPlansEnabled" #title-icon>
<IconWorkspaces class="size-4" />
</template>
<template v-if="!isWorkspaceNewPlansEnabled">
<LayoutSidebarMenuGroup
v-for="workspaceItem in workspaceItems"
:key="`workspace-item-${workspaceItem.slug}`"
:title="workspaceItem.name"
collapsible
:collapsed="slug !== workspaceItem.slug"
:tag="
workspaceItem.plan?.status === WorkspacePlanStatuses.Trial ||
!workspaceItem.plan?.status
? 'TRIAL'
: undefined
"
nested
>
<template #title-icon>
<WorkspaceAvatar
:logo="workspaceItem.logo"
:name="workspaceItem.name"
size="sm"
/>
</template>
<NuxtLink
v-for="workspaceMenuItem in workspaceMenuItems"
:key="`workspace-menu-item-${workspaceMenuItem.name}-${workspaceItem.slug}`"
:to="
!isAdmin &&
(workspaceMenuItem.disabled ||
needsSsoSession(workspaceItem, workspaceMenuItem.name))
? undefined
: workspaceMenuItem.route(workspaceItem.slug)
"
@click="isOpenMobile = false"
>
<LayoutSidebarMenuGroupItem
v-if="workspaceMenuItem.permission?.includes(workspaceItem.role as WorkspaceRoles)"
:label="workspaceMenuItem.title"
:active="
route.name?.toString().startsWith(workspaceMenuItem.name) &&
route.params.slug === workspaceItem.slug
"
:tooltip-text="
needsSsoSession(workspaceItem, workspaceMenuItem.name)
? 'Log in with your SSO provider to access this page'
: workspaceMenuItem.tooltipText
"
:disabled="
!isAdmin &&
(workspaceMenuItem.disabled ||
needsSsoSession(workspaceItem, workspaceMenuItem.name))
"
class="!pl-8"
/>
</NuxtLink>
</LayoutSidebarMenuGroup>
</template>
<template v-else-if="activeWorkspaceItem">
<NuxtLink
v-for="workspaceMenuItem in workspaceMenuItems"
:key="`workspace-menu-item-${workspaceMenuItem.name}-${workspaceItem.slug}`"
:key="`workspace-menu-item-${workspaceMenuItem.name}-${activeWorkspaceItem}`"
:to="
!isAdmin &&
(workspaceMenuItem.disabled ||
needsSsoSession(workspaceItem, workspaceMenuItem.name))
needsSsoSession(activeWorkspaceItem, workspaceMenuItem.name))
? undefined
: workspaceMenuItem.route(workspaceItem.slug)
: workspaceMenuItem.route(activeWorkspaceItem.slug)
"
@click="isOpenMobile = false"
>
<LayoutSidebarMenuGroupItem
v-if="workspaceMenuItem.permission?.includes(workspaceItem.role as WorkspaceRoles)"
v-if="workspaceMenuItem.permission?.includes(activeWorkspaceItem.role as WorkspaceRoles)"
:label="workspaceMenuItem.title"
:active="
route.name?.toString().startsWith(workspaceMenuItem.name) &&
route.params.slug === workspaceItem.slug
route.params.slug === activeWorkspaceItem.slug
"
:tooltip-text="
needsSsoSession(workspaceItem, workspaceMenuItem.name)
needsSsoSession(activeWorkspaceItem, workspaceMenuItem.name)
? 'Log in with your SSO provider to access this page'
: workspaceMenuItem.tooltipText
"
:disabled="
!isAdmin &&
(workspaceMenuItem.disabled ||
needsSsoSession(workspaceItem, workspaceMenuItem.name))
needsSsoSession(activeWorkspaceItem, workspaceMenuItem.name))
"
class="!pl-8"
/>
</NuxtLink>
</LayoutSidebarMenuGroup>
</template>
</LayoutSidebarMenuGroup>
</LayoutSidebarMenu>
</LayoutSidebar>
@@ -144,16 +186,22 @@ import {
} from '@speckle/ui-components'
import { graphql } from '~~/lib/common/generated/gql'
import type { WorkspaceRoles } from '@speckle/shared'
import { homeRoute, settingsWorkspaceRoutes } from '~/lib/common/helpers/route'
import {
homeRoute,
projectsRoute,
settingsWorkspaceRoutes,
workspaceRoute
} from '~/lib/common/helpers/route'
import {
WorkspacePlanStatuses,
type SettingsMenu_WorkspaceFragment
} from '~/lib/common/generated/gql/graphql'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import { useBreakpoints } from '@vueuse/core'
import { useNavigation } from '~~/lib/navigation/composables/navigation'
graphql(`
fragment SettingsDialog_Workspace on Workspace {
fragment SettingsSidebar_Workspace on Workspace {
...SettingsMenu_Workspace
id
slug
@@ -162,6 +210,7 @@ graphql(`
logo
plan {
status
name
}
creationState {
completed
@@ -170,20 +219,22 @@ graphql(`
`)
graphql(`
fragment SettingsDialog_User on User {
fragment SettingsSidebar_User on User {
id
workspaces {
items {
...SettingsDialog_Workspace
...SettingsSidebar_Workspace
}
}
}
`)
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { activeWorkspaceSlug } = useNavigation()
const settingsMenuState = useSettingsMenuState()
const { isAdmin } = useActiveUser()
const route = useRoute()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { result: workspaceResult } = useQuery(settingsSidebarQuery, null, {
enabled: computed(() => isWorkspacesEnabled.value)
})
@@ -200,6 +251,9 @@ const workspaceItems = computed(
(item) => item.creationState?.completed !== false // Removed workspaces that are not completely created
) || []
)
const activeWorkspaceItem = computed(() =>
workspaceItems.value.find((item) => item.slug === activeWorkspaceSlug.value)
)
const needsSsoSession = (
workspace: SettingsMenu_WorkspaceFragment,
@@ -212,9 +266,19 @@ const needsSsoSession = (
}
const exitSettingsRoute = computed(() => {
if (import.meta.server || !settingsMenuState.value.previousRoute) {
return homeRoute
if (import.meta.server) return homeRoute
if (!settingsMenuState.value.previousRoute) {
return activeWorkspaceSlug.value
? workspaceRoute(activeWorkspaceSlug.value)
: projectsRoute
}
return settingsMenuState.value.previousRoute
})
const showWorkspaceSettings = computed(() => {
if (!isWorkspacesEnabled.value) return false
if (isWorkspaceNewPlansEnabled.value) return !!activeWorkspaceSlug.value
return true
})
</script>
@@ -13,7 +13,7 @@
</div>
<p class="text-body mt-1">
<span class="font-medium">
{{ formatPrice(planPrice?.[Roles.Workspace.Member]) }}
{{ formatPrice(finalPlanPrice) }}
</span>
per seat/month
</p>
@@ -141,6 +141,15 @@ const planPrice = computed(
prices.value?.[props.plan]?.[props.yearlyIntervalSelected ? 'yearly' : 'monthly']
)
const finalPlanPrice = computed(() => {
const basePrice = prices.value?.[props.plan].monthly?.['workspace:member']
if (!basePrice) return undefined
return {
...basePrice,
amount: props.yearlyIntervalSelected ? basePrice.amount * 0.8 : basePrice.amount
}
})
const hasCta = computed(() => !!slots.cta)
const canUpgradeToPlan = computed(() => {
if (!props.currentPlan) return false
@@ -1,8 +1,13 @@
<template>
<div ref="rendererparent" class="absolute w-full h-full"></div>
<div
ref="rendererparent"
class="absolute w-full h-full"
data-dd-action-name="Viewer Canvas"
></div>
</template>
<script setup lang="ts">
import { useInjectedViewer } from '~~/lib/viewer/composables/setup'
import { useCommentContext } from '~~/lib/viewer/composables/commentManagement'
const rendererparent = ref<HTMLElement>()
const {
@@ -11,6 +16,8 @@ const {
init: { promise: isInitializedPromise }
} = useInjectedViewer()
const { cleanupThreadContext } = useCommentContext()
onMounted(async () => {
if (!import.meta.client) return
@@ -26,7 +33,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
if (!import.meta.client) return
container.style.display = 'none'
cleanupThreadContext()
document.body.appendChild(container)
})
</script>
@@ -97,7 +97,11 @@
:url="route.path"
/>
<Portal to="primary-actions">
<HeaderNavShare v-if="project" :resource-id-string="modelId" :project="project" />
<HeaderNavShare
v-if="project"
:resource-id-string="resourceIdString"
:project="project"
/>
</Portal>
</div>
</template>
@@ -113,17 +117,28 @@ import { useFilterUtilities } from '~/lib/viewer/composables/ui'
import { projectsRoute } from '~~/lib/common/helpers/route'
import { workspaceRoute } from '~/lib/common/helpers/route'
import { useMixpanel } from '~/lib/core/composables/mp'
import { writableAsyncComputed } from '~/lib/common/composables/async'
const emit = defineEmits<{
setup: [InjectableViewerState]
}>()
const router = useRouter()
const route = useRoute()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const modelId = computed(() => route.params.modelId as string)
const projectId = computed(() => route.params.id as string)
const resourceIdString = computed(() => route.params.modelId as string)
const projectId = writableAsyncComputed({
get: () => route.params.id as string,
set: async (value: string) => {
// Just rewrite route id param
await router.push({
params: { id: value }
})
},
initialState: route.params.id as string,
asyncRead: false
})
const state = useSetupViewer({
projectId
@@ -110,6 +110,24 @@
/>
</div>
</div>
<div
v-if="showBanner"
class="flex items-center justify-between gap-4 border-b border-outline-2 py-2 px-4 w-full"
>
<div class="text-body-2xs text-foreground-2 font-medium">
{{ bannerText }}
</div>
<div class="-mr-1 flex">
<FormButton
:icon-right="bannerButton.icon"
size="sm"
color="outline"
@click="bannerButton.action"
>
{{ bannerButton.text }}
</FormButton>
</div>
</div>
<div
class="relative w-full md:pr-3 sm:w-80 flex flex-col flex-1 justify-between"
>
@@ -117,22 +135,6 @@
ref="commentsContainer"
class="max-h-[200px] sm:max-h-[300px] 2xl:max-h-[500px] overflow-y-auto simple-scrollbar flex flex-col space-y-1 py-2 sm:pr-3"
>
<div
v-if="!isThreadResourceLoaded"
class="pl-2.5 pr-1.5 py-1 flex items-center justify-between text-body-2xs text-foreground border border-outline-2 rounded-md ml-2 mr-1 md:-mr-1 bg-foundation-page dark:bg-foundation"
>
<span>Conversation started in a different version</span>
<FormButton
v-tippy="'Load thread context'"
size="sm"
text
@click="onLoadThreadContext"
>
<ArrowDownCircleIcon
class="w-5 h-5 text-foreground-2 hover:text-foreground"
/>
</FormButton>
</div>
<ViewerAnchoredPointThreadComment
v-for="comment in comments"
:key="comment.id"
@@ -190,7 +192,8 @@ import {
XMarkIcon,
CheckIcon,
ArrowTopRightOnSquareIcon,
ArrowDownCircleIcon
ArrowLeftIcon,
ArrowUpRightIcon
} from '@heroicons/vue/24/outline'
import { ensureError, Roles } from '@speckle/shared'
import type { Nullable } from '@speckle/shared'
@@ -202,20 +205,13 @@ import type { CommentBubbleModel } from '~~/lib/viewer/composables/commentBubble
import {
useArchiveComment,
useCheckViewerCommentingAccess,
useMarkThreadViewed
useMarkThreadViewed,
useCommentContext
} from '~~/lib/viewer/composables/commentManagement'
import {
useInjectedViewerLoadedResources,
useInjectedViewerState
} from '~~/lib/viewer/composables/setup'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { ResourceType } from '~~/lib/common/generated/gql/graphql'
import { getLinkToThread } from '~~/lib/viewer/helpers/comments'
import {
StateApplyMode,
useApplySerializedState
} from '~~/lib/viewer/composables/serialization'
import { useDisableGlobalTextSelection } from '~~/lib/common/composables/window'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useThreadUtilities } from '~~/lib/viewer/composables/ui'
@@ -251,11 +247,11 @@ const {
const { projectId } = useInjectedViewerState()
const canReply = useCheckViewerCommentingAccess()
const { disableTextSelection } = useDisableGlobalTextSelection()
const markThreadViewed = useMarkThreadViewed()
const { usersTyping } = useViewerThreadTypingTracking(threadId)
const { ellipsis, controls } = useAnimatingEllipsis()
const applyState = useApplySerializedState()
const { threadResourceStatus, hasClickedFullContext, goBack, handleContextClick } =
useCommentContext()
const { isOpenThread, open, closeAllThreads } = useThreadUtilities()
const commentsContainer = ref(null as Nullable<HTMLElement>)
@@ -410,27 +406,6 @@ const canArchiveOrUnarchive = computed(
project.value?.role === Roles.Stream.Owner)
)
const { resourceItems } = useInjectedViewerLoadedResources()
const isThreadResourceLoaded = computed(() => {
const thread = props.modelValue
const loadedResources = resourceItems.value
const resourceLinks = thread.resources
const objectLinks = resourceLinks
.filter((l) => l.resourceType === ResourceType.Object)
.map((l) => l.resourceId)
const commitLinks = resourceLinks
.filter((l) => l.resourceType === ResourceType.Commit)
.map((l) => l.resourceId)
if (loadedResources.some((lr) => objectLinks.includes(lr.objectId))) return true
if (loadedResources.some((lr) => lr.versionId && commitLinks.includes(lr.versionId)))
return true
return false
})
const toggleCommentResolvedStatus = async () => {
await archiveComment({
commentId: props.modelValue.id,
@@ -467,13 +442,6 @@ const onThreadClick = () => {
changeExpanded(!isExpanded.value)
}
const onLoadThreadContext = async () => {
const state = props.modelValue.viewerState
if (!state) return
await applyState(state, StateApplyMode.TheadFullContextOpen)
}
const onCopyLink = async () => {
if (import.meta.server) return
const url = getLinkToThread(projectId.value, props.modelValue)
@@ -545,6 +513,41 @@ onMounted(() => {
emit('update:expanded', true)
}
})
const showBanner = computed(
() =>
threadResourceStatus.value.isDifferentVersion ||
threadResourceStatus.value.isFederatedModel ||
hasClickedFullContext.value
)
const bannerText = computed(() => {
if (hasClickedFullContext.value) return 'Viewing full context'
if (
threadResourceStatus.value.isDifferentVersion &&
threadResourceStatus.value.isFederatedModel
)
return 'References multiple models with different versions'
if (threadResourceStatus.value.isDifferentVersion)
return 'Conversation started in a different version'
if (threadResourceStatus.value.isFederatedModel) return 'References multiple models'
return ''
})
const bannerButton = computed(() => {
if (hasClickedFullContext.value) {
return {
text: 'Back',
icon: ArrowLeftIcon,
action: goBack
}
}
return {
text: 'Full context',
icon: ArrowUpRightIcon,
action: handleContextClick
}
})
</script>
<style scoped>
@media (max-width: 640px) {
@@ -21,13 +21,24 @@
<div class="truncate text-body-2xs text-foreground dark:text-foreground-2">
{{ thread.rawText }}
</div>
<div class="text-body-3xs flex items-center space-x-3 text-foreground-2 mb-1">
<span
v-if="!isThreadResourceLoaded"
v-tippy="'Conversation started in a different version.'"
<div class="text-body-3xs flex items-center space-x-3 text-foreground-3 mb-1">
<div
v-if="itemStatus.isDifferentVersion || itemStatus.isFederatedModel"
class="flex items-center space-x-1"
>
<ExclamationCircleIcon class="w-4 h-4" />
</span>
<div
v-if="itemStatus.isDifferentVersion"
v-tippy="'Conversation started in a different version.'"
>
<ClockIcon class="w-4 h-4" />
</div>
<div
v-if="itemStatus.isFederatedModel"
v-tippy="'References models not currently loaded.'"
>
<ExclamationCircleIcon class="w-4 h-4" />
</div>
</div>
<span>
{{ thread.replies.totalCount }}
{{ thread.replies.totalCount === 1 ? 'reply' : 'replies' }}
@@ -49,18 +60,19 @@
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon } from '@heroicons/vue/24/solid'
import { CheckCircleIcon, ClockIcon } from '@heroicons/vue/24/solid'
import { CheckCircleIcon as CheckCircleIconOutlined } from '@heroicons/vue/24/outline'
import { ExclamationCircleIcon } from '@heroicons/vue/20/solid'
import type { LoadedCommentThread } from '~~/lib/viewer/composables/setup'
import {
useInjectedViewerInterfaceState,
useInjectedViewerLoadedResources,
useInjectedViewerState
} from '~~/lib/viewer/composables/setup'
import { ResourceType } from '~~/lib/common/generated/gql/graphql'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { useArchiveComment } from '~~/lib/viewer/composables/commentManagement'
import {
useArchiveComment,
useCommentContext
} from '~~/lib/viewer/composables/commentManagement'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { Roles } from '@speckle/shared'
import { useMixpanel } from '~~/lib/core/composables/mp'
@@ -70,7 +82,6 @@ const props = defineProps<{
thread: LoadedCommentThread
}>()
const { resourceItems } = useInjectedViewerLoadedResources()
const {
threads: { openThread }
} = useInjectedViewerInterfaceState()
@@ -96,6 +107,9 @@ const open = (id: string) => {
})
}
const { calculateThreadResourceStatus } = useCommentContext()
const itemStatus = computed(() => calculateThreadResourceStatus(props.thread))
const createdAt = computed(() => {
return {
full: formattedFullDate(props.thread.createdAt),
@@ -103,25 +117,6 @@ const createdAt = computed(() => {
}
})
const isThreadResourceLoaded = computed(() => {
const thread = props.thread
const loadedResources = resourceItems.value
const resourceLinks = thread.resources
const objectLinks = resourceLinks
.filter((l) => l.resourceType === ResourceType.Object)
.map((l) => l.resourceId)
const commitLinks = resourceLinks
.filter((l) => l.resourceType === ResourceType.Commit)
.map((l) => l.resourceId)
if (loadedResources.some((lr) => objectLinks.includes(lr.objectId))) return true
if (loadedResources.some((lr) => lr.versionId && commitLinks.includes(lr.versionId)))
return true
return false
})
const isOpenInViewer = computed(() => openThread.thread.value?.id === props.thread.id)
const threadAuthors = computed(() => {
@@ -7,7 +7,7 @@
>
<div
class="h-full w-full bg-cover bg-center bg-no-repeat flex items-center justify-center"
:style="logo ? { backgroundImage: `url('${logo}')` } : undefined"
:style="logo ? { backgroundImage: `url('${logo}')` } : {}"
>
<span v-if="!logo" class="text-foreground-3 uppercase leading-none">
{{ name[0] }}
@@ -14,7 +14,7 @@
<Portal v-if="workspace?.name" to="navigation">
<HeaderNavLink
:to="workspaceRoute(workspaceSlug)"
:name="workspace?.name"
:name="isWorkspaceNewPlansEnabled ? 'Home' : workspace?.name"
:separator="false"
/>
</Portal>
@@ -128,6 +128,10 @@ graphql(`
}
`)
const props = defineProps<{
workspaceSlug: string
}>()
const { validateCheckoutSession } = useBillingActions()
const areQueriesLoading = useQueryLoading()
const route = useRoute()
@@ -138,10 +142,7 @@ const {
} = useDebouncedTextInput({
debouncedBy: 800
})
const props = defineProps<{
workspaceSlug: string
}>()
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
const showMoveProjectsDialog = ref(false)
const selectedRoles = ref(undefined as Optional<StreamRoles[]>)
@@ -0,0 +1,34 @@
<template>
<LayoutDialog v-model:open="open" max-width="md" :buttons="dialogButtons">
<template #header>Join existing workspaces</template>
<p class="text-body-xs text-foreground-2 pb-3">
Workspaces that match your email domain
</p>
<div class="flex flex-col gap-y-3">
<WorkspaceDiscoverableWorkspacesCard
v-for="workspace in discoverableWorkspacesAndJoinRequests"
:key="workspace.id"
:workspace="workspace"
/>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
const { discoverableWorkspacesAndJoinRequests } = useDiscoverableWorkspaces()
const open = defineModel<boolean>('open', { required: true })
const dialogButtons = computed((): LayoutDialogButton[] => {
return [
{
text: 'Close',
onClick: () => {
open.value = false
}
}
]
})
</script>
@@ -12,33 +12,40 @@
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3 lg:gap-4">
<WorkspaceAvatar
v-tippy="workspaceInfo.logo ? undefined : 'Add a workspace icon'"
:name="workspaceInfo.name"
:logo="workspaceInfo.logo"
size="lg"
class="hidden md:block"
:class="{ 'cursor-pointer': !workspaceInfo.logo }"
is-button
@click="
workspaceInfo.logo
? undefined
: navigateTo(settingsWorkspaceRoutes.general.route(workspaceInfo.slug))
"
/>
<WorkspaceAvatar
class="md:hidden"
:name="workspaceInfo.name"
:logo="workspaceInfo.logo"
/>
<h1 class="text-heading-sm md:text-heading line-clamp-2">
{{ workspaceInfo.name }}
</h1>
<CommonBadge rounded color-classes="bg-highlight-3 text-foreground-2">
<span class="capitalize">
{{ workspaceInfo.role?.split(':').reverse()[0] }}
</span>
</CommonBadge>
<template v-if="isWorkspaceNewPlansEnabled">
<h1 class="text-heading-sm md:text-heading line-clamp-2">
Hello, {{ activeUser?.name }}
</h1>
</template>
<template v-else>
<WorkspaceAvatar
v-tippy="workspaceInfo.logo ? undefined : 'Add a workspace icon'"
:name="workspaceInfo.name"
:logo="workspaceInfo.logo"
size="lg"
class="hidden md:block"
:class="{ 'cursor-pointer': !workspaceInfo.logo }"
is-button
@click="
workspaceInfo.logo
? undefined
: navigateTo(settingsWorkspaceRoutes.general.route(workspaceInfo.slug))
"
/>
<WorkspaceAvatar
class="md:hidden"
:name="workspaceInfo.name"
:logo="workspaceInfo.logo"
/>
<h1 class="text-heading-sm md:text-heading line-clamp-2">
{{ workspaceInfo.name }}
</h1>
<CommonBadge rounded color-classes="bg-highlight-3 text-foreground-2">
<span class="capitalize">
{{ workspaceInfo.role?.split(':').reverse()[0] }}
</span>
</CommonBadge>
</template>
</div>
<div class="flex gap-1.5 md:gap-2">
@@ -107,6 +114,9 @@ const props = defineProps<{
workspaceInfo: WorkspaceHeader_WorkspaceFragment
}>()
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
const { activeUser } = useActiveUser()
const isWorkspaceAdmin = computed(
() => props.workspaceInfo.role === Roles.Workspace.Admin
)
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="h-12 w-full shrink-0"></div>
<div class="relative flex h-[calc(100dvh-3rem)]">
<DashboardSidebar />
<DashboardSidebarWrapper />
<main class="w-full h-full overflow-y-auto simple-scrollbar pt-4 lg:pt-6 pb-16">
<div class="container mx-auto px-6 md:px-8">
@@ -6,7 +6,7 @@
<div class="h-12 w-full shrink-0"></div>
<div class="relative flex h-[calc(100dvh-3rem)]">
<DashboardSidebar />
<DashboardSidebarWrapper />
<main class="w-full h-full overflow-y-auto simple-scrollbar pt-4 lg:pt-6 pb-16">
<div class="container mx-auto px-6 md:px-8">
@@ -71,6 +71,10 @@ export const activeUserWorkspaceExistenceCheckQuery = graphql(`
}
workspaces(limit: 0) {
totalCount
items {
id
slug
}
}
discoverableWorkspaces {
id
@@ -81,3 +85,30 @@ export const activeUserWorkspaceExistenceCheckQuery = graphql(`
}
}
`)
export const activeUserActiveWorkspaceCheckQuery = graphql(`
query ActiveUserActiveWorkspaceCheck {
activeUser {
id
isProjectsActive
activeWorkspace {
id
slug
}
}
}
`)
export const projectWorkspaceAccessCheckQuery = graphql(`
query projectWorkspaceAccessCheck($projectId: String!) {
project(id: $projectId) {
id
role
workspace {
id
slug
role
}
}
}
`)
@@ -48,9 +48,13 @@ export const useBillingActions = () => {
const { mutate: cancelCheckoutSessionMutation } = useMutation(
settingsBillingCancelCheckoutSessionMutation
)
const logger = useLogger()
const billingPortalRedirect = async (workspaceId: MaybeNullOrUndefined<string>) => {
if (!workspaceId) return
if (!workspaceId) {
logger.error('[Billing Portal] No workspaceId provided, returning early')
return
}
mixpanel.track('Workspace Billing Portal Button Clicked', {
// eslint-disable-next-line camelcase
@@ -65,7 +69,12 @@ export const useBillingActions = () => {
})
if (result.data?.workspace.customerPortalUrl) {
window.location.href = result.data.workspace.customerPortalUrl
window.open(result.data.workspace.customerPortalUrl, '_blank')
} else {
logger.warn(
'[Billing Portal] No portal URL returned, full response:',
result.data
)
}
}
@@ -48,6 +48,7 @@ type Documents = {
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": typeof types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormUsersSelectItem on LimitedUser {\n id\n name\n avatar\n }\n": typeof types.FormUsersSelectItemFragmentDoc,
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderWorkspaceSwitcher_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n": typeof types.HeaderWorkspaceSwitcher_WorkspaceFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n": typeof types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n ...InviteDialogProjectWorkspaceMembers_Project\n workspace {\n id\n name\n defaultProjectRole\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n }\n": typeof types.InviteDialogProject_ProjectFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {\n role\n id\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n": typeof types.InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragmentDoc,
@@ -105,8 +106,8 @@ type Documents = {
"\n query ProjectsMoveToWorkspaceDialog {\n activeUser {\n id\n ...ProjectsMoveToWorkspaceDialog_User\n }\n }\n": typeof types.ProjectsMoveToWorkspaceDialogDocument,
"\n fragment ProjectsWorkspaceSelect_Workspace on Workspace {\n id\n role\n name\n logo\n readOnly\n slug\n }\n": typeof types.ProjectsWorkspaceSelect_WorkspaceFragmentDoc,
"\n fragment ProjectsInviteBanner on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": typeof types.ProjectsInviteBannerFragmentDoc,
"\n fragment SettingsDialog_Workspace on Workspace {\n ...SettingsMenu_Workspace\n id\n slug\n role\n name\n logo\n plan {\n status\n }\n creationState {\n completed\n }\n }\n": typeof types.SettingsDialog_WorkspaceFragmentDoc,
"\n fragment SettingsDialog_User on User {\n id\n workspaces {\n items {\n ...SettingsDialog_Workspace\n }\n }\n }\n": typeof types.SettingsDialog_UserFragmentDoc,
"\n fragment SettingsSidebar_Workspace on Workspace {\n ...SettingsMenu_Workspace\n id\n slug\n role\n name\n logo\n plan {\n status\n name\n }\n creationState {\n completed\n }\n }\n": typeof types.SettingsSidebar_WorkspaceFragmentDoc,
"\n fragment SettingsSidebar_User on User {\n id\n workspaces {\n items {\n ...SettingsSidebar_Workspace\n }\n }\n }\n": typeof types.SettingsSidebar_UserFragmentDoc,
"\n fragment SettingsServerRegionsAddEditDialog_ServerRegionItem on ServerRegionItem {\n id\n name\n description\n key\n }\n": typeof types.SettingsServerRegionsAddEditDialog_ServerRegionItemFragmentDoc,
"\n fragment SettingsServerRegionsTable_ServerRegionItem on ServerRegionItem {\n id\n name\n key\n description\n }\n": typeof types.SettingsServerRegionsTable_ServerRegionItemFragmentDoc,
"\n fragment SettingsSharedDeleteUserDialog_Workspace on Workspace {\n id\n plan {\n status\n name\n }\n subscription {\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n }\n": typeof types.SettingsSharedDeleteUserDialog_WorkspaceFragmentDoc,
@@ -157,7 +158,9 @@ type Documents = {
"\n query AuthRegisterPanel($token: String) {\n serverInfo {\n inviteOnly\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n ...ServerTermsOfServicePrivacyPolicyFragment\n }\n serverInviteByToken(token: $token) {\n id\n email\n }\n }\n": typeof types.AuthRegisterPanelDocument,
"\n query AuthLoginPanelWorkspaceInvite($token: String) {\n workspaceInvite(token: $token) {\n id\n email\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n ...AuthLoginWithEmailBlock_PendingWorkspaceCollaborator\n }\n }\n": typeof types.AuthLoginPanelWorkspaceInviteDocument,
"\n query AuthorizableAppMetadata($id: String!) {\n app(id: $id) {\n id\n name\n description\n trustByDefault\n redirectUrl\n scopes {\n name\n description\n }\n author {\n name\n id\n avatar\n }\n }\n }\n": typeof types.AuthorizableAppMetadataDocument,
"\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n": typeof types.ActiveUserWorkspaceExistenceCheckDocument,
"\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n items {\n id\n slug\n }\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n": typeof types.ActiveUserWorkspaceExistenceCheckDocument,
"\n query ActiveUserActiveWorkspaceCheck {\n activeUser {\n id\n isProjectsActive\n activeWorkspace {\n id\n slug\n }\n }\n }\n": typeof types.ActiveUserActiveWorkspaceCheckDocument,
"\n query projectWorkspaceAccessCheck($projectId: String!) {\n project(id: $projectId) {\n id\n role\n workspace {\n id\n slug\n role\n }\n }\n }\n": typeof types.ProjectWorkspaceAccessCheckDocument,
"\n fragment FunctionRunStatusForSummary on AutomateFunctionRun {\n id\n status\n }\n": typeof types.FunctionRunStatusForSummaryFragmentDoc,
"\n fragment TriggeredAutomationsStatusSummary on TriggeredAutomationsStatus {\n id\n automationRuns {\n id\n functionRuns {\n id\n ...FunctionRunStatusForSummary\n }\n }\n }\n": typeof types.TriggeredAutomationsStatusSummaryFragmentDoc,
"\n fragment AutomationRunDetails on AutomateRun {\n id\n status\n functionRuns {\n ...FunctionRunStatusForSummary\n statusMessage\n }\n trigger {\n ... on VersionCreatedTrigger {\n version {\n id\n }\n model {\n id\n }\n }\n }\n createdAt\n updatedAt\n }\n": typeof types.AutomationRunDetailsFragmentDoc,
@@ -204,6 +207,9 @@ type Documents = {
"\n query InviteUserSearch($input: UsersRetrievalInput!) {\n users(input: $input) {\n items {\n id\n name\n avatar\n }\n }\n }\n": typeof types.InviteUserSearchDocument,
"\n mutation CreateNewRegion($input: CreateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n create(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": typeof types.CreateNewRegionDocument,
"\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": typeof types.UpdateRegionDocument,
"\n fragment UseNavigation_Workspace on Workspace {\n ...HeaderWorkspaceSwitcher_Workspace\n id\n }\n": typeof types.UseNavigation_WorkspaceFragmentDoc,
"\n mutation SetActiveWorkspace($slug: String, $isProjectsActive: Boolean) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug, isProjectsActive: $isProjectsActive)\n }\n }\n": typeof types.SetActiveWorkspaceDocument,
"\n query HeaderWorkspaceSwitcher($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...HeaderWorkspaceSwitcher_Workspace\n }\n }\n": typeof types.HeaderWorkspaceSwitcherDocument,
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n": typeof types.ProjectPageTeamInternals_ProjectFragmentDoc,
"\n fragment ProjectPageTeamInternals_Workspace on Workspace {\n id\n team {\n items {\n id\n role\n user {\n id\n }\n }\n }\n }\n": typeof types.ProjectPageTeamInternals_WorkspaceFragmentDoc,
"\n fragment ProjectDashboardItemNoModels on Project {\n id\n name\n createdAt\n updatedAt\n role\n team {\n id\n user {\n id\n name\n avatar\n }\n }\n ...ProjectPageModelsCardProject\n }\n": typeof types.ProjectDashboardItemNoModelsFragmentDoc,
@@ -302,7 +308,7 @@ type Documents = {
"\n mutation DeleteWorkspaceDomain($input: WorkspaceDomainDeleteInput!) {\n workspaceMutations {\n deleteDomain(input: $input) {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_Workspace\n }\n }\n }\n": typeof types.DeleteWorkspaceDomainDocument,
"\n mutation SettingsLeaveWorkspace($leaveId: ID!) {\n workspaceMutations {\n leave(id: $leaveId)\n }\n }\n": typeof types.SettingsLeaveWorkspaceDocument,
"\n mutation SettingsBillingCancelCheckoutSession($input: CancelCheckoutSessionInput!) {\n workspaceMutations {\n billing {\n cancelCheckoutSession(input: $input)\n }\n }\n }\n": typeof types.SettingsBillingCancelCheckoutSessionDocument,
"\n query SettingsSidebar {\n activeUser {\n ...SettingsDialog_User\n }\n }\n": typeof types.SettingsSidebarDocument,
"\n query SettingsSidebar {\n activeUser {\n ...SettingsSidebar_User\n }\n }\n": typeof types.SettingsSidebarDocument,
"\n query SettingsSidebarAutomateFunctions {\n activeUser {\n ...Sidebar_User\n }\n }\n": typeof types.SettingsSidebarAutomateFunctionsDocument,
"\n query SettingsWorkspaceGeneral($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesGeneral_Workspace\n }\n }\n": typeof types.SettingsWorkspaceGeneralDocument,
"\n query SettingsWorkspaceBilling($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesBilling_Workspace\n }\n }\n": typeof types.SettingsWorkspaceBillingDocument,
@@ -361,7 +367,7 @@ type Documents = {
"\n fragment WorkspaceSecurity_Workspace on Workspace {\n id\n slug\n domains {\n id\n domain\n }\n }\n": typeof types.WorkspaceSecurity_WorkspaceFragmentDoc,
"\n mutation UpdateRole($input: WorkspaceRoleUpdateInput!) {\n workspaceMutations {\n updateRole(input: $input) {\n team {\n items {\n id\n role\n }\n }\n }\n }\n }\n": typeof types.UpdateRoleDocument,
"\n mutation InviteToWorkspace(\n $workspaceId: String!\n $input: [WorkspaceInviteCreateInput!]!\n ) {\n workspaceMutations {\n invites {\n batchCreate(workspaceId: $workspaceId, input: $input) {\n id\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n }\n }\n": typeof types.InviteToWorkspaceDocument,
"\n mutation CreateWorkspace($input: WorkspaceCreateInput!) {\n workspaceMutations {\n create(input: $input) {\n id\n ...SettingsDialog_Workspace\n }\n }\n }\n": typeof types.CreateWorkspaceDocument,
"\n mutation CreateWorkspace($input: WorkspaceCreateInput!) {\n workspaceMutations {\n create(input: $input) {\n id\n ...SettingsSidebar_Workspace\n }\n }\n }\n": typeof types.CreateWorkspaceDocument,
"\n mutation ProcessWorkspaceInvite($input: WorkspaceInviteUseInput!) {\n workspaceMutations {\n invites {\n use(input: $input)\n }\n }\n }\n": typeof types.ProcessWorkspaceInviteDocument,
"\n mutation SetDefaultWorkspaceRegion($workspaceId: String!, $regionKey: String!) {\n workspaceMutations {\n setDefaultRegion(workspaceId: $workspaceId, regionKey: $regionKey) {\n id\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n }\n": typeof types.SetDefaultWorkspaceRegionDocument,
"\n mutation DeleteWorkspaceSsoProvider($workspaceId: String!) {\n workspaceMutations {\n deleteSsoProvider(workspaceId: $workspaceId)\n }\n }\n": typeof types.DeleteWorkspaceSsoProviderDocument,
@@ -441,6 +447,7 @@ const documents: Documents = {
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormUsersSelectItem on LimitedUser {\n id\n name\n avatar\n }\n": types.FormUsersSelectItemFragmentDoc,
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderWorkspaceSwitcher_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n": types.HeaderWorkspaceSwitcher_WorkspaceFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n": types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n ...InviteDialogProjectWorkspaceMembers_Project\n workspace {\n id\n name\n defaultProjectRole\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n }\n": types.InviteDialogProject_ProjectFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {\n role\n id\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n": types.InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragmentDoc,
@@ -498,8 +505,8 @@ const documents: Documents = {
"\n query ProjectsMoveToWorkspaceDialog {\n activeUser {\n id\n ...ProjectsMoveToWorkspaceDialog_User\n }\n }\n": types.ProjectsMoveToWorkspaceDialogDocument,
"\n fragment ProjectsWorkspaceSelect_Workspace on Workspace {\n id\n role\n name\n logo\n readOnly\n slug\n }\n": types.ProjectsWorkspaceSelect_WorkspaceFragmentDoc,
"\n fragment ProjectsInviteBanner on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": types.ProjectsInviteBannerFragmentDoc,
"\n fragment SettingsDialog_Workspace on Workspace {\n ...SettingsMenu_Workspace\n id\n slug\n role\n name\n logo\n plan {\n status\n }\n creationState {\n completed\n }\n }\n": types.SettingsDialog_WorkspaceFragmentDoc,
"\n fragment SettingsDialog_User on User {\n id\n workspaces {\n items {\n ...SettingsDialog_Workspace\n }\n }\n }\n": types.SettingsDialog_UserFragmentDoc,
"\n fragment SettingsSidebar_Workspace on Workspace {\n ...SettingsMenu_Workspace\n id\n slug\n role\n name\n logo\n plan {\n status\n name\n }\n creationState {\n completed\n }\n }\n": types.SettingsSidebar_WorkspaceFragmentDoc,
"\n fragment SettingsSidebar_User on User {\n id\n workspaces {\n items {\n ...SettingsSidebar_Workspace\n }\n }\n }\n": types.SettingsSidebar_UserFragmentDoc,
"\n fragment SettingsServerRegionsAddEditDialog_ServerRegionItem on ServerRegionItem {\n id\n name\n description\n key\n }\n": types.SettingsServerRegionsAddEditDialog_ServerRegionItemFragmentDoc,
"\n fragment SettingsServerRegionsTable_ServerRegionItem on ServerRegionItem {\n id\n name\n key\n description\n }\n": types.SettingsServerRegionsTable_ServerRegionItemFragmentDoc,
"\n fragment SettingsSharedDeleteUserDialog_Workspace on Workspace {\n id\n plan {\n status\n name\n }\n subscription {\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n }\n": types.SettingsSharedDeleteUserDialog_WorkspaceFragmentDoc,
@@ -550,7 +557,9 @@ const documents: Documents = {
"\n query AuthRegisterPanel($token: String) {\n serverInfo {\n inviteOnly\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n ...ServerTermsOfServicePrivacyPolicyFragment\n }\n serverInviteByToken(token: $token) {\n id\n email\n }\n }\n": types.AuthRegisterPanelDocument,
"\n query AuthLoginPanelWorkspaceInvite($token: String) {\n workspaceInvite(token: $token) {\n id\n email\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n ...AuthLoginWithEmailBlock_PendingWorkspaceCollaborator\n }\n }\n": types.AuthLoginPanelWorkspaceInviteDocument,
"\n query AuthorizableAppMetadata($id: String!) {\n app(id: $id) {\n id\n name\n description\n trustByDefault\n redirectUrl\n scopes {\n name\n description\n }\n author {\n name\n id\n avatar\n }\n }\n }\n": types.AuthorizableAppMetadataDocument,
"\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserWorkspaceExistenceCheckDocument,
"\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n items {\n id\n slug\n }\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserWorkspaceExistenceCheckDocument,
"\n query ActiveUserActiveWorkspaceCheck {\n activeUser {\n id\n isProjectsActive\n activeWorkspace {\n id\n slug\n }\n }\n }\n": types.ActiveUserActiveWorkspaceCheckDocument,
"\n query projectWorkspaceAccessCheck($projectId: String!) {\n project(id: $projectId) {\n id\n role\n workspace {\n id\n slug\n role\n }\n }\n }\n": types.ProjectWorkspaceAccessCheckDocument,
"\n fragment FunctionRunStatusForSummary on AutomateFunctionRun {\n id\n status\n }\n": types.FunctionRunStatusForSummaryFragmentDoc,
"\n fragment TriggeredAutomationsStatusSummary on TriggeredAutomationsStatus {\n id\n automationRuns {\n id\n functionRuns {\n id\n ...FunctionRunStatusForSummary\n }\n }\n }\n": types.TriggeredAutomationsStatusSummaryFragmentDoc,
"\n fragment AutomationRunDetails on AutomateRun {\n id\n status\n functionRuns {\n ...FunctionRunStatusForSummary\n statusMessage\n }\n trigger {\n ... on VersionCreatedTrigger {\n version {\n id\n }\n model {\n id\n }\n }\n }\n createdAt\n updatedAt\n }\n": types.AutomationRunDetailsFragmentDoc,
@@ -597,6 +606,9 @@ const documents: Documents = {
"\n query InviteUserSearch($input: UsersRetrievalInput!) {\n users(input: $input) {\n items {\n id\n name\n avatar\n }\n }\n }\n": types.InviteUserSearchDocument,
"\n mutation CreateNewRegion($input: CreateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n create(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": types.CreateNewRegionDocument,
"\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": types.UpdateRegionDocument,
"\n fragment UseNavigation_Workspace on Workspace {\n ...HeaderWorkspaceSwitcher_Workspace\n id\n }\n": types.UseNavigation_WorkspaceFragmentDoc,
"\n mutation SetActiveWorkspace($slug: String, $isProjectsActive: Boolean) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug, isProjectsActive: $isProjectsActive)\n }\n }\n": types.SetActiveWorkspaceDocument,
"\n query HeaderWorkspaceSwitcher($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...HeaderWorkspaceSwitcher_Workspace\n }\n }\n": types.HeaderWorkspaceSwitcherDocument,
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n": types.ProjectPageTeamInternals_ProjectFragmentDoc,
"\n fragment ProjectPageTeamInternals_Workspace on Workspace {\n id\n team {\n items {\n id\n role\n user {\n id\n }\n }\n }\n }\n": types.ProjectPageTeamInternals_WorkspaceFragmentDoc,
"\n fragment ProjectDashboardItemNoModels on Project {\n id\n name\n createdAt\n updatedAt\n role\n team {\n id\n user {\n id\n name\n avatar\n }\n }\n ...ProjectPageModelsCardProject\n }\n": types.ProjectDashboardItemNoModelsFragmentDoc,
@@ -695,7 +707,7 @@ const documents: Documents = {
"\n mutation DeleteWorkspaceDomain($input: WorkspaceDomainDeleteInput!) {\n workspaceMutations {\n deleteDomain(input: $input) {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_Workspace\n }\n }\n }\n": types.DeleteWorkspaceDomainDocument,
"\n mutation SettingsLeaveWorkspace($leaveId: ID!) {\n workspaceMutations {\n leave(id: $leaveId)\n }\n }\n": types.SettingsLeaveWorkspaceDocument,
"\n mutation SettingsBillingCancelCheckoutSession($input: CancelCheckoutSessionInput!) {\n workspaceMutations {\n billing {\n cancelCheckoutSession(input: $input)\n }\n }\n }\n": types.SettingsBillingCancelCheckoutSessionDocument,
"\n query SettingsSidebar {\n activeUser {\n ...SettingsDialog_User\n }\n }\n": types.SettingsSidebarDocument,
"\n query SettingsSidebar {\n activeUser {\n ...SettingsSidebar_User\n }\n }\n": types.SettingsSidebarDocument,
"\n query SettingsSidebarAutomateFunctions {\n activeUser {\n ...Sidebar_User\n }\n }\n": types.SettingsSidebarAutomateFunctionsDocument,
"\n query SettingsWorkspaceGeneral($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesGeneral_Workspace\n }\n }\n": types.SettingsWorkspaceGeneralDocument,
"\n query SettingsWorkspaceBilling($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesBilling_Workspace\n }\n }\n": types.SettingsWorkspaceBillingDocument,
@@ -754,7 +766,7 @@ const documents: Documents = {
"\n fragment WorkspaceSecurity_Workspace on Workspace {\n id\n slug\n domains {\n id\n domain\n }\n }\n": types.WorkspaceSecurity_WorkspaceFragmentDoc,
"\n mutation UpdateRole($input: WorkspaceRoleUpdateInput!) {\n workspaceMutations {\n updateRole(input: $input) {\n team {\n items {\n id\n role\n }\n }\n }\n }\n }\n": types.UpdateRoleDocument,
"\n mutation InviteToWorkspace(\n $workspaceId: String!\n $input: [WorkspaceInviteCreateInput!]!\n ) {\n workspaceMutations {\n invites {\n batchCreate(workspaceId: $workspaceId, input: $input) {\n id\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n }\n }\n": types.InviteToWorkspaceDocument,
"\n mutation CreateWorkspace($input: WorkspaceCreateInput!) {\n workspaceMutations {\n create(input: $input) {\n id\n ...SettingsDialog_Workspace\n }\n }\n }\n": types.CreateWorkspaceDocument,
"\n mutation CreateWorkspace($input: WorkspaceCreateInput!) {\n workspaceMutations {\n create(input: $input) {\n id\n ...SettingsSidebar_Workspace\n }\n }\n }\n": types.CreateWorkspaceDocument,
"\n mutation ProcessWorkspaceInvite($input: WorkspaceInviteUseInput!) {\n workspaceMutations {\n invites {\n use(input: $input)\n }\n }\n }\n": types.ProcessWorkspaceInviteDocument,
"\n mutation SetDefaultWorkspaceRegion($workspaceId: String!, $regionKey: String!) {\n workspaceMutations {\n setDefaultRegion(workspaceId: $workspaceId, regionKey: $regionKey) {\n id\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n }\n": types.SetDefaultWorkspaceRegionDocument,
"\n mutation DeleteWorkspaceSsoProvider($workspaceId: String!) {\n workspaceMutations {\n deleteSsoProvider(workspaceId: $workspaceId)\n }\n }\n": types.DeleteWorkspaceSsoProviderDocument,
@@ -950,6 +962,10 @@ export function graphql(source: "\n fragment FormUsersSelectItem on LimitedUser
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n"): (typeof documents)["\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\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 fragment HeaderWorkspaceSwitcher_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n"): (typeof documents)["\n fragment HeaderWorkspaceSwitcher_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1181,11 +1197,11 @@ export function graphql(source: "\n fragment ProjectsInviteBanner on PendingStr
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsDialog_Workspace on Workspace {\n ...SettingsMenu_Workspace\n id\n slug\n role\n name\n logo\n plan {\n status\n }\n creationState {\n completed\n }\n }\n"): (typeof documents)["\n fragment SettingsDialog_Workspace on Workspace {\n ...SettingsMenu_Workspace\n id\n slug\n role\n name\n logo\n plan {\n status\n }\n creationState {\n completed\n }\n }\n"];
export function graphql(source: "\n fragment SettingsSidebar_Workspace on Workspace {\n ...SettingsMenu_Workspace\n id\n slug\n role\n name\n logo\n plan {\n status\n name\n }\n creationState {\n completed\n }\n }\n"): (typeof documents)["\n fragment SettingsSidebar_Workspace on Workspace {\n ...SettingsMenu_Workspace\n id\n slug\n role\n name\n logo\n plan {\n status\n name\n }\n creationState {\n completed\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 fragment SettingsDialog_User on User {\n id\n workspaces {\n items {\n ...SettingsDialog_Workspace\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsDialog_User on User {\n id\n workspaces {\n items {\n ...SettingsDialog_Workspace\n }\n }\n }\n"];
export function graphql(source: "\n fragment SettingsSidebar_User on User {\n id\n workspaces {\n items {\n ...SettingsSidebar_Workspace\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsSidebar_User on User {\n id\n workspaces {\n items {\n ...SettingsSidebar_Workspace\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1389,7 +1405,15 @@ export function graphql(source: "\n query AuthorizableAppMetadata($id: String!)
/**
* 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 ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n"];
export function graphql(source: "\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n items {\n id\n slug\n }\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n items {\n id\n slug\n }\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\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 query ActiveUserActiveWorkspaceCheck {\n activeUser {\n id\n isProjectsActive\n activeWorkspace {\n id\n slug\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserActiveWorkspaceCheck {\n activeUser {\n id\n isProjectsActive\n activeWorkspace {\n id\n slug\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 query projectWorkspaceAccessCheck($projectId: String!) {\n project(id: $projectId) {\n id\n role\n workspace {\n id\n slug\n role\n }\n }\n }\n"): (typeof documents)["\n query projectWorkspaceAccessCheck($projectId: String!) {\n project(id: $projectId) {\n id\n role\n workspace {\n id\n slug\n role\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1574,6 +1598,18 @@ export function graphql(source: "\n mutation CreateNewRegion($input: CreateServ
* 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 UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\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 fragment UseNavigation_Workspace on Workspace {\n ...HeaderWorkspaceSwitcher_Workspace\n id\n }\n"): (typeof documents)["\n fragment UseNavigation_Workspace on Workspace {\n ...HeaderWorkspaceSwitcher_Workspace\n id\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 SetActiveWorkspace($slug: String, $isProjectsActive: Boolean) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug, isProjectsActive: $isProjectsActive)\n }\n }\n"): (typeof documents)["\n mutation SetActiveWorkspace($slug: String, $isProjectsActive: Boolean) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug, isProjectsActive: $isProjectsActive)\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 query HeaderWorkspaceSwitcher($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...HeaderWorkspaceSwitcher_Workspace\n }\n }\n"): (typeof documents)["\n query HeaderWorkspaceSwitcher($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...HeaderWorkspaceSwitcher_Workspace\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1969,7 +2005,7 @@ export function graphql(source: "\n mutation SettingsBillingCancelCheckoutSessi
/**
* 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 SettingsSidebar {\n activeUser {\n ...SettingsDialog_User\n }\n }\n"): (typeof documents)["\n query SettingsSidebar {\n activeUser {\n ...SettingsDialog_User\n }\n }\n"];
export function graphql(source: "\n query SettingsSidebar {\n activeUser {\n ...SettingsSidebar_User\n }\n }\n"): (typeof documents)["\n query SettingsSidebar {\n activeUser {\n ...SettingsSidebar_User\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2205,7 +2241,7 @@ export function graphql(source: "\n mutation InviteToWorkspace(\n $workspace
/**
* 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 CreateWorkspace($input: WorkspaceCreateInput!) {\n workspaceMutations {\n create(input: $input) {\n id\n ...SettingsDialog_Workspace\n }\n }\n }\n"): (typeof documents)["\n mutation CreateWorkspace($input: WorkspaceCreateInput!) {\n workspaceMutations {\n create(input: $input) {\n id\n ...SettingsDialog_Workspace\n }\n }\n }\n"];
export function graphql(source: "\n mutation CreateWorkspace($input: WorkspaceCreateInput!) {\n workspaceMutations {\n create(input: $input) {\n id\n ...SettingsSidebar_Workspace\n }\n }\n }\n"): (typeof documents)["\n mutation CreateWorkspace($input: WorkspaceCreateInput!) {\n workspaceMutations {\n create(input: $input) {\n id\n ...SettingsSidebar_Workspace\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -17,7 +17,6 @@ export const verifyEmailCountdownRoute = '/verify-email?source=registration'
export const serverManagementRoute = '/server-management'
export const connectorsRoute = '/connectors'
export const tutorialsRoute = '/tutorials'
export const downloadManagerUrl = 'https://speckle.systems/download'
export const docsPageUrl = 'https://speckle.guide/'
export const forumPageUrl = 'https://speckle.community/'
export const defaultZapierWebhookUrl =
@@ -18,7 +18,7 @@ export const connectorItems: ConnectorItem[] = [
title: 'Revit',
slug: 'revit',
description:
'Extract BIM data for further processing and visualisation, or dynamically create models from other CAD applications using Speckle for Revit! Supports Revit 2020 to 2025.',
'Publish and load models to boost design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/revit',
image: '/images/connectors/revit.png',
categories: [ConnectorCategory.NextGen, ConnectorCategory.BIM]
@@ -27,7 +27,7 @@ export const connectorItems: ConnectorItem[] = [
title: 'Rhino',
slug: 'rhino',
description:
'From sending and receiving geometry to scaffolding BIM models from simple geometry: Speckle for Rhino is here to help. Supports versions 6, 7 and 8 on Windows and version 7 on Mac.',
'Publish and load Rhino models for high-quality design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/rhino',
image: '/images/connectors/rhino.png',
categories: [ConnectorCategory.NextGen, ConnectorCategory.CADAndModeling]
@@ -36,7 +36,7 @@ export const connectorItems: ConnectorItem[] = [
title: 'Power BI',
slug: 'powerbi',
description:
"Speckle's Power BI Connector allows you to integrate data from various AEC apps (like Revit, Archicad, IFC and more)! You can create detailed analysis and interactive 3D visualisations.",
'Load Power BI models to boost design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/power-bi',
image: '/images/connectors/powerbi.png',
categories: [ConnectorCategory.BusinessIntelligence]
@@ -45,7 +45,7 @@ export const connectorItems: ConnectorItem[] = [
title: 'SketchUp',
slug: 'sketchup',
description:
'Be an early adopter and try the Speckle Connector for SketchUp (Beta). Send your SketchUp models out and receive models from other CAD/BIM apps. Supports versions 2021, 2022, 2023 and 2024.',
'Publish and load SketchUp models for high-quality design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/sketchup-beta',
image: '/images/connectors/sketchup.png',
categories: [ConnectorCategory.NextGen, ConnectorCategory.CADAndModeling]
@@ -54,16 +54,25 @@ export const connectorItems: ConnectorItem[] = [
title: 'QGIS',
slug: 'qgis',
description:
'The Speckle Connector for QGIS, compatible with QGIS 3.20 onwards. You can install it from Speckle Manager or directly from the QGIS Plugins menu.',
'Publish QGIS models to boost design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/qgis',
image: '/images/connectors/qgis.png',
categories: [ConnectorCategory.GIS]
},
{
title: 'ArcGIS',
slug: 'arcgis',
description:
'Publish ArcGIS models to boost design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/arcgis',
image: '/images/connectors/arcgis.png',
categories: [ConnectorCategory.GIS]
},
{
title: 'AutoCAD',
slug: 'autocad',
description:
'Exchange and extract geometry using the Speckle AutoCAD Connector. Supports versions 2021, 2022, 2023, 2024 and 2025',
'Publish and load AutoCAD models for high-quality design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/autocad',
image: '/images/connectors/autocad.png',
categories: [ConnectorCategory.NextGen, ConnectorCategory.CADAndModeling]
@@ -72,7 +81,7 @@ export const connectorItems: ConnectorItem[] = [
title: 'Civil3D',
slug: 'civil3d',
description:
'Exchange and extract data from Civil3D using Speckle - alignments and more! Supports versions 2021, 2022, 2023, 2024 and 2025.',
'Publish and load Civil 3D models to boost design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/civil3d',
image: '/images/connectors/civil3d.png',
categories: [
@@ -85,7 +94,7 @@ export const connectorItems: ConnectorItem[] = [
title: 'ETABS',
slug: 'etabs',
description:
'Connect to Speckle with our (alpha) Connector for ETABS 18, 19, 20 and 21. Send and receive structural model data in customisable ways to enhance your workflows!',
'Publish ETABS models to boost design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/etabs',
image: '/images/connectors/etabs.png',
categories: [ConnectorCategory.Structural]
@@ -94,7 +103,7 @@ export const connectorItems: ConnectorItem[] = [
title: 'Navisworks',
slug: 'navisworks',
description:
"Share aggregated models from Navisworks (2020-2025) to Speckle: publish geometry and properties from specific search sets, selections, views, or clash results! Speckle's Navisworks Connector allows targeted exports so that you can focus on the most relevant aspects of your model data for enhanced usability in collaborative workflows.",
'Publish Navisworks models to boost design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/navisworks',
image: '/images/connectors/navisworks.png',
categories: [ConnectorCategory.NextGen, ConnectorCategory.BIM]
@@ -103,16 +112,16 @@ export const connectorItems: ConnectorItem[] = [
title: 'Archicad',
slug: 'archicad',
description:
'Extract BIM data for further processing and visualisation, or dynamically create models from other CAD applications using Speckle for Archicad! Supports Archicad 25 to 27.',
'Publish Archicad models to boost design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/archicad',
image: '/images/connectors/archicad.png',
categories: [ConnectorCategory.NextGen, ConnectorCategory.BIM]
},
{
title: 'TeklaStructures',
title: 'Tekla',
slug: 'teklastructures',
description:
'Connect to Speckle with our Connector for Tekla Structures. Send and receive BIM data in customisable ways to enhance your workflows.',
'Publish Tekla Structures models to boost design coordination and business intelligence workflows.',
url: 'https://www.speckle.systems/connectors/teklastructures-alpha',
image: '/images/connectors/teklastructures.png',
categories: [ConnectorCategory.NextGen, ConnectorCategory.Structural]
@@ -122,8 +131,7 @@ export const connectorItems: ConnectorItem[] = [
{
title: 'Excel',
slug: 'excel',
description:
"Create geometry, schedules and analyse your geometry's metadata. Available on the Microsoft Office Store.",
description: "Create geometry, schedules and analyse your geometry's metadata.",
image: '/images/connectors/excel.png',
categories: [ConnectorCategory.BusinessIntelligence],
isComingSoon: true
@@ -131,8 +139,7 @@ export const connectorItems: ConnectorItem[] = [
{
title: 'Blender',
slug: 'blender',
description:
'Blender is a powerful 3D modeling software and much more than that. Supports Blender 3.X & 4.X versions on Windows and Mac!',
description: 'Load Blender models to boost design coordination workflows.',
image: '/images/connectors/blender.png',
categories: [ConnectorCategory.Visualisation, ConnectorCategory.CADAndModeling],
isComingSoon: true
@@ -141,7 +148,7 @@ export const connectorItems: ConnectorItem[] = [
title: 'Grasshopper',
slug: 'grasshopper',
description:
'Create anything from simple to advanced custom workflows using Speckle for Grasshopper, the original Speckle Connector!',
'Publish and load models to boost design coordination and BI workflows.',
image: '/images/connectors/grasshopper.png',
categories: [ConnectorCategory.VisualProgramming],
isComingSoon: true
@@ -0,0 +1,88 @@
import { setActiveWorkspaceMutation } from '~/lib/navigation/graphql/mutations'
import { useMutation, useQuery } from '@vue/apollo-composable'
import { headerWorkspaceSwitcherQuery } from '~/lib/navigation/graphql/queries'
import { graphql } from '~/lib/common/generated/gql'
import type { UseNavigation_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
graphql(`
fragment UseNavigation_Workspace on Workspace {
...HeaderWorkspaceSwitcher_Workspace
id
}
`)
export const useNavigationState = () =>
useState<{
activeWorkspaceSlug: string | null
isProjectsActive: boolean
cachedWorkspaceData: UseNavigation_WorkspaceFragment | null
}>('navigation-state', () => ({
activeWorkspaceSlug: null,
isProjectsActive: false,
cachedWorkspaceData: null
}))
export const useNavigation = () => {
const state = useNavigationState()
const { mutate } = useMutation(setActiveWorkspaceMutation)
const activeWorkspaceSlug = computed({
get: () => state.value.activeWorkspaceSlug,
set: (newVal) => (state.value.activeWorkspaceSlug = newVal)
})
const {
result,
loading: workspaceLoading,
onResult
} = useQuery(
headerWorkspaceSwitcherQuery,
() => ({
slug: activeWorkspaceSlug.value || ''
}),
() => ({
enabled: !!activeWorkspaceSlug.value
})
)
const isProjectsActive = computed({
get: () => state.value.isProjectsActive,
set: (newVal) => (state.value.isProjectsActive = newVal)
})
// Set state and mutate
const mutateActiveWorkspaceSlug = async (newVal: string) => {
state.value.activeWorkspaceSlug = newVal
state.value.isProjectsActive = false
await mutate({ slug: newVal, isProjectsActive: false })
}
const mutateIsProjectsActive = async (isActive: boolean) => {
state.value.isProjectsActive = isActive
state.value.activeWorkspaceSlug = null
state.value.cachedWorkspaceData = null
await mutate({ isProjectsActive: state.value.isProjectsActive, slug: null })
}
// Use the cached data or the current result
const workspaceData = computed(() => {
return result.value?.workspaceBySlug || state.value.cachedWorkspaceData
})
// Save data in the state, the prevent flickering when the component remount in between navigation
onResult((result) => {
const workspace = result.data?.workspaceBySlug
if (workspace) {
state.value.cachedWorkspaceData = workspace
}
})
return {
activeWorkspaceSlug,
isProjectsActive,
mutateActiveWorkspaceSlug,
mutateIsProjectsActive,
workspaceData,
workspaceLoading
}
}
@@ -0,0 +1,9 @@
import { graphql } from '~~/lib/common/generated/gql'
export const setActiveWorkspaceMutation = graphql(`
mutation SetActiveWorkspace($slug: String, $isProjectsActive: Boolean) {
activeUserMutations {
setActiveWorkspace(slug: $slug, isProjectsActive: $isProjectsActive)
}
}
`)
@@ -0,0 +1,9 @@
import { graphql } from '~/lib/common/generated/gql'
export const headerWorkspaceSwitcherQuery = graphql(`
query HeaderWorkspaceSwitcher($slug: String!) {
workspaceBySlug(slug: $slug) {
...HeaderWorkspaceSwitcher_Workspace
}
}
`)
@@ -3,7 +3,7 @@ import { graphql } from '~~/lib/common/generated/gql'
export const settingsSidebarQuery = graphql(`
query SettingsSidebar {
activeUser {
...SettingsDialog_User
...SettingsSidebar_User
}
}
`)
@@ -0,0 +1,24 @@
import { useQuery } from '@vue/apollo-composable'
import { settingsSidebarQuery } from '~/lib/settings/graphql/queries'
export const useUserWorkspaces = () => {
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { result } = useQuery(settingsSidebarQuery, null, {
enabled: isWorkspacesEnabled.value
})
const workspaces = computed(() =>
result.value?.activeUser
? result.value.activeUser.workspaces.items.filter(
(workspace) => workspace.creationState?.completed !== false
)
: []
)
const hasWorkspaces = computed(() => workspaces.value.length > 0)
return {
workspaces,
hasWorkspaces
}
}
@@ -13,8 +13,8 @@ import {
useSelectionEvents,
useViewerCameraControlEndTracker
} from '~~/lib/viewer/composables/viewer'
import { SpeckleViewer } from '@speckle/shared'
import type { Nullable } from '@speckle/shared'
import { SpeckleViewer, xor } from '@speckle/shared'
import type { Nullable, Optional } from '@speckle/shared'
import { Vector3 } from 'three'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { broadcastViewerUserActivityMutation } from '~~/lib/viewer/graphql/mutations'
@@ -82,8 +82,16 @@ export function useViewerUserActivityBroadcasting(
const apollo = useApolloClient().client
const { isEnabled: isEmbedEnabled } = useEmbed()
const isSameMessage = (
previousSerializedMessage: Optional<string>,
newMessage: ViewerUserActivityMessageInput
) => {
if (xor(previousSerializedMessage, newMessage)) return false
if (!previousSerializedMessage && !newMessage) return false
return previousSerializedMessage === JSON.stringify(newMessage)
}
const invokeMutation = async (message: ViewerUserActivityMessageInput) => {
if (!isLoggedIn.value || isEmbedEnabled.value) return false
const result = await apollo
.mutate({
mutation: broadcastViewerUserActivityMutation,
@@ -98,14 +106,33 @@ export function useViewerUserActivityBroadcasting(
return result.data?.broadcastViewerUserActivity || false
}
let serializedPreviousMessage: Optional<string> = undefined
const invokeObservabilityEvent = async (message: ViewerUserActivityMessageInput) => {
const dd = window.DD_RUM
if (!dd || !('addAction' in dd)) return
if (isSameMessage(serializedPreviousMessage, message)) return
serializedPreviousMessage = JSON.stringify(message)
dd.addAction('Viewer User Activity', { message })
}
const invoke = async (message: ViewerUserActivityMessageInput) => {
if (!isLoggedIn.value || isEmbedEnabled.value) return false
return await Promise.all([
invokeMutation(message),
invokeObservabilityEvent(message)
])
}
return {
emitDisconnected: async () =>
invokeMutation({
await invoke({
...getMainMetadata(),
status: ViewerUserActivityStatus.Disconnected
}),
emitViewing: async () => {
await invokeMutation({
await invoke({
...getMainMetadata(),
status: ViewerUserActivityStatus.Viewing
})
@@ -9,7 +9,8 @@ import type {
ArchiveCommentInput,
CommentContentInput,
CreateCommentReplyInput,
OnViewerCommentsUpdatedSubscription
OnViewerCommentsUpdatedSubscription,
ViewerResourceItem
} from '~~/lib/common/generated/gql/graphql'
import {
convertThrowIntoFetchResult,
@@ -23,12 +24,20 @@ import {
markCommentViewedMutation
} from '~~/lib/viewer/graphql/mutations'
import { onViewerCommentsUpdatedSubscription } from '~~/lib/viewer/graphql/subscriptions'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import {
useInjectedViewerState,
type LoadedCommentThread
} from '~~/lib/viewer/composables/setup'
import type { MaybeNullOrUndefined, SpeckleViewer } from '@speckle/shared'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import type { SuccessfullyUploadedFileItem } from '~~/lib/core/api/blobStorage'
import { isValidCommentContentInput } from '~~/lib/viewer/helpers/comments'
import { useStateSerialization } from '~~/lib/viewer/composables/serialization'
import {
useStateSerialization,
useApplySerializedState,
StateApplyMode
} from '~~/lib/viewer/composables/serialization'
import type { CommentBubbleModel } from '~/lib/viewer/composables/commentBubbles'
export function useViewerCommentUpdateTracking(
params: {
@@ -237,3 +246,147 @@ export function useCheckViewerCommentingAccess() {
return hasRole || allowPublicComments
})
}
const useActiveThreadContext = () => {
type ThreadContext = {
threadId: string | null
previousState: SpeckleViewer.ViewerState.SerializedViewerState | null
}
return useState<ThreadContext>('thread-context', () => ({
threadId: null,
previousState: null
}))
}
export const useCommentContext = () => {
const applyState = useApplySerializedState()
const { serialize } = useStateSerialization()
const state = useInjectedViewerState()
const threadContext = useActiveThreadContext()
const thread = computed(() => state.ui.threads.openThread.thread.value)
const calculateThreadResourceStatus = (
threadData: LoadedCommentThread | CommentBubbleModel | null | undefined
) => {
if (!threadData) return { isLoaded: false }
const loadedResources = state.resources.response.resourceItems.value
const resourceLinks = threadData?.resources
if (!resourceLinks) {
return { isLoaded: false }
}
// Check if any of the thread's objects are loaded
const objectLinks = resourceLinks
.filter((l) => l.resourceType === 'object')
.map((l) => l.resourceId)
const commitLinks = resourceLinks
.filter((l) => l.resourceType === 'commit')
.map((l) => l.resourceId)
// Check if ALL of the thread's objects are loaded
const hasLoadedObjects =
objectLinks.length > 0 &&
objectLinks.every((objId) => loadedResources.some((lr) => lr.objectId === objId))
// Check if ALL of the thread's commits are loaded
const hasLoadedVersions =
commitLinks.length > 0 &&
commitLinks.every((commitId) =>
loadedResources.some((lr) => lr.versionId && lr.versionId === commitId)
)
// Resource is loaded, check versions and federation
const currentModels = state.resources.response.modelsAndVersionIds.value
const threadModels = threadData.viewerResources.filter(
(r): r is ViewerResourceItem & { modelId: string; versionId: string } =>
r.modelId !== null && r.versionId !== null
)
// Check if any thread models are not in current view (federated)
const hasFederatedModels = threadModels.some(
(threadModel) => !currentModels.some((m) => m.model.id === threadModel.modelId)
)
// For models that exist in both states, check version differences
const hasDifferentVersions = threadModels.some((threadModel) => {
const currentModel = currentModels.find((m) => m.model.id === threadModel.modelId)
return currentModel && currentModel.versionId !== threadModel.versionId
})
return {
isLoaded: hasLoadedObjects || hasLoadedVersions,
isDifferentVersion: hasDifferentVersions,
isFederatedModel: hasFederatedModels
}
}
const threadResourceStatus = computed(() =>
calculateThreadResourceStatus(thread.value)
)
const hasClickedFullContext = computed(() => {
const threadId = thread.value?.id
return threadContext.value.threadId === threadId
})
const loadContext = async (
mode: StateApplyMode.ThreadFullContextOpen | StateApplyMode.FederatedContext
) => {
const state = thread.value?.viewerState
const threadId = thread.value?.id ?? null
if (!state) return
// Store both current state and thread ID
threadContext.value = {
threadId,
previousState: serialize()
}
await applyState(state, mode)
}
const loadThreadVersionContext = () =>
loadContext(StateApplyMode.ThreadFullContextOpen)
const loadFederatedContext = () => loadContext(StateApplyMode.FederatedContext)
const handleContextClick = () => {
if (threadResourceStatus.value.isDifferentVersion) {
loadThreadVersionContext()
} else {
loadFederatedContext()
}
}
const goBack = async () => {
if (!threadContext.value.previousState) {
return
}
await applyState(
threadContext.value.previousState,
StateApplyMode.ThreadFullContextOpen
)
threadContext.value = {
threadId: null,
previousState: null
}
}
const cleanupThreadContext = () => {
threadContext.value = {
threadId: null,
previousState: null
}
}
return {
threadResourceStatus,
calculateThreadResourceStatus,
handleContextClick,
goBack,
hasClickedFullContext,
cleanupThreadContext
}
}
@@ -10,8 +10,9 @@ import {
useFilterUtilities,
useSelectionUtilities
} from '~~/lib/viewer/composables/ui'
import { CameraController, ViewMode } from '@speckle/viewer'
import { CameraController, ViewMode, VisualDiffMode } from '@speckle/viewer'
import type { NumericPropertyInfo } from '@speckle/viewer'
import type { PartialDeep } from 'type-fest'
type SerializedViewerState = SpeckleViewer.ViewerState.SerializedViewerState
@@ -130,12 +131,14 @@ export function useStateSerialization() {
export enum StateApplyMode {
Spotlight,
ThreadOpen,
TheadFullContextOpen,
Reset
ThreadFullContextOpen,
Reset,
FederatedContext
}
export function useApplySerializedState() {
const {
projectId,
ui: {
camera: { position, target, isOrthoProjection },
sectionBox,
@@ -165,61 +168,71 @@ export function useApplySerializedState() {
const { setSelectionFromObjectIds } = useSelectionUtilities()
const logger = useLogger()
return async (state: SerializedViewerState, mode: StateApplyMode) => {
return async (state: PartialDeep<SerializedViewerState>, mode: StateApplyMode) => {
if (mode === StateApplyMode.Reset) {
resetState()
return
}
if (state.projectId && state.projectId !== projectId.value) {
await projectId.update(state.projectId)
}
if (
[StateApplyMode.Spotlight, StateApplyMode.ThreadFullContextOpen].includes(mode)
) {
await resourceIdString.update(state.resources?.request?.resourceIdString || '')
}
position.value = new Vector3(
state.ui.camera.position[0],
state.ui.camera.position[1],
state.ui.camera.position[2]
state.ui?.camera?.position?.[0],
state.ui?.camera?.position?.[1],
state.ui?.camera?.position?.[2]
)
target.value = new Vector3(
state.ui.camera.target[0],
state.ui.camera.target[1],
state.ui.camera.target[2]
state.ui?.camera?.target?.[0],
state.ui?.camera?.target?.[1],
state.ui?.camera?.target?.[2]
)
isOrthoProjection.value = state.ui.camera.isOrthoProjection
isOrthoProjection.value = !!state.ui?.camera?.isOrthoProjection
sectionBox.value = state.ui.sectionBox
sectionBox.value = state.ui?.sectionBox
? new Box3(
new Vector3(
state.ui.sectionBox.min[0],
state.ui.sectionBox.min[1],
state.ui.sectionBox.min[2]
state.ui.sectionBox.min?.[0],
state.ui.sectionBox.min?.[1],
state.ui.sectionBox.min?.[2]
),
new Vector3(
state.ui.sectionBox.max[0],
state.ui.sectionBox.max[1],
state.ui.sectionBox.max[2]
state.ui.sectionBox.max?.[0],
state.ui.sectionBox.max?.[1],
state.ui.sectionBox.max?.[2]
)
)
: null
const filters = state.ui.filters
if (filters.hiddenObjectIds.length) {
const filters = state.ui?.filters || {}
if (filters.hiddenObjectIds?.length) {
resetFilters()
hideObjects(filters.hiddenObjectIds, { replace: true })
} else if (filters.isolatedObjectIds.length) {
} else if (filters.isolatedObjectIds?.length) {
resetFilters()
isolateObjects(filters.isolatedObjectIds, { replace: true })
} else {
resetFilters()
}
const propertyFilterApplied = state.ui.filters.propertyFilter.isApplied
const propertyFilterApplied = filters.propertyFilter?.isApplied
if (propertyFilterApplied) {
applyPropertyFilter()
} else {
unApplyPropertyFilter()
}
const propertyInfoKey = state.ui.filters.propertyFilter.key
const passMin = state.viewer.metadata.filteringState?.passMin
const passMax = state.viewer.metadata.filteringState?.passMax
const propertyInfoKey = filters.propertyFilter?.key
const passMin = state.viewer?.metadata?.filteringState?.passMin
const passMax = state.viewer?.metadata?.filteringState?.passMax
if (propertyInfoKey) {
removePropertyFilter()
@@ -249,30 +262,54 @@ export function useApplySerializedState() {
}
if (mode === StateApplyMode.Spotlight) {
highlightedObjectIds.value = filters.selectedObjectIds.slice()
highlightedObjectIds.value = (filters.selectedObjectIds || []).slice()
} else {
if (filters.selectedObjectIds.length) {
if (filters.selectedObjectIds?.length) {
setSelectionFromObjectIds(filters.selectedObjectIds)
}
}
// Handle resource string updates
if (
[StateApplyMode.Spotlight, StateApplyMode.TheadFullContextOpen].includes(mode)
[StateApplyMode.Spotlight, StateApplyMode.ThreadFullContextOpen].includes(mode)
) {
await resourceIdString.update(state.resources.request.resourceIdString)
await resourceIdString.update(state.resources?.request?.resourceIdString || '')
} else if (mode === StateApplyMode.FederatedContext) {
// For federated context, append only model IDs (without versions) to show latest
const { parseUrlParameters, ViewerModelResource, createGetParamFromResources } =
SpeckleViewer.ViewerRoute
const currentResources = parseUrlParameters(resourceIdString.value)
const newResources = parseUrlParameters(
state.resources?.request?.resourceIdString ?? ''
).map((resource) => {
if (resource instanceof ViewerModelResource) {
// Only keep model ID, drop version
return new ViewerModelResource(resource.modelId)
}
return resource
})
if (newResources.length) {
const allResources = [...currentResources, ...newResources]
const newResourceString = createGetParamFromResources(allResources)
await resourceIdString.update(newResourceString)
}
}
if ([StateApplyMode.Spotlight].includes(mode)) {
await urlHashState.focusedThreadId.update(state.ui.threads.openThread.threadId)
await urlHashState.focusedThreadId.update(
state.ui?.threads?.openThread?.threadId || null
)
}
const command = state.ui.diff.command
const command = state.ui?.diff?.command
? deserializeDiffCommand(state.ui.diff.command)
: null
const activeDiffEnabled = !!diff.enabled.value
if (command && command.diffs.length) {
diff.time.value = state.ui.diff.time
diff.mode.value = state.ui.diff.mode
if (command && command.diffs.length && state.ui?.diff) {
diff.time.value = state.ui.diff.time || 0.5
diff.mode.value = state.ui?.diff.mode || VisualDiffMode.COLORED
const instruction = command.diffs[0]
await diffModelVersions(
@@ -285,16 +322,16 @@ export function useApplySerializedState() {
}
// Restore view mode
if (state.ui.viewMode) {
if (state.ui?.viewMode) {
viewMode.value = state.ui.viewMode
} else {
viewMode.value = ViewMode.DEFAULT
}
explodeFactor.value = state.ui.explodeFactor
explodeFactor.value = state.ui?.explodeFactor || 0
lightConfig.value = {
...lightConfig.value,
...state.ui.lightConfig
...(state.ui?.lightConfig || {})
}
}
}
@@ -18,7 +18,6 @@ import {
type VisualDiffMode,
ViewMode
} from '@speckle/viewer'
import type { MaybeRef } from '@vueuse/shared'
import { inject, ref, provide } from 'vue'
import type { ComputedRef, WritableComputedRef, Raw, Ref, ShallowRef } from 'vue'
import { useScopedState } from '~~/lib/common/composables/scopedState'
@@ -82,7 +81,7 @@ export type InjectableViewerState = Readonly<{
/**
* The project which we're opening in the viewer (all loaded models should belong to it)
*/
projectId: ComputedRef<string>
projectId: AsyncWritableComputedRef<string>
/**
* User viewer session ID. The same user will have different IDs in different tabs if multiple are open.
* This is used to ignore user activity messages from the same tab.
@@ -400,8 +399,6 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
public: { viewerDebug }
} = useRuntimeConfig()
const projectId = computed(() => unref(params.projectId))
const sessionId = computed(() => nanoid())
const isInitialized = ref(false)
const { instance, initPromise, container } = useScopedState(
@@ -412,7 +409,7 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
const hasDoneInitialLoad = ref(false)
return {
projectId,
projectId: params.projectId,
sessionId,
viewer: import.meta.server
? ({
@@ -1030,7 +1027,7 @@ function setupInterfaceState(
}
}
type UseSetupViewerParams = { projectId: MaybeRef<string> }
type UseSetupViewerParams = { projectId: AsyncWritableComputedRef<string> }
export function useSetupViewer(params: UseSetupViewerParams): InjectableViewerState {
// Initialize full state object - each subsequent state initialization depends on
@@ -1,6 +1,13 @@
import { ViewerEvent } from '@speckle/viewer'
import {
StateApplyMode,
useApplySerializedState,
useStateSerialization
} from '~/lib/viewer/composables/serialization'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useViewerEventListener } from '~~/lib/viewer/composables/viewer'
import type { SpeckleViewer } from '@speckle/shared'
import { get, isString } from 'lodash-es'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function useDebugViewerEvents() {
@@ -12,22 +19,67 @@ function useDebugViewerEvents() {
}
function useDebugViewer() {
const state = useInjectedViewerState()
const fullViewerState = useInjectedViewerState()
const apply = useApplySerializedState()
const { serialize } = useStateSerialization()
const {
viewer: { instance }
} = state
} = fullViewerState
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const ensureObj = <O>(obj: O | string): O => {
return isString(obj) ? JSON.parse(obj) : obj
}
const applyState = (
state: SpeckleViewer.ViewerState.SerializedViewerState | string
) => {
return apply(ensureObj(state), StateApplyMode.ThreadFullContextOpen)
}
// Get current viewer instance
window.VIEWER = instance
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.VIEWER_STATE = () => state
// Get current viewer state
window.VIEWER_STATE = () => fullViewerState
// Get serialized version of current state
window.VIEWER_SERIALIZED_STATE = (...args: Parameters<typeof serialize>) => {
const serialized = serialize(...args)
return JSON.stringify(serialized)
}
// Apply viewer state
window.APPLY_VIEWER_STATE = (
state: SpeckleViewer.ViewerState.SerializedViewerState
) => applyState(state)
// Apply DD user activity event
window.APPLY_VIEWER_DD_EVENT = (
event:
| {
content: {
attributes: {
context: {
message: { state: SpeckleViewer.ViewerState.SerializedViewerState }
}
}
}
}
| string
) => {
event = ensureObj(event)
const path = 'content.attributes.context.message.state'
const state = get(event, path)
if (!state) {
throw new Error('Cant find serialized state at path: ' + path)
}
return applyState(state)
}
}
export function setupDebugMode() {
if (import.meta.server) return
if (!import.meta.dev) return
// useDebugViewerEvents()
useDebugViewer()
@@ -19,6 +19,7 @@ import { mapMainRoleToGqlWorkspaceRole } from '~/lib/workspaces/helpers/roles'
import { mapServerRoleToGqlServerRole } from '~/lib/common/helpers/roles'
import { Roles } from '@speckle/shared'
import { useMixpanel } from '~/lib/core/composables/mp'
import { useNavigation } from '~/lib/navigation/composables/navigation'
const emptyState: WorkspaceWizardState = {
name: '',
@@ -62,6 +63,7 @@ export const useWorkspacesWizard = () => {
const { mutate: updateWorkspaceCreationState } = useMutation(
updateWorkspaceCreationStateMutation
)
const { mutateActiveWorkspaceSlug } = useNavigation()
const isLoading = computed({
get: () => wizardState.value.isLoading,
@@ -180,6 +182,7 @@ export const useWorkspacesWizard = () => {
} else {
// Keep loading state for a second
await new Promise((resolve) => setTimeout(resolve, 1000))
mutateActiveWorkspaceSlug(wizardState.value.state.slug)
await router.push(workspaceRoute(wizardState.value.state.slug))
await new Promise((resolve) => setTimeout(resolve, 1000))
isLoading.value = false
@@ -189,6 +192,7 @@ export const useWorkspacesWizard = () => {
const finalizeWizard = async (state: WorkspaceWizardState, workspaceId: string) => {
isLoading.value = true
mutateActiveWorkspaceSlug(workspaceId)
if (state.region?.key && state.plan === PaidWorkspacePlans.Business) {
await updateWorkspaceDefaultRegion({
@@ -38,7 +38,7 @@ export const createWorkspaceMutation = graphql(`
workspaceMutations {
create(input: $input) {
id
...SettingsDialog_Workspace
...SettingsSidebar_Workspace
}
}
}

Some files were not shown because too many files have changed in this diff Show More