Merge branch 'main' into iain/task-id-to-request-context
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" ]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import '../dist/src/bin.js'
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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()
|
||||
+127
-37
@@ -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()
|
||||
+124
-46
@@ -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()
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -4,9 +4,7 @@
|
||||
"tools": {
|
||||
"csharpier": {
|
||||
"version": "0.30.1",
|
||||
"commands": [
|
||||
"dotnet-csharpier"
|
||||
]
|
||||
"commands": ["dotnet-csharpier"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-11
@@ -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);
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+5
-6
@@ -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)
|
||||
+8
-10
@@ -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 }
|
||||
+7
-7
@@ -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)
|
||||
+7
-15
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -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']
|
||||
})
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user