Merge branch 'main' into iain/fix-service-account-secrets

This commit is contained in:
Iain Sproat 2024-11-15 17:07:24 +00:00
Родитель 45755e31da f961a6da81
Коммит eb124da8f8
Не найден ключ, соответствующий данной подписи
661 изменённых файлов: 34355 добавлений и 12090 удалений

Просмотреть файл

@ -16,16 +16,23 @@ workflows:
- main
- hotfix*
- test-server:
- test-server: &test-server-job-definition
context:
- speckle-server-licensing
- stripe-integration
filters: &filters-allow-all
tags:
# run tests for any commit on any branch, including any tags
only: /.*/
requires:
- docker-publish-postgres-container
- test-server-no-ff:
filters: *filters-allow-all
requires:
- docker-publish-postgres-container
- test-server-multiregion: *test-server-job-definition
- test-frontend-2:
filters: *filters-allow-all
@ -145,6 +152,12 @@ workflows:
requires:
- get-version
- docker-build-postgres-container:
context: *build-context
filters: *filters-build
requires:
- get-version
- docker-build-monitor-container:
context: *build-context
filters: *filters-build
@ -179,6 +192,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-frontend:
@ -194,6 +208,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-frontend-2:
@ -209,6 +224,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-webhooks:
@ -224,6 +240,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-file-imports:
@ -239,6 +256,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-previews:
@ -254,6 +272,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-test-container:
@ -269,8 +288,15 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-postgres-container:
context: *docker-hub-context
filters: *filters-publish
requires:
- docker-build-postgres-container
- docker-publish-monitor-container:
context: *docker-hub-context
filters: *filters-publish
@ -284,6 +310,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-docker-compose-ingress:
@ -299,6 +326,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- publish-helm-chart:
@ -339,6 +367,7 @@ workflows:
- get-version
- test-server
- test-server-no-ff
- test-server-multiregion
- test-ui-components
- test-frontend-2
- test-viewer
@ -438,11 +467,12 @@ jobs:
docker:
- image: cimg/node:18.19.0
- image: cimg/redis:7.2.4
- image: cimg/postgres:14.11
- image: 'speckle/speckle-postgres'
environment:
POSTGRES_DB: speckle2_test
POSTGRES_PASSWORD: speckle
POSTGRES_USER: speckle
command: -c 'max_connections=1000'
- image: 'minio/minio'
command: server /data --console-address ":9001"
# environment:
@ -452,6 +482,7 @@ jobs:
NODE_ENV: test
DATABASE_URL: 'postgres://speckle:speckle@127.0.0.1:5432/speckle2_test'
PGDATABASE: speckle2_test
POSTGRES_MAX_CONNECTIONS_SERVER: 20
PGUSER: speckle
SESSION_SECRET: 'keyboard cat'
STRATEGY_LOCAL: 'true'
@ -464,6 +495,7 @@ jobs:
REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1'
AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
FF_BILLING_INTEGRATION_ENABLED: 'true'
steps:
- checkout
- restore_cache:
@ -550,6 +582,52 @@ jobs:
FF_WORKSPACES_SSO_ENABLED: 'false'
FF_MULTIPLE_EMAILS_MODULE_ENABLED: 'false'
FF_GENDOAI_MODULE_ENABLED: 'false'
FF_GATEKEEPER_MODULE_ENABLED: 'false'
FF_BILLING_INTEGRATION_ENABLED: 'false'
test-server-multiregion:
<<: *test-server-job
docker:
- image: cimg/node:18.19.0
- image: cimg/redis:7.2.4
- image: 'speckle/speckle-postgres'
environment:
POSTGRES_DB: speckle2_test
POSTGRES_PASSWORD: speckle
POSTGRES_USER: speckle
command: -c 'max_connections=1000' -c 'wal_level=logical'
- image: 'speckle/speckle-postgres'
environment:
POSTGRES_DB: speckle2_test
POSTGRES_PASSWORD: speckle
POSTGRES_USER: speckle
command: -c 'max_connections=1000' -c 'port=5433' -c 'wal_level=logical'
- image: 'minio/minio'
command: server /data --console-address ":9001"
environment:
# Same as test-server:
NODE_ENV: test
DATABASE_URL: 'postgres://speckle:speckle@127.0.0.1:5432/speckle2_test'
PGDATABASE: speckle2_test
POSTGRES_MAX_CONNECTIONS_SERVER: 20
PGUSER: speckle
SESSION_SECRET: 'keyboard cat'
STRATEGY_LOCAL: 'true'
CANONICAL_URL: 'http://127.0.0.1:3000'
S3_ENDPOINT: 'http://127.0.0.1:9000'
S3_ACCESS_KEY: 'minioadmin'
S3_SECRET_KEY: 'minioadmin'
S3_BUCKET: 'speckle-server'
S3_CREATE_BUCKET: 'true'
REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1'
AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
FF_BILLING_INTEGRATION_ENABLED: 'true'
# These are the only different env keys:
MULTI_REGION_CONFIG_PATH: '../../.circleci/multiregion.test-ci.json'
FF_WORKSPACES_MODULE_ENABLED: 'true'
FF_WORKSPACES_MULTI_REGION_ENABLED: 'true'
RUN_TESTS_IN_MULTIREGION_MODE: true
test-frontend-2:
docker: &docker-node-browsers-image
@ -618,7 +696,7 @@ jobs:
test-preview-service:
docker:
- image: cimg/node:18.19.0
- image: cimg/postgres:14.11
- image: cimg/postgres:16.4@sha256:2e4f1a965bdd9ba77aa6a0a7b93968c07576ba2a8a7cf86d5eb7b31483db1378
environment:
POSTGRES_DB: preview_service_test
POSTGRES_PASSWORD: preview_service_test
@ -965,6 +1043,12 @@ jobs:
FOLDER: utils
SPECKLE_SERVER_PACKAGE: test-deployment
docker-build-postgres-container:
<<: *build-job
environment:
FOLDER: utils
SPECKLE_SERVER_PACKAGE: postgres
docker-build-monitor-container:
<<: *build-job
environment:
@ -1029,6 +1113,12 @@ jobs:
FOLDER: utils
SPECKLE_SERVER_PACKAGE: test-deployment
docker-publish-postgres-container:
<<: *publish-job
environment:
FOLDER: utils
SPECKLE_SERVER_PACKAGE: postgres
docker-publish-monitor-container:
<<: *publish-job
environment:
@ -1117,6 +1207,7 @@ jobs:
publish-viewer-sandbox-cloudflare-pages:
docker: *docker-node-image
working_directory: *work-dir
resource_class: large
steps:
- checkout
- restore_cache:

Просмотреть файл

@ -45,11 +45,12 @@ k8s_yaml('./manifests/priorityclass.yaml')
k8s_yaml('./manifests/speckle-server.secret.yaml')
# Install charts
# Postgres 16.4 is packaged in chart 15.5.38
helm_resource('postgresql',
release_name='postgresql',
namespace='postgres',
chart='oci://registry-1.docker.io/bitnamicharts/postgresql',
flags=['--version=^12.0.0',
flags=['--version=^15.5.38',
'--values=./values/postgres.values.yaml',
'--kube-context=kind-speckle-server'],
deps=['./values/postgres.values.yaml'],

Просмотреть файл

@ -0,0 +1,14 @@
{
"main": {
"postgres": {
"connectionUri": "postgresql://speckle:speckle@127.0.0.1:5432/speckle2_test"
}
},
"regions": {
"region1": {
"postgres": {
"connectionUri": "postgresql://speckle:speckle@127.0.0.1:5433/speckle2_test"
}
}
}
}

Просмотреть файл

@ -69,7 +69,7 @@ jobs:
services:
postgres:
# Docker Hub image
image: postgres:14
image: postgres:16.4-bookworm@sha256:91f464e7ba0ad91a106c94cff079fb4384139291b8c0502fd36989cf2c788bbb
env:
POSTGRES_DB: preview_service_test
POSTGRES_PASSWORD: preview_service_test

6
.gitignore поставляемый
Просмотреть файл

@ -71,4 +71,8 @@ minio-data/
postgres-data/
redis-data/
.tshy-build
.tshy-build
# Server
multiregion.json
multiregion.test.json

Просмотреть файл

@ -2,12 +2,14 @@
<img src="https://user-images.githubusercontent.com/2679513/131189167-18ea5fe1-c578-47f6-9785-3748178e4312.png" width="150px"/><br/>
Speckle | Server
</h1>
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&amp;style=flat-square&amp;logo=discourse&amp;logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://speckle.guide/dev/"><img src="https://img.shields.io/badge/docs-speckle.guide-orange?style=flat-square&amp;logo=read-the-docs&amp;logoColor=white" alt="docs"></a></p>
> Speckle is the first AEC data hub that connects with your favorite AEC tools. Speckle exists to overcome the challenges of working in a fragmented industry where communication, creative workflows, and the exchange of data are often hindered by siloed software and processes. It is here to make the industry better.
<h3 align="center">
Server and Web packages
</h3>
<p align="center"><b>Speckle</b> is data infrastructure for the AEC industry.</p><br/>
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&amp;style=flat-square&amp;logo=discourse&amp;logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://speckle.guide/dev/"><img src="https://img.shields.io/badge/docs-speckle.guide-orange?style=flat-square&amp;logo=read-the-docs&amp;logoColor=white" alt="docs"></a></p>
<p align="center">
<a href="https://codecov.io/gh/specklesystems/speckle-server">
@ -18,37 +20,6 @@
</a>
</p>
# About Speckle
What is Speckle? Check our [![YouTube Video Views](https://img.shields.io/youtube/views/B9humiSpHzM?label=Speckle%20in%201%20minute%20video&style=social)](https://www.youtube.com/watch?v=B9humiSpHzM)
## Features
- **Object-based:** say goodbye to files! Speckle is the first object based platform for the AEC industry
- **Version control:** Speckle is the Git & Hub for geometry and BIM data
- **Collaboration:** share your designs collaborate with others
- **3D Viewer:** see your CAD and BIM models online, share and embed them anywhere
- **Connectivity:** get your CAD and BIM models into other software without exporting or importing
- **Real time:** get real time updates and notifications and changes
- **GraphQL API:** get what you need anywhere you want it
- **Webhooks:** the base for a automation and next-gen pipelines
- **Built for developers:** we are building Speckle with developers in mind and have tools for every stack
- **Built for the AEC industry:** Speckle connectors are plugins for the most common software used in the industry such as Revit, Rhino, Grasshopper, AutoCAD, Civil 3D, Excel, Unreal Engine, Unity, QGIS, Blender, ArchiCAD and more!
## Try Speckle now!
Give Speckle a try in no time by:
- [![app.speckle.systems](https://img.shields.io/badge/https://-app.speckle.systems-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ Create an account at app.speckle.systems
- [![Deploy on your own infrastructure with docker compose](https://img.shields.io/badge/https://-speckle.guide-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](<[https://](https://speckle.guide/dev/server-manualsetup.html)>) ⇒ Deploy on your own infrastructure with Docker Compose
- [![Deploy on your own infrastructure with docker compose](https://img.shields.io/badge/https://-speckle.guide-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](<[https://](https://speckle.guide/dev/server-setup-k8s.html)>) ⇒ Deploy on your own infrastructure with Kubernetes
## Resources
- [![Community forum users](https://img.shields.io/badge/community-forum-green?style=for-the-badge&logo=discourse&logoColor=white)](https://speckle.community) for help, feature requests or just to hang with other speckle enthusiasts, check out our community forum!
- [![website](https://img.shields.io/badge/tutorials-speckle.systems-royalblue?style=for-the-badge&logo=youtube)](https://speckle.systems) our tutorials portal is full of resources to get you started using Speckle
- [![docs](https://img.shields.io/badge/docs-speckle.guide-orange?style=for-the-badge&logo=read-the-docs&logoColor=white)](https://speckle.guide/dev/) reference on almost any end-user and developer functionality
# Repo structure
This monorepo is the home of the Speckle v2 web packages:
@ -65,8 +36,10 @@ This monorepo is the home of the Speckle v2 web packages:
Make sure to also check and ⭐️ these other Speckle repositories:
- [`speckle-sharp`](https://github.com/specklesystems/speckle-sharp): .NET tooling, connectors and interoperability
- [`speckle-sharp-connectors`](https://github.com/specklesystems/speckle-sharp-connectors): .NET connectors and desktop UI
- [`speckle-sharp-sdk`](https://github.com/specklesystems/speckle-sharp-sdk): .NET SDK, tests, and Objects
- [`specklepy`](https://github.com/specklesystems/specklepy): Python SDK 🐍
- [`speckle-sketchup`](https://github.com/specklesystems/speckle-sketchup): Sketchup connector
- [`speckle-excel`](https://github.com/specklesystems/speckle-excel): Excel connector
- [`speckle-unity`](https://github.com/specklesystems/speckle-unity): Unity 3D connector
- [`speckle-blender`](https://github.com/specklesystems/speckle-blender): Blender connector

Просмотреть файл

@ -5,7 +5,7 @@ services:
postgres:
build:
context: .
dockerfile: docker/postgres/Dockerfile
dockerfile: utils/postgres/Dockerfile
restart: always
environment:
POSTGRES_DB: speckle
@ -18,6 +18,22 @@ services:
ports:
- '127.0.0.1:5432:5432'
postgres-region1:
build:
context: .
dockerfile: utils/postgres/Dockerfile
restart: always
environment:
POSTGRES_DB: speckle
POSTGRES_USER: speckle
POSTGRES_PASSWORD: speckle
volumes:
- postgres-region1-data:/var/lib/postgresql/data/
- ./setup/db/10-docker_postgres_init.sql:/docker-entrypoint-initdb.d/10-docker_postgres_init.sql
- ./setup/db/11-docker_postgres_keycloack_init.sql:/docker-entrypoint-initdb.d/11-docker_postgres_keycloack_init.sql
ports:
- '127.0.0.1:5401:5432'
redis:
image: 'redis:7-alpine'
restart: always
@ -106,6 +122,7 @@ services:
volumes:
postgres-data:
postgres-region1-data:
redis-data:
pgadmin-data:
redis_insight-data:

Просмотреть файл

@ -1,25 +0,0 @@
FROM postgres:14.5-alpine as builder
RUN apk add --no-cache 'git=~2.36' \
'build-base=~0.5' \
'clang=~13.0' \
'llvm13=~13.0'
WORKDIR /
RUN git clone --branch 1.1.9 https://github.com/aiven/aiven-extras.git aiven-extras
WORKDIR /aiven-extras
RUN git checkout 36598ab \
&& git clean -df \
&& make \
&& make install
FROM postgres:14.5-alpine
COPY --from=builder /aiven-extras/aiven_extras.control /usr/local/share/postgresql/extension/aiven_extras.control
COPY --from=builder /aiven-extras/sql/aiven_extras.sql /usr/local/share/postgresql/extension/aiven_extras--1.1.9.sql
COPY --from=builder /aiven-extras/aiven_extras.so /usr/local/lib/postgresql/aiven_extras.so
EXPOSE 5432
CMD ["postgres"]

Просмотреть файл

@ -21,6 +21,7 @@
"dev:docker": "docker compose -f ./docker-compose-deps.yml",
"dev:docker:up": "docker compose -f ./docker-compose-deps.yml up -d",
"dev:docker:down": "docker compose -f ./docker-compose-deps.yml down",
"dev:docker:restart": "yarn dev:docker:down && yarn dev:docker:up",
"dev:kind:up": "ctlptl apply --filename ./.circleci/deployment/cluster-config.yaml",
"dev:kind:down": "ctlptl delete -f ./.circleci/deployment/cluster-config.yaml",
"dev:kind:helm:up": "yarn dev:kind:up && tilt up --file ./.circleci/deployment/Tiltfile.helm --context kind-speckle-server",

Просмотреть файл

@ -52,7 +52,7 @@ export type Activity = {
export type ActivityCollection = {
__typename?: 'ActivityCollection';
cursor?: Maybe<Scalars['String']['output']>;
items?: Maybe<Array<Maybe<Activity>>>;
items: Array<Activity>;
totalCount: Scalars['Int']['output'];
};
@ -1766,6 +1766,7 @@ export type PendingWorkspaceCollaborator = {
user?: Maybe<LimitedUser>;
workspaceId: Scalars['String']['output'];
workspaceName: Scalars['String']['output'];
workspaceSlug: Scalars['String']['output'];
};
export type PendingWorkspaceCollaboratorsFilter = {
@ -2491,6 +2492,7 @@ export type Query = {
/** Validates the slug, to make sure it contains only valid characters and its not taken. */
validateWorkspaceSlug: Scalars['Boolean']['output'];
workspace: Workspace;
workspaceBySlug: Workspace;
/**
* Look for an invitation to a workspace, for the current user (authed or not).
*
@ -2635,7 +2637,13 @@ export type QueryWorkspaceArgs = {
};
export type QueryWorkspaceBySlugArgs = {
slug: Scalars['String']['input'];
};
export type QueryWorkspaceInviteArgs = {
options?: InputMaybe<WorkspaceInviteLookupOptions>;
token?: InputMaybe<Scalars['String']['input']>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
@ -4003,6 +4011,11 @@ export type WorkspaceInviteCreateInput = {
userId?: InputMaybe<Scalars['String']['input']>;
};
export type WorkspaceInviteLookupOptions = {
/** If true, the query will assume workspaceId is actually the workspace slug, and do the lookup by slug */
useSlug?: InputMaybe<Scalars['Boolean']['input']>;
};
export type WorkspaceInviteMutations = {
__typename?: 'WorkspaceInviteMutations';
batchCreate: Workspace;
@ -4163,8 +4176,8 @@ export type WorkspaceRoleUpdateInput = {
};
export type WorkspaceTeamFilter = {
/** Limit team members to provided role */
role?: InputMaybe<Scalars['String']['input']>;
/** Limit team members to provided role(s) */
roles?: InputMaybe<Array<Scalars['String']['input']>>;
/** Search for team members by name or email */
search?: InputMaybe<Scalars['String']['input']>;
};

Просмотреть файл

@ -5,11 +5,13 @@
"version": "0.2.0",
"configurations": [
{
"name": "Launch via NPM",
"name": "Launch via Yarn",
"request": "launch",
"runtimeArgs": ["run-script", "dev"],
"runtimeExecutable": "npm",
"console": "integratedTerminal",
"runtimeArgs": ["dev"],
"runtimeExecutable": "yarn",
"skipFiles": ["<node_internals>/**"],
"envFile": "${workspaceFolder}/.env",
"type": "node"
}
]

Просмотреть файл

@ -5,17 +5,21 @@ const bcrypt = require('bcrypt')
const { chunk } = require('lodash')
const { logger: parentLogger } = require('../observability/logging')
const knex = require('../knex')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const Streams = () => knex('streams')
const Branches = () => knex('branches')
const Objects = () => knex('objects')
const Closures = () => knex('object_children_closure')
const ApiTokens = () => knex('api_tokens')
const TokenScopes = () => knex('token_scopes')
const tables = (db) => ({
objects: db('objects'),
closures: db('object_children_closure'),
branches: db('branches'),
streams: db('streams'),
apiTokens: db('api_tokens'),
tokenScopes: db('token_scopes')
})
module.exports = class ServerAPI {
constructor({ streamId, logger }) {
constructor({ db, streamId, logger }) {
this.tables = tables(db)
this.db = db
this.streamId = streamId
this.isSending = false
this.buffer = []
@ -68,10 +72,10 @@ module.exports = class ServerAPI {
totalChildrenCountByDepth
)
await Objects().insert(insertionObject).onConflict().ignore()
await this.tables.objects.insert(insertionObject).onConflict().ignore()
if (closures.length > 0) {
await Closures().insert(closures).onConflict().ignore()
await this.tables.closures.insert(closures).onConflict().ignore()
}
return insertionObject.id
@ -123,7 +127,7 @@ module.exports = class ServerAPI {
const batches = chunk(objsToInsert, objectsBatchSize)
for (const [index, batch] of batches.entries()) {
this.prepInsertionObjectBatch(batch)
await Objects().insert(batch).onConflict().ignore()
await this.tables.objects.insert(batch).onConflict().ignore()
this.logger.info(
{
currentBatchCount: batch.length,
@ -141,7 +145,7 @@ module.exports = class ServerAPI {
for (const [index, batch] of batches.entries()) {
this.prepInsertionClosureBatch(batch)
await Closures().insert(batch).onConflict().ignore()
await this.tables.closures.insert(batch).onConflict().ignore()
this.logger.info(
{
currentBatchCount: batch.length,
@ -196,10 +200,10 @@ module.exports = class ServerAPI {
}
async getBranchByNameAndStreamId({ streamId, name }) {
const query = Branches()
const query = this.tables.branches
.select('*')
.where({ streamId })
.andWhere(knex.raw('LOWER(name) = ?', [name]))
.andWhere(this.db.raw('LOWER(name) = ?', [name]))
.first()
return await query
}
@ -212,10 +216,12 @@ module.exports = class ServerAPI {
branch.name = name.toLowerCase()
branch.description = description
await Branches().returning('id').insert(branch)
await this.tables.branches.returning('id').insert(branch)
// update stream updated at
await Streams().where({ id: streamId }).update({ updatedAt: knex.fn.now() })
await this.tables.streams
.where({ id: streamId })
.update({ updatedAt: this.db.fn.now() })
return branch.id
}
@ -244,14 +250,14 @@ module.exports = class ServerAPI {
}
const tokenScopes = scopes.map((scope) => ({ tokenId, scopeName: scope }))
await ApiTokens().insert(token)
await TokenScopes().insert(tokenScopes)
await this.tables.apiTokens.insert(token)
await this.tables.tokenScopes.insert(tokenScopes)
return { id: tokenId, token: tokenId + tokenString }
}
async revokeTokenById(tokenId) {
const delCount = await ApiTokens()
const delCount = await this.tables.apiTokens
.where({ id: tokenId.slice(0, 10) })
.del()

Просмотреть файл

@ -3,13 +3,15 @@ const { logger: parentLogger } = require('../observability/logging')
const TMP_RESULTS_PATH = '/tmp/import_result.json'
const { parseAndCreateCommit } = require('./index')
const { parseAndCreateCommitFactory } = require('./index')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const getDbClients = require('../knex')
async function main() {
const cmdArgs = process.argv.slice(2)
const [filePath, userId, streamId, branchName, commitMessage, fileId] = cmdArgs
const [filePath, userId, streamId, branchName, commitMessage, fileId, regionName] =
cmdArgs
const logger = Observability.extendLoggerComponent(
parentLogger.child({ streamId, branchName, userId, fileId, filePath }),
'ifc'
@ -34,8 +36,10 @@ async function main() {
error: 'Unknown error'
}
const dbClients = await getDbClients()
const knex = dbClients[regionName].public
try {
const commitId = await parseAndCreateCommit(ifcInput)
const commitId = await parseAndCreateCommitFactory({ db: knex })(ifcInput)
output = {
success: true,
commitId

Просмотреть файл

@ -1,86 +1,88 @@
const { performance } = require('perf_hooks')
const { fetch } = require('undici')
const Parser = require('./parser_v2')
const Parser = require('./parser')
const ServerAPI = require('./api.js')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const { logger: parentLogger } = require('../observability/logging')
async function parseAndCreateCommit({
data,
streamId,
branchName = 'uploads',
userId,
message = 'Manual IFC file upload',
fileId,
logger
}) {
if (!logger) {
logger = Observability.extendLoggerComponent(
parentLogger.child({ streamId, branchName, userId, fileId }),
'ifc'
const parseAndCreateCommitFactory =
({ db }) =>
async ({
data,
streamId,
branchName = 'uploads',
userId,
message = 'Manual IFC file upload',
fileId,
logger
}) => {
if (!logger) {
logger = Observability.extendLoggerComponent(
parentLogger.child({ streamId, branchName, userId, fileId }),
'ifc'
)
}
const serverApi = new ServerAPI({ db, streamId, logger })
const myParser = new Parser({ serverApi, fileId, logger })
const start = performance.now()
const { id, tCount } = await myParser.parse(data)
logger = logger.child({ objectId: id })
const end = performance.now()
logger.info(
{
fileProcessingDurationMs: (end - start).toFixed(2)
},
'Total processing time V2: {fileProcessingDurationMs}ms'
)
}
const serverApi = new ServerAPI({ streamId, logger })
const myParser = new Parser({ serverApi, fileId, logger })
const start = performance.now()
const { id, tCount } = await myParser.parse(data)
logger = logger.child({ objectId: id })
const end = performance.now()
logger.info(
{
fileProcessingDurationMs: (end - start).toFixed(2)
},
'Total processing time V2: {fileProcessingDurationMs}ms'
)
const commit = {
streamId,
branchName,
objectId: id,
message,
sourceApplication: 'IFC',
totalChildrenCount: tCount
}
const branch = await serverApi.getBranchByNameAndStreamId({
streamId,
name: branchName
})
if (!branch) {
logger.info("Branch '{branchName}' not found, creating it.")
await serverApi.createBranch({
name: branchName,
const commit = {
streamId,
description: branchName === 'uploads' ? 'File upload branch' : null,
authorId: userId
branchName,
objectId: id,
message,
sourceApplication: 'IFC',
totalChildrenCount: tCount
}
const branch = await serverApi.getBranchByNameAndStreamId({
streamId,
name: branchName
})
if (!branch) {
logger.info("Branch '{branchName}' not found, creating it.")
await serverApi.createBranch({
name: branchName,
streamId,
description: branchName === 'uploads' ? 'File upload branch' : null,
authorId: userId
})
}
const userToken = process.env.USER_TOKEN
const serverBaseUrl = process.env.SPECKLE_SERVER_URL || 'http://127.0.0.1:3000'
logger.info(`Creating commit for object ({objectId}), with message "${message}"`)
const response = await fetch(serverBaseUrl + '/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userToken}`
},
body: JSON.stringify({
query:
'mutation createCommit( $myCommitInput: CommitCreateInput!) { commitCreate( commit: $myCommitInput ) }',
variables: {
myCommitInput: commit
}
})
})
const json = await response.json()
logger.info(json, 'Commit created')
return json.data.commitCreate
}
const userToken = process.env.USER_TOKEN
const serverBaseUrl = process.env.SPECKLE_SERVER_URL || 'http://127.0.0.1:3000'
logger.info(`Creating commit for object ({objectId}), with message "${message}"`)
const response = await fetch(serverBaseUrl + '/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userToken}`
},
body: JSON.stringify({
query:
'mutation createCommit( $myCommitInput: CommitCreateInput!) { commitCreate( commit: $myCommitInput ) }',
variables: {
myCommitInput: commit
}
})
})
const json = await response.json()
logger.info(json, 'Commit created')
return json.data.commitCreate
}
module.exports = { parseAndCreateCommit }
module.exports = { parseAndCreateCommitFactory }

Просмотреть файл

@ -1,328 +1,403 @@
/* eslint-disable camelcase */
const { performance } = require('perf_hooks')
const WebIFC = require('web-ifc/web-ifc-api-node')
const { logger } = require('../observability/logging.js')
const ServerAPI = require('./api.js')
const {
getHash,
IfcElements,
PropNames,
GeometryTypes,
IfcTypesMap
} = require('./utils')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const { logger: parentLogger } = require('../observability/logging')
module.exports = class IFCParser {
constructor({ serverApi, logger }) {
this.api = new WebIFC.IfcAPI()
this.serverApi = serverApi || new ServerAPI({ logger })
constructor({ serverApi, fileId, logger }) {
this.ifcapi = new WebIFC.IfcAPI()
this.ifcapi.SetWasmPath('./', false)
this.serverApi = serverApi
this.fileId = fileId
this.logger =
logger ||
Observability.extendLoggerComponent(parentLogger.child({ fileId }), 'ifc')
}
async parse(data) {
if (this.api.wasmModule === undefined) await this.api.Init()
this.modelId = this.api.OpenModel(data, {
COORDINATE_TO_ORIGIN: true,
await this.ifcapi.Init()
this.modelId = this.ifcapi.OpenModel(new Uint8Array(data), {
USE_FAST_BOOLS: true
})
this.projectId = this.api.GetLineIDsWithType(this.modelId, WebIFC.IFCPROJECT).get(0)
this.startTime = performance.now()
this.project = this.api.GetLine(this.modelId, this.projectId, true)
this.project.__closure = {}
// prepoulate types
this.types = await this.getAllTypesOfModel()
this.cache = {}
this.closureCache = {}
// prime caches for property sets and their relating objects, as well as,
// most importantly, all the properties.
const { psetLines, psetRelations, properties } = await this.getAllProps()
this.psetLines = psetLines
this.psetRelations = psetRelations
this.properties = properties
// Steps: create and store in speckle all the geometries (meshes) from this project and store them
// as reference objects in this.productGeo
this.productGeo = {}
await this.createGeometries()
logger.info(`Geometries created: ${Object.keys(this.productGeo).length} meshes.`)
this.propCache = {}
// Lastly, traverse the ifc project object and parse it into something friendly; as well as
// replace all its geometries with actual references to speckle meshes from the productGeo map
// This is used to pre-batch ifc objects that need to be persisted.
this.objectBucket = []
await this.traverse(this.project, true, 0)
// create and save the geometries; we're storing only references locally.
this.geometryReferences = await this.createAndSaveMeshes()
const id = await this.serverApi.saveObject(this.project)
return { id, tCount: Object.keys(this.project.__closure).length }
// create and save the spatial tree, populating both properties and geometry references
// where appropriate
this.spatialNodeCount = 0
const structure = await this.createSpatialStructure()
return { id: structure.id, tCount: structure.closureLen }
}
async createGeometries() {
this.rawGeo = this.api.LoadAllGeometry(this.modelId)
async createSpatialStructure() {
const chunks = await this.getSpatialTreeChunks()
const allProjectLines = await this.ifcapi.GetLineIDsWithType(
this.modelId,
WebIFC.IFCPROJECT
)
const project = {
expressID: allProjectLines.get(0),
type: 'IFCPROJECT',
// eslint-disable-next-line camelcase
speckle_type: 'Base',
elements: []
}
for (let i = 0; i < this.rawGeo.size(); i++) {
const mesh = this.rawGeo.get(i)
const prodId = mesh.expressID
this.productGeo[prodId] = []
await this.populateSpatialNode(project, chunks, [], 0)
for (let j = 0; j < mesh.geometries.size(); j++) {
const placedGeom = mesh.geometries.get(j)
const geom = this.api.GetGeometry(this.modelId, placedGeom.geometryExpressID)
this.endTime = performance.now()
project.parseTime = (this.endTime - this.startTime).toFixed(2) + 'ms'
project.fileId = this.fileId
const matrix = placedGeom.flatTransformation
const raw = {
color: geom.color, // NOTE: material: x, y, z = rgb, w = opacity
vertices: this.api.GetVertexArray(
geom.GetVertexData(),
geom.GetVertexDataSize()
),
indices: this.api.GetIndexArray(geom.GetIndexData(), geom.GetIndexDataSize())
// Last save to db call, empty the last bucket
if (this.objectBucket.length !== 0) {
await this.flushObjectBucket()
}
return project
}
async populateSpatialNode(node, chunks, closures, depth) {
depth++
this.logger.debug(`${this.spatialNodeCount++} nodes generated.`)
closures.push([])
await this.getChildren(node, chunks, PropNames.aggregates, closures, depth)
await this.getChildren(node, chunks, PropNames.spatial, closures, depth)
node.closure = [...new Set(closures.pop())]
// get geometry, set displayValue
// add geometry ids to closure
if (
this.geometryReferences[node.expressID] &&
this.geometryReferences[node.expressID].length !== 0
) {
node['@displayValue'] = this.geometryReferences[node.expressID]
node.closure.push(
...this.geometryReferences[node.expressID].map((ref) => ref.referencedId)
)
}
// node.closureLen = node.closure.length
node.__closure = this.formatClosure(node.closure)
node.id = getHash(node)
// Save to db
this.objectBucket.push(node)
if (this.objectBucket.length > 3000) {
await this.flushObjectBucket()
}
// remove project level node closure
if (depth === 1) {
delete node.closure
}
return node.id
}
async flushObjectBucket() {
if (this.objectBucket.length === 0) return
await this.serverApi.saveObjectBatch(this.objectBucket)
this.objectBucket = []
}
formatClosure(idsArray) {
const cl = {}
for (const id of idsArray) cl[id] = 1
return cl
}
async getChildren(node, chunks, propName, closures) {
const children = chunks[node.expressID]
if (!children) return
const prop = propName.key
const nodes = []
for (let i = 0; i < children.length; i++) {
const child = children[i]
let cnode = this.createNode(child)
cnode = { ...cnode, ...(await this.getItemProperties(cnode.expressID)) }
cnode.id = await this.populateSpatialNode(cnode, chunks, closures)
for (const closure of closures) {
closure.push(cnode.id)
if (cnode['closure'].length > 30_000)
for (const id of cnode['closure']) closure.push(id)
else closure.push(...cnode['closure']) // can stack overflow for large arguments
}
delete cnode.closure
nodes.push(cnode)
}
node[prop] = nodes.map((node) => ({
// eslint-disable-next-line camelcase
speckle_type: 'reference',
referencedId: node.id
}))
}
async getItemProperties(id) {
if (this.propCache[id]) return this.propCache[id]
let props = {}
const directProps = this.properties[id.toString()]
props = { ...directProps }
const psetIds = []
for (let i = 0; i < this.psetRelations.length; i++) {
if (this.psetRelations[i].includes(id))
psetIds.push(this.psetLines.get(i).toString())
}
const rawPsetIds = psetIds.map((id) =>
this.properties[id].RelatingPropertyDefinition.toString()
)
const rawPsets = rawPsetIds.map((id) => this.properties[id])
for (const pset of rawPsets) {
props[pset.Name] = this.unpackPsetOrComplexProp(pset)
}
this.propCache[id] = props
return props
}
unpackPsetOrComplexProp(pset) {
const parsed = {}
if (!pset.HasProperties || !Array.isArray(pset.HasProperties)) return parsed
for (const id of pset.HasProperties) {
const value = this.properties[id.toString()]
if (value?.type === 'IFCCOMPLEXPROPERTY') {
parsed[value.Name] = this.unpackPsetOrComplexProp(value)
} else if (value?.type === 'IFCPROPERTYSINGLEVALUE') {
parsed[value.Name] = value.NominalValue
}
}
return parsed
}
async getSpatialTreeChunks() {
const treeChunks = {}
await this.getChunks(treeChunks, PropNames.aggregates)
await this.getChunks(treeChunks, PropNames.spatial)
return treeChunks
}
async getChunks(chunks, propName) {
const relation = await this.ifcapi.GetLineIDsWithType(this.modelId, propName.name)
for (let i = 0; i < relation.size(); i++) {
const rel = await this.ifcapi.GetLine(this.modelId, relation.get(i), false)
this.saveChunk(chunks, propName, rel)
}
}
saveChunk(chunks, propName, rel) {
const relating = rel[propName.relating].value
const related = rel[propName.related].map((r) => r.value)
if (chunks[relating] === undefined) {
chunks[relating] = related
} else {
chunks[relating] = chunks[relating].concat(related)
}
}
async getAllTypesOfModel() {
const result = {}
const elements = Object.keys(IfcElements).map((e) => parseInt(e))
for (let i = 0; i < elements.length; i++) {
const element = elements[i]
const lines = await this.ifcapi.GetLineIDsWithType(this.modelId, element)
const size = lines.size()
for (let i = 0; i < size; i++) result[lines.get(i)] = element
}
return result
}
async getAllProps() {
const psetLines = this.ifcapi.GetLineIDsWithType(
this.modelId,
WebIFC.IFCRELDEFINESBYPROPERTIES
)
const psetRelations = []
const properties = {}
const geometryIds = await this.getAllGeometriesIds()
const allLinesIDs = await this.ifcapi.GetAllLines(this.modelId)
const allLinesCount = allLinesIDs.size()
for (let i = 0; i < allLinesCount; i++) {
this.logger.debug(`${((i / allLinesCount) * 100).toFixed(3)}% props.`)
const id = allLinesIDs.get(i)
if (!geometryIds.has(id)) {
const props = await this.getItemProperty(id)
if (props) {
if (props.type === 'IFCRELDEFINESBYPROPERTIES' && props.RelatedObjects) {
psetRelations.push(props.RelatedObjects)
}
properties[id] = props
}
}
}
const { vertices } = this.extractVertexData(raw.vertices)
return { psetLines, psetRelations, properties }
}
for (let k = 0; k < vertices.length; k += 3) {
const x = vertices[k],
y = vertices[k + 1],
z = vertices[k + 2]
vertices[k] = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]
vertices[k + 1] =
(matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]) * -1
vertices[k + 2] = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]
}
async getItemProperty(id) {
try {
const props = await this.ifcapi.GetLine(this.modelId, id)
if (props.type) {
props.type = IfcTypesMap[props.type]
}
this.inPlaceFormatItemProperties(props)
return props
} catch (e) {
this.logger.error(e, `There was an issue getting props of id ${id}`)
}
}
// Since all faces are triangles, we must add a `0` before each group of 3.
const spcklFaces = []
for (let i = 0; i < raw.indices.length; i++) {
if (i % 3 === 0) spcklFaces.push(0)
spcklFaces.push(raw.indices[i])
}
inPlaceFormatItemProperties(props) {
Object.keys(props).forEach((key) => {
const value = props[key]
if (value && value.value !== undefined) props[key] = value.value
else if (Array.isArray(value))
props[key] = value.map((item) => {
if (item && item.value) return item.value
return item
})
})
}
// Create a proper Speckle Mesh
const spcklMesh = {
createNode(id) {
const typeName = this.getNodeType(id)
return {
// eslint-disable-next-line camelcase
speckle_type: typeName,
expressID: id,
type: typeName,
elements: [],
properties: null
}
}
getNodeType(id) {
const typeID = this.types[id]
return IfcElements[typeID]
}
async getAllGeometriesIds() {
const geometriesIds = new Set()
const geomTypesArray = Array.from(GeometryTypes)
for (let i = 0; i < geomTypesArray.length; i++) {
const category = geomTypesArray[i]
const ids = await this.ifcapi.GetLineIDsWithType(this.modelId, category)
const idsSize = ids.size()
for (let j = 0; j < idsSize; j++) {
geometriesIds.add(ids.get(j))
}
}
this.geometryIdsCount = geometriesIds.size
return geometriesIds
}
async createAndSaveMeshes() {
const geometryReferences = {}
let count = 0
const speckleMeshes = []
this.ifcapi.StreamAllMeshes(this.modelId, async (mesh) => {
const placedGeometries = mesh.geometries
geometryReferences[mesh.expressID] = []
for (let i = 0; i < placedGeometries.size(); i++) {
const placedGeometry = placedGeometries.get(i)
const geometry = this.ifcapi.GetGeometry(
this.modelId,
placedGeometry.geometryExpressID
)
const verts = [
...this.ifcapi.GetVertexArray(
geometry.GetVertexData(),
geometry.GetVertexDataSize()
)
]
const indices = [
...this.ifcapi.GetIndexArray(
geometry.GetIndexData(),
geometry.GetIndexDataSize()
)
]
const { vertices } = this.extractVertexData(
verts,
placedGeometry.flatTransformation
)
const faces = this.extractFaces(indices)
const speckleMesh = {
// eslint-disable-next-line camelcase
speckle_type: 'Objects.Geometry.Mesh',
units: 'm',
volume: 0,
area: 0,
faces: spcklFaces,
vertices: Array.from(vertices),
renderMaterial: placedGeom.color
? this.colorToMaterial(placedGeom.color)
// random: Math.random(), // TODO: remove, this is here just for performance benchmarking/explicit cache poisoning
vertices,
faces,
renderMaterial: placedGeometry.color
? this.colorToMaterial(placedGeometry.color)
: null
}
const id = await this.serverApi.saveObject(spcklMesh)
const ref = { speckle_type: 'reference', referencedId: id }
this.productGeo[prodId].push(ref)
speckleMesh.id = getHash(speckleMesh)
// Note: the web-ifc api disposes of the data post callback, and doesn't know that it's async;
// we cannot and should not await things in here. I'm not entirely sure what's going on :)
// await this.serverApi.saveObject(speckleMesh)
speckleMeshes.push(speckleMesh)
geometryReferences[mesh.expressID].push({
// eslint-disable-next-line camelcase
speckle_type: 'reference',
referencedId: speckleMesh.id
})
this.logger.debug(`${(count++).toFixed(3)} geoms generated.`)
}
}
})
await this.serverApi.saveObjectBatch(speckleMeshes)
return geometryReferences
}
async traverse(
element,
recursive = true,
depth = 0,
specialTypes = [
{ type: 'IfcProject', key: 'Name' },
{ type: 'IfcBuilding', key: 'Name' },
{ type: 'IfcSite', key: 'Name' }
]
) {
// Fast exit if null/undefined
if (!element) return
// If array, traverse all items in it.
if (Array.isArray(element)) {
return await Promise.all(
element.map(
async (el) => await this.traverse(el, recursive, depth + 1, specialTypes)
)
)
}
// If it has no expressID, its either a simple type or a { type, value } object.
if (!element.expressID) {
return await Promise.resolve(
element.value !== null && element.value !== undefined ? element.value : element
)
}
if (this.cache[element.expressID.toString()])
return this.cache[element.expressID.toString()]
// If you got here -> It's an IFC Element: create base object, upload and return ref.
// logger.debug( `Traversing element ${element.expressID}; Recurse: ${recursive}; Stack ${depth}` )
// Traverse all key/value pairs first.
for (const key of Object.keys(element)) {
element[key] = await this.traverse(
element[key],
recursive,
depth + 1,
specialTypes
)
}
// Assign speckle_type and empty closure table.
element.speckle_type = element.constructor.name
element.__closure = {}
// Find spatial children and assign to element
const spatialChildrenIds = this.getAllRelatedItemsOfType(
element.expressID,
WebIFC.IFCRELAGGREGATES,
'RelatingObject',
'RelatedObjects'
)
if (spatialChildrenIds.length > 0)
element.rawSpatialChildren = spatialChildrenIds.map((childId) =>
this.api.GetLine(this.modelId, childId, true)
)
// Find children and populate element
const childrenIds = this.getAllRelatedItemsOfType(
element.expressID,
WebIFC.IFCRELCONTAINEDINSPATIALSTRUCTURE,
'RelatingStructure',
'RelatedElements'
)
if (childrenIds.length > 0)
element.rawChildren = childrenIds.map((childId) =>
this.api.GetLine(this.modelId, childId, true)
)
// Find related property sets
const psetsIds = this.getAllRelatedItemsOfType(
element.expressID,
WebIFC.IFCRELDEFINESBYPROPERTIES,
'RelatingPropertyDefinition',
'RelatedObjects'
)
if (psetsIds.length > 0)
element.rawPsets = psetsIds.map((childId) =>
this.api.GetLine(this.modelId, childId, true)
)
// Find related type properties
const typePropsId = this.getAllRelatedItemsOfType(
element.expressID,
WebIFC.IFCRELDEFINESBYTYPE,
'RelatingType',
'RelatedObjects'
)
if (typePropsId.length > 0)
element.rawTypeProps = typePropsId.map((childId) =>
this.api.GetLine(this.modelId, childId, true)
)
// Lookup geometry in generated geometries object
if (this.productGeo[element.expressID]) {
element['@displayValue'] = this.productGeo[element.expressID]
this.productGeo[element.expressID].forEach((ref) => {
this.project.__closure[ref.referencedId.toString()] = depth
element.__closure[ref.referencedId.toString()] = 1
})
}
const isSpecial = specialTypes.find((t) => t.type === element.speckle_type)
// Recurse all children
if (recursive) {
await this.processSubElements(
element,
'rawSpatialChildren',
'spatialChildren',
isSpecial,
recursive,
depth,
specialTypes
)
await this.processSubElements(
element,
'rawChildren',
'children',
isSpecial,
recursive,
depth,
specialTypes
)
await this.processSubElements(
element,
'rawPsets',
'propertySets',
false,
recursive,
depth,
specialTypes
)
await this.processSubElements(
element,
'rawTypeProps',
'typeProps',
false,
recursive,
depth,
specialTypes
)
if (
element.children ||
element.spatialChildren ||
element.propertySets ||
element.typeProps
) {
logger.info(
`${element.constructor.name} ${element.GlobalId}:\n\tchildren count: ${
element.children ? element.children.length : '0'
};\n\tspatial children count: ${
element.spatialChildren ? element.spatialChildren.length : '0'
};\n\tproperty sets count: ${
element.propertySets ? element.propertySets.length : 0
};\n\ttype properties: ${element.typeProps ? element.typeProps.length : 0}`
)
}
}
if (
this.productGeo[element.expressID] ||
element.spatialChildren ||
element.children
) {
const id = await this.serverApi.saveObject(element)
const ref = { speckle_type: 'reference', referencedId: id }
this.cache[element.expressID.toString()] = ref
this.closureCache[element.expressID.toString()] = element.__closure
return ref
} else {
this.cache[element.expressID.toString()] = element
this.closureCache[element.expressID.toString()] = element.__closure
return element
extractFaces(indices) {
const faces = []
for (let i = 0; i < indices.length; i++) {
if (i % 3 === 0) faces.push(0)
faces.push(indices[i])
}
return faces
}
async processSubElements(
element,
key,
newKey,
isSpecial,
recursive,
depth,
specialTypes
) {
if (element[key]) {
if (!isSpecial) element[newKey] = []
const childCount = {}
for (const child of element[key]) {
const res = await this.traverse(child, recursive, depth + 1, specialTypes)
if (res.referencedId) {
if (isSpecial) {
let name = child[isSpecial.key]
if (!name || name.length === 0) name = 'Undefined'
if (!childCount[name]) childCount[name] = 0
if (childCount[name] > 0) name += '-' + childCount[name]++
element[name] = res
} else element[newKey].push(res)
this.project.__closure[res.referencedId.toString()] = depth
element.__closure[res.referencedId.toString()] = 1
// adds to parent (this element) the child's closure tree.
if (this.closureCache[child.expressID.toString()]) {
for (const key of Object.keys(
this.closureCache[child.expressID.toString()]
)) {
element.__closure[key] =
this.closureCache[child.expressID.toString()][key] + 1
}
}
}
}
delete element[key]
}
}
// (c) https://github.com/agviegas/web-ifc-three
extractVertexData(vertexData) {
extractVertexData(vertexData, matrix) {
const vertices = []
const normals = []
let isNormalData = false
@ -330,45 +405,37 @@ module.exports = class IFCParser {
isNormalData ? normals.push(vertexData[i]) : vertices.push(vertexData[i])
if ((i + 1) % 3 === 0) isNormalData = !isNormalData
}
// apply the transform
for (let k = 0; k < vertices.length; k += 3) {
const x = vertices[k],
y = vertices[k + 1],
z = vertices[k + 2]
vertices[k] = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]
vertices[k + 1] =
(matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]) * -1
vertices[k + 2] = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]
}
return { vertices, normals }
}
// (c) https://github.com/agviegas/web-ifc-three/blob/907e08b5673d5e1c18261a4fceade7189d6b2db7/src/IFC/PropertyManager.ts#L110
getAllRelatedItemsOfType(elementID, type, relation, relatedProperty) {
const lines = this.api.GetLineIDsWithType(this.modelId, type)
const IDs = []
for (let i = 0; i < lines.size(); i++) {
const relID = lines.get(i)
const rel = this.api.GetLine(this.modelId, relID)
const relatedItems = rel[relation]
let foundElement = false
if (Array.isArray(relatedItems)) {
const values = relatedItems.map((item) => item.value)
foundElement = values.includes(elementID)
} else foundElement = relatedItems.value === elementID
if (foundElement) {
const element = rel[relatedProperty]
if (!Array.isArray(element)) IDs.push(element.value)
else element.forEach((ele) => IDs.push(ele.value))
}
}
return IDs
}
colorToMaterial(color) {
const intColor =
(color.w << 24) + ((color.x * 255) << 16) + ((color.y * 255) << 8) + color.z * 255
return {
const intColor = Math.floor(
((color.w * 255) << 24) +
((color.x * 255) << 16) +
((color.y * 255) << 8) +
color.z * 255
)
const material = {
diffuse: intColor,
opacity: color.w,
metalness: 0,
roughness: 1,
// eslint-disable-next-line camelcase
speckle_type: 'Objects.Other.RenderMaterial'
}
material.id = getHash(material)
return material
}
}

Просмотреть файл

@ -1,441 +0,0 @@
const { performance } = require('perf_hooks')
const WebIFC = require('web-ifc/web-ifc-api-node')
const {
getHash,
IfcElements,
PropNames,
GeometryTypes,
IfcTypesMap
} = require('./utils')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const { logger: parentLogger } = require('../observability/logging')
module.exports = class IFCParser {
constructor({ serverApi, fileId, logger }) {
this.ifcapi = new WebIFC.IfcAPI()
this.ifcapi.SetWasmPath('./', false)
this.serverApi = serverApi
this.fileId = fileId
this.logger =
logger ||
Observability.extendLoggerComponent(parentLogger.child({ fileId }), 'ifc')
}
async parse(data) {
await this.ifcapi.Init()
this.modelId = this.ifcapi.OpenModel(new Uint8Array(data), {
USE_FAST_BOOLS: true
})
this.startTime = performance.now()
// prepoulate types
this.types = await this.getAllTypesOfModel()
// prime caches for property sets and their relating objects, as well as,
// most importantly, all the properties.
const { psetLines, psetRelations, properties } = await this.getAllProps()
this.psetLines = psetLines
this.psetRelations = psetRelations
this.properties = properties
this.propCache = {}
// This is used to pre-batch ifc objects that need to be persisted.
this.objectBucket = []
// create and save the geometries; we're storing only references locally.
this.geometryReferences = await this.createAndSaveMeshes()
// create and save the spatial tree, populating both properties and geometry references
// where appropriate
this.spatialNodeCount = 0
const structure = await this.createSpatialStructure()
return { id: structure.id, tCount: structure.closureLen }
}
async createSpatialStructure() {
const chunks = await this.getSpatialTreeChunks()
const allProjectLines = await this.ifcapi.GetLineIDsWithType(
this.modelId,
WebIFC.IFCPROJECT
)
const project = {
expressID: allProjectLines.get(0),
type: 'IFCPROJECT',
// eslint-disable-next-line camelcase
speckle_type: 'Base',
elements: []
}
await this.populateSpatialNode(project, chunks, [], 0)
this.endTime = performance.now()
project.parseTime = (this.endTime - this.startTime).toFixed(2) + 'ms'
project.fileId = this.fileId
// Last save to db call, empty the last bucket
if (this.objectBucket.length !== 0) {
await this.flushObjectBucket()
}
return project
}
async populateSpatialNode(node, chunks, closures, depth) {
depth++
this.logger.debug(`${this.spatialNodeCount++} nodes generated.`)
closures.push([])
await this.getChildren(node, chunks, PropNames.aggregates, closures, depth)
await this.getChildren(node, chunks, PropNames.spatial, closures, depth)
node.closure = [...new Set(closures.pop())]
// get geometry, set displayValue
// add geometry ids to closure
if (
this.geometryReferences[node.expressID] &&
this.geometryReferences[node.expressID].length !== 0
) {
node['@displayValue'] = this.geometryReferences[node.expressID]
node.closure.push(
...this.geometryReferences[node.expressID].map((ref) => ref.referencedId)
)
}
// node.closureLen = node.closure.length
node.__closure = this.formatClosure(node.closure)
node.id = getHash(node)
// Save to db
this.objectBucket.push(node)
if (this.objectBucket.length > 3000) {
await this.flushObjectBucket()
}
// remove project level node closure
if (depth === 1) {
delete node.closure
}
return node.id
}
async flushObjectBucket() {
if (this.objectBucket.length === 0) return
await this.serverApi.saveObjectBatch(this.objectBucket)
this.objectBucket = []
}
formatClosure(idsArray) {
const cl = {}
for (const id of idsArray) cl[id] = 1
return cl
}
async getChildren(node, chunks, propName, closures) {
const children = chunks[node.expressID]
if (!children) return
const prop = propName.key
const nodes = []
for (let i = 0; i < children.length; i++) {
const child = children[i]
let cnode = this.createNode(child)
cnode = { ...cnode, ...(await this.getItemProperties(cnode.expressID)) }
cnode.id = await this.populateSpatialNode(cnode, chunks, closures)
for (const closure of closures) {
closure.push(cnode.id)
if (cnode['closure'].length > 30_000)
for (const id of cnode['closure']) closure.push(id)
else closure.push(...cnode['closure']) // can stack overflow for large arguments
}
delete cnode.closure
nodes.push(cnode)
}
node[prop] = nodes.map((node) => ({
// eslint-disable-next-line camelcase
speckle_type: 'reference',
referencedId: node.id
}))
}
async getItemProperties(id) {
if (this.propCache[id]) return this.propCache[id]
let props = {}
const directProps = this.properties[id.toString()]
props = { ...directProps }
const psetIds = []
for (let i = 0; i < this.psetRelations.length; i++) {
if (this.psetRelations[i].includes(id))
psetIds.push(this.psetLines.get(i).toString())
}
const rawPsetIds = psetIds.map((id) =>
this.properties[id].RelatingPropertyDefinition.toString()
)
const rawPsets = rawPsetIds.map((id) => this.properties[id])
for (const pset of rawPsets) {
props[pset.Name] = this.unpackPsetOrComplexProp(pset)
}
this.propCache[id] = props
return props
}
unpackPsetOrComplexProp(pset) {
const parsed = {}
if (!pset.HasProperties || !Array.isArray(pset.HasProperties)) return parsed
for (const id of pset.HasProperties) {
const value = this.properties[id.toString()]
if (value?.type === 'IFCCOMPLEXPROPERTY') {
parsed[value.Name] = this.unpackPsetOrComplexProp(value)
} else if (value?.type === 'IFCPROPERTYSINGLEVALUE') {
parsed[value.Name] = value.NominalValue
}
}
return parsed
}
async getSpatialTreeChunks() {
const treeChunks = {}
await this.getChunks(treeChunks, PropNames.aggregates)
await this.getChunks(treeChunks, PropNames.spatial)
return treeChunks
}
async getChunks(chunks, propName) {
const relation = await this.ifcapi.GetLineIDsWithType(this.modelId, propName.name)
for (let i = 0; i < relation.size(); i++) {
const rel = await this.ifcapi.GetLine(this.modelId, relation.get(i), false)
this.saveChunk(chunks, propName, rel)
}
}
saveChunk(chunks, propName, rel) {
const relating = rel[propName.relating].value
const related = rel[propName.related].map((r) => r.value)
if (chunks[relating] === undefined) {
chunks[relating] = related
} else {
chunks[relating] = chunks[relating].concat(related)
}
}
async getAllTypesOfModel() {
const result = {}
const elements = Object.keys(IfcElements).map((e) => parseInt(e))
for (let i = 0; i < elements.length; i++) {
const element = elements[i]
const lines = await this.ifcapi.GetLineIDsWithType(this.modelId, element)
const size = lines.size()
for (let i = 0; i < size; i++) result[lines.get(i)] = element
}
return result
}
async getAllProps() {
const psetLines = this.ifcapi.GetLineIDsWithType(
this.modelId,
WebIFC.IFCRELDEFINESBYPROPERTIES
)
const psetRelations = []
const properties = {}
const geometryIds = await this.getAllGeometriesIds()
const allLinesIDs = await this.ifcapi.GetAllLines(this.modelId)
const allLinesCount = allLinesIDs.size()
for (let i = 0; i < allLinesCount; i++) {
this.logger.debug(`${((i / allLinesCount) * 100).toFixed(3)}% props.`)
const id = allLinesIDs.get(i)
if (!geometryIds.has(id)) {
const props = await this.getItemProperty(id)
if (props) {
if (props.type === 'IFCRELDEFINESBYPROPERTIES' && props.RelatedObjects) {
psetRelations.push(props.RelatedObjects)
}
properties[id] = props
}
}
}
return { psetLines, psetRelations, properties }
}
async getItemProperty(id) {
try {
const props = await this.ifcapi.GetLine(this.modelId, id)
if (props.type) {
props.type = IfcTypesMap[props.type]
}
this.inPlaceFormatItemProperties(props)
return props
} catch (e) {
this.logger.error(e, `There was an issue getting props of id ${id}`)
}
}
inPlaceFormatItemProperties(props) {
Object.keys(props).forEach((key) => {
const value = props[key]
if (value && value.value !== undefined) props[key] = value.value
else if (Array.isArray(value))
props[key] = value.map((item) => {
if (item && item.value) return item.value
return item
})
})
}
createNode(id) {
const typeName = this.getNodeType(id)
return {
// eslint-disable-next-line camelcase
speckle_type: typeName,
expressID: id,
type: typeName,
elements: [],
properties: null
}
}
getNodeType(id) {
const typeID = this.types[id]
return IfcElements[typeID]
}
async getAllGeometriesIds() {
const geometriesIds = new Set()
const geomTypesArray = Array.from(GeometryTypes)
for (let i = 0; i < geomTypesArray.length; i++) {
const category = geomTypesArray[i]
const ids = await this.ifcapi.GetLineIDsWithType(this.modelId, category)
const idsSize = ids.size()
for (let j = 0; j < idsSize; j++) {
geometriesIds.add(ids.get(j))
}
}
this.geometryIdsCount = geometriesIds.size
return geometriesIds
}
async createAndSaveMeshes() {
const geometryReferences = {}
let count = 0
const speckleMeshes = []
this.ifcapi.StreamAllMeshes(this.modelId, async (mesh) => {
const placedGeometries = mesh.geometries
geometryReferences[mesh.expressID] = []
for (let i = 0; i < placedGeometries.size(); i++) {
const placedGeometry = placedGeometries.get(i)
const geometry = this.ifcapi.GetGeometry(
this.modelId,
placedGeometry.geometryExpressID
)
const verts = [
...this.ifcapi.GetVertexArray(
geometry.GetVertexData(),
geometry.GetVertexDataSize()
)
]
const indices = [
...this.ifcapi.GetIndexArray(
geometry.GetIndexData(),
geometry.GetIndexDataSize()
)
]
const { vertices } = this.extractVertexData(
verts,
placedGeometry.flatTransformation
)
const faces = this.extractFaces(indices)
const speckleMesh = {
// eslint-disable-next-line camelcase
speckle_type: 'Objects.Geometry.Mesh',
units: 'm',
volume: 0,
area: 0,
// random: Math.random(), // TODO: remove, this is here just for performance benchmarking/explicit cache poisoning
vertices,
faces,
renderMaterial: placedGeometry.color
? this.colorToMaterial(placedGeometry.color)
: null
}
speckleMesh.id = getHash(speckleMesh)
// Note: the web-ifc api disposes of the data post callback, and doesn't know that it's async;
// we cannot and should not await things in here. I'm not entirely sure what's going on :)
// await this.serverApi.saveObject(speckleMesh)
speckleMeshes.push(speckleMesh)
geometryReferences[mesh.expressID].push({
// eslint-disable-next-line camelcase
speckle_type: 'reference',
referencedId: speckleMesh.id
})
this.logger.debug(`${(count++).toFixed(3)} geoms generated.`)
}
})
await this.serverApi.saveObjectBatch(speckleMeshes)
return geometryReferences
}
extractFaces(indices) {
const faces = []
for (let i = 0; i < indices.length; i++) {
if (i % 3 === 0) faces.push(0)
faces.push(indices[i])
}
return faces
}
extractVertexData(vertexData, matrix) {
const vertices = []
const normals = []
let isNormalData = false
for (let i = 0; i < vertexData.length; i++) {
isNormalData ? normals.push(vertexData[i]) : vertices.push(vertexData[i])
if ((i + 1) % 3 === 0) isNormalData = !isNormalData
}
// apply the transform
for (let k = 0; k < vertices.length; k += 3) {
const x = vertices[k],
y = vertices[k + 1],
z = vertices[k + 2]
vertices[k] = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]
vertices[k + 1] =
(matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]) * -1
vertices[k + 2] = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]
}
return { vertices, normals }
}
colorToMaterial(color) {
const intColor = Math.floor(
((color.w * 255) << 24) +
((color.x * 255) << 16) +
((color.y * 255) << 8) +
color.z * 255
)
const material = {
diffuse: intColor,
opacity: color.w,
metalness: 0,
roughness: 1,
// eslint-disable-next-line camelcase
speckle_type: 'Objects.Other.RenderMaterial'
}
material.id = getHash(material)
return material
}
}

Просмотреть файл

@ -1,18 +1,52 @@
/* eslint-disable camelcase */
'use strict'
module.exports = require('knex')({
client: 'pg',
connection: {
application_name: 'speckle_fileimport_service',
connectionString:
process.env.PG_CONNECTION_STRING || 'postgres://speckle:speckle@127.0.0.1/speckle'
},
pool: {
min: 0,
max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE) || 1,
acquireTimeoutMillis: 16000, //allows for 3x creation attempts plus idle time between attempts
createTimeoutMillis: 5000
const Environment = require('@speckle/shared/dist/commonjs/environment/index.js')
const {
loadMultiRegionsConfig,
configureKnexClient
} = require('@speckle/shared/dist/commonjs/environment/multiRegionConfig.js')
const { logger } = require('./observability/logging')
const { FF_WORKSPACES_MULTI_REGION_ENABLED } = Environment.getFeatureFlags()
const isDevEnv = process.env.NODE_ENV !== 'production'
let dbClients
const getDbClients = async () => {
if (dbClients) return dbClients
const maxConnections =
parseInt(process.env.POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE) || 1
const configArgs = {
migrationDirs: [],
isTestEnv: isDevEnv,
isDevOrTestEnv: isDevEnv,
logger,
maxConnections,
applicationName: 'speckle_fileimport_service'
}
// migrations are in managed in the server package
})
if (!FF_WORKSPACES_MULTI_REGION_ENABLED) {
const mainClient = configureKnexClient(
{
postgres: {
connectionUri:
process.env.PG_CONNECTION_STRING ||
'postgres://speckle:speckle@127.0.0.1/speckle'
}
},
configArgs
)
dbClients = { main: mainClient }
} else {
const configPath = process.env.MULTI_REGION_CONFIG_PATH || 'multiregion.json'
const config = await loadMultiRegionsConfig({ path: configPath })
const clients = [['main', configureKnexClient(config.main, configArgs)]]
Object.entries(config.regions).map(([key, config]) => {
clients.push([key, configureKnexClient(config, configArgs)])
})
dbClients = Object.fromEntries(clients)
}
return dbClients
}
module.exports = getDbClients

Просмотреть файл

@ -159,6 +159,8 @@ if __name__ == "__main__":
commit_id = import_obj()
if not commit_id:
raise Exception("Can't create commit")
if isinstance(commit_id, Exception):
raise commit_id
results = {"success": True, "commitId": commit_id}
except Exception as ex:
LOG.exception(ex)

Просмотреть файл

@ -3,10 +3,10 @@
"private": true,
"version": "2.5.4",
"description": "Parse and import files of various types into a stream",
"author": "Dimitrie Stefanescu <didimitrie@gmail.com>",
"author": "Speckle Systems <hello@speckle.systems>",
"homepage": "https://github.com/specklesystems/speckle-server#readme",
"license": "SEE LICENSE IN readme.md",
"main": "index.js",
"main": "daemon.js",
"repository": {
"type": "git",
"url": "git+https://github.com/specklesystems/speckle-server.git"
@ -34,7 +34,9 @@
"prom-client": "^14.0.1",
"undici": "^5.28.4",
"valid-filename": "^3.1.0",
"web-ifc": "^0.0.36"
"web-ifc": "^0.0.36",
"znv": "^0.4.0",
"zod": "^3.22.4"
},
"devDependencies": {
"cross-env": "^7.0.3",

Просмотреть файл

@ -6,8 +6,7 @@ const {
metricInputFileSize,
metricOperationErrors
} = require('./prometheusMetrics')
const knex = require('../knex')
const FileUploads = () => knex('file_uploads')
const getDbClients = require('../knex')
const { downloadFile } = require('./filesApi')
const fs = require('fs')
@ -16,7 +15,7 @@ const { spawn } = require('child_process')
const ServerAPI = require('../ifc/api')
const objDependencies = require('./objDependencies')
const { logger } = require('../observability/logging')
const { Scopes } = require('@speckle/shared')
const { Scopes, wait } = require('@speckle/shared')
const HEALTHCHECK_FILE_PATH = '/tmp/last_successful_query'
@ -31,10 +30,10 @@ let TIME_LIMIT = 10 * 60 * 1000
const providedTimeLimit = parseInt(process.env.FILE_IMPORT_TIME_LIMIT_MIN)
if (providedTimeLimit) TIME_LIMIT = providedTimeLimit * 60 * 1000
async function startTask() {
async function startTask(knex) {
const { rows } = await knex.raw(`
UPDATE file_uploads
SET
SET
"convertedStatus" = 1,
"convertedLastUpdate" = NOW()
FROM (
@ -49,15 +48,16 @@ async function startTask() {
return rows[0]
}
async function doTask(task) {
async function doTask(mainDb, regionName, taskDb, task) {
const taskId = task.id
// Mark task as started
await knex.raw(`NOTIFY file_import_started, '${task.id}'`)
await mainDb.raw(`NOTIFY file_import_started, '${task.id}'`)
let taskLogger = logger.child({ taskId })
let tempUserToken = null
let serverApi = null
let mainServerApi = null
let taskServerApi = null
let fileTypeForMetric = 'unknown'
let fileSizeForMetric = 0
@ -67,7 +67,7 @@ async function doTask(task) {
try {
taskLogger.info("Doing task '{taskId}'.")
const info = await FileUploads().where({ id: taskId }).first()
const info = await taskDb('file_uploads').where({ id: taskId }).first()
if (!info) {
throw new Error('Internal error: DB inconsistent')
}
@ -85,13 +85,22 @@ async function doTask(task) {
})
fs.mkdirSync(TMP_INPUT_DIR, { recursive: true })
serverApi = new ServerAPI({ streamId: info.streamId, logger: taskLogger })
mainServerApi = new ServerAPI({
db: mainDb,
streamId: info.streamId,
logger: taskLogger
})
taskServerApi = new ServerAPI({
db: taskDb,
streamId: info.streamId,
logger: taskLogger
})
branchMetadata = {
branchName: info.branchName,
streamId: info.streamId
}
const existingBranch = await serverApi.getBranchByNameAndStreamId({
const existingBranch = await taskServerApi.getBranchByNameAndStreamId({
streamId: info.streamId,
name: info.branchName
})
@ -99,7 +108,7 @@ async function doTask(task) {
newBranchCreated = true
}
const { token } = await serverApi.createToken({
const { token } = await mainServerApi.createToken({
userId: info.userId,
name: 'temp upload token',
scopes: [Scopes.Streams.Write, Scopes.Streams.Read],
@ -126,7 +135,8 @@ async function doTask(task) {
info.streamId,
info.branchName,
`File upload: ${info.fileName}`,
info.id
info.id,
regionName
],
{
USER_TOKEN: tempUserToken
@ -185,7 +195,7 @@ async function doTask(task) {
const commitId = output.commitId
await knex.raw(
await taskDb.raw(
`
UPDATE file_uploads
SET
@ -199,7 +209,7 @@ async function doTask(task) {
)
} catch (err) {
taskLogger.error(err)
await knex.raw(
await taskDb.raw(
`
UPDATE file_uploads
SET
@ -208,12 +218,13 @@ async function doTask(task) {
"convertedMessage" = ?
WHERE "id" = ?
`,
[err.toString(), task.id]
// DB only accepts a varchar 255
[err.toString().substring(0, 254), task.id]
)
metricOperationErrors.labels(fileTypeForMetric).inc()
} finally {
const { streamId, branchName } = branchMetadata
await knex.raw(
await mainDb.raw(
`NOTIFY file_import_update, '${task.id}:::${streamId}:::${branchName}:::${
newBranchCreated ? 1 : 0
}'`
@ -226,7 +237,7 @@ async function doTask(task) {
if (fs.existsSync(TMP_RESULTS_PATH)) fs.unlinkSync(TMP_RESULTS_PATH)
if (tempUserToken) {
await serverApi.revokeTokenById(tempUserToken)
await mainServerApi.revokeTokenById(tempUserToken)
}
}
@ -305,42 +316,53 @@ function wrapLogLine(line, isErr, logger) {
logger.info({ parserLogLine: line }, 'ParserLog: {parserLogLine}')
}
async function tick() {
if (shouldExit) {
process.exit(0)
}
try {
const task = await startTask()
fs.writeFile(HEALTHCHECK_FILE_PATH, '' + Date.now(), () => {})
if (!task) {
setTimeout(tick, 1000)
return
const doStuff = async () => {
const dbClients = await getDbClients()
const mainDb = dbClients.main.public
const dbClientsIterator = infiniteDbClientsIterator(dbClients)
while (!shouldExit) {
const [regionName, taskDb] = dbClientsIterator.next().value
try {
const task = await startTask(taskDb)
fs.writeFile(HEALTHCHECK_FILE_PATH, '' + Date.now(), () => {})
if (!task) {
await wait(1000)
continue
}
await doTask(mainDb, regionName, taskDb, task)
await wait(10)
} catch (err) {
metricOperationErrors.labels('main_loop').inc()
logger.error(err, 'Error executing task')
await wait(5000)
}
await doTask(task)
// Check for another task very soon
setTimeout(tick, 10)
} catch (err) {
metricOperationErrors.labels('main_loop').inc()
logger.error(err, 'Error executing task')
setTimeout(tick, 5000)
}
}
async function main() {
logger.info('Starting FileUploads Service...')
initPrometheusMetrics()
await initPrometheusMetrics()
process.on('SIGTERM', () => {
shouldExit = true
logger.info('Shutting down...')
})
tick()
await doStuff()
process.exit(0)
}
function* infiniteDbClientsIterator(dbClients) {
let index = 0
const dbClientEntries = [...Object.entries(dbClients)]
const clientCount = dbClientEntries.length
while (true) {
// reset index
if (index === clientCount) index = 0
const [regionName, dbConnection] = dbClientEntries[index]
index++
yield [regionName, dbConnection.public]
}
}
main()

Просмотреть файл

@ -3,7 +3,7 @@
const http = require('http')
const prometheusClient = require('prom-client')
const knex = require('../knex')
const getDbClients = require('../knex')
let metricFree = null
let metricUsed = null
@ -24,101 +24,105 @@ prometheusClient.collectDefaultMetrics()
let prometheusInitialized = false
function initKnexPrometheusMetrics() {
metricFree = new prometheusClient.Gauge({
name: 'speckle_server_knex_free',
help: 'Number of free DB connections',
collect() {
this.set(knex.client.pool.numFree())
}
})
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(knex.client.pool.numUsed())
}
})
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(knex.client.pool.numPendingAcquires())
}
})
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(knex.client.pool.numPendingCreates())
}
})
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(knex.client.pool.numPendingValidations())
}
})
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 =
knex.client.pool.numUsed() +
knex.client.pool.numPendingCreates() +
knex.client.pool.numPendingValidations() +
knex.client.pool.numPendingAcquires()
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))
}
})
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'
})
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'
})
metricQueryErrors = new prometheusClient.Counter({
name: 'speckle_server_knex_query_errors',
help: 'Number of DB queries with errors'
})
knex.on('query', (data) => {
const queryId = data.__knexQueryUid + ''
queryStartTime[queryId] = Date.now()
})
db.on('query', (data) => {
const queryId = data.__knexQueryUid + ''
queryStartTime[queryId] = Date.now()
})
knex.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-response', (data, obj, builder) => {
const queryId = obj.__knexQueryUid + ''
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
})
knex.on('query-error', (err, querySpec) => {
const queryId = querySpec.__knexQueryUid + ''
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
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()
})
}
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
metricQueryErrors.inc()
})
}
module.exports = {
initPrometheusMetrics() {
async initPrometheusMetrics() {
if (prometheusInitialized) return
prometheusInitialized = true
initKnexPrometheusMetrics()
const db = (await getDbClients()).main.public
initDBPrometheusMetricsFactory({ db })()
// Define the HTTP server
const server = http.createServer(async (req, res) => {

Просмотреть файл

@ -69,6 +69,8 @@ if __name__ == "__main__":
try:
commit_id = import_stl()
if isinstance(commit_id, Exception):
raise commit_id
results = {"success": True, "commitId": commit_id}
except Exception as ex:
results = {"success": False, "error": str(ex)}

Просмотреть файл

@ -42,3 +42,5 @@ NUXT_PUBLIC_ENABLE_DIRECT_PREVIEWS=true
# Ghost API
NUXT_PUBLIC_GHOST_API_KEY=
NUXT_WEBFLOW_API_TOKEN=8c9bea4c120742a21ff308cda0bea73f13e89ffe26dcc886990bba353549b652

Просмотреть файл

@ -4,7 +4,7 @@
<slot name="icon" />
</div>
<div class="flex-1">
<div v-if="title || description" class="flex-1">
<div v-if="title" class="flex items-center gap-2">
<p class="text-heading-sm text-foreground">{{ title }}</p>
<CommonBadge v-if="badge" rounded>{{ badge }}</CommonBadge>

Просмотреть файл

@ -0,0 +1,49 @@
<template>
<NuxtLink :to="webflowItem.url" target="_blank">
<div
class="bg-foundation border border-outline-3 rounded-xl flex flex-col overflow-hidden hover:border-outline-5 transition"
>
<NuxtImg
v-if="webflowItem.featureImageUrl"
:src="webflowItem.featureImageUrl"
:alt="webflowItem.title"
class="h-32 w-full object-cover"
width="400"
height="225"
/>
<div
v-else
class="bg-foundation-page w-full h-32 flex items-center justify-center"
>
<HeaderLogoBlock no-link minimal class="scale-150" />
</div>
<div class="p-5 pb-4">
<h3 class="text-body-2xs text-foreground truncate">
{{ webflowItem.title }}
</h3>
<p class="text-body-3xs text-foreground-2 mt-2">
<span v-tippy="createdOn.full">
{{ createdOn.relative }}
</span>
<template v-if="webflowItem.readTime">
<span class="pl-1 pr-2"></span>
{{ webflowItem.readTime }}m read
</template>
</p>
</div>
</div>
</NuxtLink>
</template>
<script lang="ts" setup>
import type { WebflowItem } from '~/lib/dashboard/helpers/types'
const props = defineProps<{
webflowItem: WebflowItem
}>()
const createdOn = computed(() => ({
full: formattedFullDate(props.webflowItem.createdOn),
relative: formattedRelativeDate(props.webflowItem.createdOn, { capitalize: true })
}))
</script>

Просмотреть файл

@ -0,0 +1,31 @@
<template>
<section v-if="!error">
<h2 class="text-heading-sm text-foreground-2 mb-4">Blog</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<DashboardBlogCard
v-for="webflowItem in webflowItems"
:key="webflowItem.id"
:webflow-item="webflowItem"
/>
</div>
</section>
<section v-else />
</template>
<script setup lang="ts">
import type { WebflowItem } from '~/lib/dashboard/helpers/types'
const logger = useLogger()
const { data: webflowData, error } = await useAsyncData<{
items: WebflowItem[]
}>('webflow-items', () =>
$fetch('/api/webflow', {
onResponseError({ response }) {
logger.error('API Response Error:', response.status, response.statusText)
}
})
)
const webflowItems = computed(() => webflowData.value?.items || [])
</script>

Просмотреть файл

@ -152,14 +152,6 @@
</NuxtLink>
</LayoutSidebarMenuGroup>
</LayoutSidebarMenu>
<template #promo>
<LayoutSidebarPromo
title="SpeckleCon 2024"
text="Join us in London on Nov 13-14 for the ultimate community event."
button-text="Get tickets"
@on-click="onPromoClick"
/>
</template>
</LayoutSidebar>
</div>
</template>
@ -177,7 +169,6 @@
import {
FormButton,
LayoutSidebar,
LayoutSidebarPromo,
LayoutSidebarMenu,
LayoutSidebarMenuGroup,
LayoutSidebarMenuGroupItem
@ -248,15 +239,6 @@ onWorkspaceResult((result) => {
}
})
const onPromoClick = () => {
mixpanel.track('Promo Banner Clicked', {
source: 'sidebar',
campaign: 'specklecon2024'
})
window.open('https://conf.speckle.systems/', '_blank')
}
const openFeedbackDialog = () => {
showFeedbackDialog.value = true
isOpenMobile.value = false

Просмотреть файл

@ -1,41 +0,0 @@
<template>
<NuxtLink :to="tutorial.url" target="_blank">
<div
class="bg-foundation border border-outline-3 rounded-xl flex flex-col overflow-hidden hover:border-outline-5 transition"
>
<div
:style="{ backgroundImage: `url(${tutorial.featureImage})` }"
class="bg-foundation-page bg-cover bg-center w-full h-32"
/>
<div class="p-5 pb-4">
<h3 v-if="tutorial.title" class="text-body-2xs text-foreground truncate">
{{ tutorial.title }}
</h3>
<p class="text-body-3xs text-foreground-2 mt-2">
<span v-tippy="updatedAt.full">
{{ updatedAt.relative }}
</span>
<template v-if="tutorial.readingTime">
<span class="pl-1 pr-2"></span>
{{ tutorial.readingTime }}m read
</template>
</p>
</div>
</div>
</NuxtLink>
</template>
<script lang="ts" setup>
import type { TutorialItem } from '~~/lib/dashboard/helpers/types'
const props = defineProps<{
tutorial: TutorialItem
}>()
const updatedAt = computed(() => {
return {
full: formattedFullDate(props.tutorial.publishedAt),
relative: formattedRelativeDate(props.tutorial.publishedAt, { capitalize: true })
}
})
</script>

Просмотреть файл

@ -6,7 +6,7 @@
:on-submit="onSubmit"
max-width="md"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-body-xs text-foreground font-medium">
How can we improve Speckle? If you have a feature request, please also share how
you would use it and why it's important to you
@ -18,6 +18,13 @@
label="Feedback"
color="foundation"
/>
<p class="text-body-xs !leading-4">
Need help? For support, head over to our
<FormButton to="https://speckle.community/" target="_blank" link text>
community forum
</FormButton>
where we can chat and solve problems together.
</p>
</div>
</LayoutDialog>
</template>

Просмотреть файл

@ -13,6 +13,7 @@
:fully-control-value="fullyControlValue"
:disabled="disabled"
:disabled-item-predicate="disabledItemPredicate"
:disabled-item-tooltip="disabledItemTooltip"
:clearable="clearable"
>
<template #nothing-selected>
@ -82,6 +83,10 @@ const props = defineProps({
required: false,
type: Array as PropType<WorkspaceRoles[]>
},
currentRole: {
type: String as PropType<WorkspaceRoles>,
required: false
},
showLabel: Boolean,
clearable: Boolean,
hideItems: {
@ -115,8 +120,15 @@ const roles = computed(() => {
return Object.values(Roles.Workspace)
})
const disabledItemTooltip = computed(() =>
props.currentRole
? `User is already a ${RoleInfo.Workspace[props.currentRole].title}`
: undefined
)
const disabledItemPredicate = (item: WorkspaceRoles) =>
props.disabledItems && props.disabledItems.length > 0
? props.disabledItems.includes(item)
: false
(props.disabledItems &&
props.disabledItems.length > 0 &&
props.disabledItems.includes(item)) ||
item === props.currentRole
</script>

Просмотреть файл

@ -9,16 +9,16 @@
viewBox="0 0 8 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-outline-2"
class="text-outline-5"
>
<path d="M2 18L6 6" stroke="currentColor" />
</svg>
</div>
<NuxtLink
:to="disableLink ? undefined : to"
class="flex gap-1 items-center text-body-xs ml-0.5 text-foreground-2 select-none truncate font-medium"
class="flex gap-1 items-center text-body-xs ml-0.5 text-foreground-2 select-none truncate"
:class="disableLink ? '' : 'hover:!text-foreground'"
active-class="group is-active !text-foreground"
active-class="group is-active !text-foreground font-medium"
>
<div class="truncate">
{{ name || to }}

Просмотреть файл

@ -98,7 +98,7 @@ const token = computed(
)
const mainClasses = computed(() => {
const classParts = [
'flex flex-col space-y-4 px-4 py-5 transition border-x border-b border-outline-2 first:border-t first:rounded-t-lg last:rounded-b-lg'
'flex flex-col space-y-4 px-4 py-5 transition bg-foundation border-x border-b border-outline-2 first:border-t first:rounded-t-lg last:rounded-b-lg'
]
if (props.block) {

Просмотреть файл

@ -53,7 +53,7 @@ const emit = defineEmits<{
const props = defineProps<{
versions: ProjectModelPageDialogDeleteVersionFragment[]
open: boolean
projectId?: string
projectId: string
modelId?: string
}>()
@ -71,10 +71,10 @@ const onDelete = async () => {
loading.value = true
const success = await deleteVersions(
{
projectId: props.projectId,
versionIds: props.versions.map((v) => v.id)
},
{
projectId: props.projectId,
modelId: props.modelId
}
)

Просмотреть файл

@ -57,6 +57,7 @@ const emit = defineEmits<{
}>()
const props = defineProps<{
projectId: string
version: Nullable<ProjectModelPageDialogDeleteVersionFragment>
open: boolean
}>()
@ -85,6 +86,7 @@ const onSubmit = handleSubmit(async ({ newMessage }) => {
loading.value = true
const success = !!(await updateVersion({
projectId: props.projectId,
versionId: props.version?.id,
message: newMessage
}))

Просмотреть файл

@ -81,13 +81,13 @@ const onMove = async (targetModelName: string, newModelCreated?: boolean) => {
loading.value = true
const success = await moveVersions(
{
projectId: props.projectId,
versionIds: props.versions.map((v) => v.id),
targetModelName
},
{
previousModelId: props.modelId,
newModelCreated,
projectId: props.projectId
newModelCreated
}
)
loading.value = false

Просмотреть файл

@ -1,9 +1,5 @@
<template>
<ProjectEmptyState
:small="small"
title="No discussions, yet."
:text="small ? undefined : 'Open a model and start the collaboration today!'"
>
<ProjectEmptyState :small="small" title="No discussions, yet." :text="text">
<template #cta>
<div v-if="showButton" class="mt-3">
<FormButton :icon-left="PlusIcon" @click="() => $emit('new-discussion')">
@ -23,5 +19,6 @@ defineEmits<{
defineProps<{
small?: boolean
showButton?: boolean
text?: string
}>()
</script>

Просмотреть файл

@ -118,7 +118,8 @@ const emit = defineEmits<{
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const createProject = useCreateProject()
const router = useRouter()
const { handleSubmit, meta } = useForm<FormValues>()
const logger = useLogger()
const { handleSubmit, meta, isSubmitting } = useForm<FormValues>()
const { result: workspaceResult } = useQuery(projectWorkspaceSelectQuery, null, () => ({
enabled: isWorkspacesEnabled.value
}))
@ -127,43 +128,62 @@ const visibility = ref(ProjectVisibility.Unlisted)
const selectedWorkspace = ref<ProjectsAddDialog_WorkspaceFragment>()
const showConfirmDialog = ref(false)
const confirmActionType = ref<'navigate' | 'close' | null>(null)
const isClosing = ref(false)
const open = defineModel<boolean>('open', { required: true })
const mp = useMixpanel()
const onSubmit = handleSubmit(async (values) => {
await createProject({
name: values.name,
description: values.description,
visibility: visibility.value,
workspaceId: props.workspaceId || selectedWorkspace.value?.id
})
emit('created')
mp.track('Stream Action', {
type: 'action',
name: 'create',
// eslint-disable-next-line camelcase
workspace_id: props.workspaceId
})
open.value = false
if (isClosing.value) return // Prevent submission while closing
try {
isClosing.value = true
const workspaceId = props.workspaceId || selectedWorkspace.value?.id
await createProject({
name: values.name,
description: values.description,
visibility: visibility.value,
...(workspaceId ? { workspaceId } : {})
})
emit('created')
mp.track('Stream Action', {
type: 'action',
name: 'create',
// eslint-disable-next-line camelcase
workspace_id: props.workspaceId
})
open.value = false
} catch (error) {
isClosing.value = false
logger.error('Failed to create project:', error)
}
})
const workspaces = computed(
() => workspaceResult.value?.activeUser?.workspaces.items ?? []
)
const hasWorkspaces = computed(() => workspaces.value.length > 0)
const dialogButtons = computed((): LayoutDialogButton[] => {
const isDisabled = isSubmitting.value || isClosing.value
return [
{
text: 'Cancel',
props: { color: 'outline' },
props: {
color: 'outline',
disabled: isDisabled
},
onClick: confirmCancel
},
{
text: 'Create',
props: {
submit: true
submit: true,
loading: isDisabled,
disabled: isDisabled
},
onClick: onSubmit
}
@ -204,6 +224,7 @@ const handleConfirmAction = () => {
watch(open, (newVal, oldVal) => {
if (newVal && !oldVal) {
selectedWorkspace.value = undefined
isClosing.value = false
}
})
</script>

Просмотреть файл

@ -1,29 +1,31 @@
<template>
<div>
<div
v-if="hasBanners"
class="bg-foundation divide-y divide-outline-3 mb-8 empty:mb-0"
>
<div v-if="hasBanners" class="mb-8 empty:mb-0">
<ProjectsInviteBanners
v-if="projectsInvites?.projectInvites?.length"
:invites="projectsInvites"
/>
<WorkspaceInviteBanners
v-if="
workspacesInvites?.workspaceInvites?.length ||
workspacesInvites?.discoverableWorkspaces?.length
"
:invites="workspacesInvites"
<WorkspaceInviteBanner
v-for="invite in workspaceInvites"
:key="invite.id"
:invite="invite"
/>
<WorkspaceInviteDiscoverableWorkspaceBanner
v-for="workspace in discoverableWorkspaces"
:key="workspace.id"
:workspace="workspace"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
import { graphql } from '~/lib/common/generated/gql'
import type {
ProjectsDashboardHeaderProjects_UserFragment,
ProjectsDashboardHeaderWorkspaces_UserFragment
} from '~/lib/common/generated/gql/graphql'
import { CookieKeys } from '~/lib/common/helpers/constants'
graphql(`
fragment ProjectsDashboardHeaderProjects_User on User {
@ -33,7 +35,12 @@ graphql(`
graphql(`
fragment ProjectsDashboardHeaderWorkspaces_User on User {
...WorkspaceInviteBanners_User
discoverableWorkspaces {
...WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace
}
workspaceInvites {
...WorkspaceInviteBanner_PendingWorkspaceCollaborator
}
}
`)
@ -42,11 +49,26 @@ const props = defineProps<{
workspacesInvites?: ProjectsDashboardHeaderWorkspaces_UserFragment
}>()
const dismissedDiscoverableWorkspaces = useSynchronizedCookie<string[]>(
CookieKeys.DismissedDiscoverableWorkspaces,
{
default: () => []
}
)
const workspaceInvites = computed(() => props.workspacesInvites?.workspaceInvites || [])
const discoverableWorkspaces = computed(
() =>
props.workspacesInvites?.discoverableWorkspaces?.filter(
(workspace) => !dismissedDiscoverableWorkspaces.value.includes(workspace.id)
) || []
)
const hasBanners = computed(() => {
return (
props.projectsInvites?.projectInvites?.length ||
props.workspacesInvites?.workspaceInvites?.length ||
props.workspacesInvites?.discoverableWorkspaces?.length
workspaceInvites.value.length ||
discoverableWorkspaces.value.length
)
})
</script>

Просмотреть файл

@ -20,11 +20,7 @@
</FormButton>
</div>
</div>
<p
v-if="text"
class="text-body-xs text-foreground-2 pt-1"
:class="{ 'pt-6': subheading }"
>
<p v-if="text" class="text-body-xs text-foreground-2 pt-1">
{{ text }}
</p>
<hr v-if="!subheading && !hideDivider" class="my-6 border-outline-2" />

Просмотреть файл

@ -161,11 +161,11 @@ const showActionsMenu = ref<Record<string, boolean>>({})
const actionItems: LayoutMenuItem[][] = [
[
{
title: 'Change role',
title: 'Change role...',
id: ActionTypes.ChangeRole
},
{
title: 'Remove user',
title: 'Remove user...',
id: ActionTypes.RemoveUser
}
]

Просмотреть файл

@ -129,7 +129,7 @@ const invites = computed(() => result.value?.admin.inviteList.items || [])
const actionItems: LayoutMenuItem[][] = [
[
{ title: 'Resend invitation', id: 'resend-invite' },
{ title: 'Delete invitation', id: 'delete-invite' }
{ title: 'Delete invitation...', id: 'delete-invite' }
]
]

Просмотреть файл

@ -0,0 +1,76 @@
<template>
<section>
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader
title="Regions"
text="Manage the regions available for customizing data residency"
/>
<div class="flex flex-col space-y-6">
<div class="flex flex-row-reverse">
<div v-tippy="disabledMessage">
<FormButton :disabled="!canCreateRegion" @click="onCreate">
Create
</FormButton>
</div>
</div>
<SettingsServerRegionsTable :items="tableItems" @edit="onEditRegion" />
</div>
</div>
<SettingsServerRegionsAddEditDialog
v-model="editModel"
v-model:open="isAddEditDialogOpen"
:available-region-keys="availableKeys"
/>
</section>
</template>
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable'
import type { SettingsServerRegionsTable_ServerRegionItemFragment } from '~/lib/common/generated/gql/graphql'
import { graphql } from '~~/lib/common/generated/gql'
const isAddEditDialogOpen = ref(false)
const query = graphql(`
query SettingsServerRegions {
serverInfo {
multiRegion {
regions {
id
...SettingsServerRegionsTable_ServerRegionItem
}
availableKeys
}
}
}
`)
const editModel = ref<SettingsServerRegionsTable_ServerRegionItemFragment>()
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
const { result } = useQuery(query, undefined, () => ({
fetchPolicy: pageFetchPolicy.value
}))
const tableItems = computed(() => result.value?.serverInfo?.multiRegion?.regions)
const availableKeys = computed(
() => result.value?.serverInfo?.multiRegion?.availableKeys || []
)
const canCreateRegion = computed(() => availableKeys.value.length > 0)
const disabledMessage = computed(() => {
if (canCreateRegion.value) return undefined
if (!availableKeys.value.length) return 'No available region keys'
return undefined
})
const onCreate = () => {
editModel.value = undefined
isAddEditDialogOpen.value = true
}
const onEditRegion = (item: SettingsServerRegionsTable_ServerRegionItemFragment) => {
editModel.value = item
isAddEditDialogOpen.value = true
}
</script>

Просмотреть файл

@ -0,0 +1,126 @@
<template>
<LayoutDialog
v-model:open="open"
max-width="sm"
:buttons="dialogButtons"
hide-closer
prevent-close-on-click-outside
:on-submit="onSubmit"
>
<template #header>Create a new region</template>
<div class="flex flex-col gap-y-4 mb-2">
<FormTextInput
name="name"
label="Region name"
placeholder="Name"
color="foundation"
:rules="[isRequired, isStringOfLength({ maxLength: 64 })]"
auto-focus
autocomplete="off"
show-required
show-label
help="Human readable name for the region."
/>
<FormTextArea
name="description"
label="Region description"
placeholder="Description"
color="foundation"
size="lg"
show-label
show-optional
:rules="[isStringOfLength({ maxLength: 65536 })]"
/>
<SettingsServerRegionsKeySelect
show-label
name="key"
:items="availableRegionKeys"
label="Region key"
:rules="[isRequired]"
show-required
help="These keys come from the server multi region configuration file."
/>
</div>
</LayoutDialog>
</template>
<script lang="ts" setup>
import { isRequired, isStringOfLength } from '~~/lib/common/helpers/validation'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { graphql } from '~/lib/common/generated/gql'
import type { SettingsServerRegionsAddEditDialog_ServerRegionItemFragment } from '~/lib/common/generated/gql/graphql'
import { useForm } from 'vee-validate'
import {
useCreateRegion,
useUpdateRegion
} from '~/lib/multiregion/composables/management'
import { useMutationLoading } from '@vue/apollo-composable'
graphql(`
fragment SettingsServerRegionsAddEditDialog_ServerRegionItem on ServerRegionItem {
id
name
description
key
}
`)
type ServerRegionItem = SettingsServerRegionsAddEditDialog_ServerRegionItemFragment
type DialogModel = Omit<ServerRegionItem, 'id'>
defineProps<{
availableRegionKeys: string[]
}>()
const open = defineModel<boolean>('open', { required: true })
const model = defineModel<DialogModel>()
const { handleSubmit, setValues } = useForm<DialogModel>()
const createRegion = useCreateRegion()
const updateRegion = useUpdateRegion()
const loading = useMutationLoading()
const dialogButtons = computed((): LayoutDialogButton[] => {
return [
{
text: 'Cancel',
props: { color: 'outline' },
onClick: () => (open.value = false)
},
{
text: isEditMode.value ? 'Update' : 'Create',
props: {
submit: true,
disabled: loading.value
},
onClick: noop
}
]
})
const isEditMode = computed(() => !!model.value)
const onSubmit = handleSubmit(async (values) => {
const action = isEditMode.value ? updateRegion : createRegion
const res = await action({
input: {
key: values.key,
name: values.name,
description: values.description
}
})
if (res?.id) {
open.value = false
}
})
watch(
model,
(newVal, oldVal) => {
if (newVal && newVal !== oldVal) {
setValues(newVal)
} else if (!newVal && oldVal) {
setValues({ name: '', description: '', key: '' })
}
},
{ immediate: true }
)
</script>

Просмотреть файл

@ -0,0 +1,57 @@
<template>
<FormSelectBase
v-bind="props"
v-model="selectedValue"
:name="name || 'regions-key'"
:allow-unset="false"
mount-menu-on-body
>
<template #option="{ item }">
<div class="flex items-center">
<span class="truncate">{{ item }}</span>
</div>
</template>
<template #nothing-selected>
{{ multiple ? 'Select region keys' : 'Select a region key' }}
</template>
<template #something-selected="{ value }">
<template v-if="isArray(value)">
{{ value.join(', ') }}
</template>
<template v-else>
{{ value }}
</template>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { isArray } from 'lodash-es'
import type { RuleExpression } from 'vee-validate'
import { useFormSelectChildInternals } from '~/lib/form/composables/select'
type ValueType = string | string[] | undefined
const emit = defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = defineProps<{
modelValue?: ValueType
label: string
items: string[]
multiple?: boolean
name?: string
showOptional?: boolean
showRequired?: boolean
showLabel?: boolean
labelId?: string
buttonId?: string
help?: string
rules?: RuleExpression<string | string[] | undefined>
}>()
const { selectedValue } = useFormSelectChildInternals<string>({
props: toRefs(props),
emit
})
</script>

Просмотреть файл

@ -0,0 +1,86 @@
<template>
<div>
<LayoutTable
:items="items"
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3 truncate' },
{ id: 'key', header: 'Key', classes: 'col-span-2 truncate' },
{ id: 'description', header: 'Description', classes: 'col-span-6 truncate' },
{ id: 'actions', header: '', classes: 'col-span-1' }
]"
empty-message="No regions defined"
>
<template #name="{ item }">
{{ item.name }}
</template>
<template #key="{ item }">
<span class="text-foreground-2">{{ item.key }}</span>
</template>
<template #description="{ item }">
<span class="text-foreground-2">{{ item.description }}</span>
</template>
<template #actions="{ item }">
<LayoutMenu
v-model:open="showActionsMenu[item.id]"
:items="actionItems"
mount-menu-on-body
:menu-position="HorizontalDirection.Left"
@chosen="({ item: actionItem }) => onActionChosen(actionItem, item)"
>
<FormButton
:color="showActionsMenu[item.id] ? 'outline' : 'subtle'"
hide-text
:icon-right="showActionsMenu[item.id] ? XMarkIcon : EllipsisHorizontalIcon"
@click.stop="toggleMenu(item.id)"
/>
</LayoutMenu>
</template>
</LayoutTable>
</div>
</template>
<script setup lang="ts">
import type { LayoutMenuItem } from '@speckle/ui-components'
import { HorizontalDirection } from '~~/lib/common/composables/window'
import { EllipsisHorizontalIcon, XMarkIcon } from '@heroicons/vue/24/outline'
import { graphql } from '~/lib/common/generated/gql'
import type { SettingsServerRegionsTable_ServerRegionItemFragment } from '~/lib/common/generated/gql/graphql'
graphql(`
fragment SettingsServerRegionsTable_ServerRegionItem on ServerRegionItem {
id
name
key
description
}
`)
enum ActionTypes {
Edit = 'edit'
}
const emit = defineEmits<{
edit: [item: SettingsServerRegionsTable_ServerRegionItemFragment]
}>()
defineProps<{
items: SettingsServerRegionsTable_ServerRegionItemFragment[] | undefined
}>()
const showActionsMenu = ref<Record<string, boolean>>({})
const actionItems: LayoutMenuItem[][] = [
[{ title: 'Edit region', id: ActionTypes.Edit }]
]
const toggleMenu = (itemId: string) => {
showActionsMenu.value[itemId] = !showActionsMenu.value[itemId]
}
const onActionChosen = (
actionItem: LayoutMenuItem,
item: SettingsServerRegionsTable_ServerRegionItemFragment
) => {
if (actionItem.id === ActionTypes.Edit) {
emit('edit', item)
}
}
</script>

Просмотреть файл

@ -7,6 +7,7 @@
label="New role"
fully-control-value
:disabled-items="disabledItems"
:current-role="currentRole"
show-label
show-description
/>
@ -43,8 +44,8 @@ const emit = defineEmits<{
}>()
const props = defineProps<{
name: string
workspaceDomainPolicyCompliant?: boolean | null
currentRole?: WorkspaceRoles
}>()
const open = defineModel<boolean>('open', { required: true })

Просмотреть файл

@ -177,8 +177,8 @@ const showActionsMenu = ref<Record<string, boolean>>({})
const actionItems: LayoutMenuItem[][] = [
[
{ title: 'View project...', id: ActionTypes.ViewProject },
{ title: 'Edit members...', id: ActionTypes.EditMembers },
{ title: 'View project', id: ActionTypes.ViewProject },
{ title: 'Edit members', id: ActionTypes.EditMembers },
{ title: 'Remove project...', id: ActionTypes.RemoveProject }
]
]

Просмотреть файл

@ -42,6 +42,7 @@ import {
getFirstErrorMessage,
convertThrowIntoFetchResult
} from '~~/lib/common/helpers/graphql'
import { useMixpanel } from '~/lib/core/composables/mp'
graphql(`
fragment SettingsUserEmails_User on User {
@ -58,6 +59,7 @@ const { handleSubmit } = useForm<FormValues>()
const { triggerNotification } = useGlobalToast()
const { result: userEmailsResult } = useQuery(settingsUserEmailsQuery)
const { mutate: createMutation } = useMutation(settingsCreateUserEmailMutation)
const mixpanel = useMixpanel()
const email = ref('')
@ -81,6 +83,8 @@ const onAddEmailSubmit = handleSubmit(async () => {
type: ToastNotificationType.Success,
title: `${email.value} added`
})
mixpanel.track('Email Added')
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({

Просмотреть файл

@ -22,6 +22,7 @@ import {
getFirstErrorMessage,
convertThrowIntoFetchResult
} from '~~/lib/common/helpers/graphql'
import { useMixpanel } from '~/lib/core/composables/mp'
const props = defineProps<{
emailId: string
@ -31,6 +32,7 @@ const isOpen = defineModel<boolean>('open', { required: true })
const { mutate: deleteMutation } = useMutation(settingsDeleteUserEmailMutation)
const { triggerNotification } = useGlobalToast()
const mixpanel = useMixpanel()
const dialogButtons = computed((): LayoutDialogButton[] => [
{
@ -58,6 +60,8 @@ const onDeleteEmail = async () => {
type: ToastNotificationType.Success,
title: `${props.email} deleted`
})
mixpanel.track('Email Deleted')
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({

Просмотреть файл

@ -22,6 +22,9 @@ import {
getFirstErrorMessage,
convertThrowIntoFetchResult
} from '~~/lib/common/helpers/graphql'
import { resolveMixpanelUserId } from '@speckle/shared'
import { useMixpanel } from '~/lib/core/composables/mp'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
const props = defineProps<{
emailId: string
@ -31,6 +34,8 @@ const isOpen = defineModel<boolean>('open', { required: true })
const { triggerNotification } = useGlobalToast()
const { mutate: updateMutation } = useMutation(settingsSetPrimaryUserEmailMutation)
const mixpanel = useMixpanel()
const { distinctId } = useActiveUser()
const dialogButtons = computed((): LayoutDialogButton[] => [
{
@ -51,6 +56,8 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
])
const onSetPrimary = async () => {
// Create a copy of the original email to use in the alias event before it's overwritten
const originalDistinctId = toRaw(distinctId.value)
const result = await updateMutation({ input: { id: props.emailId } }).catch(
convertThrowIntoFetchResult
)
@ -59,6 +66,12 @@ const onSetPrimary = async () => {
type: ToastNotificationType.Success,
title: `Made ${props.email} primary`
})
if (originalDistinctId) {
mixpanel.alias(resolveMixpanelUserId(props.email), originalDistinctId)
}
mixpanel.track('Primary Email Changed')
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({

Просмотреть файл

@ -1,96 +1,213 @@
<template>
<section>
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
<div class="md:max-w-4xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader title="Billing" text="Your workspace billing details" />
<CommonCard v-if="versionCount" class="text-body-xs bg-foundation">
<p class="text-foreground font-medium">Workspaces are free while in beta.</p>
<p class="py-6">
Once the beta period ends, workspaces are still free up to
{{ versionCount.max }} model versions.
<br />
To store more versions across your projects you will need to upgrade to a paid
plan.
</p>
<CommonProgressBar
class="my-3"
:current-value="versionCount.current"
:max-value="versionCount.max"
/>
<div class="flex flex-row justify-between">
<p class="text-foreground-2">
Current model versions:
<span class="text-foreground">{{ versionCount.current }}</span>
</p>
<p class="text-foreground-2">
Free model versions limit:
<span class="text-foreground">{{ versionCount.max }}</span>
</p>
</div>
</CommonCard>
<template v-if="isBillingIntegrationEnabled">
<div class="flex flex-col gap-y-4 md:gap-y-6">
<SettingsSectionHeader title="Billing summary" subheading class="pt-4" />
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<CommonCard class="gap-y-1 bg-foundation">
<p class="text-body-xs text-foreground-2 font-medium">
{{ isTrialPeriod ? 'Trial plan' : 'Current plan' }}
</p>
<h4 class="text-heading-lg text-foreground capitalize">
{{ currentPlan?.name }} plan
</h4>
<p
v-if="currentPlan?.name && subscription?.billingInterval"
class="text-body-xs text-foreground-2"
>
£{{ seatPrice }} per seat/month, billed
{{ subscription?.billingInterval }}
</p>
</CommonCard>
<CommonCard class="gap-y-1 bg-foundation">
<p class="text-body-xs text-foreground-2">
{{
isTrialPeriod
? 'Expected bill'
: subscription?.billingInterval === BillingInterval.Monthly
? 'Monthly bill'
: 'Yearly bill'
}}
</p>
<h4 class="text-heading-lg text-foreground capitalize">Coming soon</h4>
</CommonCard>
<CommonCard class="gap-y-1 bg-foundation">
<p class="text-body-xs text-foreground-2">
{{ isTrialPeriod ? 'First payment due' : 'Next payment due' }}
</p>
<h4 class="text-heading-lg text-foreground capitalize">
{{
isPaidPlan
? dayjs(subscription?.currentBillingCycleEnd).format('MMMM D, YYYY')
: 'Never'
}}
</h4>
<p v-if="isPaidPlan" class="text-body-xs text-foreground-2">
<span class="capitalize">{{ subscription?.billingInterval }}</span>
billing period
</p>
</CommonCard>
</div>
<SettingsSectionHeader
title="What your workspace bill on a team plan will look like"
class="pt-6 pb-4 md:pt-10 md:pb-6"
subheading
/>
<BillingSummary v-if="billing?.cost" :workspace-cost="billing.cost" />
<div
v-if="discount && billing?.cost?.subTotal"
class="flex mt-6 bg-foundation border-dashed border border-success"
>
<p class="flex-1 p-3">{{ discount.name }}</p>
<p class="w-32 md:w-40 ml-4 p-3">
£{{ billing.cost.subTotal * discount.amount }} / month
</p>
</div>
<div class="p-3 mt-2 flex flex-col md:flex-row md:items-center">
<p class="text-body-xs text-foreground flex-1">
To learn more about our pricing plans
</p>
<div class="pt-4 md:pt-0 md:pl-4 md:w-40">
<FormButton
:external="true"
to="mailto:hello@speckle.systems"
color="primary"
>
Talk to us
</FormButton>
<CommonCard v-if="isActivePlan" class="bg-foundation">
<div class="flex flex-row gap-x-4 items-center">
<p class="text-body-xs text-foreground-2 flex-1">
View invoices, edit payment details, and manage your subscription from
the billing portal
</p>
<FormButton
color="outline"
:icon-right="ArrowTopRightOnSquareIcon"
@click="openCustomerPortal"
>
Open billing portal
</FormButton>
</div>
</CommonCard>
<SettingsSectionHeader title="Price plans" subheading class="pt-4" />
<div class="flex items-center gap-x-4">
<div class="flex-col pr-6 gap-y-1">
<p class="text-body-xs font-medium text-foreground">Annual billing</p>
<p class="text-body-xs text-foreground-2 leading-5 max-w-md">
Choose annual billing for a 20% discount
</p>
</div>
<FormSwitch
v-model="isYearlyPlan"
:show-label="false"
name="annual billing"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<CommonCard
v-for="pricingPlan in pricingPlans"
:key="pricingPlan.name"
class="gap-y-4"
>
<h4 class="text-heading text-foreground capitalize">
{{ pricingPlan.name }}
</h4>
<FormButton
color="outline"
full-width
@click="onUpgradePlanClick(pricingPlan.name)"
>
Upgrade
</FormButton>
</CommonCard>
</div>
</div>
</div>
</template>
<template v-else>Coming soon</template>
</div>
</section>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { graphql } from '~/lib/common/generated/gql'
import { useQuery } from '@vue/apollo-composable'
import { settingsWorkspaceBillingQuery } from '~/lib/settings/graphql/queries'
import { useQuery, useApolloClient } from '@vue/apollo-composable'
import {
settingsWorkspaceBillingQuery,
settingsWorkspacePricingPlansQuery,
settingsWorkspaceBillingCustomerPortalQuery
} from '~/lib/settings/graphql/queries'
import { useIsBillingIntegrationEnabled } from '~/composables/globals'
import {
WorkspacePlans,
WorkspacePlanStatuses,
BillingInterval
} from '~/lib/common/generated/gql/graphql'
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
import { isWorkspacePricingPlans } from '~/lib/settings/helpers/types'
graphql(`
fragment SettingsWorkspacesBilling_Workspace on Workspace {
billing {
cost {
subTotal
total
...BillingSummary_WorkspaceCost
}
versionsCount {
current
max
}
id
plan {
name
status
}
subscription {
billingInterval
currentBillingCycleEnd
}
}
`)
type SeatPrices = {
[key in WorkspacePlans]: {
[BillingInterval.Monthly]: number
[BillingInterval.Yearly]: number
}
}
const props = defineProps<{
workspaceId: string
}>()
const { result } = useQuery(settingsWorkspaceBillingQuery, () => ({
const isBillingIntegrationEnabled = useIsBillingIntegrationEnabled()
const isYearlyPlan = ref(false)
// TODO: get these from the backend when available
const seatPrices = ref<SeatPrices>({
[WorkspacePlans.Team]: { monthly: 12, yearly: 10 },
[WorkspacePlans.Pro]: { monthly: 40, yearly: 36 },
[WorkspacePlans.Business]: { monthly: 79, yearly: 63 },
[WorkspacePlans.Academia]: { monthly: 0, yearly: 0 },
[WorkspacePlans.Unlimited]: { monthly: 0, yearly: 0 }
})
const { client: apollo } = useApolloClient()
const { result: workspaceResult } = useQuery(settingsWorkspaceBillingQuery, () => ({
workspaceId: props.workspaceId
}))
const { result: pricingPlansResult } = useQuery(settingsWorkspacePricingPlansQuery)
const billing = computed(() => result.value?.workspace.billing)
const versionCount = computed(() => billing.value?.versionsCount)
const discount = computed(() => billing.value?.cost?.discount)
const currentPlan = computed(() => workspaceResult.value?.workspace.plan)
const subscription = computed(() => workspaceResult.value?.workspace.subscription)
const isPaidPlan = computed(
() =>
currentPlan.value?.name !== WorkspacePlans.Academia &&
currentPlan.value?.name !== WorkspacePlans.Unlimited
)
const isTrialPeriod = computed(
() => currentPlan.value?.status === WorkspacePlanStatuses.Trial
)
const isActivePlan = computed(
() =>
currentPlan.value?.status !== WorkspacePlanStatuses.Trial &&
currentPlan.value?.status !== WorkspacePlanStatuses.Canceled
)
const seatPrice = computed(() =>
currentPlan.value && subscription.value
? seatPrices.value[currentPlan.value?.name][subscription.value?.billingInterval]
: 0
)
const pricingPlans = computed(() =>
isWorkspacePricingPlans(pricingPlansResult.value)
? pricingPlansResult.value?.workspacePricingPlans.workspacePlanInformation
: undefined
)
const onUpgradePlanClick = (plan: WorkspacePlans) => {
const cycle = isYearlyPlan.value ? BillingInterval.Yearly : BillingInterval.Monthly
window.location.href = `/api/v1/billing/workspaces/${props.workspaceId}/checkout-session/${plan}/${cycle}`
}
const openCustomerPortal = async () => {
// We need to fetch this on click because the link expires very quickly
const result = await apollo.query({
query: settingsWorkspaceBillingCustomerPortalQuery,
variables: { workspaceId: props.workspaceId },
fetchPolicy: 'no-cache'
})
if (result.data?.workspace.customerPortalUrl) {
window.location.href = result.data.workspace.customerPortalUrl
}
}
</script>

Просмотреть файл

@ -290,6 +290,8 @@ const updateWorkspaceSlug = async (newSlug: string) => {
title: 'Workspace short ID updated'
})
showEditSlugDialog.value = false
slug.value = newSlug
if (route.params.slug === oldSlug) {

Просмотреть файл

@ -5,23 +5,36 @@
max-width="sm"
:buttons="dialogButtons"
>
<p class="text-body-xs text-foreground">
Are you sure you want to
<span class="font-medium">permanently delete</span>
the selected workspace?
</p>
<div
class="rounded border bg-foundation-2 border-outline-3 text-body-2xs text-foreground font-medium py-3 px-4 my-4"
>
{{ workspace.name }}
</div>
<p class="text-body-xs text-foreground">
This action
<span class="font-medium">cannot</span>
be undone.
<p class="text-body-xs text-foreground mb-2">
Are you sure you want to permanently delete
<span class="font-medium">{{ workspace.name }}?</span>
This action cannot be undone.
</p>
<FormTextInput
v-model="workspaceNameInput"
name="workspaceNameConfirm"
label="To confirm deletion, type the workspace name below."
placeholder="Type the workspace name here..."
full-width
show-label
hide-error-message
class="text-sm mb-2"
color="foundation"
/>
<FormTextArea
v-model="feedback"
name="reasonForDeletion"
label="Why did you delete this workspace?"
placeholder="We want to improve so we're curious about your honest feedback"
show-label
show-optional
full-width
class="text-sm mb-2"
color="foundation"
/>
</LayoutDialog>
</template>
<script setup lang="ts">
import { graphql } from '~~/lib/common/generated/gql'
import type {
@ -29,7 +42,7 @@ import type {
UserWorkspacesArgs,
User
} from '~/lib/common/generated/gql/graphql'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { FormTextInput, type LayoutDialogButton } from '@speckle/ui-components'
import { useMutation, useApolloClient } from '@vue/apollo-composable'
import { deleteWorkspaceMutation } from '~/lib/settings/graphql/mutations'
import {
@ -43,6 +56,8 @@ import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { isUndefined } from 'lodash-es'
import { useMixpanel } from '~/lib/core/composables/mp'
import { homeRoute } from '~/lib/common/helpers/route'
import { useZapier } from '~/lib/core/composables/zapier'
import { useForm } from 'vee-validate'
graphql(`
fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {
@ -63,10 +78,14 @@ const { activeUser } = useActiveUser()
const router = useRouter()
const apollo = useApolloClient().client
const mixpanel = useMixpanel()
const { sendWebhook } = useZapier()
const { resetForm } = useForm<{ feedback: string }>()
const workspaceNameInput = ref('')
const feedback = ref('')
const onDelete = async () => {
router.push(homeRoute)
isOpen.value = false
if (workspaceNameInput.value !== props.workspace.name) return
const cache = apollo.cache
const result = await deleteWorkspace({
@ -98,16 +117,30 @@ const onDelete = async () => {
)
}
mixpanel.track('Workspace Deleted', {
// eslint-disable-next-line camelcase
workspace_id: props.workspace.id,
feedback: feedback.value
})
// Only send zapier-discord webhook if not in dev environment
if (!import.meta.dev) {
await sendWebhook('https://hooks.zapier.com/hooks/catch/12120532/2m4okri/', {
userId: activeUser.value?.id ?? '',
feedback: feedback.value
? `Action: Workspace Deleted(${props.workspace.name}) Feedback: ${feedback.value}`
: `Action: Workspace Deleted(${props.workspace.name}) - No feedback provided`
})
}
triggerNotification({
type: ToastNotificationType.Success,
title: 'Workspace deleted',
description: `The ${props.workspace.name} workspace has been deleted`
})
mixpanel.track('Workspace Deleted', {
// eslint-disable-next-line camelcase
workspace_id: props.workspace.id
})
router.push(homeRoute)
isOpen.value = false
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
@ -129,9 +162,14 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Delete',
props: {
color: 'danger'
color: 'danger',
disabled: workspaceNameInput.value !== props.workspace.name
},
onClick: onDelete
}
])
watch(isOpen, () => {
resetForm()
})
</script>

Просмотреть файл

@ -19,11 +19,16 @@
<FormTextInput
v-model:model-value="workspaceShortId"
name="slug"
label="Short ID"
label="New short ID"
:help="`${baseUrl}${workspaceRoute(workspaceShortId)}`"
color="foundation"
:rules="[isStringOfLength({ maxLength: 50, minLength: 3 }), isValidWorkspaceSlug]"
:rules="[isStringOfLength({ maxLength: 50, minLength: 3 })]"
:custom-error-message="
workspaceShortId !== originalSlug ? error?.graphQLErrors[0]?.message : undefined
"
:loading="loading"
show-label
@update:model-value="updateDebouncedShortId"
/>
</LayoutDialog>
</template>
@ -33,11 +38,11 @@ import { useForm } from 'vee-validate'
import { graphql } from '~~/lib/common/generated/gql'
import type { LayoutDialogButton } from '@speckle/ui-components'
import type { SettingsWorkspacesGeneralEditSlugDialog_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import {
isStringOfLength,
isValidWorkspaceSlug
} from '~~/lib/common/helpers/validation'
import { isStringOfLength } from '~~/lib/common/helpers/validation'
import { workspaceRoute } from '~/lib/common/helpers/route'
import { useQuery } from '@vue/apollo-composable'
import { validateWorkspaceSlugQuery } from '~/lib/workspaces/graphql/queries'
import { debounce } from 'lodash'
graphql(`
fragment SettingsWorkspacesGeneralEditSlugDialog_Workspace on Workspace {
@ -57,13 +62,27 @@ const emit = defineEmits<{
(e: 'update:slug', newSlug: string): void
}>()
const { handleSubmit } = useForm<{ slug: string }>()
// Main ref that holds the current value of the slug input.
const workspaceShortId = ref(props.workspace.slug)
// Used to debounce API calls for slug validation.
const debouncedWorkspaceShortId = ref(props.workspace.slug)
// Keeps track of the initially generated slug to prevent unnecessary validations.
const originalSlug = ref(props.workspace.slug)
const { error, loading } = useQuery(
validateWorkspaceSlugQuery,
() => ({
slug: debouncedWorkspaceShortId.value
}),
() => ({
enabled: debouncedWorkspaceShortId.value !== props.workspace.slug
})
)
const { handleSubmit, resetForm } = useForm<{ slug: string }>()
const updateSlug = handleSubmit(() => {
emit('update:slug', workspaceShortId.value)
isOpen.value = false
})
const dialogButtons = computed((): LayoutDialogButton[] => [
@ -78,12 +97,16 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
text: 'Update',
props: {
color: 'primary',
disabled: workspaceShortId.value === props.workspace.slug
disabled: workspaceShortId.value === props.workspace.slug || error.value !== null
},
submit: true
}
])
const updateDebouncedShortId = debounce((value: string) => {
debouncedWorkspaceShortId.value = value
}, 300)
watch(
() => props.workspace.slug,
(newValue) => {
@ -91,4 +114,14 @@ watch(
},
{ immediate: true }
)
watch(
() => isOpen.value,
(newValue) => {
if (!newValue) {
resetForm()
error.value = null
}
}
)
</script>

Просмотреть файл

@ -0,0 +1,83 @@
<template>
<section>
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader title="Regions" text="Manage your data residency" />
<SettingsSectionHeader
title="Default region"
text="The default region will be used to store project data for new projects"
subheading
/>
<div class="pt-6">
<SettingsWorkspacesRegionsSelect
v-model="defaultRegion"
show-label
label="Default region"
:items="availableRegions || []"
:disabled="!availableRegions?.length || isLoading"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { useMutationLoading, useQuery } from '@vue/apollo-composable'
import { debounce } from 'lodash-es'
import { graphql } from '~/lib/common/generated/gql'
import type { SettingsWorkspacesRegionsSelect_ServerRegionItemFragment } from '~/lib/common/generated/gql/graphql'
import { settingsWorkspaceRegionsQuery } from '~/lib/settings/graphql/queries'
import { useSetDefaultWorkspaceRegion } from '~/lib/workspaces/composables/management'
graphql(`
fragment SettingsWorkspacesRegions_Workspace on Workspace {
id
defaultRegion {
id
...SettingsWorkspacesRegionsSelect_ServerRegionItem
}
availableRegions {
id
...SettingsWorkspacesRegionsSelect_ServerRegionItem
}
}
`)
const props = defineProps<{
workspaceId: string
}>()
const isLoading = useMutationLoading()
const setDefaultWorkspaceRegion = useSetDefaultWorkspaceRegion()
const { result } = useQuery(settingsWorkspaceRegionsQuery, () => ({
workspaceId: props.workspaceId
}))
const defaultRegion = ref<SettingsWorkspacesRegionsSelect_ServerRegionItemFragment>()
const availableRegions = computed(() => result.value?.workspace.availableRegions || [])
const saveDefaultRegion = async () => {
const regionKey = defaultRegion.value?.key
if (!regionKey) return
if (regionKey === result.value?.workspace.defaultRegion?.key) return
await setDefaultWorkspaceRegion({
workspaceId: props.workspaceId,
regionKey
})
}
const debouncedSaveDefaultRegion = debounce(saveDefaultRegion, 1000)
watch(
result,
() => {
defaultRegion.value = result.value?.workspace.defaultRegion || undefined
},
{ immediate: true }
)
watch(defaultRegion, (newVal, oldVal) => {
if (newVal === oldVal) return
if (newVal?.id === oldVal?.id) return
debouncedSaveDefaultRegion()
})
</script>

Просмотреть файл

@ -84,6 +84,15 @@
:user="userToModify"
:workspace-id="workspaceId"
/>
<SettingsSharedChangeRoleDialog
v-model:open="showChangeUserRoleDialog"
:workspace-domain-policy-compliant="
userToModify?.user.workspaceDomainPolicyCompliant
"
:current-role="Roles.Workspace.Guest"
@update-role="onUpdateRole"
/>
</div>
</template>
@ -93,10 +102,9 @@ import type {
WorkspaceCollaborator
} from '~/lib/common/generated/gql/graphql'
import { graphql } from '~/lib/common/generated/gql'
import { Roles } from '@speckle/shared'
import { Roles, type WorkspaceRoles } from '@speckle/shared'
import { settingsWorkspacesMembersSearchQuery } from '~~/lib/settings/graphql/queries'
import { useQuery } from '@vue/apollo-composable'
import { useMixpanel } from '~/lib/core/composables/mp'
import { useWorkspaceUpdateRole } from '~/lib/workspaces/composables/management'
import { HorizontalDirection } from '~~/lib/common/composables/window'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
@ -138,7 +146,8 @@ graphql(`
enum ActionTypes {
ChangeProjectPermissions = 'change-project-permissions',
RemoveMember = 'remove-member'
RemoveMember = 'remove-member',
ChangeRole = 'change-role'
}
const props = defineProps<{
@ -151,12 +160,12 @@ const showActionsMenu = ref<Record<string, boolean>>({})
const showDeleteUserRoleDialog = ref(false)
const showGuestsPermissionsDialog = ref(false)
const userIdToModify = ref<string | null>(null)
const showChangeUserRoleDialog = ref(false)
const userToModify = computed(
() => guests.value.find((guest) => guest.id === userIdToModify.value) || null
)
const mixpanel = useMixpanel()
const updateUserRole = useWorkspaceUpdateRole()
const { result: searchResult, loading: searchResultLoading } = useQuery(
@ -190,6 +199,10 @@ const actionItems = computed(() => {
[{ title: 'Remove guest...', id: ActionTypes.RemoveMember }]
]
if (isWorkspaceAdmin.value) {
items.unshift([{ title: 'Update role...', id: ActionTypes.ChangeRole }])
}
if (guests.value.find((guest) => guest.projectRoles.length)) {
items.unshift([
{
@ -208,6 +221,9 @@ const onActionChosen = (actionItem: LayoutMenuItem, user: WorkspaceCollaborator)
if (actionItem.id === ActionTypes.ChangeProjectPermissions) {
showGuestsPermissionsDialog.value = true
}
if (actionItem.id === ActionTypes.ChangeRole) {
showChangeUserRoleDialog.value = true
}
if (actionItem.id === ActionTypes.RemoveMember) {
showDeleteUserRoleDialog.value = true
}
@ -221,14 +237,19 @@ const onRemoveUser = async () => {
role: null,
workspaceId: props.workspaceId
})
mixpanel.track('Workspace User Removed', {
// eslint-disable-next-line camelcase
workspace_id: props.workspaceId
})
}
const toggleMenu = (itemId: string) => {
showActionsMenu.value[itemId] = !showActionsMenu.value[itemId]
}
const onUpdateRole = async (newRoleValue: WorkspaceRoles) => {
if (!userToModify.value || !newRoleValue) return
await updateUserRole({
userId: userToModify.value.id,
role: newRoleValue,
workspaceId: props.workspaceId
})
}
</script>

Просмотреть файл

@ -82,9 +82,8 @@
</LayoutTable>
<SettingsSharedChangeRoleDialog
v-model:open="showChangeUserRoleDialog"
:name="userToModify?.name ?? ''"
:is-workspace-admin="isWorkspaceAdmin"
:workspace-domain-policy-compliant="userToModify?.workspaceDomainPolicyCompliant"
:current-role="currentUserRole"
@update-role="onUpdateRole"
/>
<SettingsSharedDeleteUserDialog
@ -117,7 +116,6 @@ import {
import { useWorkspaceUpdateRole } from '~/lib/workspaces/composables/management'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
import { HorizontalDirection } from '~~/lib/common/composables/window'
import { useMixpanel } from '~/lib/core/composables/mp'
import { getRoleLabel } from '~~/lib/settings/helpers/utils'
type UserItem = (typeof members)['value'][0]
@ -182,7 +180,6 @@ const { result: searchResult, loading: searchResultLoading } = useQuery(
)
const updateUserRole = useWorkspaceUpdateRole()
const mixpanel = useMixpanel()
const { activeUser } = useActiveUser()
const showChangeUserRoleDialog = ref(false)
@ -218,6 +215,14 @@ const hasNoResults = computed(
(search.value.length || roleFilter.value) &&
searchResult.value?.workspace.team.items.length === 0
)
const currentUserRole = computed<WorkspaceRoles | undefined>(() => {
if (userToModify.value?.role && isWorkspaceRole(userToModify.value.role)) {
return userToModify.value.role
}
return undefined
})
const filteredActionsItems = (user: UserItem) => {
const baseItems: LayoutMenuItem[][] = []
@ -262,12 +267,6 @@ const onUpdateRole = async (newRoleValue: WorkspaceRoles) => {
role: newRoleValue,
workspaceId: props.workspaceId
})
mixpanel.track('Workspace User Role Updated', {
newRole: newRoleValue,
// eslint-disable-next-line camelcase
workspace_id: props.workspaceId
})
}
const onRemoveUser = async () => {
@ -278,11 +277,6 @@ const onRemoveUser = async () => {
role: null,
workspaceId: props.workspaceId
})
mixpanel.track('Workspace User Removed', {
// eslint-disable-next-line camelcase
workspace_id: props.workspaceId
})
}
const onActionChosen = (actionItem: LayoutMenuItem, user: UserItem) => {

Просмотреть файл

@ -0,0 +1,71 @@
<template>
<FormSelectBase
v-bind="props"
v-model="selectedValue"
:name="name || 'regions'"
:allow-unset="false"
mount-menu-on-body
>
<template #option="{ item }">
<div class="flex flex-col items-start justify-center">
<span class="truncate">{{ item.name }}</span>
<span class="text-foreground-2 truncate">{{ item.description }}</span>
</div>
</template>
<template #nothing-selected>
{{ multiple ? 'Select regions' : 'Select a region' }}
</template>
<template #something-selected="{ value }">
<template v-if="isArray(value)">
{{ value.map((v) => v.name).join(', ') }}
</template>
<template v-else>
{{ value.name }}
</template>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { isArray } from 'lodash-es'
import type { RuleExpression } from 'vee-validate'
import { graphql } from '~/lib/common/generated/gql'
import type { SettingsWorkspacesRegionsSelect_ServerRegionItemFragment } from '~/lib/common/generated/gql/graphql'
import { useFormSelectChildInternals } from '~/lib/form/composables/select'
graphql(`
fragment SettingsWorkspacesRegionsSelect_ServerRegionItem on ServerRegionItem {
id
key
name
description
}
`)
type ItemType = SettingsWorkspacesRegionsSelect_ServerRegionItemFragment
type ValueType = ItemType | ItemType[] | undefined
const emit = defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = defineProps<{
modelValue?: ValueType
label: string
items: ItemType[]
multiple?: boolean
name?: string
showOptional?: boolean
showRequired?: boolean
showLabel?: boolean
labelId?: string
buttonId?: string
help?: string
disabled?: boolean
rules?: RuleExpression<ItemType | ItemType[] | undefined>
}>()
const { selectedValue } = useFormSelectChildInternals<ItemType>({
props: toRefs(props),
emit
})
</script>

Просмотреть файл

@ -51,7 +51,9 @@
:rules="[isRequired, isUrl, isStringOfLength({ minLength: 5 })]"
/>
<div class="mt-6">
<FormButton color="primary" @click="onSubmit">Save</FormButton>
<FormButton :disabled="!challenge" color="primary" @click="onSubmit">
Save
</FormButton>
</div>
</div>
</form>
@ -63,6 +65,8 @@ import { useForm } from 'vee-validate'
import { isRequired, isStringOfLength, isUrl } from '~~/lib/common/helpers/validation'
import { graphql } from '~~/lib/common/generated/gql'
import type { SettingsWorkspacesSecuritySso_WorkspaceFragment } from '~~/lib/common/generated/gql/graphql'
import { usePostAuthRedirect } from '~/lib/auth/composables/postAuthRedirect'
import { useLoginOrRegisterUtils } from '~/lib/auth/composables/auth'
graphql(`
fragment SettingsWorkspacesSecuritySso_Workspace on Workspace {
@ -83,6 +87,8 @@ const props = defineProps<{
}>()
const apiOrigin = useApiOrigin()
const { challenge } = useLoginOrRegisterUtils()
const postAuthRedirect = usePostAuthRedirect()
const { handleSubmit } = useForm<FormValues>()
const providerName = ref('')
@ -96,10 +102,13 @@ const onSubmit = handleSubmit(() => {
`providerName=${providerName.value}`,
`clientId=${clientId.value}`,
`clientSecret=${clientSecret.value}`,
`issuerUrl=${issuerUrl.value}`
`issuerUrl=${issuerUrl.value}`,
`challenge=${challenge.value}`
]
const route = `${baseUrl}?${params.join('&')}`
postAuthRedirect.set(`/workspaces/${props.workspace.slug}?settings=server/general`)
navigateTo(route, {
external: true
})

Просмотреть файл

@ -22,7 +22,7 @@
>
<div
v-show="item.expanded"
class="transition bg-foundation rounded-lg shadow-md mb-8 mx-2 px-4 py-4 gap-2 sm:gap-4 sm:ml-12 sm:max-w-xs pointer-events-auto"
class="transition bg-foundation-page border border-outline-3 rounded-lg shadow-md mb-8 mx-2 gap-2 sm:gap-4 sm:ml-12 sm:max-w-xs pointer-events-auto"
>
<div
class="sm:hidden flex items-center justify-center w-full gap-3 mt-1 mb-3"
@ -41,21 +41,35 @@
></div>
</div>
<slot></slot>
<div class="px-6 py-4">
<slot></slot>
</div>
<div class="flex items-center justify-between pointer-events-auto mt-4">
<div
class="flex items-center justify-between pointer-events-auto px-6 py-2 border-t border-outline-3"
>
<slot name="actions">
<FormButton text color="outline" @click="$emit('skip')">Skip</FormButton>
<div class="flex justify-center space-x-2">
<FormButton text size="sm" color="outline" @click="$emit('skip')">
Skip
</FormButton>
<div class="flex justify-center items-center space-x-2">
<FormButton
v-show="index !== 0"
:icon-left="ArrowLeftIcon"
size="sm"
color="outline"
text
@click="prev(index)"
>
<ArrowLeftIcon class="h-3 w-3 mr-1" />
Previous
</FormButton>
<FormButton :icon-right="ArrowRightIcon" @click="next(index)">
<div v-if="index === 2">
<div v-if="!disableNext" v-tippy="'First add another model'">
<FormButton disabled>Finish</FormButton>
</div>
<FormButton v-else @click="$emit('skip')">Finish</FormButton>
</div>
<FormButton v-else :icon-right="ArrowRightIcon" @click="next(index)">
Next
</FormButton>
</div>
@ -83,6 +97,7 @@ defineEmits(['skip', 'previous', 'next'])
const props = defineProps<{
index: number
item: SlideshowItem
disableNext: boolean
}>()
const {

Просмотреть файл

@ -2,48 +2,24 @@
<div
class="relative max-w-4xl w-screen h-[100dvh] flex items-center justify-center z-50"
>
<TourSegmentation v-if="showSegmentation && step === 0" @next="step++" />
<TourSlideshow v-if="step === 1" @next="step++" />
<!-- <OnboardingDialogManager v-if="step === 2" allow-escape @cancel="step++" /> -->
<div
v-if="step === 2 && !hasCompletedChecklistV1"
class="relative w-full pointer-events-auto space-y-2 z-50"
>
<div
v-if="!isSmallerOrEqualSm"
class="pointer-events-none fixed inset-0 bg-neutral-100/70 dark:bg-neutral-900/70 transition-opacity z-50"
/>
<div v-if="!isSmallerOrEqualSm" class="relative z-50">
<OnboardingChecklistV1 show-bottom-escape background @dismiss="step++" />
</div>
</div>
<TourSegmentation v-if="showSegmentation" />
<TourSlideshow v-else @next="$emit('complete')" />
</div>
</template>
<script setup lang="ts">
import { useViewerTour } from '~/lib/viewer/composables/tour'
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
import { useMixpanel } from '~~/lib/core/composables/mp'
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
const step = ref(0)
const hasCompletedChecklistV1 = useSynchronizedCookie<boolean>(
`hasCompletedChecklistV1`,
{ default: () => false }
)
defineEmits(['complete'])
const { showSegmentation } = useViewerTour()
const mp = useMixpanel()
watch(step, (val) => {
let stepName = 'segmentation'
if (val === 1) stepName = 'slideshow'
if (val === 2) stepName = 'checklist'
watch(showSegmentation, (val) => {
mp.track('Onboarding Action', {
type: 'action',
name: 'step-activation',
step: val,
stepName
stepName: val ? 'slideshow' : 'segmentation'
})
})
</script>

Просмотреть файл

@ -15,9 +15,10 @@
:class="isSmallerOrEqualSm ? 'bottom-0 left-0 w-screen' : ''"
:style="isSmallerOrEqualSm ? undefined : item.style"
:show-controls="item.showControls"
:disable-next="hasAddedOverlay"
@skip="finishSlideshow()"
>
<Component :is="tourItems[index]" />
<Component :is="tourItems[index]" @has-added-overlay="hasAddedOverlay = true" />
</TourComment>
<!-- In case the bubble is closed by the user, we need to display something -->
<Transition
@ -76,6 +77,7 @@ const tourItems = [FirstTip, BasicViewerNavigation, OverlayModel /* , LastTip */
const slideshowItems = ref(slideshowItemsRaw.slice(0, tourItems.length))
provide('slideshowItems', slideshowItems)
const hasAddedOverlay = ref(false)
const lastOpenIndex = ref(0)
const mp = useMixpanel()

Просмотреть файл

@ -4,18 +4,16 @@
<p class="text-sm">
Speckle allows you to load multiple models in the same viewer.
</p>
<p class="text-sm mt-2">
<p class="text-sm mt-3">
<span v-show="!hasAddedOverlay">
<FormButton
link
text
:icon-right="hasAddedOverlay ? CheckIcon : null"
color="outline"
:icon-right="hasAddedOverlay ? CheckIcon : PlusIcon"
:disabled="hasAddedOverlay"
@click="addOverlay()"
>
Click here
Add another model
</FormButton>
to give it a try!
</span>
</p>
</div>
@ -31,7 +29,7 @@
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from '@heroicons/vue/24/solid'
import { CheckIcon, PlusIcon } from '@heroicons/vue/24/solid'
import { SpeckleViewer } from '@speckle/shared'
import { useQuery } from '@vue/apollo-composable'
import { latestModelsQuery } from '~~/lib/projects/graphql/queries'
@ -42,6 +40,8 @@ import {
import { SECOND_MODEL_NAME } from '~~/lib/auth/composables/onboarding'
const emit = defineEmits(['hasAddedOverlay'])
const { items } = useInjectedViewerRequestedResources()
const { project } = useInjectedViewerLoadedResources()
const id = project.value?.id as string
@ -62,6 +62,7 @@ async function addOverlay() {
])
hasAddedOverlay.value = true
emit('hasAddedOverlay')
}
onBeforeUnmount(() => {

Просмотреть файл

@ -78,15 +78,11 @@
(!isEmbedEnabled && spotlightUserSessionId && spotlightUser) ||
followers.length !== 0
"
class="absolute w-screen z-10 p-1"
:class="
isEmbedEnabled
? 'h-[calc(100dvh-3.5rem)]'
: 'h-[calc(100dvh-3.5rem)] mt-[3.5rem]'
"
class="absolute w-screen z-10 p-1 h-[calc(100dvh-3rem)]"
:class="isEmbedEnabled ? '' : 'mt-[3rem]'"
>
<div
class="w-full h-full outline -outline-offset-0 outline-8 rounded-md outline-blue-500"
class="w-full h-full outline -outline-offset-0 outline-8 rounded-md outline-primary"
>
<div class="absolute top-0 left-0 w-full justify-center flex">
<svg
@ -96,7 +92,7 @@
viewBox="0 0 8 8"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0C4.5 0 8 3.5 8 8V0H0Z" class="fill-blue-500" />
<path d="M0 0C4.5 0 8 3.5 8 8V0H0Z" class="fill-primary" />
</svg>
<div
class="pointer-events-auto bg-primary text-white text-xs px-3 h-8 flex items-center rounded-b-md cursor-default"
@ -131,7 +127,7 @@
viewBox="0 0 8 8"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0H8C3.5 0 0 3.5 0 8V0Z" class="fill-blue-500" />
<path d="M0 0H8C3.5 0 0 3.5 0 8V0Z" class="fill-primary" />
</svg>
</div>
</div>

Просмотреть файл

@ -118,7 +118,7 @@
v-tippy="isSmallerOrEqualSm ? undefined : sectionBoxShortcut"
flat
secondary
:active="isSectionBoxEnabled"
:active="isSectionBoxVisible"
@click="toggleSectionBox()"
>
<ScissorsIcon class="h-4 w-4 md:h-5 md:w-5" />
@ -238,6 +238,9 @@
</div>
</div>
</div>
<Portal v-if="isSectionBoxEnabled && isSectionBoxEdited" to="pocket-actions">
<FormButton @click="resetSectionBox()">Reset section box</FormButton>
</Portal>
</div>
<div v-else />
</template>
@ -268,7 +271,6 @@ import {
useInjectedViewerState
} from '~~/lib/viewer/composables/setup'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
import { useViewerTour } from '~/lib/viewer/composables/tour'
@ -346,7 +348,13 @@ type ActiveControl =
| 'gendo'
const { resourceItems, modelsAndVersionIds } = useInjectedViewerLoadedResources()
const { toggleSectionBox, isSectionBoxEnabled } = useSectionBoxUtilities()
const {
resetSectionBox,
isSectionBoxEnabled,
isSectionBoxVisible,
toggleSectionBox,
isSectionBoxEdited
} = useSectionBoxUtilities()
const { getActiveMeasurement, removeMeasurement, enableMeasurements } =
useMeasurementUtilities()
const { showNavbar, showControls } = useViewerTour()
@ -388,12 +396,12 @@ const {
} = useInjectedViewerInterfaceState()
const map: Record<ViewerKeyboardActions, [ModifierKeys[], string]> = {
[ViewerKeyboardActions.ToggleModels]: [[ModifierKeys.Shift], 'm'],
[ViewerKeyboardActions.ToggleExplorer]: [[ModifierKeys.Shift], 'e'],
[ViewerKeyboardActions.ToggleDiscussions]: [[ModifierKeys.Shift], 't'],
[ViewerKeyboardActions.ToggleMeasurements]: [[ModifierKeys.Shift], 'r'],
[ViewerKeyboardActions.ToggleProjection]: [[ModifierKeys.Shift], 'p'],
[ViewerKeyboardActions.ToggleSectionBox]: [[ModifierKeys.Shift], 'b'],
[ViewerKeyboardActions.ToggleModels]: [[ModifierKeys.Shift], 'M'],
[ViewerKeyboardActions.ToggleExplorer]: [[ModifierKeys.Shift], 'E'],
[ViewerKeyboardActions.ToggleDiscussions]: [[ModifierKeys.Shift], 'T'],
[ViewerKeyboardActions.ToggleMeasurements]: [[ModifierKeys.Shift], 'R'],
[ViewerKeyboardActions.ToggleProjection]: [[ModifierKeys.Shift], 'P'],
[ViewerKeyboardActions.ToggleSectionBox]: [[ModifierKeys.Shift], 'B'],
[ViewerKeyboardActions.ZoomExtentsOrSelection]: [[ModifierKeys.Shift], 'space']
}
@ -491,14 +499,6 @@ const trackAndtoggleProjection = () => {
})
}
watch(isSectionBoxEnabled, (val) => {
mp.track('Viewer Action', {
type: 'action',
name: 'section-box',
status: val
})
})
const scrollControlsToBottom = () => {
// TODO: Currently this will scroll to the very bottom, which doesn't make sense when there are multiple models loaded
// if (scrollableControlsContainer.value)
@ -515,10 +515,6 @@ onMounted(() => {
activeControl.value = isSmallerOrEqualSm.value ? 'none' : 'models'
})
watch(isSmallerOrEqualSm, (newVal) => {
activeControl.value = newVal ? 'none' : 'models'
})
onKeyStroke('Escape', () => {
const isActiveMeasurement = getActiveMeasurement()
@ -531,4 +527,24 @@ onKeyStroke('Escape', () => {
activeControl.value = 'none'
}
})
watch(isSmallerOrEqualSm, (newVal) => {
activeControl.value = newVal ? 'none' : 'models'
})
watch(isSectionBoxEnabled, (val) => {
mp.track('Viewer Action', {
type: 'action',
name: 'section-box',
status: val
})
})
watch(isSectionBoxVisible, (val) => {
mp.track('Viewer Action', {
type: 'action',
name: 'section-box-visibility',
status: val
})
})
</script>

Просмотреть файл

@ -32,7 +32,7 @@
v-if="showTour"
class="fixed w-full h-[100dvh] flex justify-center items-center pointer-events-none z-[100]"
>
<TourOnboarding />
<TourOnboarding @complete="showTour = false" />
</div>
<!-- Viewer host -->
<div

Просмотреть файл

@ -62,7 +62,10 @@
<div v-if="commentThreads.length === 0" class="pb-4">
<ProjectPageLatestItemsCommentsEmptyState
small
:show-button="canPostComment"
:show-button="canPostComment && hasSelectedObjects"
:text="
hasSelectedObjects ? undefined : 'Select an object to start collaborating'
"
@new-discussion="onNewDiscussion"
/>
</div>
@ -88,6 +91,7 @@ import {
} from '~~/lib/viewer/composables/setup'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useCheckViewerCommentingAccess } from '~~/lib/viewer/composables/commentManagement'
import { useSelectionUtilities } from '~~/lib/viewer/composables/ui'
defineEmits(['close'])
@ -168,7 +172,12 @@ watch(includeArchived, (newVal) =>
})
)
const { objectIds: selectedObjectIds } = useSelectionUtilities()
const hasSelectedObjects = computed(() => selectedObjectIds.value.size > 0)
const onNewDiscussion = () => {
if (!hasSelectedObjects.value) return
newThreadEditor.value = true
}
</script>

Просмотреть файл

@ -14,6 +14,7 @@
</div>
</template>
<script setup lang="ts">
import { isNullOrUndefined } from '@speckle/shared'
import { CommonLoadingBar } from '@speckle/ui-components'
import { useLazyQuery } from '@vue/apollo-composable'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
@ -42,8 +43,7 @@ const kvps = computed(() => {
const keys = Object.keys(obj)
const localKvps = []
for (const key of keys) {
// if (!obj[key]) continue // TODO: deal with null/undef
const value = obj[key] || obj[key] === 0 ? obj[key] : 'null'
const value = !isNullOrUndefined(obj[key]) ? obj[key] : 'null/undefined'
localKvps.push({
key,
value,

Просмотреть файл

@ -115,6 +115,11 @@ const {
} = useFilterUtilities()
const revitPropertyRegex = /^parameters\./
// Note: we've split this regex check in two to not clash with navis properties. This makes generally makes dim very sad, as we're layering hacks.
// Navis object properties come under `properties`, same as revit ones - as such we can't assume they're the same. Here we're targeting revit's
// specific two subcategories of `properties`.
const revitPropertyRegexDui3000InstanceProps = /^properties\.Instance/ // note this is partially valid for civil3d, or dim should test against it
const revitPropertyRegexDui3000TypeProps = /^properties\.Type/ // note this is partially valid for civil3d, or dim should test against it
const showAllFilters = ref(false)
@ -123,7 +128,11 @@ const props = defineProps<{
}>()
const isRevitProperty = (key: string): boolean => {
return revitPropertyRegex.test(key)
return (
revitPropertyRegex.test(key) ||
revitPropertyRegexDui3000InstanceProps.test(key) ||
revitPropertyRegexDui3000TypeProps.test(key)
)
}
const relevantFilters = computed(() => {
@ -144,6 +153,9 @@ const relevantFilters = computed(() => {
f.key.includes('midPoint.') ||
f.key.includes('startPoint.') ||
f.key.includes('startPoint.') ||
f.key.includes('.materialName') ||
f.key.includes('.materialClass') ||
f.key.includes('.materialCategory') ||
f.key.includes('displayStyle') ||
f.key.includes('displayValue') ||
f.key.includes('displayMesh')

Просмотреть файл

@ -6,7 +6,7 @@
:item="vg"
/>
<div v-if="itemCount < filter.valueGroups.length" class="mb-2">
<FormButton size="sm" text full-width @click="itemCount += 10">
<FormButton size="sm" text full-width @click="itemCount += 30">
View more ({{ filter.valueGroups.length - itemCount }})
</FormButton>
</div>

Просмотреть файл

@ -76,7 +76,14 @@ const timeOutWait = ref(false)
const enqueMagic = async () => {
isLoading.value = true
const screenshot = await viewerInstance.getExtension(PassReader).read()
const [depthData, width, height] = await viewerInstance
.getExtension(PassReader)
.read('DEPTH')
const screenshot = PassReader.toBase64(
PassReader.decodeDepth(depthData),
width,
height
)
void lodgeRequest(screenshot)
timeOutWait.value = true
@ -117,7 +124,7 @@ const lodgeRequest = async (screenshot: string) => {
const err = getFirstErrorMessage(res.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to enque Gendo render',
title: 'Failed to enqueue Gendo render',
description: err
})
} else {

Просмотреть файл

@ -31,7 +31,8 @@
</div>
</button>
</div>
<div v-if="unfold" class="ml-1 space-y-1 px-2 py-1">
<div v-if="unfold" class="space-y-1 px-0 py-1">
<!-- key value pair display -->
<div
v-for="(kvp, index) in [
...categorisedValuePairs.primitives,
@ -63,6 +64,9 @@
>
{{ kvp.value === null ? 'null' : kvp.value }}
</span>
<span v-if="kvp.units" class="truncate opacity-70">
{{ kvp.units }}
</span>
<button
v-if="isCopyable(kvp)"
:class="isCopyable(kvp) ? 'cursor-pointer' : 'cursor-default'"
@ -83,7 +87,7 @@
<ViewerSelectionObject
:object="(kvp.value as SpeckleObject) || {}"
:title="(kvp.key as string)"
:unfold="false"
:unfold="autoUnfoldKeys.includes(kvp.key)"
/>
</div>
<div
@ -156,6 +160,7 @@ const props = withDefaults(
const { highlightObjects, unhighlightObjects } = useHighlightedObjectsUtilities()
const unfold = ref(props.unfold)
const autoUnfoldKeys = ['properties', 'Instance Parameters']
const isAdded = computed(() => {
if (!diffEnabled.value) return false
@ -250,7 +255,7 @@ const ignoredProps = [
]
const keyValuePairs = computed(() => {
const kvps = [] as Record<string, unknown>[]
const kvps = [] as (Record<string, unknown> & { key: string; value: unknown })[]
// handle revit paramters
if (props.title === 'parameters') {
@ -273,6 +278,7 @@ const keyValuePairs = computed(() => {
const objectKeys = Object.keys(props.object)
for (const key of objectKeys) {
if (ignoredProps.includes(key)) continue
const type = Array.isArray(props.object[key]) ? 'array' : typeof props.object[key]
let innerType = null
let arrayLength = null
@ -286,6 +292,21 @@ const keyValuePairs = computed(() => {
if (arr.length > 10) arrayPreview += ' ...' // in case truncate doesn't hit
}
}
if (
props.object[key] &&
isNameValuePair(props.object[key] as Record<string, unknown>)
) {
// note: handles name value pairs from dui3 -
const { value, units } = props.object[key] as { value: string; units?: string }
kvps.push({
key,
type: typeof value,
value: value as string,
units
})
continue
}
kvps.push({
key,
type,
@ -299,14 +320,24 @@ const keyValuePairs = computed(() => {
return kvps
})
const isNameValuePair = (obj: Record<string, unknown>) => {
const keys = Object.keys(obj)
return keys.includes('name') && keys.includes('value')
}
const categorisedValuePairs = computed(() => {
return {
primitives: keyValuePairs.value.filter(
(item) => item.type !== 'object' && item.type !== 'array' && item.value !== null
),
objects: keyValuePairs.value.filter(
(item) => item.type === 'object' && item.value !== null
),
objects: keyValuePairs.value
.filter((item) => item.type === 'object' && item.value !== null)
.filter((item) => {
const keys = Object.keys(item.value as unknown as Record<string, unknown>)
const nvp = keys.includes('name') && keys.includes('value')
return !nvp
}) // filters out name value pairs - note on new properties structure coming out of DUI3
.sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase())),
nonPrimitiveArrays: keyValuePairs.value.filter(
(item) =>
item.type === 'array' &&

Просмотреть файл

@ -23,12 +23,11 @@
label="Short ID"
:help="getShortIdHelp"
color="foundation"
:rules="[
isStringOfLength({ maxLength: 50, minLength: 3 }),
isValidWorkspaceSlug
]"
:loading="loading"
:rules="isStringOfLength({ maxLength: 50, minLength: 3 })"
:custom-error-message="error?.graphQLErrors[0]?.message"
show-label
@update:model-value="shortIdManuallyEdited = true"
@update:model-value="onSlugChange"
/>
<UserAvatarEditable
v-model:edit-mode="editAvatarMode"
@ -49,13 +48,11 @@ import type { MaybeNullOrUndefined } from '@speckle/shared'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useCreateWorkspace } from '~/lib/workspaces/composables/management'
import { useWorkspacesAvatar } from '~/lib/workspaces/composables/avatar'
import {
isRequired,
isStringOfLength,
isValidWorkspaceSlug
} from '~~/lib/common/helpers/validation'
import { isRequired, isStringOfLength } from '~~/lib/common/helpers/validation'
import { generateSlugFromName } from '@speckle/shared'
import { debounce } from 'lodash'
import { useQuery } from '@vue/apollo-composable'
import { validateWorkspaceSlugQuery } from '~/lib/workspaces/graphql/queries'
const emit = defineEmits<(e: 'created') => void>()
@ -69,15 +66,25 @@ const isOpen = defineModel<boolean>('open', { required: true })
const createWorkspace = useCreateWorkspace()
const { generateDefaultLogoIndex, getDefaultAvatar } = useWorkspacesAvatar()
const { handleSubmit } = useForm<{ name: string; slug: string }>()
const { handleSubmit, resetForm } = useForm<{ name: string; slug: string }>()
const workspaceName = ref('')
const workspaceShortId = ref('')
const debouncedWorkspaceShortId = ref('')
const editAvatarMode = ref(false)
const workspaceLogo = ref<MaybeNullOrUndefined<string>>()
const defaultLogoIndex = ref(0)
const shortIdManuallyEdited = ref(false)
const customShortIdError = ref('')
const { error, loading } = useQuery(
validateWorkspaceSlugQuery,
() => ({
slug: debouncedWorkspaceShortId.value
}),
() => ({
enabled: !!debouncedWorkspaceShortId.value
})
)
const baseUrl = useRuntimeConfig().public.baseUrl
@ -105,7 +112,7 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
disabled:
!workspaceName.value.trim() ||
!workspaceShortId.value.trim() ||
!!customShortIdError.value
error.value !== null
}
}
])
@ -122,7 +129,7 @@ const handleCreateWorkspace = handleSubmit(async () => {
{ source: props.eventSource }
)
if (newWorkspace) {
if (newWorkspace && !newWorkspace?.errors) {
emit('created')
isOpen.value = false
}
@ -135,21 +142,36 @@ const onLogoSave = (newVal: MaybeNullOrUndefined<string>) => {
const reset = () => {
defaultLogoIndex.value = generateDefaultLogoIndex()
workspaceName.value = ''
workspaceShortId.value = ''
debouncedWorkspaceShortId.value = ''
workspaceLogo.value = null
editAvatarMode.value = false
shortIdManuallyEdited.value = false
customShortIdError.value = ''
error.value = null
}
const updateShortId = debounce((newName: string) => {
if (!shortIdManuallyEdited.value) {
workspaceShortId.value = generateSlugFromName({ name: newName })
const newSlug = generateSlugFromName({ name: newName })
workspaceShortId.value = newSlug
updateDebouncedShortId(newSlug)
}
}, 600)
const updateDebouncedShortId = debounce((newSlug: string) => {
debouncedWorkspaceShortId.value = newSlug
}, 300)
const onSlugChange = (newSlug: string) => {
workspaceShortId.value = newSlug
shortIdManuallyEdited.value = true
updateDebouncedShortId(newSlug)
}
// Seperate resets to avoid a temporary invalid state on submission
watch(isOpen, (newVal) => {
if (newVal) reset()
if (!newVal) {
reset()
resetForm()
}
})
</script>

Просмотреть файл

@ -29,7 +29,7 @@
<span
v-tippy="
project.role !== Roles.Stream.Owner &&
'Only project owners can move projects'
'Only the project owner can move this project'
"
>
<FormButton
@ -44,7 +44,7 @@
</div>
</div>
<p v-else class="py-4 text-body-xs text-foreground-2">
You don't have any projects that are moveable to this workspace
You don't have any projects that can be moved into this workspace
</p>
<ProjectsMoveToWorkspaceDialog

Просмотреть файл

@ -47,7 +47,7 @@
<FormButton
v-else
class="hidden md:block"
color="outline"
color="subtle"
@click="showMoveProjectsDialog = true"
>
Move projects
@ -277,6 +277,9 @@ const onShowSettingsDialog = (target: AvailableSettingsMenuKeys) => {
onResult((queryResult) => {
if (queryResult.data?.workspaceBySlug) {
workspaceMixpanelUpdateGroup(queryResult.data.workspaceBySlug)
useHeadSafe({
title: queryResult.data.workspaceBySlug.name
})
}
})
</script>

Просмотреть файл

@ -35,14 +35,8 @@
</div>
</div>
</div>
<div
class="flex justify-between md:items-center gap-x-3 md:flex-row"
:class="[isWorkspaceAdmin ? 'flex-col' : 'flex-row items-center']"
>
<div
class="flex items-center gap-x-3 md:mb-0"
:class="[isWorkspaceAdmin ? 'mb-3' : ' flex-1']"
>
<div class="flex justify-between items-center gap-x-3">
<div class="flex items-center gap-x-3 md:mb-0 flex-1">
<CommonBadge rounded :color-classes="'text-foreground-2 bg-primary-muted'">
{{ workspaceInfo.totalProjects.totalCount || 0 }} Project{{
workspaceInfo.totalProjects.totalCount === 1 ? '' : 's'
@ -55,26 +49,22 @@
</CommonBadge>
</div>
<div class="flex items-center gap-x-3">
<div v-if="workspaceInfo.billing" class="flex-1 md:flex-auto">
<button
class="block"
@click="openSettingsDialog(SettingMenuKeys.Workspace.Billing)"
>
<WorkspacePageVersionCount
:versions-count="workspaceInfo.billing.versionsCount"
/>
</button>
</div>
<div class="flex items-center gap-x-3">
<button
class="block"
@click="openSettingsDialog(SettingMenuKeys.Workspace.Members)"
<div
v-if="!isWorkspaceGuest"
v-tippy="isWorkspaceAdmin ? 'Manage members' : 'View members'"
>
<UserAvatarGroup
:users="team.map((teamMember) => teamMember.user)"
class="max-w-[104px]"
/>
</button>
<button
class="block"
@click="openSettingsDialog(SettingMenuKeys.Workspace.Members)"
>
<UserAvatarGroup
:users="team.map((teamMember) => teamMember.user)"
class="max-w-[104px]"
hide-tooltips
/>
</button>
</div>
<FormButton
v-if="isWorkspaceAdmin"
class="hidden md:block"
@ -83,11 +73,20 @@
>
Invite
</FormButton>
<FormButton
v-if="isWorkspaceAdmin"
class="hidden md:block"
color="outline"
@click="openSettingsDialog(SettingMenuKeys.Workspace.General)"
>
Settings
</FormButton>
<LayoutMenu
v-model:open="showActionsMenu"
:items="actionsItems"
:menu-position="HorizontalDirection.Left"
:menu-id="menuId"
class="md:hidden"
@click.stop.prevent
@chosen="onActionChosen"
>
@ -136,11 +135,6 @@ graphql(`
totalProjects: projects {
totalCount
}
billing {
versionsCount {
...WorkspacePageVersionCount_WorkspaceVersionsCount
}
}
team {
items {
id
@ -189,17 +183,17 @@ const actionsItems = computed<LayoutMenuItem[][]>(() => [
[
...(isMobile.value
? [
...(isWorkspaceAdmin.value
? [{ title: 'Move projects', id: ActionTypes.MoveProjects }]
: []),
{ title: 'Settings', id: ActionTypes.Settings },
...(!isWorkspaceGuest.value
? [{ title: 'Invite', id: ActionTypes.Invite }]
? [{ title: 'Invite...', id: ActionTypes.Invite }]
: []),
...(isWorkspaceAdmin.value
? [{ title: 'Move projects...', id: ActionTypes.MoveProjects }]
: [])
]
: []),
{ title: 'Copy link', id: ActionTypes.CopyLink }
],
[{ title: 'Settings...', id: ActionTypes.Settings }]
: [])
]
])
const openSettingsDialog = (target: AvailableSettingsMenuKeys) => {

Просмотреть файл

@ -1,54 +0,0 @@
<template>
<div class="flex flex-col">
<WorkspaceInviteBanner
v-for="invite in invites"
:key="invite.id"
:invite="invite"
/>
<WorkspaceInviteDiscoverableWorkspaceBanner
v-for="workspace in discoverableWorkspaces"
:key="workspace.id"
:workspace="workspace"
/>
</div>
</template>
<script setup lang="ts">
import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
import { CookieKeys } from '~/lib/common/helpers/constants'
import { graphql } from '~~/lib/common/generated/gql'
import type { WorkspaceInviteBanners_UserFragment } from '~~/lib/common/generated/gql/graphql'
/**
* TODO: Add this to new dashboard page and remove from projects dashboard
*/
graphql(`
fragment WorkspaceInviteBanners_User on User {
discoverableWorkspaces {
...WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace
}
workspaceInvites {
...WorkspaceInviteBanner_PendingWorkspaceCollaborator
}
}
`)
const props = defineProps<{
invites: WorkspaceInviteBanners_UserFragment
}>()
const dismissedDiscoverableWorkspaces = useSynchronizedCookie<string[]>(
CookieKeys.DismissedDiscoverableWorkspaces,
{
default: () => []
}
)
const invites = computed(() => props.invites.workspaceInvites || [])
const discoverableWorkspaces = computed(
() =>
props.invites.discoverableWorkspaces?.filter(
(workspace) => !dismissedDiscoverableWorkspaces.value.includes(workspace.id)
) || []
)
</script>

Просмотреть файл

@ -14,7 +14,7 @@ import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
import { graphql } from '~/lib/common/generated/gql'
import {
DashboardJoinWorkspaceDocument,
type WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspaceFragment
type WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspaceFragment
} from '~/lib/common/generated/gql/graphql'
import { CookieKeys } from '~/lib/common/helpers/constants'
import {
@ -22,12 +22,14 @@ import {
getFirstErrorMessage,
modifyObjectField
} from '~/lib/common/helpers/graphql'
import { workspaceRoute } from '~/lib/common/helpers/route'
import { useMixpanel } from '~~/lib/core/composables/mp'
graphql(`
fragment WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace on DiscoverableWorkspace {
fragment WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace on LimitedWorkspace {
id
name
slug
description
logo
defaultLogoIndex
@ -46,7 +48,7 @@ graphql(`
`)
const props = defineProps<{
workspace: WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspaceFragment
workspace: WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspaceFragment
}>()
const mixpanel = useMixpanel()
@ -76,7 +78,7 @@ const processJoin = async (accept: boolean) => {
props.workspace.id
]
apollo.cache.evict({
id: getCacheId('DiscoverableWorkspace', props.workspace.id)
id: getCacheId('LimitedWorkspace', props.workspace.id)
})
return
}
@ -115,7 +117,7 @@ const processJoin = async (accept: boolean) => {
if (result?.data) {
apollo.cache.evict({
id: getCacheId('DiscoverableWorkspace', props.workspace.id)
id: getCacheId('LimitedWorkspace', props.workspace.id)
})
triggerNotification({
@ -130,7 +132,7 @@ const processJoin = async (accept: boolean) => {
workspace_id: props.workspace.id
})
router.push(`/workspaces/${props.workspace.id}`)
router.push(workspaceRoute(props.workspace.slug))
} else {
triggerNotification({
type: ToastNotificationType.Danger,

Просмотреть файл

@ -1,31 +0,0 @@
<template>
<div class="w-40 flex flex-col items-center md:mx-4">
<CommonProgressBar
class="mb-1"
:current-value="versionsCount.current"
:max-value="versionsCount.max"
/>
<div class="text-body-3xs text-foreground">
<span class="font-medium">
{{ versionsCount.current }}/{{ versionsCount.max }}
</span>
model versions used
</div>
</div>
</template>
<script lang="ts" setup>
import { graphql } from '~/lib/common/generated/gql'
import type { WorkspacePageVersionCount_WorkspaceVersionsCountFragment } from '~~/lib/common/generated/gql/graphql'
graphql(`
fragment WorkspacePageVersionCount_WorkspaceVersionsCount on WorkspaceVersionsCount {
current
max
}
`)
defineProps<{
versionsCount: WorkspacePageVersionCount_WorkspaceVersionsCountFragment
}>()
</script>

Просмотреть файл

@ -38,9 +38,9 @@
</CommonCard>
<CommonCard
title="Sovereign Data Regions"
title="Data residency"
badge="Coming soon"
description="Store each project's data in the geographical location that you need, with granular precision going beyond continents."
description="Store your workspace projects in the geographical region of your choice."
>
<template #icon>
<GlobeAltIcon class="size-6 text-foreground-2 ml-1" />
@ -49,7 +49,7 @@
<CommonCard
title="... and more!"
description="We will be rolling out new features, like advanced permissions, audit logs, bigger uploads and more over the coming months."
description="We will be rolling out new features, like advanced permissions, audit logs, and more over the coming months."
>
<template #icon>
<PlusIcon class="size-6 text-foreground-2 ml-1" />

Просмотреть файл

@ -41,4 +41,11 @@ export const useIsGendoModuleEnabled = () => {
return ref(FF_GENDOAI_MODULE_ENABLED)
}
export const useIsBillingIntegrationEnabled = () => {
const {
public: { FF_BILLING_INTEGRATION_ENABLED }
} = useRuntimeConfig()
return ref(FF_BILLING_INTEGRATION_ENABLED)
}
export { useGlobalToast, useActiveUser, usePageQueryStandardFetchPolicy }

Просмотреть файл

@ -382,6 +382,24 @@ export const useAuthManager = (
goHome({ query: { access_code: accessCode } })
}
/**
* Initiate SSO flow. Will create a user if one does not already exist.
*/
const signInOrSignUpWithSso = (params: {
challenge: string
workspaceSlug: string
}) => {
postAuthRedirect.set(`/workspaces/${params.workspaceSlug}`)
const authUrl = new URL(
`/api/v1/workspaces/${params.workspaceSlug}/sso/auth`,
apiOrigin
)
authUrl.searchParams.set('challenge', params.challenge)
navigateTo(authUrl.toString(), { external: true })
}
/**
* Log out
*/
@ -425,6 +443,7 @@ export const useAuthManager = (
authToken,
loginWithEmail,
signUpWithEmail,
signInOrSignUpWithSso,
logout,
watchAuthQueryString,
inviteToken

Просмотреть файл

@ -11,7 +11,7 @@ export function useServerFileUploadLimit() {
const { result } = useQuery(serverInfoBlobSizeLimitQuery)
const maxSizeInBytes = computed(
() => result.value?.serverInfo.blobSizeLimitBytes || 0
() => result.value?.serverInfo.configuration.blobSizeLimitBytes || 0
)
const maxSizeDisplayString = computed(() => prettyFileSize(maxSizeInBytes.value))

Просмотреть файл

@ -90,7 +90,7 @@ const documents = {
"\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n ": types.OnUserProjectsUpdateDocument,
"\n fragment ProjectsDashboardFilled on ProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n": types.ProjectsDashboardFilledFragmentDoc,
"\n fragment ProjectsDashboardHeaderProjects_User on User {\n ...ProjectsInviteBanners\n }\n": types.ProjectsDashboardHeaderProjects_UserFragmentDoc,
"\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n ...WorkspaceInviteBanners_User\n }\n": types.ProjectsDashboardHeaderWorkspaces_UserFragmentDoc,
"\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n": types.ProjectsDashboardHeaderWorkspaces_UserFragmentDoc,
"\n fragment ProjectsMoveToWorkspaceDialog_Workspace on Workspace {\n id\n role\n name\n defaultLogoIndex\n logo\n }\n": types.ProjectsMoveToWorkspaceDialog_WorkspaceFragmentDoc,
"\n fragment ProjectsMoveToWorkspaceDialog_User on User {\n workspaces {\n items {\n ...ProjectsMoveToWorkspaceDialog_Workspace\n }\n }\n }\n": types.ProjectsMoveToWorkspaceDialog_UserFragmentDoc,
"\n fragment ProjectsMoveToWorkspaceDialog_Project on Project {\n id\n name\n modelCount: models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n }\n": types.ProjectsMoveToWorkspaceDialog_ProjectFragmentDoc,
@ -101,6 +101,9 @@ const documents = {
"\n fragment SettingsDialog_Workspace on Workspace {\n ...WorkspaceAvatar_Workspace\n id\n slug\n role\n name\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 SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsServerProjects_ProjectCollectionFragmentDoc,
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": types.SettingsServerRegionsDocument,
"\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 SettingsSharedProjects_Project on Project {\n id\n name\n visibility\n createdAt\n updatedAt\n models {\n totalCount\n }\n versions {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n": types.SettingsSharedProjects_ProjectFragmentDoc,
"\n fragment SettingsUserEmails_User on User {\n id\n emails {\n ...SettingsUserEmailCards_UserEmail\n }\n }\n": types.SettingsUserEmails_UserFragmentDoc,
"\n fragment SettingsUserNotifications_User on User {\n id\n notificationPreferences\n }\n": types.SettingsUserNotifications_UserFragmentDoc,
@ -110,13 +113,14 @@ const documents = {
"\n fragment SettingsUserProfileDeleteAccount_User on User {\n id\n email\n }\n": types.SettingsUserProfileDeleteAccount_UserFragmentDoc,
"\n fragment SettingsUserProfileDetails_User on User {\n id\n name\n company\n ...UserProfileEditDialogAvatar_User\n }\n": types.SettingsUserProfileDetails_UserFragmentDoc,
"\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n": types.UserProfileEditDialogAvatar_UserFragmentDoc,
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n billing {\n cost {\n subTotal\n total\n ...BillingSummary_WorkspaceCost\n }\n versionsCount {\n current\n max\n }\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n id\n plan {\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n defaultProjectRole\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n": types.SettingsWorkspaceGeneralDeleteDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n defaultLogoIndex\n }\n": types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditSlugDialog_Workspace on Workspace {\n id\n name\n slug\n }\n": types.SettingsWorkspacesGeneralEditSlugDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n team {\n items {\n id\n role\n }\n }\n invitedTeam(filter: $invitesFilter) {\n user {\n id\n }\n }\n }\n": types.SettingsWorkspacesMembers_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsWorkspacesProjects_ProjectCollectionFragmentDoc,
"\n fragment SettingsWorkspacesRegions_Workspace on Workspace {\n id\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n availableRegions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n": types.SettingsWorkspacesRegions_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n ...SettingsWorkspacesSecuritySso_Workspace\n }\n\n fragment SettingsWorkspacesSecurity_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n": types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n projectRoles {\n role\n project {\n id\n name\n }\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceFragmentDoc,
@ -125,6 +129,7 @@ const documents = {
"\n fragment SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n workspaceDomainPolicyCompliant(workspaceId: $workspaceId)\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n name\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n role\n ...WorkspaceInviteDialog_Workspace\n }\n": types.SettingsWorkspacesMembersTableHeader_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesRegionsSelect_ServerRegionItem on ServerRegionItem {\n id\n key\n name\n description\n }\n": types.SettingsWorkspacesRegionsSelect_ServerRegionItemFragmentDoc,
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {\n id\n domain\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragmentDoc,
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecuritySso_Workspace on Workspace {\n id\n slug\n }\n": types.SettingsWorkspacesSecuritySso_WorkspaceFragmentDoc,
@ -133,16 +138,14 @@ const documents = {
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": types.ViewerCommentsListItemFragmentDoc,
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": types.ViewerModelVersionCardItemFragmentDoc,
"\n fragment WorkspaceAvatar_Workspace on Workspace {\n id\n logo\n defaultLogoIndex\n }\n": types.WorkspaceAvatar_WorkspaceFragmentDoc,
"\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n id\n team {\n items {\n id\n user {\n id\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n": types.WorkspaceInviteDialog_WorkspaceFragmentDoc,
"\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n id\n team {\n items {\n id\n user {\n id\n role\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n": types.WorkspaceInviteDialog_WorkspaceFragmentDoc,
"\n fragment MoveProjectsDialog_Workspace on Workspace {\n id\n ...ProjectsMoveToWorkspaceDialog_Workspace\n projects {\n items {\n id\n modelCount: models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n }\n }\n }\n": types.MoveProjectsDialog_WorkspaceFragmentDoc,
"\n fragment MoveProjectsDialog_User on User {\n projects {\n items {\n ...ProjectsMoveToWorkspaceDialog_Project\n role\n workspace {\n id\n }\n }\n }\n }\n": types.MoveProjectsDialog_UserFragmentDoc,
"\n fragment WorkspaceProjectList_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...ProjectDashboardItem\n }\n cursor\n }\n": types.WorkspaceProjectList_ProjectCollectionFragmentDoc,
"\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceAvatar_Workspace\n id\n slug\n role\n name\n logo\n description\n totalProjects: projects {\n totalCount\n }\n billing {\n versionsCount {\n ...WorkspacePageVersionCount_WorkspaceVersionsCount\n }\n }\n team {\n items {\n id\n user {\n id\n name\n ...LimitedUserAvatar\n }\n }\n }\n ...WorkspaceInviteDialog_Workspace\n }\n": types.WorkspaceHeader_WorkspaceFragmentDoc,
"\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceAvatar_Workspace\n id\n slug\n role\n name\n logo\n description\n totalProjects: projects {\n totalCount\n }\n team {\n items {\n id\n user {\n id\n name\n ...LimitedUserAvatar\n }\n }\n }\n ...WorkspaceInviteDialog_Workspace\n }\n": types.WorkspaceHeader_WorkspaceFragmentDoc,
"\n fragment WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.WorkspaceInviteBanner_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspaceInviteBanners_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n": types.WorkspaceInviteBanners_UserFragmentDoc,
"\n fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.WorkspaceInviteBlock_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace on DiscoverableWorkspace {\n id\n name\n description\n logo\n defaultLogoIndex\n }\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_Workspace on Workspace {\n id\n name\n description\n createdAt\n updatedAt\n logo\n defaultLogoIndex\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n": types.WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspaceFragmentDoc,
"\n fragment WorkspacePageVersionCount_WorkspaceVersionsCount on WorkspaceVersionsCount {\n current\n max\n }\n": types.WorkspacePageVersionCount_WorkspaceVersionsCountFragmentDoc,
"\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace on LimitedWorkspace {\n id\n name\n slug\n description\n logo\n defaultLogoIndex\n }\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_Workspace on Workspace {\n id\n name\n description\n createdAt\n updatedAt\n logo\n defaultLogoIndex\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n": types.WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspaceFragmentDoc,
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserMainMetadataDocument,
"\n mutation CreateOnboardingProject {\n projectMutations {\n createForOnboarding {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n ": types.CreateOnboardingProjectDocument,
"\n mutation FinishOnboarding {\n activeUserMutations {\n finishOnboarding\n }\n }\n": types.FinishOnboardingDocument,
@ -163,10 +166,10 @@ const documents = {
"\n query AutomateFunctionsPagePagination($search: String, $cursor: String) {\n ...AutomateFunctionsPageItems_Query\n }\n": types.AutomateFunctionsPagePaginationDocument,
"\n query MentionsUserSearch($query: String!, $emailOnly: Boolean = false) {\n userSearch(\n query: $query\n limit: 5\n cursor: null\n archived: false\n emailOnly: $emailOnly\n ) {\n items {\n id\n name\n company\n }\n }\n }\n": types.MentionsUserSearchDocument,
"\n query UserSearch(\n $query: String!\n $limit: Int\n $cursor: String\n $archived: Boolean\n $workspaceId: String\n ) {\n userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived) {\n cursor\n items {\n id\n name\n bio\n company\n avatar\n verified\n role\n workspaceDomainPolicyCompliant(workspaceId: $workspaceId)\n }\n }\n }\n": types.UserSearchDocument,
"\n query ServerInfoBlobSizeLimit {\n serverInfo {\n blobSizeLimitBytes\n }\n }\n": types.ServerInfoBlobSizeLimitDocument,
"\n query ServerInfoBlobSizeLimit {\n serverInfo {\n configuration {\n blobSizeLimitBytes\n }\n }\n }\n": types.ServerInfoBlobSizeLimitDocument,
"\n query ServerInfoAllScopes {\n serverInfo {\n scopes {\n name\n description\n }\n }\n }\n": types.ServerInfoAllScopesDocument,
"\n query ProjectModelsSelectorValues($projectId: String!, $cursor: String) {\n project(id: $projectId) {\n id\n models(limit: 100, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...CommonModelSelectorModel\n }\n }\n }\n }\n": types.ProjectModelsSelectorValuesDocument,
"\n query MainServerInfoData {\n serverInfo {\n adminContact\n blobSizeLimitBytes\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n": types.MainServerInfoDataDocument,
"\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n": types.MainServerInfoDataDocument,
"\n mutation DashboardJoinWorkspace($input: JoinWorkspaceInput!) {\n workspaceMutations {\n join(input: $input) {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_Workspace\n }\n }\n }\n": types.DashboardJoinWorkspaceDocument,
"\n query DashboardProjectsPageQuery {\n activeUser {\n id\n projects(limit: 3) {\n items {\n ...DashboardProjectCard_Project\n }\n }\n ...ProjectsDashboardHeaderProjects_User\n }\n }\n": types.DashboardProjectsPageQueryDocument,
"\n query DashboardProjectsPageWorkspaceQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n": types.DashboardProjectsPageWorkspaceQueryDocument,
@ -186,6 +189,8 @@ const documents = {
"\n query GendoAIRenders($versionId: String!, $projectId: String!) {\n project(id: $projectId) {\n id\n version(id: $versionId) {\n id\n gendoAIRenders {\n totalCount\n items {\n id\n createdAt\n updatedAt\n status\n gendoGenerationId\n prompt\n camera\n }\n }\n }\n }\n }\n": types.GendoAiRendersDocument,
"\n subscription ProjectVersionGendoAIRenderCreated($id: String!, $versionId: String!) {\n projectVersionGendoAIRenderCreated(id: $id, versionId: $versionId) {\n id\n createdAt\n updatedAt\n status\n gendoGenerationId\n prompt\n camera\n }\n }\n": types.ProjectVersionGendoAiRenderCreatedDocument,
"\n subscription ProjectVersionGendoAIRenderUpdated($id: String!, $versionId: String!) {\n projectVersionGendoAIRenderUpdated(id: $id, versionId: $versionId) {\n id\n projectId\n modelId\n versionId\n createdAt\n updatedAt\n gendoGenerationId\n status\n prompt\n camera\n responseImage\n }\n }\n": types.ProjectVersionGendoAiRenderUpdatedDocument,
"\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 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,
@ -198,6 +203,7 @@ const documents = {
"\n fragment ProjectPageLatestItemsCommentItem on Comment {\n id\n author {\n ...FormUsersSelectItem\n }\n screenshot\n rawText\n createdAt\n updatedAt\n archived\n repliesCount: replies(limit: 0) {\n totalCount\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n }\n": types.ProjectPageLatestItemsCommentItemFragmentDoc,
"\n mutation CreateModel($input: CreateModelInput!) {\n modelMutations {\n create(input: $input) {\n ...ProjectPageLatestItemsModelItem\n }\n }\n }\n": types.CreateModelDocument,
"\n mutation CreateProject($input: ProjectCreateInput) {\n projectMutations {\n create(input: $input) {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n": types.CreateProjectDocument,
"\n mutation CreateWorkspaceProject($input: WorkspaceProjectCreateInput!) {\n workspaceMutations {\n projects {\n create(input: $input) {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n }\n": types.CreateWorkspaceProjectDocument,
"\n mutation UpdateModel($input: UpdateModelInput!) {\n modelMutations {\n update(input: $input) {\n ...ProjectPageLatestItemsModelItem\n }\n }\n }\n": types.UpdateModelDocument,
"\n mutation DeleteModel($input: DeleteModelInput!) {\n modelMutations {\n delete(input: $input)\n }\n }\n": types.DeleteModelDocument,
"\n mutation UpdateProjectRole($input: ProjectUpdateRoleInput!) {\n projectMutations {\n updateRole(input: $input) {\n id\n team {\n id\n role\n user {\n ...LimitedUserAvatar\n }\n }\n }\n }\n }\n": types.UpdateProjectRoleDocument,
@ -284,6 +290,9 @@ const documents = {
"\n query SettingsSidebar {\n activeUser {\n ...SettingsDialog_User\n }\n }\n": types.SettingsSidebarDocument,
"\n query SettingsWorkspaceGeneral($id: String!) {\n workspace(id: $id) {\n ...SettingsWorkspacesGeneral_Workspace\n }\n }\n": types.SettingsWorkspaceGeneralDocument,
"\n query SettingsWorkspaceBilling($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...SettingsWorkspacesBilling_Workspace\n }\n }\n": types.SettingsWorkspaceBillingDocument,
"\n query SettingsWorkspacePricingPlans {\n workspacePricingPlans\n }\n": types.SettingsWorkspacePricingPlansDocument,
"\n query SettingsWorkspaceBillingCustomerPortal($workspaceId: String!) {\n workspace(id: $workspaceId) {\n customerPortalUrl\n }\n }\n": types.SettingsWorkspaceBillingCustomerPortalDocument,
"\n query SettingsWorkspaceRegions($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...SettingsWorkspacesRegions_Workspace\n }\n }\n": types.SettingsWorkspaceRegionsDocument,
"\n query SettingsWorkspacesMembers(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembers_Workspace\n ...SettingsWorkspacesMembersMembersTable_Workspace\n ...SettingsWorkspacesMembersGuestsTable_Workspace\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n": types.SettingsWorkspacesMembersDocument,
"\n query SettingsWorkspacesMembersSearch(\n $workspaceId: String!\n $filter: WorkspaceTeamFilter\n ) {\n workspace(id: $workspaceId) {\n id\n team(filter: $filter) {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n }\n": types.SettingsWorkspacesMembersSearchDocument,
"\n query SettingsWorkspacesInvitesSearch(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n": types.SettingsWorkspacesInvitesSearchDocument,
@ -321,11 +330,13 @@ const documents = {
"\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 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 query WorkspaceAccessCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n }\n }\n": types.WorkspaceAccessCheckDocument,
"\n query WorkspacePageQuery(\n $workspaceSlug: String!\n $filter: WorkspaceProjectsFilter\n $cursor: String\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n $token: String\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n ...MoveProjectsDialog_Workspace\n ...WorkspaceHeader_Workspace\n ...WorkspaceMixpanelUpdateGroup_Workspace\n projectListProject: projects(filter: $filter, cursor: $cursor, limit: 10) {\n ...WorkspaceProjectList_ProjectCollection\n }\n }\n workspaceInvite(\n workspaceId: $workspaceSlug\n token: $token\n options: { useSlug: true }\n ) {\n id\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n ...WorkspaceInviteBlock_PendingWorkspaceCollaborator\n }\n }\n": types.WorkspacePageQueryDocument,
"\n query WorkspaceProjectsQuery(\n $workspaceSlug: String!\n $filter: WorkspaceProjectsFilter\n $cursor: String\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n projects(filter: $filter, cursor: $cursor, limit: 10) {\n ...WorkspaceProjectList_ProjectCollection\n }\n }\n }\n": types.WorkspaceProjectsQueryDocument,
"\n query WorkspaceInvite(\n $workspaceId: String\n $token: String\n $options: WorkspaceInviteLookupOptions\n ) {\n workspaceInvite(workspaceId: $workspaceId, token: $token, options: $options) {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n ...WorkspaceInviteBlock_PendingWorkspaceCollaborator\n }\n }\n": types.WorkspaceInviteDocument,
"\n query MoveProjectsDialog {\n activeUser {\n ...MoveProjectsDialog_User\n }\n }\n": types.MoveProjectsDialogDocument,
"\n query ValidateWorkspaceSlug($slug: String!) {\n validateWorkspaceSlug(slug: $slug)\n }\n": types.ValidateWorkspaceSlugDocument,
"\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n project(id: $streamId) {\n modelByName(name: $branchName) {\n id\n }\n }\n }\n": types.LegacyBranchRedirectMetadataDocument,
"\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n project(id: $streamId) {\n version(id: $commitId) {\n id\n model {\n id\n }\n }\n }\n }\n": types.LegacyViewerCommitRedirectMetadataDocument,
"\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n project(id: $streamId) {\n id\n versions(limit: 1) {\n totalCount\n items {\n id\n model {\n id\n }\n }\n }\n }\n }\n": types.LegacyViewerStreamRedirectMetadataDocument,
@ -665,7 +676,7 @@ export function graphql(source: "\n fragment ProjectsDashboardHeaderProjects_Us
/**
* 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 ProjectsDashboardHeaderWorkspaces_User on User {\n ...WorkspaceInviteBanners_User\n }\n"): (typeof documents)["\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n ...WorkspaceInviteBanners_User\n }\n"];
export function graphql(source: "\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -706,6 +717,18 @@ export function graphql(source: "\n fragment SettingsDialog_User on User {\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 SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n"): (typeof documents)["\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\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 SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n"): (typeof documents)["\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\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 SettingsServerRegionsAddEditDialog_ServerRegionItem on ServerRegionItem {\n id\n name\n description\n key\n }\n"): (typeof documents)["\n fragment SettingsServerRegionsAddEditDialog_ServerRegionItem on ServerRegionItem {\n id\n name\n description\n key\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 SettingsServerRegionsTable_ServerRegionItem on ServerRegionItem {\n id\n name\n key\n description\n }\n"): (typeof documents)["\n fragment SettingsServerRegionsTable_ServerRegionItem on ServerRegionItem {\n id\n name\n key\n description\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -745,7 +768,7 @@ export function graphql(source: "\n fragment UserProfileEditDialogAvatar_User o
/**
* 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 SettingsWorkspacesBilling_Workspace on Workspace {\n billing {\n cost {\n subTotal\n total\n ...BillingSummary_WorkspaceCost\n }\n versionsCount {\n current\n max\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n billing {\n cost {\n subTotal\n total\n ...BillingSummary_WorkspaceCost\n }\n versionsCount {\n current\n max\n }\n }\n }\n"];
export function graphql(source: "\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n id\n plan {\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n id\n plan {\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -770,6 +793,10 @@ export function graphql(source: "\n fragment SettingsWorkspacesMembers_Workspac
* 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 SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\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 SettingsWorkspacesRegions_Workspace on Workspace {\n id\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n availableRegions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesRegions_Workspace on Workspace {\n id\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n availableRegions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -802,6 +829,10 @@ export function graphql(source: "\n fragment SettingsWorkspacesMembersMembersTa
* 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 SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n role\n ...WorkspaceInviteDialog_Workspace\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n role\n ...WorkspaceInviteDialog_Workspace\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 SettingsWorkspacesRegionsSelect_ServerRegionItem on ServerRegionItem {\n id\n key\n name\n description\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesRegionsSelect_ServerRegionItem on ServerRegionItem {\n id\n key\n name\n description\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -837,7 +868,7 @@ export function graphql(source: "\n fragment WorkspaceAvatar_Workspace on Works
/**
* 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 WorkspaceInviteDialog_Workspace on Workspace {\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n id\n team {\n items {\n id\n user {\n id\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n id\n team {\n items {\n id\n user {\n id\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"];
export function graphql(source: "\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n id\n team {\n items {\n id\n user {\n id\n role\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n id\n team {\n items {\n id\n user {\n id\n role\n }\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -853,15 +884,11 @@ export function graphql(source: "\n fragment WorkspaceProjectList_ProjectCollec
/**
* 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 WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceAvatar_Workspace\n id\n slug\n role\n name\n logo\n description\n totalProjects: projects {\n totalCount\n }\n billing {\n versionsCount {\n ...WorkspacePageVersionCount_WorkspaceVersionsCount\n }\n }\n team {\n items {\n id\n user {\n id\n name\n ...LimitedUserAvatar\n }\n }\n }\n ...WorkspaceInviteDialog_Workspace\n }\n"): (typeof documents)["\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceAvatar_Workspace\n id\n slug\n role\n name\n logo\n description\n totalProjects: projects {\n totalCount\n }\n billing {\n versionsCount {\n ...WorkspacePageVersionCount_WorkspaceVersionsCount\n }\n }\n team {\n items {\n id\n user {\n id\n name\n ...LimitedUserAvatar\n }\n }\n }\n ...WorkspaceInviteDialog_Workspace\n }\n"];
export function graphql(source: "\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceAvatar_Workspace\n id\n slug\n role\n name\n logo\n description\n totalProjects: projects {\n totalCount\n }\n team {\n items {\n id\n user {\n id\n name\n ...LimitedUserAvatar\n }\n }\n }\n ...WorkspaceInviteDialog_Workspace\n }\n"): (typeof documents)["\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceAvatar_Workspace\n id\n slug\n role\n name\n logo\n description\n totalProjects: projects {\n totalCount\n }\n team {\n items {\n id\n user {\n id\n name\n ...LimitedUserAvatar\n }\n }\n }\n ...WorkspaceInviteDialog_Workspace\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 WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"): (typeof documents)["\n fragment WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\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 WorkspaceInviteBanners_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n fragment WorkspaceInviteBanners_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -869,11 +896,7 @@ export function graphql(source: "\n fragment WorkspaceInviteBlock_PendingWorksp
/**
* 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 WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace on DiscoverableWorkspace {\n id\n name\n description\n logo\n defaultLogoIndex\n }\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_Workspace on Workspace {\n id\n name\n description\n createdAt\n updatedAt\n logo\n defaultLogoIndex\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n"): (typeof documents)["\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_DiscoverableWorkspace on DiscoverableWorkspace {\n id\n name\n description\n logo\n defaultLogoIndex\n }\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_Workspace on Workspace {\n id\n name\n description\n createdAt\n updatedAt\n logo\n defaultLogoIndex\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\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 WorkspacePageVersionCount_WorkspaceVersionsCount on WorkspaceVersionsCount {\n current\n max\n }\n"): (typeof documents)["\n fragment WorkspacePageVersionCount_WorkspaceVersionsCount on WorkspaceVersionsCount {\n current\n max\n }\n"];
export function graphql(source: "\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace on LimitedWorkspace {\n id\n name\n slug\n description\n logo\n defaultLogoIndex\n }\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_Workspace on Workspace {\n id\n name\n description\n createdAt\n updatedAt\n logo\n defaultLogoIndex\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n"): (typeof documents)["\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace on LimitedWorkspace {\n id\n name\n slug\n description\n logo\n defaultLogoIndex\n }\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_Workspace on Workspace {\n id\n name\n description\n createdAt\n updatedAt\n logo\n defaultLogoIndex\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -957,7 +980,7 @@ export function graphql(source: "\n query UserSearch(\n $query: String!\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 ServerInfoBlobSizeLimit {\n serverInfo {\n blobSizeLimitBytes\n }\n }\n"): (typeof documents)["\n query ServerInfoBlobSizeLimit {\n serverInfo {\n blobSizeLimitBytes\n }\n }\n"];
export function graphql(source: "\n query ServerInfoBlobSizeLimit {\n serverInfo {\n configuration {\n blobSizeLimitBytes\n }\n }\n }\n"): (typeof documents)["\n query ServerInfoBlobSizeLimit {\n serverInfo {\n configuration {\n blobSizeLimitBytes\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -969,7 +992,7 @@ export function graphql(source: "\n query ProjectModelsSelectorValues($projectI
/**
* 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 MainServerInfoData {\n serverInfo {\n adminContact\n blobSizeLimitBytes\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n"): (typeof documents)["\n query MainServerInfoData {\n serverInfo {\n adminContact\n blobSizeLimitBytes\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n"];
export function graphql(source: "\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n"): (typeof documents)["\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -1046,6 +1069,14 @@ export function graphql(source: "\n subscription ProjectVersionGendoAIRenderCre
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n subscription ProjectVersionGendoAIRenderUpdated($id: String!, $versionId: String!) {\n projectVersionGendoAIRenderUpdated(id: $id, versionId: $versionId) {\n id\n projectId\n modelId\n versionId\n createdAt\n updatedAt\n gendoGenerationId\n status\n prompt\n camera\n responseImage\n }\n }\n"): (typeof documents)["\n subscription ProjectVersionGendoAIRenderUpdated($id: String!, $versionId: String!) {\n projectVersionGendoAIRenderUpdated(id: $id, versionId: $versionId) {\n id\n projectId\n modelId\n versionId\n createdAt\n updatedAt\n gendoGenerationId\n status\n prompt\n camera\n responseImage\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation 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 documents)["\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"];
/**
* 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.
*/
@ -1094,6 +1125,10 @@ export function graphql(source: "\n mutation CreateModel($input: CreateModelInp
* 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 CreateProject($input: ProjectCreateInput) {\n projectMutations {\n create(input: $input) {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n"): (typeof documents)["\n mutation CreateProject($input: ProjectCreateInput) {\n projectMutations {\n create(input: $input) {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation CreateWorkspaceProject($input: WorkspaceProjectCreateInput!) {\n workspaceMutations {\n projects {\n create(input: $input) {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n }\n"): (typeof documents)["\n mutation CreateWorkspaceProject($input: WorkspaceProjectCreateInput!) {\n workspaceMutations {\n projects {\n create(input: $input) {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -1438,6 +1473,18 @@ export function graphql(source: "\n query SettingsWorkspaceGeneral($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 SettingsWorkspaceBilling($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...SettingsWorkspacesBilling_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspaceBilling($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...SettingsWorkspacesBilling_Workspace\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 SettingsWorkspacePricingPlans {\n workspacePricingPlans\n }\n"): (typeof documents)["\n query SettingsWorkspacePricingPlans {\n workspacePricingPlans\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 SettingsWorkspaceBillingCustomerPortal($workspaceId: String!) {\n workspace(id: $workspaceId) {\n customerPortalUrl\n }\n }\n"): (typeof documents)["\n query SettingsWorkspaceBillingCustomerPortal($workspaceId: String!) {\n workspace(id: $workspaceId) {\n customerPortalUrl\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 SettingsWorkspaceRegions($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...SettingsWorkspacesRegions_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspaceRegions($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...SettingsWorkspacesRegions_Workspace\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -1586,6 +1633,10 @@ export function graphql(source: "\n mutation CreateWorkspace($input: WorkspaceC
* 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 ProcessWorkspaceInvite($input: WorkspaceInviteUseInput!) {\n workspaceMutations {\n invites {\n use(input: $input)\n }\n }\n }\n"): (typeof documents)["\n mutation ProcessWorkspaceInvite($input: WorkspaceInviteUseInput!) {\n workspaceMutations {\n invites {\n use(input: $input)\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation 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 documents)["\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"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -1606,6 +1657,10 @@ export function graphql(source: "\n query WorkspaceInvite(\n $workspaceId: S
* 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 MoveProjectsDialog {\n activeUser {\n ...MoveProjectsDialog_User\n }\n }\n"): (typeof documents)["\n query MoveProjectsDialog {\n activeUser {\n ...MoveProjectsDialog_User\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 ValidateWorkspaceSlug($slug: String!) {\n validateWorkspaceSlug(slug: $slug)\n }\n"): (typeof documents)["\n query ValidateWorkspaceSlug($slug: String!) {\n validateWorkspaceSlug(slug: $slug)\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

Просмотреть файл

@ -45,7 +45,9 @@ export const userSearchQuery = graphql(`
export const serverInfoBlobSizeLimitQuery = graphql(`
query ServerInfoBlobSizeLimit {
serverInfo {
blobSizeLimitBytes
configuration {
blobSizeLimitBytes
}
}
}
`)

Просмотреть файл

@ -13,6 +13,7 @@ export type MixpanelClient = Merge<
| 'people'
| 'add_group'
| 'get_group'
| 'alias'
>,
{
people: Pick<OverridedMixpanel['people'], 'set' | 'set_once'>
@ -33,5 +34,6 @@ export const fakeMixpanelClient = (): MixpanelClient => ({
set_once: noop
},
add_group: noop,
get_group: noop as MixpanelClient['get_group']
get_group: noop as MixpanelClient['get_group'],
alias: noop
})

Просмотреть файл

@ -8,7 +8,6 @@ export const mainServerInfoDataQuery = graphql(`
query MainServerInfoData {
serverInfo {
adminContact
blobSizeLimitBytes
canonicalUrl
company
description

Просмотреть файл

@ -1,13 +1,13 @@
import type { Nullable } from '@speckle/shared'
import { type LayoutDialogButton } from '@speckle/ui-components'
export type TutorialItem = {
export type WebflowItem = {
id: string
readingTime?: number
publishedAt?: Nullable<string>
url?: string
title?: string
featureImage?: Nullable<string>
title: string
createdOn: string
lastPublished: string
featureImageUrl?: string
url: string
readTime?: number
}
export type QuickStartItem = {

Просмотреть файл

@ -1,5 +1,7 @@
import { graphql } from '~~/lib/common/generated/gql'
// TODO: Clean up these operations and make them component fragment based. Also some of the props requested don't seem to even be used
export const requestGendoAIRender = graphql(`
mutation requestGendoAIRender($input: GendoAIRenderInput!) {
versionMutations {

Просмотреть файл

@ -0,0 +1,7 @@
export const useIsMultiregionEnabled = () => {
const {
public: { FF_WORKSPACES_MODULE_ENABLED, FF_WORKSPACES_MULTI_REGION_ENABLED }
} = useRuntimeConfig()
return !!(FF_WORKSPACES_MODULE_ENABLED && FF_WORKSPACES_MULTI_REGION_ENABLED)
}

Просмотреть файл

@ -0,0 +1,85 @@
import { useMutation } from '@vue/apollo-composable'
import type {
CreateNewRegionMutationVariables,
UpdateRegionMutationVariables
} from '~/lib/common/generated/gql/graphql'
import { modifyObjectField, ROOT_QUERY } from '~/lib/common/helpers/graphql'
import {
createNewRegionMutation,
updateRegionMutation
} from '~/lib/multiregion/graphql/mutations'
export const useCreateRegion = () => {
const { mutate } = useMutation(createNewRegionMutation)
const { activeUser, isAdmin } = useActiveUser()
const { triggerNotification } = useGlobalToast()
return async (input: CreateNewRegionMutationVariables) => {
if (!activeUser.value || !isAdmin.value) return
const res = await mutate(input, {
update: (cache, { data }) => {
const newRegion = data?.serverInfoMutations.multiRegion.create
if (!newRegion) return
// Add to admin region list
modifyObjectField(
cache,
ROOT_QUERY,
'serverInfo',
({ helpers: { createUpdatedValue, ref } }) =>
createUpdatedValue(({ update }) => {
update('multiRegion.regions', (regions) => [
ref('ServerRegionItem', newRegion.id),
...regions
])
})
)
}
}).catch(convertThrowIntoFetchResult)
if (res?.data?.serverInfoMutations.multiRegion.create.id) {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Region successfully created'
})
} else {
const errMsg = getFirstGqlErrorMessage(res?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to create region',
description: errMsg
})
}
return res?.data?.serverInfoMutations.multiRegion.create
}
}
export const useUpdateRegion = () => {
const { mutate } = useMutation(updateRegionMutation)
const { activeUser, isAdmin } = useActiveUser()
const { triggerNotification } = useGlobalToast()
return async (input: UpdateRegionMutationVariables) => {
if (!activeUser.value || !isAdmin.value) return
const res = await mutate(input).catch(convertThrowIntoFetchResult)
if (res?.data?.serverInfoMutations.multiRegion.update.id) {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Region successfully updated'
})
} else {
const errMsg = getFirstGqlErrorMessage(res?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to update region',
description: errMsg
})
}
return res?.data?.serverInfoMutations.multiRegion.update
}
}

Просмотреть файл

@ -0,0 +1,29 @@
import { graphql } from '~/lib/common/generated/gql'
export const createNewRegionMutation = graphql(`
mutation CreateNewRegion($input: CreateServerRegionInput!) {
serverInfoMutations {
multiRegion {
create(input: $input) {
id
...SettingsServerRegionsAddEditDialog_ServerRegionItem
...SettingsServerRegionsTable_ServerRegionItem
}
}
}
}
`)
export const updateRegionMutation = graphql(`
mutation UpdateRegion($input: UpdateServerRegionInput!) {
serverInfoMutations {
multiRegion {
update(input: $input) {
id
...SettingsServerRegionsAddEditDialog_ServerRegionItem
...SettingsServerRegionsTable_ServerRegionItem
}
}
}
}
`)

Просмотреть файл

@ -21,7 +21,10 @@ import type {
Workspace,
WorkspaceProjectInviteCreateInput,
InviteProjectUserMutation,
Project
Project,
WorkspaceProjectCreateInput,
CreateWorkspaceProjectMutation,
CreateProjectMutation
} from '~~/lib/common/generated/gql/graphql'
import {
ROOT_QUERY,
@ -43,7 +46,8 @@ import {
updateProjectRoleMutation,
updateWorkspaceProjectRoleMutation,
useProjectInviteMutation,
useMoveProjectToWorkspaceMutation
useMoveProjectToWorkspaceMutation,
createWorkspaceProjectMutation
} from '~~/lib/projects/graphql/mutations'
import { onProjectUpdatedSubscription } from '~~/lib/projects/graphql/subscriptions'
import { projectRoute } from '~/lib/common/helpers/route'
@ -117,16 +121,32 @@ export function useCreateProject() {
const { triggerNotification } = useGlobalToast()
const { activeUser } = useActiveUser()
return async (input: ProjectCreateInput) => {
return async (input: ProjectCreateInput | WorkspaceProjectCreateInput) => {
const userId = activeUser.value?.id
if (!userId) return
const res = await apollo
.mutate({
mutation: createProjectMutation,
variables: { input },
...('workspaceId' in input
? {
mutation: createWorkspaceProjectMutation,
variables: { input }
}
: {
mutation: createProjectMutation,
variables: { input }
}),
update: (cache, { data }) => {
const newProject = data?.projectMutations.create
// not sure why this isn't happening automatically
const typedData = data as
| CreateWorkspaceProjectMutation
| CreateProjectMutation
if (!typedData) return
const newProject =
'projectMutations' in typedData
? typedData.projectMutations.create
: typedData.workspaceMutations.projects.create
if (newProject?.id) {
// Existing cache update for projects
@ -136,7 +156,7 @@ export function useCreateProject() {
>(
cache,
ROOT_QUERY,
(fieldName, _variables, value, details) => {
(_fieldName, _variables, value, details) => {
const projectListFields = Object.keys(value).filter(
(k) =>
details.revolveFieldNameAndVariables(k).fieldName === 'projectList'
@ -150,7 +170,7 @@ export function useCreateProject() {
{ fieldNameWhitelist: ['admin'] }
)
if (input.workspaceId) {
if ('workspaceId' in input && input.workspaceId) {
const workspaceCacheId = getCacheId('Workspace', input.workspaceId)
modifyObjectFields<WorkspaceProjectsArgs, Workspace['projects']>(
@ -181,7 +201,18 @@ export function useCreateProject() {
})
.catch(convertThrowIntoFetchResult)
if (!res.data?.projectMutations.create.id) {
// not sure why this isn't happening automatically
const typedData = res.data as Optional<
CreateWorkspaceProjectMutation | CreateProjectMutation
>
const newProject = typedData
? 'projectMutations' in typedData
? typedData.projectMutations.create
: typedData.workspaceMutations.projects.create
: undefined
if (!newProject?.id) {
const err = getFirstErrorMessage(res.errors)
triggerNotification({
type: ToastNotificationType.Danger,
@ -195,7 +226,7 @@ export function useCreateProject() {
})
}
return res
return newProject
}
}

Просмотреть файл

@ -350,7 +350,6 @@ export function useDeleteVersions() {
* Various options for better cache updates, set if possible
*/
options?: Partial<{
projectId: string
modelId: string
}>
) => {
@ -372,26 +371,21 @@ export function useDeleteVersions() {
}
// Update totalCounts in project
if (options?.projectId) {
modifyObjectFields<ProjectVersionsArgs, Project['versions']>(
cache,
getCacheId('Project', options.projectId),
(_fieldName, _variables, data) => {
return {
...data,
...(!isUndefined(data.totalCount)
? {
totalCount: Math.max(
data.totalCount - input.versionIds.length,
0
)
}
: {})
}
},
{ fieldNameWhitelist: ['versions'] }
)
}
modifyObjectFields<ProjectVersionsArgs, Project['versions']>(
cache,
getCacheId('Project', input.projectId),
(_fieldName, _variables, data) => {
return {
...data,
...(!isUndefined(data.totalCount)
? {
totalCount: Math.max(data.totalCount - input.versionIds.length, 0)
}
: {})
}
},
{ fieldNameWhitelist: ['versions'] }
)
// Update totalCounts in model
if (options?.modelId) {
@ -458,7 +452,6 @@ export function useMoveVersions() {
options?: Partial<{
previousModelId: string
newModelCreated: boolean
projectId: string
}>
) => {
if (!input.versionIds.length || !input.targetModelName.trim()) return
@ -551,8 +544,8 @@ export function useMoveVersions() {
{ fieldNameWhitelist: ['versions'] }
)
if (options?.newModelCreated && options?.projectId) {
evictProjectModels(options.projectId)
if (options?.newModelCreated) {
evictProjectModels(input.projectId)
}
}
})

Просмотреть файл

@ -21,6 +21,19 @@ export const createProjectMutation = graphql(`
}
`)
export const createWorkspaceProjectMutation = graphql(`
mutation CreateWorkspaceProject($input: WorkspaceProjectCreateInput!) {
workspaceMutations {
projects {
create(input: $input) {
...ProjectPageProject
...ProjectDashboardItem
}
}
}
}
`)
export const updateModelMutation = graphql(`
mutation UpdateModel($input: UpdateModelInput!) {
modelMutations {

Просмотреть файл

@ -4,6 +4,7 @@ import SettingsUserNotifications from '~/components/settings/user/Notifications.
import SettingsUserDeveloper from '~/components/settings/user/developer/Developer.vue'
import SettingsUserEmails from '~/components/settings/user/Emails.vue'
import SettingsServerGeneral from '~/components/settings/server/General.vue'
import SettingsServerRegions from '~/components/settings/server/Regions.vue'
import SettingsServerProjects from '~/components/settings/server/Projects.vue'
import SettingsServerMembers from '~/components/settings/server/Members.vue'
import SettingsWorkspaceGeneral from '~/components/settings/workspaces/General.vue'
@ -11,11 +12,16 @@ import SettingsWorkspacesMembers from '~/components/settings/workspaces/Members.
import SettingsWorkspacesSecurity from '~/components/settings/workspaces/Security.vue'
import SettingsWorkspacesProjects from '~/components/settings/workspaces/Projects.vue'
import SettingsWorkspacesBilling from '~/components/settings/workspaces/Billing.vue'
import SettingsWorkspacesRegions from '~/components/settings/workspaces/Regions.vue'
import { useIsMultipleEmailsEnabled } from '~/composables/globals'
import { Roles } from '@speckle/shared'
import { SettingMenuKeys } from '~/lib/settings/helpers/types'
import { useIsMultiregionEnabled } from '~/lib/multiregion/composables/main'
export const useSettingsMenu = () => {
const isMultipleEmailsEnabled = useIsMultipleEmailsEnabled().value
const isMultiRegionEnabled = useIsMultiregionEnabled()
const workspaceMenuItems = shallowRef<SettingsMenuItems>({
[SettingMenuKeys.Workspace.General]: {
title: 'General',
@ -44,29 +50,30 @@ export const useSettingsMenu = () => {
},
[SettingMenuKeys.Workspace.Regions]: {
title: 'Regions',
disabled: true,
tooltipText: 'Set up regions for custom data residency',
permission: [Roles.Workspace.Admin, Roles.Workspace.Member]
component: SettingsWorkspacesRegions,
permission: [Roles.Workspace.Admin, Roles.Workspace.Member],
...(isMultiRegionEnabled
? {}
: {
tooltipText: 'Set up regions for custom data residency',
disabled: true
})
}
})
const multipleEmailsEnabled = useIsMultipleEmailsEnabled().value
const userMenuItemValues: SettingsMenuItems = {
const userMenuItems = shallowRef<SettingsMenuItems>({
[SettingMenuKeys.User.Profile]: {
title: 'User profile',
component: SettingsUserProfile
}
}
if (multipleEmailsEnabled) {
userMenuItemValues[SettingMenuKeys.User.Emails] = {
title: 'Emails',
component: SettingsUserEmails
}
}
Object.assign(userMenuItemValues, {
},
...(isMultipleEmailsEnabled
? {
[SettingMenuKeys.User.Emails]: {
title: 'Emails',
component: SettingsUserEmails
}
}
: {}),
[SettingMenuKeys.User.Notifications]: {
title: 'Notifications',
component: SettingsUserNotifications
@ -77,8 +84,6 @@ export const useSettingsMenu = () => {
}
})
const userMenuItems = shallowRef<SettingsMenuItems>(userMenuItemValues)
const serverMenuItems = shallowRef<SettingsMenuItems>({
[SettingMenuKeys.Server.General]: {
title: 'General',
@ -91,7 +96,15 @@ export const useSettingsMenu = () => {
[SettingMenuKeys.Server.Projects]: {
title: 'Projects',
component: SettingsServerProjects
}
},
...(isMultiRegionEnabled
? {
[SettingMenuKeys.Server.Regions]: {
title: 'Regions',
component: SettingsServerRegions
}
}
: {})
})
return {

Просмотреть файл

@ -25,6 +25,29 @@ export const settingsWorkspaceBillingQuery = graphql(`
}
`)
export const settingsWorkspacePricingPlansQuery = graphql(`
query SettingsWorkspacePricingPlans {
workspacePricingPlans
}
`)
export const settingsWorkspaceBillingCustomerPortalQuery = graphql(`
query SettingsWorkspaceBillingCustomerPortal($workspaceId: String!) {
workspace(id: $workspaceId) {
customerPortalUrl
}
}
`)
export const settingsWorkspaceRegionsQuery = graphql(`
query SettingsWorkspaceRegions($workspaceId: String!) {
workspace(id: $workspaceId) {
id
...SettingsWorkspacesRegions_Workspace
}
}
`)
export const settingsWorkspacesMembersQuery = graphql(`
query SettingsWorkspacesMembers(
$workspaceId: String!

Просмотреть файл

@ -1,4 +1,6 @@
import type { AvailableRoles } from '@speckle/shared'
import { isObjectLike, has } from 'lodash'
import type { WorkspacePlans } from '~/lib/common/generated/gql/graphql'
export type SettingsMenuItem = {
title: string
@ -23,7 +25,8 @@ export const SettingMenuKeys = Object.freeze(<const>{
General: 'server/general',
Projects: 'server/projects',
ActiveUsers: 'server/active-users',
PendingInvitations: 'server/pending-invitations'
PendingInvitations: 'server/pending-invitations',
Regions: 'server/regions'
},
Workspace: {
General: 'workspace/general',
@ -46,3 +49,22 @@ export type AvailableSettingsMenuKeys =
| UserSettingMenuKeys
| ServerSettingMenuKeys
| WorkspaceSettingMenuKeys
export type WorkspacePricingPlans = {
workspacePricingPlans: {
workspacePlanInformation: {
[key: string]: {
name: WorkspacePlans
}
}
}
}
export function isWorkspacePricingPlans(
pricingPlans: unknown
): pricingPlans is WorkspacePricingPlans {
return (
isObjectLike(pricingPlans) &&
has(pricingPlans, 'workspacePricingPlans.workspacePlanInformation')
)
}

Просмотреть файл

@ -36,7 +36,7 @@ export const items = [
}
},
{
camPos: [-39.91711, 46.26069, 42.83686, -18.44162, 29.75982, 34.91624, 0, 1],
camPos: [23.86779, 82.9541, 29.05586, -27.41942, 37.72358, 29.05586, 0, 1],
style: {} as Partial<CSSProperties>,
viewed: false,
showControls: false,

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше