Compare commits

...

28 Commits

Author SHA1 Message Date
Björn Steinhagen 8d4bbeea8b chore: comment 2026-04-15 08:30:05 +02:00
Björn Steinhagen 237501ad30 chore: community forum fallback 2026-04-15 08:26:21 +02:00
Björn Steinhagen 7160b64f55 fix: openFeedbackDialog to unwrap bool 2026-04-14 23:31:01 +02:00
Björn Steinhagen bdc904201a feat: safeguard through feedback dialog 2026-04-14 23:03:20 +02:00
Björn Steinhagen 6d6b5e1fd5 feat: use canAccesscanAccessHelpCenter to show or hide intercom 2026-04-14 22:58:17 +02:00
Björn Steinhagen b4ce93d26e feat: updates workspace fragment 2026-04-14 22:31:49 +02:00
Björn Steinhagen 2ca577fe60 fix: show inaccessible state for project collapsible when account is missing (#103)
* fix(dui): show remove option for inaccessible project groups

* fix: show inaccessible state for project collapsible when account is missing

* chore: reverting previous unrelated changes
2026-04-10 10:45:16 +02:00
Iain Sproat c37235381f feat(deployment): package as Docker image & Helm Chart (#98)
* feat(deployment): package as Docker image & Helm Chart

* remove erroneous permission request

* fix corepack issue

* fix prettier

* deployment testing of helm chart with ctlptl, tilt & kind

* fix linting

* remove need for license to be mounted

* ensure consistency in naming

* incorporate copilot comments

* fix CI pipeline

* fix

* incorporate copilot review comments

* include MIXPANEL environment variable

* remove single quotes from NODE_ENV ARG

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2026-04-10 11:42:14 +03:00
Oğuzhan Koral 8e2f507286 fix: version check on dev env in connectors (#102)
* fix: version check on dev env in connectors

* chore: bump version
2026-04-08 12:18:30 +03:00
Björn Steinhagen 9d3a623fe6 feat(dui): add disable cache toggle to main menu (#99)
* feat(dui): adds disable cache setting

* fix(dui): excludes non-sharp dui connectors manually with slug check

* chore(dui): adds todo

* feat(dui): adds version check to isDisableCacheSupported
2026-04-02 11:34:32 +02:00
Björn Steinhagen 8fc81b0b4e fix(dui): stale load settings to existing model card (#94)
Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2026-03-27 19:29:33 +03:00
Björn Steinhagen 6f2f599b1b fix: redirect to workspace on sso session error (#97) 2026-03-27 19:15:54 +03:00
Oğuzhan Koral a69de13f16 feat: refactor auth flow and enable exchange token flow (#95)
* feat: refactor auth flow and enable exchange token flow

* fix: do not cache to local storage for exchange token

* chore: remove logging

* chore: lint

* feat: pkce alignment with oauth endpoint

* feat: default log in via accountBinding.authenticateAccount if available

* feat: do not show legacy sign in if connectors has accountBinding.authenticateAccount flow

* fix: base64url safe
2026-03-25 17:21:07 +03:00
Björn Steinhagen d2b0d35119 feat: parameter updater (#92)
* feat(issues): add apply changes workflow for parameter updater

* chore(issues): remove my wip comments

* chore: conflicts on generated

* chore: resolving conflicts

* chore: new queries

* chore: reverting

* feat: refactor to dedicated IParametersBinding

* feat(dui): disable apply changes button for resolved issues

* fix(dui): assert workspaceId is non-null in issue query
2026-03-18 12:55:25 +02:00
Björn Steinhagen b026659460 refactor: upsell message (#88)
* chore: upsell message

* fix: upgrade cta

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2026-03-06 11:40:14 +03:00
Oğuzhan Koral 009cc77bab fix: correct url for create workspace action (#93) 2026-03-06 10:52:46 +03:00
Björn Steinhagen a8b802b7e3 fix(dui): prevents empty publish selection state
fix(dui): prevents empty publish selection state
2026-03-03 08:29:40 +02:00
Björn Steinhagen 6fc3df4a0d refactor: centralized filter validation and generalized 'empty selection' checks 2026-03-02 15:34:36 +02:00
Björn Steinhagen f47f19c02d Merge branch 'main' into bjorn/cnx-3125-prevent-publishing-without-a-valid-selection 2026-02-25 14:42:35 +02:00
Oğuzhan Koral 85f806368a feat: handle model card state according to given ingestion id (#89)
* feat: handle model card state according to given ingestion id

* chore: linting
2026-02-25 14:00:59 +03:00
Björn Steinhagen 35ddce1f90 fix(dui): prevents invalidate selection filter across not just selection 2026-02-25 11:01:30 +02:00
Björn Steinhagen a37b3389d6 fix(dui): prevents empty publish selection state 2026-02-25 10:23:19 +02:00
Björn Steinhagen ed4aa92ce1 fix: disable deletion of model card while ops are happening (#87)
* chore: battling git

* fix: logic to card base for sender and receiver fix
2026-02-16 11:50:28 +03:00
Björn Steinhagen 60f3bed254 feat: loading state on publish wizard
feat: loading state on publish button
2026-02-03 14:16:20 +02:00
Björn 2f412df64a feat: loading state on publish button 2026-02-03 14:11:37 +02:00
Oğuzhan Koral c7e0929eca feat: new business model changes (#85)
* feat: initial can create version implementation on model card

* feat: disable model card CTAs for send

* feat: initial model ingestion tests

* fix: apply ingestion send to all CTAs

* feat: sketchup bridge

* feat: centeralize the start ingestion logic in host app store

* fix: sketchup is handling via model ingestion

* chore: cosmetics

* feat(ingestion): add failWithError and failWithCancel GraphQL mutations

* feat(ingestion): add failIngestion and cancelIngestion methods to useModelIngestion composable

* feat(ingestion): handle ingestion failure and cancellation in hostAppStore

* fix: reviewers comments

* fix: don't know where the f that came from

* refactor(ingestion): remove unused statusData and fix lint errors

* feat(wizard): add canCreateVersion permission check to publish wizard

* TODOs

* feat(permissions): add 1s polling for canCreateVersion to reflect workspace limit changes

* fix(tooltip): undefined doesnt refresh v-tippy

* fix(wizard): too much ctrl z lol

* refactor(permissions): check canCreateVersion on action instead of polling

* feat(hostApp): adds fallback for model ingestion on older servers

* fix: ingestion available check and rock'n roll

* feat: workspace plan updated subscription boilerplate

* fix: bump the timeout to 2h

* feat: handle version limits in publish flows via subscription

* feat: align Archicad and Vectorworks with new ingestion flow

* chore: onMounted at end of file

* fix: logic and ui adjustments

* fix: refactoring and permissions

* refactor: ingestionStatus renamed to activeIngestions

* fix: error handling and notifications

* fix: global error handling

* chore: general alignment and clean up

* fix(vectorworks): now uses capital V

* chore: revert codegen

---------

Co-authored-by: Björn Steinhagen <88777268+bjoernsteinhagen@users.noreply.github.com>
Co-authored-by: Björn Steinhagen <steinhagen.bjoern@gmail.com>
2026-02-03 14:43:16 +03:00
Oğuzhan Koral eef0a59719 feat: disable intercom for non speckle distributions + partner badge (#84)
* feat: disable intercom for non speckle distributions + partner badge

* no logging
2026-01-16 18:00:49 +03:00
Dogukan Karatas 19f306756c fix: handle network connectivity in DUI (#80)
* error handler

* top-level handling

* internet check

* pass other network errors

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2026-01-12 17:51:47 +03:00
77 changed files with 4527 additions and 823 deletions
+41
View File
@@ -0,0 +1,41 @@
# Irrelevant source files
deployment/
# Build output and other temporary files
.husky/_/
.netlify/
.nuxt/
dist/
node_modules/
# Version control
.git/
.gitignore
# GitHub / CI metadata
.github/
# Environment files
.env
*.env
# Logs
*.log
# IDE / editor settings
.vscode/
.idea/
.zed/
*.iml
# OS / editor junk
.DS_Store
*.swp
*.swo
# AI
.claude/
.cursor/
# testing
tests/
+48
View File
@@ -0,0 +1,48 @@
name: Build Docker Container
on:
workflow_call:
inputs:
PUBLISH:
required: false
type: boolean
default: false
IMAGE_VERSION_TAG:
required: true
type: string
permissions: {} # purposefully empty by default at workflow level, explicitly overridden for specific jobs below
concurrency:
group: ${{ github.workflow }}-build-${{ github.ref }}
cancel-in-progress: true
jobs:
docker-build:
runs-on: blacksmith-4vcpu-ubuntu-2404
name: Build Docker image
permissions:
contents: read
packages: write # to be able to push images to ghcr.io. As permissions is static, it has to be granted even if PUBLISH is false
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
persist-credentials: false
- name: Login to Helm Chart & Container Image Registry
if: ${{ inputs.PUBLISH == true }}
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Setup Docker Builder
uses: useblacksmith/setup-docker-builder@affa10db466676f3dfb3e54caeb228ee0691510f
- name: Build and push
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1
with:
push: ${{ inputs.PUBLISH }}
tags: ghcr.io/specklesystems/speckle-dui:${{ inputs.IMAGE_VERSION_TAG }}
file: ./deployment/docker/Dockerfile
network: host # to be able to connect to Tailscale and pull private base image during build
allow: network.host # to be able to connect to Tailscale and pull private base image during build
+63
View File
@@ -0,0 +1,63 @@
name: Get Version
on:
workflow_call:
outputs:
IMAGE_VERSION_TAG:
description: 'The image version tag under which the Helm chart and docker image should be published'
value: ${{ jobs.get-version.outputs.VERSION }}
permissions: {} # purposefully empty by default at workflow level, explicitly overridden for specific jobs below
concurrency:
group: ${{ github.workflow }}-get-version-${{ github.ref }}
cancel-in-progress: true
jobs:
get-version:
outputs:
VERSION: ${{ steps.get-version.outputs.VERSION }}
name: Get Version
permissions:
contents: read
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
sparse-checkout: ''
fetch-depth: 1
fetch-tags: 1
persist-credentials: true # zizmor: ignore[artipacked] need to fetch tags in the next step and this ensures that git is configured & authenticated
- run: git fetch origin 'refs/tags/*:refs/tags/*'
- name: Get version tag
id: get-version
run: |
VERSION=""
if [[ "${GITHUB_REF_NAME}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
VERSION="${GITHUB_REF_NAME}"
echo "VERSION=${VERSION}" >> ${GITHUB_OUTPUT}
echo "${VERSION} is a valid semver, we shall use it. Exiting"
exit 0
fi
LAST_RELEASE="$(git describe --always --tags $(git rev-list --tags --max-count=1) | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)" # get the last release tag. FIXME: Fails if a commit is tagged with more than one tag: https://stackoverflow.com/questions/8089002/git-describe-with-two-tags-on-the-same-commit/56039163#56039163
LAST_RELEASE="${LAST_RELEASE:-0.0.0}"
NEXT_RELEASE="$(echo "${LAST_RELEASE}" | awk -F. -v OFS=. '{$NF += 1 ; print}')"
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
VERSION="${NEXT_RELEASE}-alpha.${GITHUB_RUN_NUMBER}"
echo "VERSION=${VERSION}" >> ${GITHUB_OUTPUT}
echo "${VERSION} will be an alpha version. Exiting"
exit 0
fi
BRANCH_NAME_TRUNCATED="$(echo "${GITHUB_REF_NAME}" | cut -c -28 | sed 's/[^a-zA-Z0-9.-]/-/g')" # docker has a 128 character tag limit, so ensuring the branch name will be short enough
PADDED_RUN_NUMBER="$(printf "%06d" "${GITHUB_RUN_NUMBER}")"
COMMIT_SHA1_TRUNCATED="$(echo "${GITHUB_SHA}" | cut -c -7)"
VERSION="${NEXT_RELEASE}-branch.${BRANCH_NAME_TRUNCATED}.${PADDED_RUN_NUMBER}-${COMMIT_SHA1_TRUNCATED}"
echo "VERSION=${VERSION}" >> ${GITHUB_OUTPUT}
echo "${VERSION} will be a branch build version. Exiting"
exit 0
+35
View File
@@ -0,0 +1,35 @@
name: Lint
on:
workflow_call: {}
permissions: {} # purposefully empty by default at workflow level, explicitly overridden for specific jobs below
concurrency:
group: ${{ github.workflow }}-lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 1
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
with:
node-version: '22.14.0'
cache: 'yarn'
- name: Install Dependencies
run: yarn install --immutable
- name: Run Linter
run: yarn lint
+30 -35
View File
@@ -1,44 +1,39 @@
name: Linting
name: Pull Request
on:
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # other running workflows get cancelled on the same branch
permissions: {} # purposefully empty by default at workflow level, explicitly overridden for specific jobs below
jobs:
lint-and-build:
runs-on: ubuntu-latest
get-version:
uses: ./.github/workflows/get-version.yml
with: {}
secrets: {}
permissions:
contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v4
lint:
uses: ./.github/workflows/lint.yml
with: {}
secrets: {}
permissions:
contents: read
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22.14.0'
- name: Enable Corepack and Install Correct Yarn Version
run: |
corepack enable
corepack prepare yarn@$(jq -r .packageManager package.json | cut -d'@' -f2) --activate
yarn --version
- name: Cache node_modules
uses: actions/cache@v4
with:
path: |
**/node_modules
.yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install Dependencies
run: yarn install --immutable
- name: Run Linter
run: yarn lint
- name: Run generate
run: yarn generate
build:
needs:
- get-version
uses: ./.github/workflows/build.yml
with:
PUBLISH: false
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
secrets: {}
permissions:
contents: read
packages: write # to be able to push images to ghcr.io, even if PUBLISH is false, as permissions is static at workflow level
+41
View File
@@ -0,0 +1,41 @@
name: Release
on:
push:
branches:
- main
tags:
- '[0-9]+.[0-9]+.[0-9]+'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # other running workflows get cancelled on the same branch
permissions: {} # purposefully empty by default at workflow level, explicitly overridden for specific jobs below
jobs:
get-version:
uses: ./.github/workflows/get-version.yml
with: {}
secrets: {}
permissions:
contents: read
lint:
uses: ./.github/workflows/lint.yml
with: {}
secrets: {}
permissions:
contents: read
build:
uses: ./.github/workflows/build.yml
needs:
- get-version
- lint
with:
PUBLISH: true
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
secrets: {}
permissions:
contents: read
packages: write # to be able to push images to ghcr.io
+3 -1
View File
@@ -15,4 +15,6 @@ dist
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
!.yarn/versions
.claude
+5 -1
View File
@@ -32,4 +32,8 @@ venv
storybook-static
.tshy
.tshy-build
.tshy-build
# Helm
deployment/helm
tests/deployment
+1
View File
@@ -0,0 +1 @@
dist/
@@ -0,0 +1,175 @@
<template>
<div v-if="!hidden" class="flex flex-col space-y-2">
<!-- idle: server URL + sign in button -->
<template v-if="state === 'idle'">
<div class="flex space-x-2">
<FormButton
v-if="canAddAccount"
full-width
color="outline"
@click="openBrowserAuth()"
>
Log in with OAuth token
</FormButton>
</div>
</template>
<!-- waiting: instructions + code input -->
<template v-if="state === 'waiting' || state === 'submitting'">
<div class="text-foreground-2 space-y-2 border rounded-lg p-2">
<div class="text-sm text-center">
Check your browser: authorize the app, then copy the exchange code and paste
it below.
</div>
<div class="py-2"><CommonLoadingBar :loading="state === 'waiting'" /></div>
<FormTextInput
v-model="exchangeCode"
name="exchangeCode"
:show-label="false"
placeholder="Paste exchange code here"
color="foundation"
autocomplete="off"
:disabled="state === 'submitting'"
/>
<FormButton
full-width
:disabled="!exchangeCode?.trim() || state === 'submitting'"
@click="submitCode()"
>
{{ state === 'submitting' ? 'Signing in...' : 'Submit' }}
</FormButton>
<div v-if="showHelp" class="p-2 rounded-md space-y-1">
<div class="text-sm text-center">Having trouble?</div>
<div class="flex justify-center">
<span>
<FormButton size="sm" text @click="retryFlow()">Retry</FormButton>
or
<FormButton text size="sm" @click="$openUrl('https://speckle.community')">
Get in touch with us
</FormButton>
</span>
</div>
</div>
</div>
</template>
<!-- error -->
<template v-if="state === 'error'">
<div class="text-foreground-2 space-y-2">
<div class="text-sm text-center text-red-500">
{{ errorMessage }}
</div>
<FormButton full-width @click="retryFlow()">Try again</FormButton>
<FormButton text size="sm" full-width @click="emit('backToSignIn')">
Back
</FormButton>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthManager } from '~/lib/authn/useAuthManager'
import { useTokenExchange, supportsOAuthToken } from '~/lib/authn/useTokenExchange'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useAccountStore } from '~/store/accounts'
import type { BaseBridge } from '~/lib/bridge/base'
const props = defineProps<{
serverUrl: string
}>()
const emit = defineEmits<{
(e: 'backToSignIn'): void
}>()
const app = useNuxtApp()
const { generateLocalChallenge } = useAuthManager()
const { exchangeAccessCode } = useTokenExchange()
const { trackEvent } = useMixpanel()
const accountStore = useAccountStore()
const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const state = ref<'idle' | 'waiting' | 'submitting' | 'error'>('idle')
const exchangeCode = ref<string | undefined>()
const errorMessage = ref('')
const showHelp = ref(false)
const hidden = ref(false)
const checkServerSupport = async (url: string) => {
const serverUrl = url ? new URL(url).origin : 'https://app.speckle.systems'
hidden.value = !(await supportsOAuthToken(serverUrl))
}
let debounceTimer: ReturnType<typeof setTimeout> | null = null
onMounted(() => checkServerSupport(props.serverUrl))
watch(
() => props.serverUrl,
(url) => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => checkServerSupport(url), 500)
}
)
let currentCodeVerifier = ''
let currentCodeChallenge = ''
let currentServerUrl = ''
const openBrowserAuth = async () => {
currentServerUrl = props.serverUrl
? new URL(props.serverUrl).origin
: 'https://app.speckle.systems'
const { codeVerifier, codeChallenge } = await generateLocalChallenge()
currentCodeVerifier = codeVerifier
currentCodeChallenge = codeChallenge
const authUrl = `${currentServerUrl}/authn/verify/sdui/${codeChallenge}?returnExchangeToken=true&code_challenge_method=S256`
app.$openUrl(authUrl)
state.value = 'waiting'
exchangeCode.value = undefined
showHelp.value = false
setTimeout(() => {
if (state.value === 'waiting') {
showHelp.value = true
}
}, 10_000)
}
const submitCode = async () => {
const code = exchangeCode.value?.trim()
if (!code || !currentCodeChallenge || !currentServerUrl) return
state.value = 'submitting'
try {
await exchangeAccessCode(
currentServerUrl,
code,
currentCodeChallenge,
currentCodeVerifier
)
void trackEvent('DUI Account Added')
// Refresh accounts so the watcher in Menu.vue detects the new account and closes the dialog
await accountStore.refreshAccounts()
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : 'Failed to sign in. Please try again.'
state.value = 'error'
}
}
const retryFlow = () => {
state.value = 'idle'
exchangeCode.value = undefined
errorMessage.value = ''
showHelp.value = false
}
</script>
+20 -48
View File
@@ -2,56 +2,30 @@
<div class="flex flex-col space-y-2">
<div v-if="isDesktopServiceAvailable">
<div v-show="!isAddingAccount" class="text-foreground-2 space-y-2">
<FormButton
text
size="sm"
full-width
@click="showCustomServerInput = !showCustomServerInput"
>
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
</FormButton>
<div v-if="showCustomServerInput">
<FormTextInput
v-model="customServerUrl"
name="name"
:show-label="false"
color="foundation"
autocomplete="off"
show-clear
@clear="showCustomServerInput = false"
/>
</div>
<div class="flex space-x-2">
<FormButton
color="outline"
class="px-1"
:icon-left="ArrowLeftIcon"
hide-text
@click="emit('backToSignIn')"
/>
<FormButton full-width @click="startAccountAddFlow()">
Sign in (Legacy)
<FormButton full-width color="outline" @click="startAccountAddFlow()">
Log in (Legacy)
</FormButton>
</div>
</div>
<div v-show="isAddingAccount" class="text-foreground-2 mt-2 mb-4 space-y-2">
<div
v-show="isAddingAccount"
class="text-foreground-2 mt-2 mb-4 space-y-2 border rounded-lg p-2"
>
<div class="text-sm text-center">
Please check your browser: waiting for authorization to complete.
</div>
<div class="py-2"><CommonLoadingBar :loading="isAddingAccount" /></div>
<div v-if="showHelp" class="bg-blue-500/10 p-2 rounded-md space-y-2">
<div v-if="showHelp" class="p-2 rounded-md space-y-1">
<div class="text-sm text-center">Having trouble?</div>
<FormButton size="sm" full-width @click="restartFlow()">Retry</FormButton>
<FormButton
text
size="sm"
full-width
@click="$openUrl('https://speckle.community')"
>
Get in touch with us
</FormButton>
<div class="flex justify-center">
<span>
<FormButton text size="sm" @click="$openUrl('https://speckle.community')">
Get in touch with us
</FormButton>
</span>
</div>
</div>
</div>
</div>
@@ -91,6 +65,10 @@ const hostApp = useHostAppStore()
const app = useNuxtApp()
const { trackEvent } = useMixpanel()
const props = defineProps<{
serverUrl: string
}>()
const emit = defineEmits<{
(e: 'backToSignIn'): void
}>()
@@ -98,7 +76,6 @@ const emit = defineEmits<{
const showCustomServerInput = ref(false)
const isAddingAccount = ref(false)
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
const customServerUrl = ref<string | undefined>('https://app.speckle.systems')
const showHelp = ref(false)
const accountCheckerIntervalFn = useIntervalFn(
@@ -123,9 +100,9 @@ const startAccountAddFlow = () => {
setTimeout(() => {
showHelp.value = true
}, 10_000)
const url = customServerUrl.value
const url = props.serverUrl
? `http://localhost:29364/auth/add-account?serverUrl=${
new URL(customServerUrl.value).origin
new URL(props.serverUrl).origin
}`
: `http://localhost:29364/auth/add-account`
@@ -149,11 +126,6 @@ const startAccountAddFlow = () => {
}, 30_000)
}
const restartFlow = () => {
isAddingAccount.value = false
showHelp.value = false
}
onMounted(async () => {
isDesktopServiceAvailable.value = await pingDesktopService()
})
+29 -17
View File
@@ -39,20 +39,24 @@
title="Add a new account"
fullscreen="none"
>
<div class="flex flex-col space-y-2">
<AccountsSignInFlow v-if="!showLegacy" />
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
<FormButton
v-if="!showLegacy"
text
full-width
size="sm"
class="text-xs"
@click="showLegacy = true"
>
Legacy Sign in
</FormButton>
<div class="flex flex-col space-y-4 p-2">
<FormTextInput
v-model="customServerUrl"
name="Server to sign in"
show-label
placeholder="https://app.speckle.systems"
color="foundation"
autocomplete="off"
show-clear
/>
<div class="space-y-2">
<AccountsSignInFlow :server-url="customServerUrl" />
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
<AccountsLegacySignInFlow
v-if="!canStartAuthAccount"
:server-url="customServerUrl"
/>
</div>
</div>
</CommonDialog>
</div>
@@ -68,10 +72,18 @@ import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useDesktopService } from '~/lib/core/composables/desktopService'
import type { BaseBridge } from '~/lib/bridge/base'
const { trackEvent } = useMixpanel()
const app = useNuxtApp()
const { pingDesktopService } = useDesktopService()
const { $accountBinding } = useNuxtApp()
const canStartAuthAccount = ['AuthenticateAccount', 'authenticateAccount'].some(
(name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const customServerUrl = ref<string>('https://app.speckle.systems')
const props = withDefaults(
defineProps<{
@@ -88,7 +100,7 @@ defineEmits<{
}>()
const showAddNewAccount = ref(false)
const showLegacy = ref(false)
const signInMode = ref<'default' | 'exchange' | 'legacy'>('default')
const showAccountsDialog = defineModel<boolean>('open', {
required: false,
@@ -110,8 +122,8 @@ watch(showAccountsDialog, (newVal) => {
watch(showAddNewAccount, (newVal) => {
if (newVal) {
// reset the current/legacy state on every add account sub-dialog
showLegacy.value = false
// reset the sign-in mode on every add account sub-dialog
signInMode.value = 'default'
}
})
+33 -31
View File
@@ -1,51 +1,53 @@
<template>
<div class="flex flex-col space-y-2">
<FormButton
text
size="sm"
full-width
@click="showCustomServerInput = !showCustomServerInput"
>
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
</FormButton>
<div v-if="showCustomServerInput">
<FormTextInput
v-model="customServerUrl"
name="name"
:show-label="false"
placeholder="https://app.speckle.systems"
color="foundation"
autocomplete="off"
show-clear
@clear="showCustomServerInput = false"
/>
</div>
<FormButton v-if="canAddAccount" full-width @click="logIn()">Sign in</FormButton>
<FormButton v-if="canAddAccount" full-width @click="logIn()">Log in</FormButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthManager } from '~/lib/authn/useAuthManager'
import type { BaseBridge } from '~/lib/bridge/base'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import { ToastNotificationType } from '@speckle/ui-components'
const customServerUrl = ref<string | undefined>('https://app.speckle.systems')
const showCustomServerInput = ref(false)
const props = defineProps<{
serverUrl: string
}>()
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const canStartAuthAccount = ['AuthenticateAccount', 'authenticateAccount'].some(
(name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const { generateChallenge } = useAuthManager()
const logIn = () => {
const serverUrl = customServerUrl.value
? new URL(customServerUrl.value).origin
const logIn = async () => {
const serverUrl = props.serverUrl
? new URL(props.serverUrl).origin
: 'https://app.speckle.systems'
const challenge = generateChallenge(serverUrl)
const authUrl = `${serverUrl}/authn/verify/sdui/${challenge}`
window.location.href = authUrl
if (canStartAuthAccount) {
const acc = await $accountBinding.authenticateAccount(serverUrl)
if (acc.token) {
await accountStore.refreshAccounts()
} else {
hostAppStore.setNotification({
title: 'Log In',
type: ToastNotificationType.Info,
description:
"Log in could not completed. Make sure you have logged in successfully, otherwise try 'Log in with OAuth token'"
})
}
} else {
const { codeChallenge } = await generateChallenge(serverUrl)
const authUrl = `${serverUrl}/authn/verify/sdui/${codeChallenge}?code_challenge_method=S256`
window.location.href = authUrl
}
}
</script>
+11 -10
View File
@@ -27,16 +27,17 @@
>
{{ notification.secondaryCta.name }}
</FormButton>
<FormButton
v-if="notification.cta"
v-tippy="notification.cta.tooltipText"
size="sm"
color="primary"
full-width
@click.stop="notification.cta?.action"
>
{{ notification.cta.name }}
</FormButton>
<div v-if="notification.cta" v-tippy="notification.cta.tooltipText">
<FormButton
:disabled="notification.cta.disabled"
size="sm"
color="primary"
full-width
@click.stop="notification.cta?.action"
>
{{ notification.cta.name }}
</FormButton>
</div>
</div>
</div>
<div
+25 -6
View File
@@ -74,7 +74,7 @@
</div>
</div>
<div
v-if="projectIsAccesible && !projectIsAccesible"
v-if="projectIsAccesible === false"
class="px-2 py-4 bg-foundation dark:bg-neutral-700/10 rounded-md shadow"
>
<CommonAlert
@@ -145,10 +145,25 @@ const projectNavigatorTippy = computed(() =>
const clientId = projectAccount.value.accountInfo.id
const { result: projectDetailsResult, refetch: refetchProjectDetails } = useQuery(
const accountExists = accountStore.isAccountExistsById(props.project.accountId)
if (!accountExists) {
projectIsAccesible.value = false
}
const {
result: projectDetailsResult,
refetch: refetchProjectDetails,
onError: onProjectDetailsError
} = useQuery(
projectDetailsQuery,
() => ({ projectId: props.project.projectId }),
() => ({ clientId, debounce: 500, fetchPolicy: 'network-only' })
() => ({
clientId,
debounce: 500,
fetchPolicy: 'network-only',
enabled: accountExists
})
)
const removeProjectModels = async () => {
@@ -162,6 +177,10 @@ watch(projectDetails, (newValue) => {
projectIsAccesible.value = newValue !== undefined
})
onProjectDetailsError(() => {
projectIsAccesible.value = false
})
const canLoad = computed(() => !!projectDetails.value?.permissions.canLoad.authorized)
const canPublish = computed(
() => !!projectDetails.value?.permissions.canPublish.authorized
@@ -194,13 +213,13 @@ const isWorkspaceReadOnly = computed(() => {
const { onResult: userProjectsUpdated } = useSubscription(
userProjectsUpdatedSubscription,
() => ({}),
() => ({ clientId })
() => ({ clientId, enabled: accountExists })
)
const { onResult: projectUpdated } = useSubscription(
projectUpdatedSubscription,
() => ({ projectId: props.project.projectId }),
() => ({ clientId })
() => ({ clientId, enabled: accountExists })
)
// to catch changes on visibility of project
@@ -236,7 +255,7 @@ const workspaceUrl = computed(() => {
const { onResult } = useSubscription(
versionCreatedSubscription,
() => ({ projectId: props.project.projectId }),
() => ({ clientId })
() => ({ clientId, enabled: accountExists })
)
onResult((res) => {
+30 -5
View File
@@ -49,9 +49,26 @@
>
<span class="">Update</span>
</FormButton> -->
<div class="text-[8px] text-foreground-disabled max-[150px]:hidden">
<div
class="text-[8px] text-foreground-disabled max-[150px]:hidden"
:class="{ 'mr-2': !hostAppStore.isDistributedBySpeckle }"
>
{{ hostAppStore.connectorVersion }}
</div>
<div
v-if="!hostAppStore.isDistributedBySpeckle && hostAppStore.hostAppName"
v-tippy="
`${hostAppStore.hostAppName
.charAt(0)
.toUpperCase()}${hostAppStore.hostAppName.slice(
1
)} connector is not distributed by Speckle.`
"
class="text-xs text-foreground-disabled max-[150px]:hidden mr-1"
>
<CommonBadge color="secondary">Partner</CommonBadge>
</div>
<HeaderButton
v-if="hostAppStore.isDistributedBySpeckle"
v-tippy="'Documentation and help'"
@@ -65,7 +82,11 @@
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
/>
</HeaderButton>
<HeaderButton v-tippy="'Send us feedback'" @click="openFeedbackDialog()">
<HeaderButton
v-if="hostAppStore.isDistributedBySpeckle"
v-tippy="'Send us feedback'"
@click="openFeedbackDialog()"
>
<ChatBubbleLeftIcon
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
/>
@@ -106,12 +127,16 @@ const { $intercom } = useNuxtApp()
const openFeedbackDialog = () => {
if (
hostAppStore.hostAppName?.toLowerCase() === 'revit' &&
hostAppStore.hostAppVersion?.includes('2022')
(hostAppStore.hostAppName?.toLowerCase() === 'revit' &&
hostAppStore.hostAppVersion?.includes('2022')) ||
!hostAppStore.isDistributedBySpeckle
) {
showFeedbackDialog.value = true
} else {
} else if ($intercom.shouldEnableIntercom.value) {
$intercom.show()
} else {
// community forum fallback
app.$openUrl('https://speckle.community')
}
}
</script>
+48 -2
View File
@@ -36,6 +36,23 @@
{{ isDarkTheme ? 'Light theme' : 'Dark theme' }}
</div>
</MenuItem>
<MenuItem
v-if="isDisableCacheSupported"
v-slot="{ active }"
@click="toggleCache"
>
<div
:class="[
active ? 'bg-highlight-1' : '',
'my-1 text-body-2xs flex justify-between px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
<span>Disable Cache</span>
<span v-if="isCacheDisabled" class="text-primary font-bold ml-2"></span>
</div>
</MenuItem>
<div class="border-t border-outline-3 mt-1">
<MenuItem v-if="app.$revitMapperBinding" v-slot="{ active }">
<button
@@ -120,12 +137,41 @@ import { storeToRefs } from 'pinia'
import { XMarkIcon, Bars3Icon } from '@heroicons/vue/20/solid'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { useConfigStore } from '~/store/config'
import { useHostAppStore } from '~/store/hostApp'
const app = useNuxtApp()
const uiConfigStore = useConfigStore()
const { isDarkTheme, hasConfigBindings, isDevMode } = storeToRefs(uiConfigStore)
const { toggleTheme } = uiConfigStore
const { isDarkTheme, hasConfigBindings, isDevMode, isCacheDisabled } =
storeToRefs(uiConfigStore)
const { toggleTheme, toggleCache } = uiConfigStore
const hostAppStore = useHostAppStore()
const { hostAppName, connectorVersion } = storeToRefs(hostAppStore)
const isDisableCacheSupported = computed(() => {
const appName = hostAppName.value
const version = connectorVersion.value
if (!appName || !version) return false
// excludes non-sharp connectors (assuming they don't have backend cache bypass)
const nonSharpApps = ['sketchup', 'archicad', 'vectorworks']
if (nonSharpApps.includes(appName.toLowerCase())) return false
// always show in dev environments
if (version.includes('dev') || version.includes('local') || version.includes('1.0.0'))
return true
// for sharp connectors, check if version is >= 3.18.0
const targetVersion = '3.19.0'
return (
version.localeCompare(targetVersion, undefined, {
numeric: true,
sensitivity: 'base'
}) >= 0
)
})
const { $showDevTools, $openUrl } = useNuxtApp()
const showAccountsDialog = ref(false)
+18 -14
View File
@@ -6,18 +6,22 @@
Welcome to Speckle
</h1>
<div v-if="isDesktopServiceAvailable || canAddAccount">
<AccountsSignInFlow v-if="!showLegacy" />
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
<FormButton
v-if="!showLegacy"
text
full-width
size="sm"
class="text-xs"
@click="showLegacy = true"
>
Legacy Sign in
</FormButton>
<div class="flex flex-col space-y-4 p-2">
<FormTextInput
v-model="customServerUrl"
name="Server to sign in"
:show-label="false"
placeholder="https://app.speckle.systems"
color="foundation"
autocomplete="off"
show-clear
/>
<div class="space-y-2">
<AccountsSignInFlow :server-url="customServerUrl" />
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
<AccountsLegacySignInFlow :server-url="customServerUrl" />
</div>
</div>
</div>
<div v-else>
<div class="text-foreground-2 mt-2 mb-4">
@@ -53,13 +57,13 @@ import type { BaseBridge } from '~/lib/bridge/base'
const accountStore = useAccountStore()
const { pingDesktopService } = useDesktopService()
const customServerUrl = ref<string>('https://app.speckle.systems')
const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const showLegacy = ref(false)
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
onMounted(async () => {
+1 -1
View File
@@ -28,7 +28,7 @@
</div>
</div>
<hr />
<IssuesSelectedItem :issue="selectedIssue" />
<IssuesSelectedItem :issue="selectedIssue" :model-card="modelCard" />
</div>
<div v-if="!selectedIssue" class="flex flex-col space-y-2">
+84 -1
View File
@@ -6,9 +6,21 @@
</div>
<IssuesBasicTiptap
v-if="issue.description?.doc"
class="border rounded-xl border-outline-3"
class="border rounded-xl border-outline-3 w-full"
:doc="issue.description?.doc"
></IssuesBasicTiptap>
<div v-if="app.$parametersBinding && hasObjectDeltas" class="w-full pt-1 pb-1">
<FormButton
class="w-full justify-center"
:disabled="isApplying || isResolved"
@click="applyChanges"
>
{{
isApplying ? 'Applying...' : isResolved ? 'Issue resolved' : 'Apply changes'
}}
</FormButton>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<IssuesStatusIcon :status="issue.status" show-label />
<IssuesPriorityIcon :priority="issue.priority" show-label />
@@ -84,14 +96,85 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { ResourceMetaType, IssueStatus } from '~/lib/common/generated/gql/graphql'
import { issueResourceMetaSearchQuery } from '~/lib/issues/graphql/queries'
import type { IssuesItemFragment } from '~/lib/common/generated/gql/graphql'
import type { IModelCard } from '~/lib/models/card'
import dayjs from 'dayjs'
import { Calendar } from 'lucide-vue-next'
const props = defineProps<{
issue: IssuesItemFragment
modelCard: IModelCard
}>()
const app = useNuxtApp()
const isApplying = ref(false)
const isResolved = computed(() => {
return props.issue.status === IssueStatus.Resolved
})
const queryVariables = computed(() => ({
workspaceId: props.modelCard.workspaceId!,
projectId: props.modelCard.projectId,
resourceType: ResourceMetaType.Issue,
resourceId: props.issue.id,
metaType: 'objectDeltas'
}))
const queryOptions = computed(() => ({
fetchPolicy: 'cache-and-network' as const,
enabled: !!props.modelCard.workspaceId,
clientId: props.modelCard.accountId
}))
const { result: resourceMetaResult } = useQuery(
issueResourceMetaSearchQuery,
queryVariables,
queryOptions
)
const hasObjectDeltas = computed<boolean>(() => {
const metadata = resourceMetaResult.value?.resourceMetaSearch
return Array.isArray(metadata) && metadata.length > 0
})
const objectDeltasPayload = computed<unknown>(() => {
if (!hasObjectDeltas.value) return null
const metadata = resourceMetaResult.value?.resourceMetaSearch
if (Array.isArray(metadata) && metadata.length > 0) {
return metadata[0]?.data as unknown
}
return null
})
const applyChanges = async () => {
if (!objectDeltasPayload.value) return
isApplying.value = true
try {
const payload =
typeof objectDeltasPayload.value === 'string'
? objectDeltasPayload.value
: JSON.stringify(objectDeltasPayload.value)
if (app.$parametersBinding) {
await app.$parametersBinding.update(payload)
} else {
console.warn('IParametersBinding not available in this host app')
}
} catch (error) {
console.error('Failed to apply changes:', error)
} finally {
isApplying.value = false
}
}
const formattedDate = computed((): string | null => {
try {
const date = props.issue.dueDate ? dayjs(props.issue.dueDate).toDate() : null
+1
View File
@@ -5,6 +5,7 @@
:icon-left="Bars3Icon"
hide-text
size="sm"
:disabled="!!props.modelCard.progress"
@click.stop="openModelCardActionsDialog = true"
/>
<CommonDialog
+22 -13
View File
@@ -5,10 +5,12 @@
>
<div v-if="modelData" class="relative px-1 py-1">
<div class="relative flex items-center space-x-2 min-w-0">
<div class="text-foreground-2 mt-[2px] flex items-center -space-x-2 relative">
<div
v-tippy="buttonTooltip"
class="text-foreground-2 mt-[2px] flex items-center -space-x-2 relative"
>
<!-- CTA button -->
<FormButton
v-tippy="buttonTooltip"
color="outline"
:icon-left="
modelCard.progress
@@ -19,7 +21,9 @@
"
hide-text
class=""
:disabled="!canEdit || isSettingsMissing"
:disabled="
(!canEdit || isSettingsMissing || ctaDisabled) && !modelCard.progress
"
@click.stop="$emit('manual-publish-or-load')"
></FormButton>
</div>
@@ -260,11 +264,18 @@ const store = useHostAppStore()
const accStore = useAccountStore()
const { trackEvent } = useMixpanel()
const props = defineProps<{
modelCard: IModelCard
project: ProjectModelGroup
canEdit: boolean
}>()
const props = withDefaults(
defineProps<{
modelCard: IModelCard
project: ProjectModelGroup
canEdit: boolean
ctaDisabled?: boolean
ctaDisabledMessage?: string
}>(),
{
ctaDisabled: false
}
)
defineEmits<{
(e: 'manual-publish-or-load'): void
@@ -275,11 +286,9 @@ const isSender = computed(() => {
})
const buttonTooltip = computed(() => {
return props.modelCard.progress
? 'Cancel'
: isSender.value
? 'Publish model'
: 'Load selected version'
if (props.modelCard.progress) return 'Cancel'
if (props.ctaDisabled) return props.ctaDisabledMessage
return isSender.value ? 'Publish model' : 'Load selected version'
})
const projectAccount = computed(() =>
+98 -25
View File
@@ -4,6 +4,8 @@
:model-card="modelCard"
:project="project"
:can-edit="canEdit"
:cta-disabled="ctaDisabled"
:cta-disabled-message="ctaDisabledMessage"
@manual-publish-or-load="sendOrCancel"
>
<div class="flex max-[275px]:w-full overflow-hidden my-2">
@@ -17,7 +19,6 @@
full-width
@click.stop="openFilterDialog = true"
>
<!-- Sending&nbsp; -->
<span class="font-bold">{{ modelCard.sendFilter?.name }}:&nbsp;</span>
<span class="truncate">{{ modelCard.sendFilter?.summary }}</span>
</FormButton>
@@ -31,13 +32,23 @@
<FilterListSelect :filter="modelCard.sendFilter" @update:filter="updateFilter" />
<div class="mt-4 flex justify-end items-center space-x-2">
<!-- TODO: Ux wise, users might want to just save the selection and publish it later. -->
<FormButton size="sm" color="outline" @click.stop="saveFilter()">
<FormButton
size="sm"
color="outline"
:disabled="isSaveDisabled"
@click.stop="saveFilter()"
>
Save
</FormButton>
<FormButton size="sm" @click.stop="saveFilterAndSend()">
Save & Publish
</FormButton>
<div v-tippy="!canCreateVersionPerm ? canCreateVersionMessage : ''">
<FormButton
size="sm"
:disabled="!canCreateVersionPerm || isSaveDisabled"
@click.stop="saveFilterAndSend()"
>
Save & Publish
</FormButton>
</div>
</div>
</CommonDialog>
@@ -108,7 +119,7 @@
</ModelCardBase>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted, computed } from 'vue'
import ModelCardBase from '~/components/model/CardBase.vue'
import { Square3Stack3DIcon } from '@heroicons/vue/20/solid'
import type { ModelCardNotification } from '~/lib/models/card/notification'
@@ -117,13 +128,22 @@ import type { ProjectModelGroup } from '~/store/hostApp'
import { useHostAppStore } from '~/store/hostApp'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { ToastNotificationType, ValidationHelpers } from '@speckle/ui-components'
import { provideApolloClient, useMutation } from '@vue/apollo-composable'
import {
provideApolloClient,
useMutation,
useSubscription
} from '@vue/apollo-composable'
import { useAccountStore, type DUIAccount } from '~/store/accounts'
import { setVersionMessageMutation } from '~/lib/graphql/mutationsAndQueries'
const hostAppStore = useHostAppStore()
import { workspacePlanUsageUpdatedSubscription } from '~/lib/workspaces/graphql/subscriptions'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
const store = useHostAppStore()
const accountStore = useAccountStore()
const { trackEvent } = useMixpanel()
const app = useNuxtApp()
const { canCreateModelIngestion } = useCheckGraphql()
const cardBase = ref<InstanceType<typeof ModelCardBase>>()
const props = defineProps<{
@@ -132,37 +152,87 @@ const props = defineProps<{
canEdit: boolean
}>()
const store = useHostAppStore()
const account = accountStore.accounts.find(
(acc) => acc.accountInfo.id === props.project.accountId
) as DUIAccount
const clientId = account.accountInfo.id
const openFilterDialog = ref(false)
app.$baseBinding?.on('documentChanged', () => {
openFilterDialog.value = false
})
const canCreateVersionPerm = ref(true)
const canCreateVersionMessage = ref<string | null>(null)
const checkPermissions = async () => {
const res = await canCreateModelIngestion(
props.modelCard.projectId,
props.modelCard.modelId,
props.modelCard.accountId
)
if (res.queryAvailable) {
canCreateVersionPerm.value = res.authorized
canCreateVersionMessage.value = res.message || null
}
}
const ctaDisabled = computed(
() => !canCreateVersionPerm.value || !!props.modelCard.progress
)
const ctaDisabledMessage = computed(() => canCreateVersionMessage.value || undefined)
const { onResult: onWorkspacePlanUsageUpdated } = useSubscription(
workspacePlanUsageUpdatedSubscription,
() => ({
input: {
workspaceId: props.modelCard.workspaceId as string
}
}),
() => ({ clientId })
)
onWorkspacePlanUsageUpdated(() => {
void checkPermissions()
})
const sendOrCancel = () => {
if (!props.canEdit) {
// check for progress first to allow cancelling even if permissions changed
if (props.modelCard.progress) {
store.sendModelCancel(props.modelCard.modelCardId)
return
}
if (props.modelCard.progress) store.sendModelCancel(props.modelCard.modelCardId)
else store.sendModel(props.modelCard.modelCardId, 'ModelCardButton')
if (!props.canEdit || !canCreateVersionPerm.value) {
return
}
store.sendModel(props.modelCard.modelCardId, 'ModelCardButton')
hasSetVersionMessage.value = false
}
let newFilter: ISendFilter
const newFilter = ref<ISendFilter>()
const updateFilter = (filter: ISendFilter) => {
newFilter = filter
newFilter.value = filter
}
const isSaveDisabled = computed(() => {
const filterToCheck = newFilter.value || props.modelCard.sendFilter
return !store.validateSendFilter(filterToCheck).valid
})
const saveFilter = async () => {
if (!newFilter.value) return // Safety check
void trackEvent('DUI3 Action', {
name: 'Publish Card Filter Change',
filter: newFilter.typeDiscriminator
filter: newFilter.value.typeDiscriminator
})
// do not reset idmap while creating a new one because it is managed by host app
newFilter.idMap = props.modelCard.sendFilter?.idMap
newFilter.value.idMap = props.modelCard.sendFilter?.idMap
await store.patchModel(props.modelCard.modelCardId, {
sendFilter: newFilter,
sendFilter: newFilter.value,
expired: true
})
openFilterDialog.value = false
@@ -173,11 +243,6 @@ const isUpdatingVersionMessage = ref(false)
const hasSetVersionMessage = ref(false)
const versionMessage = ref<string>()
const accountStore = useAccountStore()
const account = accountStore.accounts.find(
(acc) => acc.accountInfo.id === props.project.accountId
) as DUIAccount
const setVersionMessage = async (message: string) => {
if (!props.modelCard.latestCreatedVersionId) {
return
@@ -203,14 +268,14 @@ const setVersionMessage = async (message: string) => {
if (res?.data?.versionMutations.update.id) {
// seemed to noisy, and autoclose does not work for some reason.
// nicer ux to just close the dialog
// hostAppStore.setNotification({
// store.setNotification({
// type: ToastNotificationType.Info,
// title: 'Version message saved',
// autoClose: true
// })
hasSetVersionMessage.value = true
} else {
hostAppStore.setNotification({
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Request failed',
description: 'Failed to update version message.',
@@ -261,6 +326,10 @@ const expiredNotification = computed(() => {
const ctaType = props.modelCard.progress ? 'Restart' : 'Update'
notification.cta = {
name: ctaType,
disabled: !canCreateVersionPerm.value,
tooltipText: !canCreateVersionPerm.value
? canCreateVersionMessage.value || 'Publish limit reached'
: undefined,
action: async () => {
hasSetVersionMessage.value = false
if (props.modelCard.progress) {
@@ -337,4 +406,8 @@ const latestVersionNotification = computed(() => {
}
return notification
})
onMounted(() => {
void checkPermissions()
})
</script>
+10 -6
View File
@@ -178,21 +178,25 @@ const selectVersionAndAddModel = async (
if (existingModel) {
emit('close')
// Patch the existing model card with new versions!
await hostAppStore.patchModel(existingModel.modelCardId, {
const patchPayload: Record<string, unknown> = {
selectedVersionId: version.id,
selectedVersionSourceApp: version.sourceApplication,
selectedVersionUserId: version.authorUser?.id,
latestVersionId: latestVersion.id,
latestVersionSourceApp: latestVersion.sourceApplication,
latestVersionUserId: latestVersion.authorUser?.id
})
}
// apply new settings to the existing model card if they were changed
if (settingsWereChanged.value && receieveSettings.value) {
patchPayload.settings = receieveSettings.value
}
// patch the existing model card with new versions and settings
await hostAppStore.patchModel(existingModel.modelCardId, patchPayload)
await hostAppStore.receiveModel(existingModel.modelCardId, 'Wizard')
return
}
// We were tracking the source host app wrong before `getHostAppFromString`
// i.e. we were having `Revit 2023` instead of `revit`
const selectedVersionSourceApp = getSlugFromHostAppNameAndVersion(
version.sourceApplication as string
)
+99 -18
View File
@@ -21,7 +21,6 @@
@search-text-update="updateSearchText"
/>
</div>
<!-- Model selector wizard -->
<div v-if="step === 2 && selectedProject && selectedAccountId">
<WizardModelSelector
:project="selectedProject"
@@ -32,7 +31,6 @@
@next="selectModel"
/>
</div>
<!-- Version selector wizard -->
<div v-if="step === 3">
<SendFiltersAndSettings
v-model="filter"
@@ -44,8 +42,15 @@
}
"
/>
<div class="mt-2">
<FormButton full-width @click="addModel">Publish</FormButton>
<div v-tippy="publishTooltipMessage" class="mt-2">
<FormButton
full-width
:disabled="isPublishDisabled"
:loading="isLoadingPermissions"
@click="addModel"
>
Publish
</FormButton>
</div>
</div>
<div v-if="urlParseError" class="p-2 text-danger">
@@ -55,6 +60,7 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useSubscription } from '@vue/apollo-composable'
import type {
ModelListModelItemFragment,
ProjectListProjectItemFragment
@@ -63,10 +69,13 @@ import type { ISendFilter } from '~/lib/models/card/send'
import { SenderModelCard } from '~/lib/models/card/send'
import { useHostAppStore } from '~/store/hostApp'
import { useAccountStore } from '~/store/accounts'
import { useSelectionStore } from '~/store/selection'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
import type { CardSetting } from '~/lib/models/card/setting'
import { useAddByUrl } from '~/lib/core/composables/addByUrl'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
import { workspacePlanUsageUpdatedSubscription } from '~/lib/workspaces/graphql/subscriptions'
const { trackEvent } = useMixpanel()
const { trackSettingsChange } = useSettingsTracking()
@@ -87,6 +96,29 @@ const settings = ref<CardSetting[] | undefined>(undefined)
const settingsWereChanged = ref(false)
const { tryParseUrl, urlParsedData, urlParseError } = useAddByUrl()
const { canCreateModelIngestion, canCreateVersion } = useCheckGraphql()
const canPublish = ref(false)
const publishLimitMessage = ref<string | undefined>(undefined)
const isLoadingPermissions = ref(false)
const hostAppStore = useHostAppStore()
const selectionStore = useSelectionStore()
const publishValidation = computed(() => hostAppStore.validateSendFilter(filter.value))
const isPublishDisabled = computed(() => {
return (
!canPublish.value || isLoadingPermissions.value || !publishValidation.value.valid
)
})
const publishTooltipMessage = computed(() => {
if (!publishValidation.value.valid) return publishValidation.value.reason
if (!canPublish.value && !isLoadingPermissions.value)
return publishLimitMessage.value || ''
return ''
})
const updateSearchText = (text: string | undefined) => {
urlParseError.value = undefined
if (!text) return
@@ -102,9 +134,72 @@ watch(urlParsedData, (newVal) => {
watch(showSendDialog, (newVal) => {
if (newVal) {
urlParseError.value = undefined
void selectionStore.refreshSelectionFromHostApp()
}
})
const checkPermissions = async () => {
if (!selectedProject.value || !selectedModel.value) return
isLoadingPermissions.value = true
try {
const res = await canCreateModelIngestion(
selectedProject.value.id,
selectedModel.value.id,
selectedAccountId.value
)
if (res.queryAvailable) {
canPublish.value = res.authorized
publishLimitMessage.value = res.message || undefined
} else {
// check legacy canCreateVersion in else block
const legacyRes = await canCreateVersion(
selectedProject.value.id,
selectedModel.value.id,
selectedAccountId.value
)
canPublish.value = legacyRes.authorized
publishLimitMessage.value = legacyRes.message || undefined
}
} finally {
isLoadingPermissions.value = false
}
}
watch(step, async (newVal, oldVal) => {
if (newVal > oldVal) {
if (newVal === 3) {
await checkPermissions()
}
return // exit fast on forward
}
if (newVal === 1) {
selectedProject.value = undefined
selectedModel.value = undefined
}
if (newVal === 2) selectedModel.value = undefined
})
const workspaceId = computed(() => selectedProject.value?.workspace?.id)
const { onResult: onUsageUpdate } = useSubscription(
workspacePlanUsageUpdatedSubscription,
() => ({
input: {
workspaceId: workspaceId.value || ''
}
}),
() => ({
enabled: !!workspaceId.value && step.value === 3,
clientId: selectedAccountId.value
})
)
onUsageUpdate(() => {
void checkPermissions()
})
const selectProject = (accountId: string, project: ProjectListProjectItemFragment) => {
step.value++
selectedAccountId.value = accountId
@@ -125,20 +220,6 @@ const selectModel = (model: ModelListModelItemFragment) => {
void trackEvent('DUI3 Action', { name: 'Publish Wizard', step: 'model selected' })
}
// Clears data if going backwards in the wizard
watch(step, (newVal, oldVal) => {
if (newVal > oldVal) {
return // exit fast on forward
}
if (newVal === 1) {
selectedProject.value = undefined
selectedModel.value = undefined
}
if (newVal === 2) selectedModel.value = undefined
})
const hostAppStore = useHostAppStore()
// accountId, serverUrl, projectId, modelId, sendFilter, settings
const addModel = async () => {
void trackEvent('DUI3 Action', {
-27
View File
@@ -32,33 +32,6 @@
</FormButton>
</div>
</div>
<div
v-if="
canCreateModelResult &&
!canCreateModelResult.project.permissions.canCreateModel.authorized
"
>
<CommonAlert title="Cannot create new models" color="info" hide-icon>
<template #description>
{{ canCreateModelResult.project.permissions.canCreateModel.message }}
<FormButton
v-if="workspaceSlug"
full-width
color="primary"
size="sm"
class="mt-2"
@click="
$openUrl(
`${account.accountInfo.serverInfo.url}/settings/workspaces/${workspaceSlug}/billing`
)
"
>
Explore Plans
</FormButton>
</template>
</CommonAlert>
</div>
<div class="relative grid grid-cols-1 gap-2">
<CommonLoadingBar v-if="loading" loading />
+52 -66
View File
@@ -3,12 +3,18 @@
<div class="space-y-2 relative">
<div v-if="workspacesEnabled && workspaces" class="flex items-center space-x-2">
<div class="flex-grow min-w-0">
<!-- NO WORKSPACE YET -->
<div v-if="workspaces.length === 0">
<FormButton
full-width
class="flex items-center"
@click="$openUrl('https://app.speckle.systems/workspaces/actions/create')"
@click="
$openUrl(
`${activeAccount.accountInfo.serverInfo.url.replace(
/\/$/,
''
)}/workspaces/actions/create`
)
"
>
<div class="min-w-0 truncate flex-grow">
<span>{{ 'Create a workspace' }}</span>
@@ -72,13 +78,7 @@
color="foundation"
/>
<div class="flex justify-between items-center space-x-2">
<div
v-tippy="
canCreateProject
? 'Create new project'
: canCreateProjectPermissionCheck?.message
"
>
<div v-if="canCreateProject" v-tippy="'Create new project'">
<FormButton
color="outline"
:disabled="!canCreateProject"
@@ -88,6 +88,22 @@
<PlusIcon class="w-4 -mx-2" />
</FormButton>
</div>
<div
v-else
v-tippy="
canCreateProject
? 'Create new project'
: canCreateProjectPermissionCheck?.message
"
>
<FormButton
color="primary"
:class="`p-1.5 bg-foundation rounded text-foreground border`"
@click="upgradePlanButtonAction"
>
<ArrowUpCircleIcon class="w-4 -mx-2" />
</FormButton>
</div>
<CommonDialog
v-model:open="showProjectCreateDialog"
:title="`Create new project`"
@@ -132,28 +148,6 @@
</div>
</div>
</div>
<div
v-if="
canCreateProjectPermissionCheck &&
!canCreateProjectPermissionCheck.authorized &&
showUpgradePlanButton
"
>
<CommonAlert color="info" hide-icon>
<template #description>
{{ canCreateProjectPermissionCheck.message }}
<FormButton
full-width
class="mt-2"
color="primary"
size="sm"
@click="upgradePlanButtonAction()"
>
Upgrade now
</FormButton>
</template>
</CommonAlert>
</div>
<WizardPersonalProjectsWarning v-if="isPersonalProjectsAsWorkspace" />
@@ -202,7 +196,7 @@
<script setup lang="ts">
import { ChevronDownIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
import { storeToRefs } from 'pinia'
import { PlusIcon } from '@heroicons/vue/20/solid'
import { PlusIcon, ArrowUpCircleIcon } from '@heroicons/vue/20/solid'
import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import {
@@ -341,11 +335,7 @@ const activeWorkspace = computed(() => {
if (activeWorkspace) return activeWorkspace
}
// if activeWorkspace is null will mean that it is personal projects - this fallback wont be the case soon
return {
id: 'personalProject',
name: 'Personal Projects'
} as WorkspaceListWorkspaceItemFragment
return workspaces.value?.[0] // fallback to first workspace if none is active
})
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment | undefined>(
@@ -497,35 +487,6 @@ const canCreateProjectPermissionCheck = computed(() => {
return null
})
const upgradePlanButtonAction = () => {
if (!canCreateProjectPermissionCheck.value) return
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceNoEditorSeat') {
// open url to workspace/settings/users
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/members`
)
return
}
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceLimitsReached') {
// open url to workspace/billing
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/billing`
)
return
}
}
const showUpgradePlanButton = computed(() => {
if (!canCreateProjectPermissionCheck.value) return false
if (
canCreateProjectPermissionCheck.value.code === 'WorkspaceNoEditorSeat' ||
canCreateProjectPermissionCheck.value.code === 'WorkspaceLimitsReached'
) {
return true
}
return false
})
const isCreatingProject = ref(false)
const showProjectCreateDialog = ref(false)
@@ -614,6 +575,31 @@ const createNewPersonalProject = async (name: string) => {
isCreatingProject.value = false
}
const upgradePlanButtonAction = () => {
if (!canCreateProjectPermissionCheck.value) return
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceNoEditorSeat') {
// open url to workspace/settings/users
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/members`
)
return
}
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceLimitsReached') {
// open url to workspace/billing
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/billing`
)
return
}
// catch SSO session expired / any other unhandled permission flags
// redirecting to the workspace root will trigger the standard web authentication flow.
if (selectedWorkspace.value?.slug) {
$openUrl(
`${account.value.accountInfo.serverInfo.url}/workspaces/${selectedWorkspace.value?.slug}`
)
}
}
const loadMore = () => {
fetchMore({
variables: { cursor: projectsResult.value?.activeUser?.projects.cursor },
+19
View File
@@ -0,0 +1,19 @@
FROM node:22-bookworm@sha256:7e791fc54bd02fc89fd4fb39eb37e5bea753c75679c8022478d81679367d995a AS build-stage
WORKDIR /app
RUN corepack enable
COPY package.json .
COPY yarn.lock .
COPY .yarnrc.yml .
RUN yarn install --immutable || (cat /tmp/xfs-*/build.log && exit 1)
COPY . .
# NODE_ENV must be set after the dependencies are installed because @nuxt/kit is a devDependency and is required to build the application
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
ENV NUXT_PUBLIC_MIXPANEL_TOKEN_ID=acd87c5a50b56df91a795e999812a3a4
ENV NUXT_PUBLIC_MIXPANEL_API_HOST=https://analytics.speckle.systems
RUN yarn generate
FROM joseluisq/static-web-server:2.40@sha256:63528bfba5d86b00572e23b4e44ed0f7a791f931df650125156d0c24f7a8f877 AS production-stage
WORKDIR /app
COPY --from=build-stage /app/dist /app/dist
CMD ["--config-file", "/app/configuration.toml"]
+23
View File
@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+24
View File
@@ -0,0 +1,24 @@
apiVersion: v2
name: speckle-dui-chart
description: A Helm chart for deploying the Speckle DUI3 application
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "0.1.0"
@@ -0,0 +1,62 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "speckle-dui.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "speckle-dui.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "speckle-dui.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "speckle-dui.labels" -}}
helm.sh/chart: {{ include "speckle-dui.chart" . }}
{{ include "speckle-dui.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "speckle-dui.selectorLabels" -}}
app.kubernetes.io/name: {{ include "speckle-dui.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "speckle-dui.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "speckle-dui.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
@@ -0,0 +1,122 @@
kind: ConfigMap
apiVersion: v1
metadata:
name: {{ include "speckle-dui.fullname" . }}-configuration
labels:
{{- include "speckle-dui.labels" . | nindent 4 }}
data:
configuration.toml: |
[general]
#### Address & Root dir
host = "::"
port = 80
root = "/app/dist"
#### Logging
log-level = "info"
#### Cache Control headers
cache-control-headers = true
#### Auto Compression
compression = true
compression-level = "default"
#### Error pages
# Note: If a relative path is used then it will be resolved under the root directory.
page404 = "./404.html"
page50x = "./50x.html"
#### HTTP/2 + TLS
# Note: We expect TLS termination to be handled by a reverse proxy (e.g. Nginx, Traefik, Cloudflare, etc.)
http2 = false
http2-tls-cert = ""
http2-tls-key = ""
## we are terminating https upstream; redirect is at edge proxy (ingress/gateway)
https-redirect = false
https-redirect-host = "localhost"
https-redirect-from-port = 80
https-redirect-from-hosts = "localhost"
#### CORS & Security headers
## security-headers must be disabled for iframe compatibility as they include x-frame-options: deny as default
# security-headers = false
## cors-allows-origins is unset as iframe embedding does not require CORS, we are not fetching from another origin via XHR/fetch, and wildcard increases attack surface.
# cors-allow-origins = ""
#### Directory listing
directory-listing = false
#### Directory listing sorting code
directory-listing-order = 1
#### Directory listing content format
directory-listing-format = "html"
#### Directory listing download format
directory-listing-download = []
#### File descriptor binding
# fd = ""
#### Worker threads
threads-multiplier = 1
#### Grace period after a graceful shutdown
grace-period = 0
#### Page fallback for 404s
# page-fallback = ""
#### Log request Remote Address if available
log-remote-address = true
#### Log real IP from X-Forwarded-For header if available
log-forwarded-for = true
#### IPs to accept the X-Forwarded-For header from. Empty means all
trusted-proxies = {{ .Values.security.trustedProxies | toJson }}
#### Redirect to trailing slash in the requested directory uri
redirect-trailing-slash = true
#### Check for existing pre-compressed files
compression-static = true
#### Health-check endpoint (GET or HEAD `/health`)
health = true
#### Markdown content negotiation
accept-markdown = false
#### Maintenance Mode
maintenance-mode = false
# maintenance-mode-status = 503
# maintenance-mode-file = "./maintenance.html"
[advanced]
#### HTTP Headers customization
[[advanced.headers]]
source = "/*.html"
[advanced.headers.headers]
# Cache-Control = "public, max-age=36000"
Content-Security-Policy = """\
frame-ancestors {{ if .Values.security.frameAncestors }}{{ .Values.security.frameAncestors | join " " }}{{ else }}'self'{{ end }}; \
default-src 'self'; \
frame-src {{ if .Values.security.frameSource }}{{ .Values.security.frameSource | join " " }}{{ else }}'self'{{ end }}; \
script-src {{ if .Values.security.frameSource }}{{ .Values.security.frameSource | join " " }}{{ else }}'self'{{ end }} 'unsafe-inline'; \
style-src {{ if .Values.security.frameSource }}{{ .Values.security.frameSource | join " " }}{{ else }}'self'{{ end }} 'unsafe-inline'; \
img-src {{ if .Values.security.frameSource }}{{ .Values.security.frameSource | join " " }}{{ else }}'self'{{ end }} data: blob:; \
connect-src {{ if .Values.security.frameAncestors }}{{ .Values.security.frameAncestors | join " " }}{{ else }}'self'{{ end }}; \
object-src 'none'; \
base-uri 'self'; \
form-action {{ if .Values.security.frameAncestors }}{{ .Values.security.frameAncestors | join " " }}{{ else }}'self'{{ end }};\
"""
# Strict-Transport-Security = "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "geolocation=(), microphone=(), camera=()"
## Purposefully do not set X-Frame-Options as this is intended to be an iframe
@@ -0,0 +1,84 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "speckle-dui.fullname" . }}
labels:
{{- include "speckle-dui.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "speckle-dui.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "speckle-dui.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "speckle-dui.serviceAccountName" . }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80 # Needs to match port defined in deployment/docker/configuration.toml
protocol: TCP
{{- with .Values.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: configuration
mountPath: /app/configuration.toml
subPath: configuration.toml
readOnly: true
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: configuration
configMap:
name: {{ include "speckle-dui.fullname" . }}-configuration
{{- with .Values.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -0,0 +1,38 @@
{{- if .Values.httpRoute.enabled -}}
{{- $fullName := include "speckle-dui.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ $fullName }}
labels:
{{- include "speckle-dui.labels" . | nindent 4 }}
{{- with .Values.httpRoute.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
parentRefs:
{{- with .Values.httpRoute.parentRefs }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.httpRoute.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.httpRoute.rules }}
- backendRefs:
- name: {{ $fullName }}
port: {{ $svcPort }}
weight: 1
{{- with .filters }}
filters:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .matches }}
matches:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,43 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "speckle-dui.fullname" . }}
labels:
{{- include "speckle-dui.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- with .pathType }}
pathType: {{ . }}
{{- end }}
backend:
service:
name: {{ include "speckle-dui.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "speckle-dui.fullname" . }}
labels: {{- include "speckle-dui.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector: {{- include "speckle-dui.selectorLabels" . | nindent 4 }}
@@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "speckle-dui.serviceAccountName" . }}
labels:
{{- include "speckle-dui.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}
+150
View File
@@ -0,0 +1,150 @@
# Default values for speckle-dui3.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/
replicaCount: 1
# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/
image:
repository: ghcr.io/specklesystems/speckle-dui
# This sets the pull policy for images.
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
imagePullSecrets: []
# This is to override the chart name.
nameOverride: ""
fullnameOverride: ""
# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/
serviceAccount:
# Specifies whether a service account should be created.
create: true
# Automatically mount a ServiceAccount's API credentials?
automount: false
# Annotations to add to the service account.
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template.
name: ""
# This is for setting Kubernetes Annotations to a Pod.
# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/
podAnnotations: {}
# This is for setting Kubernetes Labels to a Pod.
# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/
podLabels: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/
service:
# This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types
type: ClusterIP
# This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports
port: 80
security:
## The IP addresses of trusted proxies, such as loadbalancers or WAFs, that may be forwarding traffic to the dashboards. This is important for correctly handling the X-Forwarded-For header and ensuring accurate client IP logging and security measures. Empty means all proxies are trusted, which may not be secure in production environments. We recommend setting this to the specific IP addresses of your trusted proxies.
trustedProxies: []
## A list of urls to be added as frame-ancestors of the Content-Security-Policy header. Empty means 'self', allowing embedding only from the same origin as the dashboards. We recommend setting this to the specific hostnames of your parent applications that will be embedding the dashboards in iframes.
frameAncestors: []
## A list of urls to be added as frame-src (and script-src, style-src, img-src) of the Content-Security-Policy header. Empty means 'self', allowing embedding of dashboards resources only from the same origin. We recommend setting this to the specific hostnames of Speckle DUI3.
frameSource: []
# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
# As dashboards expect to serve all paths under the root, we recommend using a dedicated hostname for the service, e.g. dashboards.example.com, and not sharing it with other services.
- host: chart-example.local
paths:
# Please retain this path, the dashboards expect to serve all paths under the root.
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
# -- Expose the service via gateway-api HTTPRoute
# Requires Gateway API resources and suitable controller installed within the cluster
# (see: https://gateway-api.sigs.k8s.io/guides/)
httpRoute:
# HTTPRoute enabled.
enabled: false
# HTTPRoute annotations.
annotations: {}
# Which Gateways this Route is attached to.
parentRefs:
- name: gateway
sectionName: http
# namespace: default
# Hostnames matching HTTP header.
hostnames:
# As dashboards expect to serve all paths under the root, we recommend using a dedicated hostname for the service, e.g. dashboards.example.com, and not sharing it with other services.
- chart-example.local
# List of rules and filters applied.
rules:
- matches:
# Please retain this path, the dashboards expect to serve all paths under the root.
- path:
type: PathPrefix
value: /
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
livenessProbe:
httpGet:
path: /health
port: http
readinessProbe:
httpGet:
path: /health
port: http
# Additional volumes on the output Deployment definition.
volumes: []
# - name: foo
# secret:
# secretName: mysecret
# optional: false
# Additional volumeMounts on the output Deployment definition.
volumeMounts: []
# - name: foo
# mountPath: "/etc/foo"
# readOnly: true
nodeSelector: {}
tolerations: []
affinity: {}
View File
+61 -9
View File
@@ -1,29 +1,81 @@
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const CHALLENGE_KEY = 'speckle_challenge'
const CHALLENGE_URL_KEY = 'speckle_url_challenge'
const CODE_VERIFIER_KEY = 'speckle_code_verifier'
function toBase64Url(buffer: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...buffer))
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* Generates a cryptographically random base64url-encoded string.
* 32 bytes → 43 characters after base64url encoding (within the RFC 7636 range of 43-128).
*/
function createCodeVerifier(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return toBase64Url(bytes)
}
/**
* Computes SHA-256 hash of a string and returns it as base64url.
* This is the PKCE code_challenge derivation from a code_verifier.
*/
async function computeS256Challenge(codeVerifier: string): Promise<string> {
const data = new TextEncoder().encode(codeVerifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return toBase64Url(new Uint8Array(digest))
}
export interface ChallengeData {
/** The raw random string (code_verifier in PKCE terms) */
codeVerifier: string
/** SHA-256 base64url hash of codeVerifier (code_challenge for S256 method) */
codeChallenge: string
}
export function useAuthManager() {
const generateChallenge = (url: string): string => {
let result = ''
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
localStorage.setItem(CHALLENGE_KEY, result) // <-- persist it
/**
* Generates a PKCE code_verifier + code_challenge pair and persists to localStorage.
* Used by redirect-based sign-in flows (SignInFlow) that need to
* recover the values after the browser navigates away and back.
*/
const generateChallenge = async (url: string): Promise<ChallengeData> => {
const codeVerifier = createCodeVerifier()
const codeChallenge = await computeS256Challenge(codeVerifier)
localStorage.setItem(CHALLENGE_KEY, codeChallenge)
localStorage.setItem(CODE_VERIFIER_KEY, codeVerifier)
localStorage.setItem(CHALLENGE_URL_KEY, url)
return result
return { codeVerifier, codeChallenge }
}
/**
* Generates a PKCE code_verifier + code_challenge pair without persisting to localStorage.
* Used by flows that keep values in memory (ExchangeTokenSignInFlow)
* so they don't overwrite the redirect flow's stored data.
*/
const generateLocalChallenge = async (): Promise<ChallengeData> => {
const codeVerifier = createCodeVerifier()
const codeChallenge = await computeS256Challenge(codeVerifier)
return { codeVerifier, codeChallenge }
}
const getChallenge = (): string | null => {
return localStorage.getItem(CHALLENGE_KEY)
}
const getCodeVerifier = (): string | null => {
return localStorage.getItem(CODE_VERIFIER_KEY)
}
const getChallengeUrl = (): string | null => {
return localStorage.getItem(CHALLENGE_URL_KEY)
}
return {
getChallenge,
getCodeVerifier,
getChallengeUrl,
generateChallenge
generateChallenge,
generateLocalChallenge
}
}
+114
View File
@@ -0,0 +1,114 @@
import { md5 } from '@speckle/shared'
import type { Account } from '~/lib/bindings/definitions/IAccountBinding'
/**
* Checks if the server supports the new /oauth/token endpoint.
* The server exposes GET /oauth/token returning 'supported' when available.
*/
export async function supportsOAuthToken(serverUrl: string): Promise<boolean> {
try {
const res = await fetch(new URL('/oauth/token', serverUrl), { method: 'GET' })
return res.ok
} catch {
return false
}
}
export function useTokenExchange() {
const { $accountBinding } = useNuxtApp()
const exchangeAccessCode = async (
rawServerUrl: string,
accessCode: string,
challenge: string,
codeVerifier?: string
): Promise<void> => {
// Normalize to origin (strips trailing slash, path, etc.)
// so account IDs stay consistent with connectors
const serverUrl = new URL(rawServerUrl).origin
const tokenHeaders = { 'Content-Type': 'application/json' }
let tokenResponse: Response
// If we have a codeVerifier, try the new PKCE-based /oauth/token endpoint first
if (codeVerifier && (await supportsOAuthToken(serverUrl))) {
tokenResponse = await fetch(new URL('/oauth/token', serverUrl), {
method: 'POST',
headers: tokenHeaders,
body: JSON.stringify({
appId: 'sdui',
appSecret: 'sdui',
accessCode,
codeVerifier
})
})
} else {
// Fall back to legacy /auth/token with plain challenge
tokenResponse = await fetch(new URL('/auth/token', serverUrl), {
method: 'POST',
headers: tokenHeaders,
body: JSON.stringify({
appId: 'sdui',
appSecret: 'sdui',
accessCode,
challenge
})
})
}
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
throw new Error(
`Token exchange failed with status ${tokenResponse.status}: ${errorText}`
)
}
const { token, refreshToken } = (await tokenResponse.json()) as {
token: string
refreshToken: string
}
// Query user and server info
const graphqlQuery = {
query:
'query { activeUser { id name email company avatar } serverInfo { name company adminContact description version } }'
}
const userAndServerInfoResponse = await fetch(new URL('/graphql', serverUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(graphqlQuery)
})
if (!userAndServerInfoResponse.ok) {
throw new Error(
`Failed to fetch user info with status ${userAndServerInfoResponse.status}`
)
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const userAndServerInfo = await userAndServerInfoResponse.json()
const accountId = md5(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
userAndServerInfo.data.activeUser.email + serverUrl
).toUpperCase()
const account: Account = {
id: accountId,
token,
refreshToken,
isDefault: true,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
serverInfo: { url: serverUrl, ...userAndServerInfo.data.serverInfo },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
userInfo: userAndServerInfo.data.activeUser
}
await $accountBinding.addAccount(accountId, account)
}
return { exchangeAccessCode }
}
@@ -9,6 +9,7 @@ export interface IAccountBinding extends IBinding<IAccountBindingEvents> {
getAccounts: () => Promise<Account[]>
addAccount: (accountId: string, account: Account) => Promise<void>
removeAccount: (accountId: string) => Promise<void>
authenticateAccount: (serverUrl: string) => Promise<Account>
}
// An almost 1-1 mapping of what we need from the Core accounts class.
@@ -60,6 +61,25 @@ export class MockedAccountBinding implements IAccountBinding {
return await console.log('no way dude', accountId, account)
}
public async authenticateAccount(serverUrl: string) {
const config = useRuntimeConfig()
return (await {
id: 'whatever',
isDefault: true,
token: config.public.speckleToken,
serverInfo: {
name: 'test',
url: serverUrl,
frontend2: true
},
userInfo: {
id: 'whatever',
avatar: 'whatever',
email: ''
}
}) as Account
}
public async removeAccount(accountId: string) {
return await console.log('no way dude', accountId)
}
@@ -26,6 +26,7 @@ export interface IBasicConnectorBinding
highlightObjects: (objectIds: string[]) => Promise<void>
removeModel: (model: IModelCard) => Promise<void>
removeModels: (models: IModelCard[]) => Promise<void>
updateParameters: (payload: string) => Promise<void>
}
export interface IBasicConnectorBindingHostEvents
@@ -107,6 +108,10 @@ export class MockedBaseBinding implements IBasicConnectorBinding {
await console.log('no way dude')
}
public async updateParameters(payload: string) {
await console.log('Mock: updateParameters called with payload:', payload)
}
public async showDevTools() {
await console.log('No way dude')
}
+2 -1
View File
@@ -31,6 +31,7 @@ export type GlobalConfig = {
export type ConnectorConfig = {
darkTheme: boolean
disableCache?: boolean
}
export type AccountsConfig = {
@@ -48,7 +49,7 @@ export class MockedConfigBinding implements IConfigBinding {
}
public async getConfig() {
return await { darkTheme: false }
return await { darkTheme: false, disableCache: false }
}
public async getGlobalConfig() {
@@ -0,0 +1,3 @@
export interface IParametersBinding {
update: (payload: string) => Promise<void>
}
+1
View File
@@ -26,6 +26,7 @@ export interface ISendBindingEvents
modelCardId: string
versionId: string
sendConversionResults: ConversionResult[]
ingestionId?: string
}) => void
setIdMap: (args: {
modelCardId: string
+69 -15
View File
@@ -16,6 +16,9 @@ import type { Emitter } from 'nanoevents'
import { useDesktopService } from '~/lib/core/composables/desktopService'
import type { ToastNotification } from '@speckle/ui-components'
import { ToastNotificationType } from '@speckle/ui-components'
import { useModelIngestion } from '../ingestion/composables/useModelIngestion'
import type { ISenderModelCard } from '../models/card/send'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
export type SendBatchViaBrowserArgs = {
modelCardId: string
@@ -466,24 +469,75 @@ export class ArchicadBridge {
}
private async createVersion(args: CreateVersionArgs) {
const accountStore = useAccountStore()
const { accounts } = storeToRefs(accountStore)
const account = accounts.value.find((acc) => acc.accountInfo.id === args.accountId)
const hostAppStore = useHostAppStore()
const { completeIngestionWithVersion } = useModelIngestion()
const { canCreateModelIngestion } = useCheckGraphql()
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
const modelCard = hostAppStore.models.find(
(model) => model.modelCardId === args.modelCardId
) as ISenderModelCard
const canCreateIngestion = await canCreateModelIngestion(
modelCard.projectId,
modelCard.modelId,
modelCard.accountId
)
const hostAppStore = useHostAppStore()
const result = await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: hostAppStore.hostAppName,
projectId: args.projectId
if (canCreateIngestion.queryAvailable) {
const ingestionId = hostAppStore.activeIngestions[args.modelCardId]
if (!ingestionId) {
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Failed',
description: 'Ingestion ID not found to create version.'
})
throw new Error(`Ingestion failed: Ingestion ID not found to create version.`)
}
})
return result?.data?.versionMutations?.create?.id
const res = await completeIngestionWithVersion(
modelCard,
ingestionId,
args.referencedObjectId
)
if (res?.statusData.__typename === 'ModelIngestionSuccessStatus') {
return res?.statusData.versionId
}
if (res?.statusData.__typename === 'ModelIngestionFailedStatus') {
const errorReason = res?.statusData.errorReason || 'Unknown error'
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Failed',
description: errorReason
})
throw new Error(`Ingestion failed: ${errorReason}.`)
}
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: 'Ingestion status does not match expected types.'
})
throw new Error(
`Ingestion status does not match with the expected types as success or failure.`
)
} else {
const accountStore = useAccountStore()
const account = accountStore.getAccountClient(args.accountId)
const { mutate } = provideApolloClient(account)(() =>
useMutation(createVersionMutation)
)
const result = await mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: args.sourceApplication || 'Archicad',
projectId: args.projectId
}
})
return result?.data?.versionMutations?.create?.id
}
}
}
+74 -22
View File
@@ -19,6 +19,9 @@ import type {
ReceiveViaBrowserArgs,
CreateVersionArgs
} from '~/lib/bridge/server'
import { useModelIngestion } from '../ingestion/composables/useModelIngestion'
import type { ISenderModelCard } from '../models/card/send'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
declare let sketchup: {
exec: (data: Record<string, unknown>) => void
@@ -297,40 +300,89 @@ export class SketchupBridge extends BaseBridge {
sourceApplication: 'sketchup',
message: message || 'send from sketchup'
}
const versionId = await this.createVersion(args)
const hostAppStore = useHostAppStore()
// TODO: Alignment needed
hostAppStore.setModelSendResult({
modelCardId: args.modelCardId,
versionId: versionId as string,
sendConversionResults
})
try {
const versionId = await this.createVersion(args)
hostAppStore.setModelSendResult({
modelCardId: args.modelCardId,
versionId: versionId as string,
sendConversionResults
})
} catch (err) {
hostAppStore.setHostAppError({
message: (err as Error).message || 'Unknown error occurred',
error: (err as Error).toString(),
stackTrace: (err as Error).stack || ''
})
}
}
public async createVersion(args: CreateVersionArgs) {
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const accountStore = useAccountStore()
const { accounts } = storeToRefs(accountStore)
const account = accounts.value.find((acc) => acc.accountInfo.id === args.accountId)
const { completeIngestionWithVersion } = useModelIngestion()
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
const modelCard = hostAppStore.models.find(
(model) => model.modelCardId === args.modelCardId
)
// sketchup versions are provided as 2 digit. i.e. 22, 23, 24
// we are safe with this string concatanation for 77 years
const hostAppName = `SketchUp 20${hostAppStore.hostAppVersion}`
if (!modelCard) {
throw new Error('Model card not found') // ctor
}
const result = await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: hostAppName,
projectId: args.projectId
const { canCreateModelIngestion } = useCheckGraphql()
const canCreateIngestion = await canCreateModelIngestion(
modelCard.projectId,
modelCard.modelId,
modelCard.accountId
)
if (canCreateIngestion.queryAvailable) {
const ingestionId = hostAppStore.activeIngestions[args.modelCardId]
if (!ingestionId) {
throw new Error(`Ingestion failed: Ingestion ID not found to create version.`)
}
})
return result?.data?.versionMutations?.create?.id
const res = await completeIngestionWithVersion(
modelCard as ISenderModelCard,
ingestionId,
args.referencedObjectId
)
if (res?.statusData.__typename === 'ModelIngestionSuccessStatus') {
return res?.statusData.versionId
}
if (res?.statusData.__typename === 'ModelIngestionFailedStatus') {
throw new Error(
`Ingestion failed: ${res?.statusData.errorReason || 'Unknown error'}.`
)
}
throw new Error(
`Ingestion status does not match with the expected types as success or failure.`
)
} else {
// for the self hosters that does not have available graphql for ingestions
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
)
// sketchup versions are provided as 2 digit. i.e. 22, 23, 24
// we are safe with this string concatanation for 77 years
const hostAppName = `SketchUp 20${hostAppStore.hostAppVersion}`
const result = await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: hostAppName,
projectId: args.projectId
}
})
return result?.data?.versionMutations?.create?.id
}
}
public async create(): Promise<boolean> {
+72 -6
View File
@@ -22,7 +22,7 @@ type Documents = {
"\n mutation CreateProject($input: ProjectCreateInput) {\n projectMutations {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n": typeof types.CreateProjectDocument,
"\n mutation CreateProjectInWorkspace($input: WorkspaceProjectCreateInput!) {\n workspaceMutations {\n projects {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": typeof types.CreateProjectInWorkspaceDocument,
"\n mutation StreamAccessRequestCreate($input: String!) {\n streamAccessRequestCreate(streamId: $input) {\n id\n }\n }\n": typeof types.StreamAccessRequestCreateDocument,
"\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n": typeof types.WorkspaceListWorkspaceItemFragmentDoc,
"\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n canAccessHelpCenter {\n authorized\n }\n }\n }\n": typeof types.WorkspaceListWorkspaceItemFragmentDoc,
"\n fragment AutomateFunctionItem on AutomateFunction {\n name\n isFeatured\n id\n creator {\n name\n }\n releases {\n items {\n inputSchema\n }\n }\n }\n": typeof types.AutomateFunctionItemFragmentDoc,
"\n mutation CreateAutomation($projectId: ID!, $input: ProjectAutomationCreateInput!) {\n projectMutations {\n automationMutations(projectId: $projectId) {\n create(input: $input) {\n id\n name\n }\n }\n }\n }\n": typeof types.CreateAutomationDocument,
"\n fragment AutomateFunctionRunItem on AutomateFunctionRun {\n id\n status\n statusMessage\n results\n contextView\n function {\n id\n name\n logo\n }\n }\n": typeof types.AutomateFunctionRunItemFragmentDoc,
@@ -33,6 +33,7 @@ type Documents = {
"\n query CanCreatePersonalProject {\n activeUser {\n permissions {\n canCreatePersonalProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": typeof types.CanCreatePersonalProjectDocument,
"\n query CanCreateProjectInWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n permissions {\n canCreateProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": typeof types.CanCreateProjectInWorkspaceDocument,
"\n query CanCreateModelInProject($projectId: String!) {\n project(id: $projectId) {\n permissions {\n canCreateModel {\n authorized\n code\n message\n }\n }\n }\n }\n": typeof types.CanCreateModelInProjectDocument,
"\n query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\n }\n }\n }\n }\n }\n": typeof types.CanCreateVersionDocument,
"\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n id\n name\n }\n }\n }\n": typeof types.ActiveWorkspaceDocument,
"\n fragment ProjectListProjectItem on Project {\n id\n name\n role\n updatedAt\n workspaceId\n workspace {\n id\n name\n slug\n role\n }\n models {\n totalCount\n }\n permissions {\n canLoad {\n authorized\n code\n message\n }\n canPublish {\n authorized\n code\n message\n }\n }\n }\n": typeof types.ProjectListProjectItemFragmentDoc,
"\n query ProjectListQuery($limit: Int!, $filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": typeof types.ProjectListQueryDocument,
@@ -52,10 +53,20 @@ type Documents = {
"\n subscription ProjectTriggeredAutomationsStatusUpdated($projectId: String!) {\n projectTriggeredAutomationsStatusUpdated(projectId: $projectId) {\n type\n version {\n id\n }\n model {\n id\n }\n project {\n id\n }\n run {\n ...AutomationRunItem\n }\n }\n }\n": typeof types.ProjectTriggeredAutomationsStatusUpdatedDocument,
"\n subscription OnUserProjectsUpdated {\n userProjectsUpdated {\n id\n project {\n id\n visibility\n team {\n id\n role\n }\n }\n }\n }\n": typeof types.OnUserProjectsUpdatedDocument,
"\n subscription ProjectUpdated($projectId: String!) {\n projectUpdated(id: $projectId) {\n id\n project {\n visibility\n }\n }\n }\n": typeof types.ProjectUpdatedDocument,
"\n subscription Subscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n": typeof types.SubscriptionDocument,
"\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n": typeof types.ModelViewingSubscriptionDocument,
"\n subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\n }\n }\n": typeof types.ProjectCommentsUpdatedDocument,
"\n mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.CreateModelIngestionDocument,
"\n mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.UpdateModelIngestionProgressDocument,
"\n mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\n }\n }\n }\n }\n }\n": typeof types.CompleteModelIngestionWithVersionDocument,
"\n mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.FailModelIngestionWithErrorDocument,
"\n mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.FailModelIngestionWithCancelDocument,
"\n query CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\n }\n }\n }\n }\n }\n": typeof types.CanCreateIngestionDocument,
"\n subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\n }\n }\n }\n }\n }\n": typeof types.ProjectModelIngestionUpdatedDocument,
"\n fragment IssuesItem on Issue {\n id\n status\n title\n priority\n viewerState\n identifier\n resourceIdString\n activities(input: { limit: 1, sortDirection: asc }) {\n totalCount\n items {\n actor {\n id\n user {\n name\n id\n avatar\n }\n }\n eventType\n createdAt\n }\n }\n replies {\n totalCount\n items {\n id\n author {\n id\n user {\n name\n id\n avatar\n }\n }\n createdAt\n description {\n doc\n }\n }\n }\n description {\n doc\n }\n labels {\n hexColor\n id\n name\n }\n author {\n id\n user {\n id\n name\n avatar\n }\n }\n dueDate\n assignee {\n id\n user {\n id\n avatar\n name\n }\n }\n }\n": typeof types.IssuesItemFragmentDoc,
"\n query IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\n }\n }\n }\n }\n": typeof types.IssuesListDocument,
"\n query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\n }\n }\n": typeof types.IssueResourceMetaSearchDocument,
"\n query WorkspaceIntercomPermission($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n permissions {\n canAccessHelpCenter {\n authorized\n }\n }\n }\n }\n": typeof types.WorkspaceIntercomPermissionDocument,
"\n subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n": typeof types.WorkspacePlanUsageUpdatedDocument,
};
const documents: Documents = {
"\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug) {\n id\n }\n }\n }\n": types.SetActiveWorkspaceMutationDocument,
@@ -66,7 +77,7 @@ const documents: Documents = {
"\n mutation CreateProject($input: ProjectCreateInput) {\n projectMutations {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n": types.CreateProjectDocument,
"\n mutation CreateProjectInWorkspace($input: WorkspaceProjectCreateInput!) {\n workspaceMutations {\n projects {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": types.CreateProjectInWorkspaceDocument,
"\n mutation StreamAccessRequestCreate($input: String!) {\n streamAccessRequestCreate(streamId: $input) {\n id\n }\n }\n": types.StreamAccessRequestCreateDocument,
"\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n": types.WorkspaceListWorkspaceItemFragmentDoc,
"\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n canAccessHelpCenter {\n authorized\n }\n }\n }\n": types.WorkspaceListWorkspaceItemFragmentDoc,
"\n fragment AutomateFunctionItem on AutomateFunction {\n name\n isFeatured\n id\n creator {\n name\n }\n releases {\n items {\n inputSchema\n }\n }\n }\n": types.AutomateFunctionItemFragmentDoc,
"\n mutation CreateAutomation($projectId: ID!, $input: ProjectAutomationCreateInput!) {\n projectMutations {\n automationMutations(projectId: $projectId) {\n create(input: $input) {\n id\n name\n }\n }\n }\n }\n": types.CreateAutomationDocument,
"\n fragment AutomateFunctionRunItem on AutomateFunctionRun {\n id\n status\n statusMessage\n results\n contextView\n function {\n id\n name\n logo\n }\n }\n": types.AutomateFunctionRunItemFragmentDoc,
@@ -77,6 +88,7 @@ const documents: Documents = {
"\n query CanCreatePersonalProject {\n activeUser {\n permissions {\n canCreatePersonalProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": types.CanCreatePersonalProjectDocument,
"\n query CanCreateProjectInWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n permissions {\n canCreateProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": types.CanCreateProjectInWorkspaceDocument,
"\n query CanCreateModelInProject($projectId: String!) {\n project(id: $projectId) {\n permissions {\n canCreateModel {\n authorized\n code\n message\n }\n }\n }\n }\n": types.CanCreateModelInProjectDocument,
"\n query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\n }\n }\n }\n }\n }\n": types.CanCreateVersionDocument,
"\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n id\n name\n }\n }\n }\n": types.ActiveWorkspaceDocument,
"\n fragment ProjectListProjectItem on Project {\n id\n name\n role\n updatedAt\n workspaceId\n workspace {\n id\n name\n slug\n role\n }\n models {\n totalCount\n }\n permissions {\n canLoad {\n authorized\n code\n message\n }\n canPublish {\n authorized\n code\n message\n }\n }\n }\n": types.ProjectListProjectItemFragmentDoc,
"\n query ProjectListQuery($limit: Int!, $filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": types.ProjectListQueryDocument,
@@ -96,10 +108,20 @@ const documents: Documents = {
"\n subscription ProjectTriggeredAutomationsStatusUpdated($projectId: String!) {\n projectTriggeredAutomationsStatusUpdated(projectId: $projectId) {\n type\n version {\n id\n }\n model {\n id\n }\n project {\n id\n }\n run {\n ...AutomationRunItem\n }\n }\n }\n": types.ProjectTriggeredAutomationsStatusUpdatedDocument,
"\n subscription OnUserProjectsUpdated {\n userProjectsUpdated {\n id\n project {\n id\n visibility\n team {\n id\n role\n }\n }\n }\n }\n": types.OnUserProjectsUpdatedDocument,
"\n subscription ProjectUpdated($projectId: String!) {\n projectUpdated(id: $projectId) {\n id\n project {\n visibility\n }\n }\n }\n": types.ProjectUpdatedDocument,
"\n subscription Subscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n": types.SubscriptionDocument,
"\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n": types.ModelViewingSubscriptionDocument,
"\n subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\n }\n }\n": types.ProjectCommentsUpdatedDocument,
"\n mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\n }\n }\n }\n }\n": types.CreateModelIngestionDocument,
"\n mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\n }\n }\n }\n }\n": types.UpdateModelIngestionProgressDocument,
"\n mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\n }\n }\n }\n }\n }\n": types.CompleteModelIngestionWithVersionDocument,
"\n mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\n }\n }\n }\n }\n": types.FailModelIngestionWithErrorDocument,
"\n mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\n }\n }\n }\n }\n": types.FailModelIngestionWithCancelDocument,
"\n query CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\n }\n }\n }\n }\n }\n": types.CanCreateIngestionDocument,
"\n subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\n }\n }\n }\n }\n }\n": types.ProjectModelIngestionUpdatedDocument,
"\n fragment IssuesItem on Issue {\n id\n status\n title\n priority\n viewerState\n identifier\n resourceIdString\n activities(input: { limit: 1, sortDirection: asc }) {\n totalCount\n items {\n actor {\n id\n user {\n name\n id\n avatar\n }\n }\n eventType\n createdAt\n }\n }\n replies {\n totalCount\n items {\n id\n author {\n id\n user {\n name\n id\n avatar\n }\n }\n createdAt\n description {\n doc\n }\n }\n }\n description {\n doc\n }\n labels {\n hexColor\n id\n name\n }\n author {\n id\n user {\n id\n name\n avatar\n }\n }\n dueDate\n assignee {\n id\n user {\n id\n avatar\n name\n }\n }\n }\n": types.IssuesItemFragmentDoc,
"\n query IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\n }\n }\n }\n }\n": types.IssuesListDocument,
"\n query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\n }\n }\n": types.IssueResourceMetaSearchDocument,
"\n query WorkspaceIntercomPermission($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n permissions {\n canAccessHelpCenter {\n authorized\n }\n }\n }\n }\n": types.WorkspaceIntercomPermissionDocument,
"\n subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n": types.WorkspacePlanUsageUpdatedDocument,
};
/**
@@ -151,7 +173,7 @@ export function graphql(source: "\n mutation StreamAccessRequestCreate($input:
/**
* 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 WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n"];
export function graphql(source: "\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n canAccessHelpCenter {\n authorized\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n canAccessHelpCenter {\n authorized\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -192,6 +214,10 @@ export function graphql(source: "\n query CanCreateProjectInWorkspace($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 query CanCreateModelInProject($projectId: String!) {\n project(id: $projectId) {\n permissions {\n canCreateModel {\n authorized\n code\n message\n }\n }\n }\n }\n"): (typeof documents)["\n query CanCreateModelInProject($projectId: String!) {\n project(id: $projectId) {\n permissions {\n canCreateModel {\n authorized\n code\n message\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 query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -271,11 +297,39 @@ export function graphql(source: "\n subscription ProjectUpdated($projectId: Str
/**
* 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 Subscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n"): (typeof documents)["\n subscription Subscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n"];
export function graphql(source: "\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n"): (typeof documents)["\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\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 subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\n }\n }\n"): (typeof documents)["\n subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\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 CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\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 UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\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 CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\n }\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 FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\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 FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\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 query CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\n }\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 subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\n }\n }\n }\n }\n }\n"): (typeof documents)["\n subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -284,6 +338,18 @@ export function graphql(source: "\n fragment IssuesItem on Issue {\n id\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 IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\n }\n }\n }\n }\n"): (typeof documents)["\n query IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\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 query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\n }\n }\n"): (typeof documents)["\n query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\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 WorkspaceIntercomPermission($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n permissions {\n canAccessHelpCenter {\n authorized\n }\n }\n }\n }\n"): (typeof documents)["\n query WorkspaceIntercomPermission($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n permissions {\n canAccessHelpCenter {\n authorized\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 subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n"): (typeof documents)["\n subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};
File diff suppressed because one or more lines are too long
+71
View File
@@ -0,0 +1,71 @@
import { canCreateVersionQuery } from '~/lib/graphql/mutationsAndQueries'
import { canCreateModelIngestionQuery } from '~/lib/ingestion/graphql/queries'
import { useAccountStore } from '~/store/accounts'
// use this composable whenever we need to check against available graphqls over servers
export function useCheckGraphql() {
/**
* Checks the ingestions available for the server,
* if available, returns with respond by appending `queryAvailable = true`
* otherwise, returns fake result object with `queryAvailable = false`
*/
const canCreateModelIngestion = async (
projectId: string,
modelId: string,
accountId: string
) => {
const accountsStore = useAccountStore()
const client = accountsStore.getAccountClient(accountId)
try {
const result = await client.query({
query: canCreateModelIngestionQuery,
variables: {
projectId,
modelId
},
fetchPolicy: 'network-only'
})
return {
...result.data.project.model.permissions.canCreateIngestion,
queryAvailable: true
}
} catch {
return { queryAvailable: false, authorized: false, message: undefined }
}
}
/**
* Checks if user can create a version for the given model.
* Used to validate before starting a publish operation.
*/
const canCreateVersion = async (
projectId: string,
modelId: string,
accountId: string
) => {
const accountsStore = useAccountStore()
const client = accountsStore.getAccountClient(accountId)
try {
const result = await client.query({
query: canCreateVersionQuery,
variables: {
projectId,
modelId
},
fetchPolicy: 'network-only'
})
return result.data.project.model.permissions.canCreateVersion
} catch (error) {
// If we can't check, allow the attempt - server will reject if not allowed
console.error('Failed to check canCreateVersion:', error)
return { authorized: true, message: null }
}
}
return {
canCreateVersion,
canCreateModelIngestion
}
}
+21 -1
View File
@@ -95,6 +95,9 @@ export const workspaceListFragment = graphql(`
code
message
}
canAccessHelpCenter {
authorized
}
}
}
`)
@@ -246,6 +249,23 @@ export const canCreateModelInProjectQuery = graphql(`
}
`)
export const canCreateVersionQuery = graphql(`
query CanCreateVersion($projectId: String!, $modelId: String!) {
project(id: $projectId) {
model(id: $modelId) {
permissions {
canCreateVersion {
authorized
code
message
errorMessage
}
}
}
}
}
`)
export const activeWorkspaceQuery = graphql(`
query ActiveWorkspace {
activeUser {
@@ -606,7 +626,7 @@ export const projectUpdatedSubscription = graphql(`
`)
export const modelViewingSubscription = graphql(`
subscription Subscription($target: ViewerUpdateTrackingTarget!) {
subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {
viewerUserActivityBroadcasted(target: $target) {
userName
userId
@@ -0,0 +1,319 @@
import {
provideApolloClient,
useMutation,
useSubscription
} from '@vue/apollo-composable'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import {
completeModelIngestionWithVersion,
createModelIngestion,
updateModelIngestionProgress,
failModelIngestionWithError,
failModelIngestionWithCancel
} from '../graphql/mutations'
import { projectModelIngestionUpdatedSubscription } from '../graphql/subscriptions'
import type {
SourceDataInput,
ProjectModelIngestionUpdatedSubscription
} from '~~/lib/common/generated/gql/graphql'
import type { ISenderModelCard } from '~/lib/models/card/send'
import { storeToRefs } from 'pinia'
import { ToastNotificationType } from '@speckle/ui-components'
/**
* New way of creating versions.
* It is essential for server to track limits on versions.
* The flow is as follows:
* 0. Check if the user has enough limits to create a new version (this is handled outside of this composable)
* 1. Start a new ingestion
* 2. Update the ingestion with the new data when connector throws progress via 'setModelProgress' event
* 3. Complete the version with the root object id that passed by connector or server/sketchup bridges in JS
*/
export const useModelIngestion = () => {
const store = useHostAppStore()
const accountStore = useAccountStore()
const startIngestion = async (
senderModelCard: ISenderModelCard,
progressMessage: string,
sourceData: SourceDataInput
) => {
const { activeIngestions } = storeToRefs(store)
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(createModelIngestion)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
modelId: senderModelCard.modelId,
progressMessage,
sourceData,
maxIdleTimeoutSeconds: 7200 // 2h
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const ingestionId = res?.data?.projectMutations.modelIngestionMutations.create.id
if (ingestionId) {
activeIngestions.value[senderModelCard.modelCardId] = ingestionId
}
return res?.data?.projectMutations.modelIngestionMutations.create
}
const updateIngestion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
progressMessage: string,
progress?: number
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(updateModelIngestionProgress)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
progressMessage,
progress
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
return res?.data?.projectMutations.modelIngestionMutations.updateProgress
}
const failIngestion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
errorReason: string,
errorStacktrace?: string
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(failModelIngestionWithError)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
errorReason,
errorStacktrace
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const { activeIngestions } = storeToRefs(store)
// clean the failed ingestion
activeIngestions.value = Object.fromEntries(
Object.entries(activeIngestions.value).filter(
([key]) => key !== senderModelCard.modelCardId
)
)
}
const cancelIngestion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
cancellationMessage: string = 'Cancelled by user'
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(failModelIngestionWithCancel)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
cancellationMessage
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const { activeIngestions } = storeToRefs(store)
// clean the cancelled ingestion
activeIngestions.value = Object.fromEntries(
Object.entries(activeIngestions.value).filter(
([key]) => key !== senderModelCard.modelCardId
)
)
}
const completeIngestionWithVersion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
rootObjectId: string
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(completeModelIngestionWithVersion)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
rootObjectId
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const { activeIngestions } = storeToRefs(store)
// clean the completed ingestion
activeIngestions.value = Object.fromEntries(
Object.entries(activeIngestions.value).filter(
([key]) => key !== senderModelCard.modelCardId
)
)
return res?.data?.projectMutations.modelIngestionMutations.completeWithVersion
}
// Tracks active ingestion subscriptions so they can be stopped on cancel or terminal state
const activeSubscriptions: Record<string, () => void> = {}
/**
* Subscribes to ingestion status updates for a given ingestionId.
* Used when the connector (.NET SDK) handles the ingestion and passes the ingestionId
* back to the DUI via setModelSendResult. The DUI then subscribes to track
* the server-side processing state until a terminal status is reached.
*
* Manages model card state directly: updates progress, sets versionId on success,
* sets error on failure, and clears progress on terminal states.
*/
const subscribeToIngestion = (
senderModelCard: ISenderModelCard,
ingestionId: string
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
senderModelCard.progress = { status: 'Remote processing...' }
const { onResult, onError, stop } = provideApolloClient(client)(() =>
useSubscription(projectModelIngestionUpdatedSubscription, () => ({
input: {
projectId: senderModelCard.projectId,
ingestionReference: { ingestionId }
}
}))
)
activeSubscriptions[senderModelCard.modelCardId] = stop
onResult((result) => {
const data = result.data as ProjectModelIngestionUpdatedSubscription | undefined
const statusData = data?.projectModelIngestionUpdated?.modelIngestion?.statusData
if (!statusData) return
switch (statusData.__typename) {
case 'ModelIngestionSuccessStatus':
senderModelCard.latestCreatedVersionId = statusData.versionId
senderModelCard.progress = undefined
unsubscribeFromIngestion(senderModelCard.modelCardId)
break
case 'ModelIngestionProcessingStatus':
senderModelCard.progress = {
status: statusData.progressMessage,
progress: statusData.progress ?? undefined
}
break
case 'ModelIngestionFailedStatus':
senderModelCard.error = {
errorMessage: statusData.errorReason,
dismissible: true
}
senderModelCard.progress = undefined
unsubscribeFromIngestion(senderModelCard.modelCardId)
break
case 'ModelIngestionCancelledStatus':
senderModelCard.progress = undefined
unsubscribeFromIngestion(senderModelCard.modelCardId)
break
case 'ModelIngestionQueuedStatus':
senderModelCard.progress = {
status: statusData.progressMessage
}
break
}
})
onError((err) => {
console.error('Ingestion subscription error:', err)
unsubscribeFromIngestion(senderModelCard.modelCardId)
})
}
const unsubscribeFromIngestion = (modelCardId: string) => {
const stop = activeSubscriptions[modelCardId]
if (stop) {
stop()
delete activeSubscriptions[modelCardId]
}
}
return {
startIngestion,
updateIngestion,
failIngestion,
cancelIngestion,
completeIngestionWithVersion,
subscribeToIngestion,
unsubscribeFromIngestion
}
}
+86
View File
@@ -0,0 +1,86 @@
import { graphql } from '~~/lib/common/generated/gql'
export const createModelIngestion = graphql(`
mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {
projectMutations {
modelIngestionMutations {
create(input: $input) {
id
}
}
}
}
`)
export const updateModelIngestionProgress = graphql(`
mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {
projectMutations {
modelIngestionMutations {
updateProgress(input: $input) {
id
}
}
}
}
`)
export const completeModelIngestionWithVersion = graphql(`
mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {
projectMutations {
modelIngestionMutations {
completeWithVersion(input: $input) {
id
statusData {
__typename
... on ModelIngestionProcessingStatus {
status
progressMessage
progress
}
... on ModelIngestionSuccessStatus {
status
versionId
}
... on ModelIngestionFailedStatus {
errorStacktrace
errorReason
status
}
... on ModelIngestionCancelledStatus {
cancellationMessage
status
}
... on ModelIngestionQueuedStatus {
progressMessage
status
}
}
}
}
}
}
`)
export const failModelIngestionWithError = graphql(`
mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {
projectMutations {
modelIngestionMutations {
failWithError(input: $input) {
id
}
}
}
}
`)
export const failModelIngestionWithCancel = graphql(`
mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {
projectMutations {
modelIngestionMutations {
failWithCancel(input: $input) {
id
}
}
}
}
`)
+17
View File
@@ -0,0 +1,17 @@
import { graphql } from '~~/lib/common/generated/gql'
export const canCreateModelIngestionQuery = graphql(`
query CanCreateIngestion($modelId: String!, $projectId: String!) {
project(id: $projectId) {
model(id: $modelId) {
permissions {
canCreateIngestion {
authorized
code
message
}
}
}
}
}
`)
+38
View File
@@ -0,0 +1,38 @@
import { graphql } from '~~/lib/common/generated/gql'
export const projectModelIngestionUpdatedSubscription = graphql(`
subscription ProjectModelIngestionUpdated(
$input: ProjectModelIngestionSubscriptionInput!
) {
projectModelIngestionUpdated(input: $input) {
type
modelIngestion {
id
statusData {
__typename
... on ModelIngestionSuccessStatus {
status
versionId
}
... on ModelIngestionProcessingStatus {
status
progressMessage
progress
}
... on ModelIngestionFailedStatus {
status
errorReason
}
... on ModelIngestionCancelledStatus {
status
cancellationMessage
}
... on ModelIngestionQueuedStatus {
status
progressMessage
}
}
}
}
}
`)
+20
View File
@@ -13,3 +13,23 @@ export const issuesListQuery = graphql(`
}
}
`)
export const issueResourceMetaSearchQuery = graphql(`
query IssueResourceMetaSearch(
$workspaceId: String!
$resourceType: ResourceMetaType!
$resourceId: String!
$projectId: String
$metaType: String
) {
resourceMetaSearch(
workspaceId: $workspaceId
resourceType: $resourceType
resourceId: $resourceId
projectId: $projectId
metaType: $metaType
) {
data
}
}
`)
+1
View File
@@ -15,6 +15,7 @@ export type ModelCardNotification = {
name: string
tooltipText?: string
action: () => void
disabled?: boolean
}
/**
* If set, will display a view report button next to cta
+57
View File
@@ -1,6 +1,24 @@
import { computed } from 'vue'
import type {
ISendFilter,
SendFilterSelect,
RevitCategoriesSendFilter,
RevitViewsSendFilter
} from '~/lib/models/card/send'
import { ValidationHelpers } from '@speckle/ui-components'
import type { GenericValidateFunction } from 'vee-validate'
export const isSelectFilter = (f: ISendFilter): f is SendFilterSelect =>
f.type === 'Select' || 'selectedItems' in f
export const isRevitCategoriesFilter = (
f: ISendFilter
): f is RevitCategoriesSendFilter =>
f.id === 'revitCategories' || f.id === 'archicadLayers'
export const isRevitViewsFilter = (f: ISendFilter): f is RevitViewsSendFilter =>
f.id === 'revitViews'
export const isEmail = ValidationHelpers.isEmail
export const isOneOrMultipleEmails = ValidationHelpers.isOneOrMultipleEmails
@@ -42,3 +60,42 @@ export function useModelNameValidationRules() {
isValidModelName
])
}
export type FilterValidationResult = { valid: boolean; reason?: string }
export function validateFilter(
filter: ISendFilter | undefined,
context: { selectionCount: number }
): FilterValidationResult {
if (!filter) return { valid: false, reason: 'No filter selected' }
// Selection Filter check
if (filter.name === 'Selection' || filter.id === 'selection') {
return context.selectionCount > 0
? { valid: true }
: { valid: false, reason: 'No objects selected to publish' }
}
// List-based filters (Rhino Layers, etc.)
if (isSelectFilter(filter)) {
return (filter.selectedItems?.length ?? 0) > 0
? { valid: true }
: { valid: false, reason: 'No items selected to publish' }
}
// Category-based filters
if (isRevitCategoriesFilter(filter)) {
return (filter.selectedCategories?.length ?? 0) > 0
? { valid: true }
: { valid: false, reason: 'No categories selected to publish' }
}
// View-based filters
if (isRevitViewsFilter(filter)) {
return filter.selectedView?.trim()
? { valid: true }
: { valid: false, reason: 'No view selected to publish' }
}
return { valid: true }
}
+14
View File
@@ -0,0 +1,14 @@
import { graphql } from '~/lib/common/generated/gql'
export const workspaceIntercomPermissionQuery = graphql(`
query WorkspaceIntercomPermission($workspaceId: String!) {
workspace(id: $workspaceId) {
id
permissions {
canAccessHelpCenter {
authorized
}
}
}
}
`)
+7
View File
@@ -0,0 +1,7 @@
import { graphql } from '~~/lib/common/generated/gql'
export const workspacePlanUsageUpdatedSubscription = graphql(`
subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {
workspacePlanUsageUpdated(input: $input)
}
`)
+9 -1
View File
@@ -10,6 +10,13 @@
"build": "nuxt build",
"dev:nuxt": "nuxt dev",
"dev": "concurrently \"nuxt dev\" \"yarn gqlgen:watch\"",
"dev:kind:up": "ctlptl apply --filename ./tests/deployment/helm/cluster-config.yaml",
"dev:kind:down": "ctlptl delete -f ./tests/deployment/helm/cluster-config.yaml",
"dev:kind:helm:up": "yarn run dev:kind:up && tilt up --file ./tests/deployment/helm/Tiltfile --context kind-speckle-dui",
"dev:kind:helm:down": "tilt down --file ./tests/deployment/helm/Tiltfile --context kind-speckle-dui",
"dev:kind:helm:ci": "tilt ci --file ./tests/deployment/helm/Tiltfile --context kind-speckle-dui --timeout 10m",
"docker:build": "docker build -f ./deployment/docker/Dockerfile -t ghcr.io/specklesystems/speckle-dui:local .",
"docker:run": "docker run --rm -p 8083:80 -v ./deployment/docker/configuration.toml:/app/configuration.toml:ro ghcr.io/specklesystems/speckle-dui:local",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
@@ -20,7 +27,8 @@
"lint": "yarn lint:js && yarn lint:tsc && yarn lint:prettier && yarn lint:css",
"lint:ci": "yarn lint:tsc && yarn lint:css",
"gqlgen": "graphql-codegen",
"gqlgen:watch": "graphql-codegen --watch"
"gqlgen:watch": "graphql-codegen --watch",
"prettier:fix": "prettier --config .prettierrc --ignore-path .prettierignore --write ."
},
"dependencies": {
"@apollo/client": "^3.7.14",
+8 -67
View File
@@ -3,17 +3,16 @@
</template>
<script setup lang="ts">
import { md5 } from '@speckle/shared'
import { ToastNotificationType } from '@speckle/ui-components'
import { useRoute, useRouter } from 'vue-router'
import { useAuthManager } from '~/lib/authn/useAuthManager'
import type { Account } from '~/lib/bindings/definitions/IAccountBinding'
import { useTokenExchange } from '~/lib/authn/useTokenExchange'
import { useHostAppStore } from '~/store/hostApp'
const route = useRoute()
const router = useRouter()
const { getChallenge, getChallengeUrl } = useAuthManager()
const { $accountBinding } = useNuxtApp()
const { getChallenge, getCodeVerifier, getChallengeUrl } = useAuthManager()
const { exchangeAccessCode } = useTokenExchange()
const hostApp = useHostAppStore()
onMounted(async () => {
@@ -22,69 +21,11 @@ onMounted(async () => {
const accessCode = route.query.access_code as string | undefined
if (accessCode && origin) {
const challenge = getChallenge()
const body = {
appId: 'sdui',
appSecret: 'sdui',
accessCode,
challenge
if (!challenge) {
throw new Error('No challenge found in storage.')
}
// Exchange the access code for a real token (optional)
const response = await fetch(new URL('/auth/token', origin), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!response.ok) {
const errorText = await response.text()
hostApp.setNotification({
title: 'Log In',
type: ToastNotificationType.Danger,
description: `Token exchange failed with status ${response.status}: ${errorText}`
})
// Stop processing and redirect immediately on failure
return router.replace('/')
}
const { token, refreshToken } = (await response.json()) as {
token: string
refreshToken: string
}
const graphqlQuery = {
query:
'query { activeUser { id name email company avatar } serverInfo { name company adminContact description version } }'
}
const userAndServerInfoResponse = await fetch(new URL('/graphql', origin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` // Add the token as a Bearer token
},
body: JSON.stringify(graphqlQuery)
})
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const userAndServerInfo = await userAndServerInfoResponse.json()
const accountId = md5(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
userAndServerInfo.data.activeUser.email + origin
).toUpperCase()
const account: Account = {
id: accountId,
token,
refreshToken,
isDefault: true,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
serverInfo: { url: origin, ...userAndServerInfo.data.serverInfo },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
userInfo: userAndServerInfo.data.activeUser
}
await $accountBinding.addAccount(accountId, account)
const codeVerifier = getCodeVerifier() ?? undefined
await exchangeAccessCode(origin, accessCode, challenge, codeVerifier)
} else {
throw new Error('No access code is found.')
}
@@ -92,7 +33,7 @@ onMounted(async () => {
hostApp.setNotification({
type: ToastNotificationType.Danger,
title: 'Failed to add your Speckle account.',
description: error as string
description: error instanceof Error ? error.message : (error as string)
})
} finally {
router.replace('/')
+7 -1
View File
@@ -8,6 +8,7 @@ import {
IAccountBindingKey,
MockedAccountBinding
} from '~/lib/bindings/definitions/IAccountBinding'
import type { IParametersBinding } from '~/lib/bindings/definitions/IParametersBinding'
import type { ITestBinding } from '~/lib/bindings/definitions/ITestBinding'
import {
@@ -132,6 +133,10 @@ export default defineNuxtPlugin(async () => {
ITopLevelExpectionHandlerBindingKey
)
const parametersBinding = await tryHoistBinding<IParametersBinding>(
'parametersBinding'
)
// Any binding implments these two methods below, we just choose one to
// expose globally to the app.
const showDevTools = () => {
@@ -157,7 +162,8 @@ export default defineNuxtPlugin(async () => {
topLevelExceptionHandlerBinding,
showDevTools,
openUrl,
revitMapperBinding
revitMapperBinding,
parametersBinding
}
}
})
+76 -33
View File
@@ -7,7 +7,10 @@ import Intercom, {
trackEvent
} from '@intercom/messenger-js-sdk'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import { useConfigStore } from '~/store/config'
import { storeToRefs } from 'pinia'
import { workspaceIntercomPermissionQuery } from '~/lib/workspaces/graphql/queries'
const disabledRoutes: string[] = []
@@ -15,15 +18,23 @@ export const useIntercom = () => {
const route = useRoute()
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const configStore = useConfigStore()
const { activeAccount } = storeToRefs(accountStore)
const { isDistributedBySpeckle } = storeToRefs(hostAppStore)
const { userSelectedWorkspaceId } = storeToRefs(configStore)
const isInitialized = ref(false)
const hasIntercomAccess = ref(false)
const isRouteBlacklisted = computed(() => {
return disabledRoutes.some((disabledRoute) => route.path.includes(disabledRoute))
})
const shouldEnableIntercom = computed(() => !isRouteBlacklisted.value)
const shouldEnableIntercom = computed(() => {
return !isRouteBlacklisted.value && hasIntercomAccess.value
})
const bootIntercom = () => {
if (!shouldEnableIntercom.value || isInitialized.value || !activeAccount.value)
@@ -41,13 +52,10 @@ export const useIntercom = () => {
}
const showIntercom = () => {
if (!isInitialized.value) return
show()
if (isInitialized.value) show()
}
const hideIntercom = () => {
if (!isInitialized.value) return
hide()
if (isInitialized.value) hide()
}
const shutdownIntercom = () => {
@@ -57,8 +65,7 @@ export const useIntercom = () => {
}
const trackIntercom = (event: string, metadata?: Record<string, unknown>) => {
if (!isInitialized.value) return
trackEvent(event, metadata)
if (isInitialized.value) trackEvent(event, metadata)
}
const updateConnectorDetails = (
@@ -66,44 +73,80 @@ export const useIntercom = () => {
hostAppVersion: string,
connectorVersion: string
) => {
update({
page_title: `CNX: (hostApp: ${hostAppName}:v${hostAppVersion}),(version: ${connectorVersion})`
})
if (isInitialized.value) {
update({
page_title: `CNX: (hostApp: ${hostAppName}:v${hostAppVersion}),(version: ${connectorVersion})`
})
}
}
// On route change, check if we need to shutodwn or boot Intercom
watch(route, () => {
if (isRouteBlacklisted.value) {
shutdownIntercom()
} else {
bootIntercom()
const checkPermissions = async () => {
if (!activeAccount.value || !userSelectedWorkspaceId.value) {
// userSelectedWorkspaceId is only null before any publish/load action,
// at which point the NavBar (and feedback button) isn't visible anyway
hasIntercomAccess.value = false
return
}
try {
const { data } = await activeAccount.value.client.query({
query: workspaceIntercomPermissionQuery,
variables: { workspaceId: userSelectedWorkspaceId.value },
fetchPolicy: 'cache-first'
})
hasIntercomAccess.value =
data?.workspace?.permissions?.canAccessHelpCenter?.authorized === true
} catch (e) {
console.warn('Failed to fetch Intercom permissions for workspace', e)
hasIntercomAccess.value = false
}
}
watch(route, () => {
if (isRouteBlacklisted.value || !shouldEnableIntercom.value) shutdownIntercom()
else bootIntercom()
})
watch(activeAccount, (newValue) => {
if (newValue) {
if (!isInitialized.value) {
bootIntercom() // if active account changed and itercom is not initialised, do it
return // we do not need to update, as that's done by default in the init
}
update({
user_id: activeAccount.value.accountInfo.userInfo.id || '',
name: activeAccount.value.accountInfo.userInfo.name,
email: activeAccount.value.accountInfo.userInfo.email
})
} else {
if (isInitialized.value) {
// we listen to changes in the host app distribution status that fetched on updateConnector composable after the intercom is initialized, we cant simply rely on activeAccount watcher
watch(isDistributedBySpeckle, (newValue) => {
if (!newValue) shutdownIntercom()
})
watch(
[activeAccount, userSelectedWorkspaceId],
async ([newAccount], [oldAccount]) => {
await checkPermissions()
if (newAccount) {
if (!shouldEnableIntercom.value) {
shutdownIntercom()
return
}
if (!isInitialized.value) {
bootIntercom()
} else if (newAccount.accountInfo.id !== oldAccount?.accountInfo?.id) {
update({
user_id: newAccount.accountInfo.userInfo.id || '',
name: newAccount.accountInfo.userInfo.name,
email: newAccount.accountInfo.userInfo.email
})
}
} else {
shutdownIntercom()
}
}
})
},
{ immediate: true }
)
return {
show: showIntercom,
hide: hideIntercom,
shutdown: shutdownIntercom,
track: trackIntercom,
updateConnectorDetails
updateConnectorDetails,
shouldEnableIntercom
}
}
+16 -8
View File
@@ -165,14 +165,13 @@ export const useAccountStore = defineStore('accountStore', () => {
// hostAppStore.setNotification(notification)
}
// if (res.networkError) {
// const notification: ToastNotification = {
// type: ToastNotificationType.Danger,
// title: 'Network Error',
// description: res.networkError.message
// }
// hostAppStore.setNotification(notification)
// }
if (res.networkError && !navigator.onLine) {
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'No Internet Connection',
description: 'Please check your network connection and try again.'
})
}
})
const link = splitLink(
@@ -295,6 +294,14 @@ export const useAccountStore = defineStore('accountStore', () => {
if (accountMatchWithServerUrl) return accountMatchWithServerUrl
}
const getAccountClient = (accountId: string) => {
return (
accounts.value.find(
(account) => account.accountInfo.id === accountId
) as DUIAccount
).client
}
const provideClients = () => {
provideApolloClients(apolloClients)
}
@@ -321,6 +328,7 @@ export const useAccountStore = defineStore('accountStore', () => {
return {
isLoading,
accounts,
getAccountClient,
defaultAccount,
activeAccount,
userSelectedAccount,
+13 -2
View File
@@ -8,11 +8,14 @@ export const useConfigStore = defineStore('configStore', () => {
const userSelectedWorkspaceId = ref<string>()
const config = ref<ConnectorConfig>({ darkTheme: true })
const config = ref<ConnectorConfig>({ darkTheme: true, disableCache: false })
const isDarkTheme = computed(() => {
return config.value?.darkTheme
})
const isCacheDisabled = computed(() => {
return config.value?.disableCache || false
})
const isDevMode = ref(false)
const toggleTheme = () => {
@@ -20,6 +23,11 @@ export const useConfigStore = defineStore('configStore', () => {
$configBinding.updateConfig(config.value)
}
const toggleCache = () => {
config.value.disableCache = !config.value.disableCache
$configBinding.updateConfig(config.value)
}
const setUserSelectedWorkspace = (workspaceId: string) => {
userSelectedWorkspaceId.value = workspaceId
try {
@@ -33,7 +41,8 @@ export const useConfigStore = defineStore('configStore', () => {
const init = async () => {
if (!$configBinding) return
config.value = await $configBinding.getConfig()
const fetchedConfig = await $configBinding.getConfig()
config.value = { disableCache: false, ...fetchedConfig }
const workspacesConfig = await $configBinding.getWorkspacesConfig()
if (workspacesConfig && workspacesConfig.userSelectedWorkspaceId) {
userSelectedWorkspaceId.value = workspacesConfig.userSelectedWorkspaceId
@@ -51,9 +60,11 @@ export const useConfigStore = defineStore('configStore', () => {
config,
hasConfigBindings,
isDarkTheme,
isCacheDisabled,
isDevMode,
userSelectedWorkspaceId,
toggleTheme,
toggleCache,
setUserSelectedWorkspace
}
})
+186 -25
View File
@@ -13,7 +13,10 @@ import type {
RevitViewsSendFilter,
SendFilterSelect
} from '~/lib/models/card/send'
import { useSelectionStore } from '~/store/selection'
import { validateFilter } from '~/lib/validation'
import type { ToastNotification } from '@speckle/ui-components'
import { ToastNotificationType } from '@speckle/ui-components'
import type { Nullable } from '@speckle/shared'
import type { HostAppError } from '~/lib/bridge/errorHandler'
import type { ConversionResult } from '~/lib/conversions/conversionResult'
@@ -28,6 +31,8 @@ import {
import { provideApolloClient, useMutation } from '@vue/apollo-composable'
import { createVersionMutation } from '~/lib/graphql/mutationsAndQueries'
import type { BaseBridge } from '~/lib/bridge/base'
import { useModelIngestion } from '~/lib/ingestion/composables/useModelIngestion'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
export type ProjectModelGroup = {
projectId: string
@@ -43,7 +48,15 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
const { $openUrl } = useNuxtApp()
const accountsStore = useAccountStore()
const { checkUpdate } = useUpdateConnector()
const {
startIngestion,
updateIngestion,
failIngestion,
cancelIngestion,
completeIngestionWithVersion,
subscribeToIngestion,
unsubscribeFromIngestion
} = useModelIngestion()
const isDistributedBySpeckle = ref<boolean>(true)
const latestAvailableVersion = ref<Version | null>(null)
@@ -65,6 +78,9 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
// Different host apps can have different kind of ISendFilterSelect send filters, and we collect them here to generalize the component we use in `ListSelect`
const availableSelectSendFilters = ref<Record<string, SendFilterSelect>>({})
// kvp for modelCardId - ingestionId
const activeIngestions = ref<Record<string, string>>({})
const dismissNotification = () => {
currentNotification.value = null
}
@@ -95,6 +111,11 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
isDistributedBySpeckle.value = val
}
const shouldHandleIngestion = computed(() => {
const hostAppsThatUsesDUIForGraphql = ['sketchup', 'archicad', 'Vectorworks']
return hostAppsThatUsesDUIForGraphql.includes(hostAppName.value as string)
})
/**
* Model Card Operations
*/
@@ -281,20 +302,59 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
const account = accountStore.accounts.find(
(acc) => acc.accountInfo.id === args.accountId
)
try {
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
)
await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: args.sourceApplication,
projectId: args.projectId
// Check if we have an ingestion ID for this model.
// If so, we are in the "New Business Model" flow and should use completeIngestionWithVersion.
const modelCard = documentModelStore.value.models.find(
(m) => m.modelId === args.modelId && m.projectId === args.projectId
) as ISenderModelCard
const { canCreateModelIngestion } = useCheckGraphql()
const canCreateIngestion = await canCreateModelIngestion(
args.projectId,
args.modelId,
args.accountId
)
if (canCreateIngestion.queryAvailable) {
const ingestionId = modelCard
? activeIngestions.value[modelCard.modelCardId]
: undefined
if (ingestionId && modelCard) {
try {
await completeIngestionWithVersion(
modelCard,
ingestionId,
args.referencedObjectId
)
} catch (err) {
console.error(`completeIngestionWithVersion failed: ${err}`)
}
})
} catch (err) {
console.error(`triggerCreateVersion is failed: ${err}`)
} else {
setNotification({
type: ToastNotificationType.Danger,
title: 'Publish Error',
description: 'Could not complete publish: Ingestion ID missing.'
})
}
} else {
// Fallback to legacy flow (Old Server)
try {
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
)
await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: args.sourceApplication,
projectId: args.projectId
}
})
} catch (err) {
console.error(`triggerCreateVersion is failed: ${err}`)
}
}
})
@@ -310,6 +370,14 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
*/
app.$sendBinding?.on('refreshSendFilters', () => void refreshSendFilters())
const validateSendFilter = (filter?: ISendFilter) => {
const selectionStore = useSelectionStore()
return validateFilter(filter, {
selectionCount: selectionStore.selectionInfo.selectedObjectIds?.length ?? 0
})
}
/**
* Send functionality
*/
@@ -318,10 +386,57 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
* Tells the host app to start sending a specific model card. This will reach inside the host application.
* @param modelId
*/
const sendModel = (modelCardId: string, actionSource: string) => {
const sendModel = async (modelCardId: string, actionSource: string) => {
const model = documentModelStore.value.models.find(
(m) => m.modelCardId === modelCardId
) as ISenderModelCard
const { canCreateModelIngestion, canCreateVersion } = useCheckGraphql()
const canCreateIngestion = await canCreateModelIngestion(
model.projectId,
model.modelId,
model.accountId
)
// for the connectors that don't have SDK to handle graqhql
if (shouldHandleIngestion.value && canCreateIngestion.queryAvailable) {
const sourceData = {
sourceApplicationSlug: hostAppName.value || 'unknown',
sourceApplicationVersion: hostAppVersion.value?.toString() || 'unknown'
}
if (canCreateIngestion.authorized) {
await startIngestion(model, 'Starting to publish', sourceData)
model.progress = { status: 'Converting the objects...' }
} else {
setNotification({
type: ToastNotificationType.Warning,
title: 'Cannot publish',
description: canCreateIngestion.message
})
return
}
} else {
// for the self hosters that does not have available graphql for ingestions
const canCreate = await canCreateVersion(
model.projectId,
model.modelId,
model.accountId
)
if (!canCreate.authorized) {
setNotification({
type: ToastNotificationType.Warning,
title: 'Cannot publish',
description: canCreate.message || 'Workspace limits have been reached'
})
return
}
}
model.latestCreatedVersionId = undefined
model.error = undefined
model.progress = { status: 'Starting to send...' }
model.expired = false
model.report = undefined
if (model.expired) {
// user sends via "Update" button
void trackEvent(
@@ -346,11 +461,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
model.accountId
)
}
model.latestCreatedVersionId = undefined
model.error = undefined
model.progress = { status: 'Starting to send...' }
model.expired = false
model.report = undefined
// You should stop asking why if you saw anything related autocad..
// It solves the press "escape" issue.
// Because probably we don't give enough time to acad complete it's previos task and it stucks.
@@ -377,6 +488,17 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
model.error = undefined
void trackEvent('DUI3 Action', { name: 'Send Cancel' }, model.accountId)
model.latestCreatedVersionId = undefined
// Clean up any active ingestion subscription from SDK-based connectors
unsubscribeFromIngestion(modelCardId)
// Cancel the ingestion if applicable
if (shouldHandleIngestion.value) {
const ingestionId = activeIngestions.value[modelCardId]
if (ingestionId) {
await cancelIngestion(model, ingestionId, 'Cancelled by user')
}
}
}
app.$sendBinding?.on('setModelsExpired', (modelCardIds) => {
@@ -393,13 +515,22 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
modelCardId: string
versionId: string
sendConversionResults: ConversionResult[]
ingestionId?: string
}) => {
const model = documentModelStore.value.models.find(
(m) => m.modelCardId === args.modelCardId
) as ISenderModelCard
model.latestCreatedVersionId = args.versionId
// Conversion results are always valid regardless of ingestion state
model.report = args.sendConversionResults
model.progress = undefined
if (args.ingestionId) {
// Connector handled ingestion via SDK — composable subscribes and manages model card state to 'Version created' bla bla
subscribeToIngestion(model, args.ingestionId)
} else {
// Legacy path or no ingestion — behave as before
model.latestCreatedVersionId = args.versionId
model.progress = undefined
}
}
app.$sendBinding?.on('setModelSendResult', setModelSendResult)
@@ -479,7 +610,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
app.$receiveBinding?.on('setModelReceiveResult', setModelReceiveResult)
// GENERIC STUFF
const handleModelProgressEvents = (args: {
const handleModelProgressEvents = async (args: {
modelCardId: string
progress?: ModelCardProgress
}) => {
@@ -487,9 +618,24 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
(m) => m.modelCardId === args.modelCardId
) as IModelCard
model.progress = args.progress
if (
model.typeDiscriminator.includes('SenderModelCard') &&
shouldHandleIngestion.value // for the connectors that don't have SDK to handle graqhql
) {
const ingestionId = activeIngestions.value[args.modelCardId]
if (ingestionId) {
await updateIngestion(
model,
ingestionId,
args.progress?.status || 'Progressing',
args.progress?.progress || 0
)
}
}
}
const setModelError = (args: {
const setModelError = async (args: {
modelCardId: string
error: string | { errorMessage: string; dismissible?: boolean }
}) => {
@@ -505,6 +651,19 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
dismissible: boolean
}
}
// Fail the ingestion if applicable
if (
model.typeDiscriminator.includes('SenderModelCard') &&
shouldHandleIngestion.value
) {
const ingestionId = activeIngestions.value[args.modelCardId]
if (ingestionId) {
const errorMessage =
typeof args.error === 'string' ? args.error : args.error.errorMessage
await failIngestion(model as ISenderModelCard, ingestionId, errorMessage)
}
}
}
// NOTE: all bindings that need to send these model events should register.
@@ -750,6 +909,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
hostAppName,
hostAppVersion,
connectorVersion,
activeIngestions,
isConnectorUpToDate,
latestAvailableVersion,
documentInfo,
@@ -788,6 +948,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
getSendSettings,
setModelSendResult,
setModelReceiveResult,
handleModelProgressEvents
handleModelProgressEvents,
validateSendFilter
}
})
+65
View File
@@ -0,0 +1,65 @@
print('🚀 Deploying Speckle DUI into a Kind Cluster via Tilt...')
# we limit tilt to run only on the kind cluster
allow_k8s_contexts(['kind-speckle-dui'])
if k8s_context() != 'kind-speckle-dui':
fail('Failing early as tilt should only ever connect to kind-speckle-dui.')
# Install extensions
load('ext://helm_resource', 'helm_resource', 'helm_repo')
load('ext://k8s_yaml_glob', 'k8s_yaml_glob')
docker_build('ghcr.io/specklesystems/speckle-dui',
context='../../..',
dockerfile='../../../deployment/docker/Dockerfile',
ignore = ['**/.nuxt', '**/node_modules', '**/dist', '**/build', '**/.git', '**/.claude', '**/.cursor', '**/deployment/**/*', '**/tests/**/*']
)
# Create namespaces
k8s_yaml_glob('./manifests/*.namespace.yaml')
k8s_yaml('./manifests/coredns.configmap.yaml')
k8s_resource(new_name='coredns',
objects=['coredns:configmap:kube-system'],
resource_deps=[],
labels=['coredns'])
# Update CoreDNS to allow for local resolution of services internally (i.e. speckle.internal will be routed to nginx)
local_resource('coredns-up',
cmd='./scripts/coredns-up.sh',
resource_deps=['coredns'],
deps=['./manifests/coredns.configmap.yaml', './scripts/coredns-up.sh'],
labels=['coredns'])
helm_repo('ingress-nginx-repo',
'https://kubernetes.github.io/ingress-nginx')
#nginx should be deployed as the last dependency as it opens ports to services
#it expects these services to exist, which are created by the helm charts above
helm_resource('ingress-nginx',
release_name='ingress-nginx',
namespace='ingress-nginx',
chart='ingress-nginx-repo/ingress-nginx',
flags=['--version=4.8.0',
'--values=./values/nginx.values.yaml',
'--kube-context=kind-speckle-dui'],
deps=['./values/nginx.values.yaml'],
resource_deps=['ingress-nginx-repo', 'coredns'],
labels=['speckle-dependencies'])
helm_resource('speckle-dui',
release_name='speckle-dui',
namespace='speckle-dui',
chart='./../../../deployment/helm/speckle-dui',
flags=['--values=./values/speckle-dui.values.yaml',
'--kube-context=kind-speckle-dui'],
image_deps=[
'ghcr.io/specklesystems/speckle-dui'
],
image_keys=[
('image.repository', 'image.tag')
],
deps=['./../../../deployment/helm/speckle-dui',
'./values/speckle-dui.values.yaml'],
resource_deps=['ingress-nginx', 'coredns'],
labels=['speckle-dui'])
+29
View File
@@ -0,0 +1,29 @@
apiVersion: ctlptl.dev/v1alpha1
kind: Registry
name: ctlptl-registry
port: 5000
---
apiVersion: ctlptl.dev/v1alpha1
kind: Cluster
product: kind
registry: ctlptl-registry
name: kind-speckle-dui
kindV1Alpha4Cluster:
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraMounts: []
extraPortMappings:
- containerPort: 80
hostPort: 80 # Docker requires privileged ports binding permissions https://docs.docker.com/desktop/mac/permission-requirements/#binding-privileged-ports
protocol: TCP
listenAddress: '127.0.0.1' #DO NOT REMOVE - this is required to prevent access from the local network or the world!!!
- containerPort: 443
hostPort: 443 # Docker requires privileged ports binding permissions https://docs.docker.com/desktop/mac/permission-requirements/#binding-privileged-ports
protocol: TCP
listenAddress: '127.0.0.1' #DO NOT REMOVE - this is required to prevent access from the local network or the world!!!
@@ -0,0 +1,24 @@
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/63dacb46bf939521bdc93981b4cbb7ecb58427a0.tar.gz") {} }:
let
corepack = pkgs.stdenv.mkDerivation {
name = "corepack";
buildInputs = [ pkgs.nodejs_22 ];
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out/bin
corepack enable --install-directory=$out/bin
'';
};
in pkgs.mkShell {
buildInputs = [
pkgs.docker
pkgs.kind
pkgs.kubectl
pkgs.nodejs_22
pkgs.ctlptl
pkgs.kubernetes-helm
pkgs.tilt
corepack
];
}
@@ -0,0 +1,28 @@
apiVersion: v1
data:
Corefile: |
.:53 {
errors
health {
lameduck 5s
}
ready
rewrite name speckle.internal ingress-nginx-controller.ingress-nginx.svc.cluster.local.
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
}
cache 30
loop
reload
loadbalance
}
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: 'ingress-nginx'
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: 'speckle-dui'
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
kubectl --context="kind-speckle-dui" --namespace="kube-system" rollout restart deployment/coredns
kubectl --context="kind-speckle-dui" --namespace="kube-system" rollout status deployment "coredns" --timeout=90s
@@ -0,0 +1,7 @@
controller:
# We must set the kind cluster listen address for every port to '127.0.0.1' when hostNetwork is true
hostNetwork: true
admissionWebhooks:
enabled: false
# progressDeadlineSeconds: 600 #HACK helm chart was complaining that this was less than minReadySeconds https://github.com/kubernetes/ingress-nginx/blob/c72441585e1ab1a32df86e760613d36fa804315d/charts/ingress-nginx/templates/controller-deployment.yaml#L26
tcp: {}
@@ -0,0 +1,16 @@
ingress:
enabled: true
className: "nginx"
annotations: {}
hosts:
- host: speckle.internal
paths:
# Please retain this path, the dashboards expect to serve all paths under the root.
- path: /
pathType: ImplementationSpecific
security:
trustedProxies: []
frameAncestors:
- "speckle.internal"
frameSource:
- "speckle.internal"