From 083999da46ce66062d7bc4efa5ca723ca83d3828 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 5 Dec 2024 12:02:48 +0000 Subject: [PATCH 01/68] chore(server/logging): use message template instead of runtime formatting (#3638) - reduce the cardinality of messages, to allow filtering by message template --- packages/preview-service/src/server/routes/objects.ts | 5 ++--- packages/server/modules/core/rest/download.ts | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/preview-service/src/server/routes/objects.ts b/packages/preview-service/src/server/routes/objects.ts index 4ddbe2dd00..6bfeb50aef 100644 --- a/packages/preview-service/src/server/routes/objects.ts +++ b/packages/preview-service/src/server/routes/objects.ts @@ -64,9 +64,8 @@ const objectsRouterFactory = () => { boundLogger.error(err, 'Error downloading object from stream') } else { boundLogger.info( - `Downloaded object from stream (size: ${ - gzipStream.bytesWritten / 1000000 - } MB)` + { megaBytesWritten: gzipStream.bytesWritten / 1000000 }, + 'Downloaded object from stream (size: {megaBytesWritten} MB)' ) } } diff --git a/packages/server/modules/core/rest/download.ts b/packages/server/modules/core/rest/download.ts index afaf1f934d..6e2b662d2a 100644 --- a/packages/server/modules/core/rest/download.ts +++ b/packages/server/modules/core/rest/download.ts @@ -85,7 +85,8 @@ export default (app: express.Express) => { boundLogger.error(err, 'Error downloading object.') } else { boundLogger.info( - `Downloaded object (size: ${gzipStream.bytesWritten / 1000000} MB)` + { megaBytesWritten: gzipStream.bytesWritten / 1000000 }, + 'Downloaded object (size: {megaBytesWritten} MB)' ) } } From b2087e75161c439fed22688d5f00cdc3eba3f2b7 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 5 Dec 2024 12:09:13 +0000 Subject: [PATCH 02/68] feat(local dev): multi-region blob storage (#3639) --- docker-compose-deps.yml | 11 +++++++++++ packages/server/multiregion.example.json | 14 ++++++++++++++ packages/server/multiregion.test.example.json | 14 ++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/docker-compose-deps.yml b/docker-compose-deps.yml index a152da6747..8671cdb4a6 100644 --- a/docker-compose-deps.yml +++ b/docker-compose-deps.yml @@ -52,6 +52,16 @@ services: - '127.0.0.1:9000:9000' - '127.0.0.1:9001:9001' + minio-region1: + image: 'minio/minio' + command: server /data --console-address ":9001" + restart: always + volumes: + - minio-region1-data:/data + ports: + - '127.0.0.1:9000:9010' + - '127.0.0.1:9001:9011' + # Local OIDC provider for testing keycloak: image: quay.io/keycloak/keycloak:25.0 @@ -127,3 +137,4 @@ volumes: pgadmin-data: redis_insight-data: minio-data: + minio-region1-data: diff --git a/packages/server/multiregion.example.json b/packages/server/multiregion.example.json index 6524e7b4b1..5b69fa32b9 100644 --- a/packages/server/multiregion.example.json +++ b/packages/server/multiregion.example.json @@ -3,6 +3,13 @@ "postgres": { "connectionUri": "postgresql://speckle:speckle@127.0.0.1:5432/speckle", "privateConnectionUri": "postgresql://speckle:speckle@postgres:5432/speckle" + }, + "blobStorage": { + "accessKey": "minioadmin", + "secretKey": "minioadmin", + "bucket": "speckle-server", + "createBucketIfNotExists": "true", + "endpoint": "http://127.0.0.1:9000" } }, "regions": { @@ -10,6 +17,13 @@ "postgres": { "connectionUri": "postgresql://speckle:speckle@127.0.0.1:5401/speckle", "privateConnectionUri": "postgresql://speckle:speckle@postgres-region1:5432/speckle" + }, + "blobStorage": { + "accessKey": "minioadmin", + "secretKey": "minioadmin", + "bucket": "speckle-server", + "createBucketIfNotExists": "true", + "endpoint": "http://127.0.0.1:9010" } } } diff --git a/packages/server/multiregion.test.example.json b/packages/server/multiregion.test.example.json index d3693edd9f..1b92b55981 100644 --- a/packages/server/multiregion.test.example.json +++ b/packages/server/multiregion.test.example.json @@ -3,6 +3,13 @@ "postgres": { "connectionUri": "postgresql://speckle:speckle@127.0.0.1:5432/speckle2_test", "privateConnectionUri": "postgresql://speckle:speckle@postgres:5432/speckle2_test" + }, + "blobStorage": { + "accessKey": "minioadmin", + "secretKey": "minioadmin", + "bucket": "speckle-server", + "createBucketIfNotExists": "true", + "endpoint": "http://127.0.0.1:9000" } }, "regions": { @@ -10,6 +17,13 @@ "postgres": { "connectionUri": "postgresql://speckle:speckle@127.0.0.1:5401/speckle2_test", "privateConnectionUri": "postgresql://speckle:speckle@postgres-region1:5432/speckle2_test" + }, + "blobStorage": { + "accessKey": "minioadmin", + "secretKey": "minioadmin", + "bucket": "speckle-server", + "createBucketIfNotExists": "true", + "endpoint": "http://127.0.0.1:9010" } } } From 97344e085c9568e683ee940094f3ab90cc9bd0ce Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 5 Dec 2024 12:17:36 +0000 Subject: [PATCH 03/68] fix(local dev): use non-conflicting ports (#3641) --- docker-compose-deps.yml | 4 ++-- packages/server/multiregion.example.json | 2 +- packages/server/multiregion.test.example.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose-deps.yml b/docker-compose-deps.yml index 8671cdb4a6..d5068bf4e4 100644 --- a/docker-compose-deps.yml +++ b/docker-compose-deps.yml @@ -59,8 +59,8 @@ services: volumes: - minio-region1-data:/data ports: - - '127.0.0.1:9000:9010' - - '127.0.0.1:9001:9011' + - '127.0.0.1:9020:9000' + - '127.0.0.1:9021:9001' # Local OIDC provider for testing keycloak: diff --git a/packages/server/multiregion.example.json b/packages/server/multiregion.example.json index 5b69fa32b9..fc84bf17cb 100644 --- a/packages/server/multiregion.example.json +++ b/packages/server/multiregion.example.json @@ -23,7 +23,7 @@ "secretKey": "minioadmin", "bucket": "speckle-server", "createBucketIfNotExists": "true", - "endpoint": "http://127.0.0.1:9010" + "endpoint": "http://127.0.0.1:9020" } } } diff --git a/packages/server/multiregion.test.example.json b/packages/server/multiregion.test.example.json index 1b92b55981..3f68d3acdf 100644 --- a/packages/server/multiregion.test.example.json +++ b/packages/server/multiregion.test.example.json @@ -23,7 +23,7 @@ "secretKey": "minioadmin", "bucket": "speckle-server", "createBucketIfNotExists": "true", - "endpoint": "http://127.0.0.1:9010" + "endpoint": "http://127.0.0.1:9020" } } } From ba19d755d3a61a151776cf6ae4321151242d4aa6 Mon Sep 17 00:00:00 2001 From: andrewwallacespeckle <139135120+andrewwallacespeckle@users.noreply.github.com> Date: Thu, 5 Dec 2024 12:31:02 +0000 Subject: [PATCH 04/68] Add custom-help-text prop to TextInput. Fix slug help (#3640) --- .../frontend-2/components/settings/workspaces/General.vue | 1 + packages/ui-components/src/components/form/TextInput.vue | 4 ++++ packages/ui-components/src/composables/form/textInput.ts | 5 +++++ 3 files changed, 10 insertions(+) diff --git a/packages/frontend-2/components/settings/workspaces/General.vue b/packages/frontend-2/components/settings/workspaces/General.vue index 010317e9fc..52e27fa30a 100644 --- a/packages/frontend-2/components/settings/workspaces/General.vue +++ b/packages/frontend-2/components/settings/workspaces/General.vue @@ -33,6 +33,7 @@ read-only :right-icon="disableSlugInput ? undefined : IconEdit" :right-icon-title="disableSlugInput ? undefined : 'Edit short ID'" + custom-help-class="!break-all" @right-icon-click="openSlugEditDialog" />
diff --git a/packages/ui-components/src/components/form/TextInput.vue b/packages/ui-components/src/components/form/TextInput.vue index 998666b3db..b821d2d193 100644 --- a/packages/ui-components/src/components/form/TextInput.vue +++ b/packages/ui-components/src/components/form/TextInput.vue @@ -302,6 +302,10 @@ const props = defineProps({ tooltipText: { type: String, default: undefined + }, + customHelpClass: { + type: String, + default: undefined } }) diff --git a/packages/ui-components/src/composables/form/textInput.ts b/packages/ui-components/src/composables/form/textInput.ts index a9e60bee14..e33be00e60 100644 --- a/packages/ui-components/src/composables/form/textInput.ts +++ b/packages/ui-components/src/composables/form/textInput.ts @@ -30,6 +30,7 @@ export function useTextInputCore(params: { hideErrorMessage?: boolean color?: InputColor labelPosition?: LabelPosition + customHelpClass?: string }> emit: { (e: 'change', val: { event?: Event; value: V }): void @@ -122,12 +123,16 @@ export function useTextInputCore(params: { ) const helpTip = computed(() => errorMessage.value || unref(props.help)) const hasHelpTip = computed(() => !!helpTip.value) + const customHelpTipClass = computed(() => unref(props.customHelpClass)) const helpTipId = computed(() => hasHelpTip.value ? `${unref(props.name)}-${internalHelpTipId.value}` : undefined ) const helpTipClasses = computed((): string => { const classParts = ['text-body-2xs break-words'] classParts.push(hasError.value ? 'text-danger' : 'text-foreground-2') + if (customHelpTipClass.value) { + classParts.push(customHelpTipClass.value) + } return classParts.join(' ') }) const shouldShowClear = computed(() => { From 9b1b1dfb711a5100e5f25fd6d52101f08490f29d Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 5 Dec 2024 13:13:34 +0000 Subject: [PATCH 05/68] feat(server/feature flags): adds multi-region blob storage ff (#3643) --- .circleci/config.yml | 1 + packages/server/package.json | 2 +- packages/shared/src/environment/index.ts | 5 +++++ packages/webhook-service/.vscode/launch.json | 3 ++- utils/helm/speckle-server/templates/_helpers.tpl | 3 +++ .../helm/speckle-server/templates/frontend_2/deployment.yml | 2 ++ 6 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7f3518a129..f94131d7b0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -621,6 +621,7 @@ jobs: MULTI_REGION_CONFIG_PATH: '../../.circleci/multiregion.test-ci.json' FF_WORKSPACES_MODULE_ENABLED: 'true' FF_WORKSPACES_MULTI_REGION_ENABLED: 'true' + FF_WORKSPACES_MULTI_REGION_BLOB_STORAGE_ENABLED: 'true' RUN_TESTS_IN_MULTIREGION_MODE: true test-frontend-2: diff --git a/packages/server/package.json b/packages/server/package.json index 89b6f99aa2..ba21affca9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -23,7 +23,7 @@ "dev:clean": "yarn build:clean && yarn dev", "dev:server:test": "cross-env DISABLE_NOTIFICATIONS_CONSUMPTION=true NODE_ENV=test LOG_LEVEL=silent LOG_PRETTY=true node ./bin/ts-www", "test": "cross-env NODE_ENV=test LOG_LEVEL=silent LOG_PRETTY=true mocha", - "test:multiregion": "cross-env RUN_TESTS_IN_MULTIREGION_MODE=true FF_WORKSPACES_MODULE_ENABLED=true FF_WORKSPACES_MULTI_REGION_ENABLED=true yarn test", + "test:multiregion": "cross-env RUN_TESTS_IN_MULTIREGION_MODE=true FF_WORKSPACES_MODULE_ENABLED=true FF_WORKSPACES_MULTI_REGION_ENABLED=true FF_WORKSPACES_MULTI_REGION_BLOB_STORAGE_ENABLED=true yarn test", "test:no-ff": "cross-env DISABLE_ALL_FFS=true yarn test", "test:coverage": "cross-env NODE_ENV=test LOG_LEVEL=silent LOG_PRETTY=true nyc --reporter lcov mocha", "test:report": "yarn test:coverage -- --reporter mocha-junit-reporter --reporter-options mochaFile=reports/test-results.xml", diff --git a/packages/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index 0f3adc4667..3da8ad3a79 100644 --- a/packages/shared/src/environment/index.ts +++ b/packages/shared/src/environment/index.ts @@ -51,6 +51,10 @@ const parseFeatureFlags = () => { schema: z.boolean(), defaults: { production: false, _: false } }, + FF_WORKSPACES_MULTI_REGION_BLOB_STORAGE_ENABLED: { + schema: z.boolean(), + defaults: { production: false, _: false } + }, // Toggles IFC parsing with experimental .Net parser FF_FILEIMPORT_IFC_DOTNET_ENABLED: { schema: z.boolean(), @@ -79,6 +83,7 @@ export function getFeatureFlags(): { FF_GATEKEEPER_MODULE_ENABLED: boolean FF_BILLING_INTEGRATION_ENABLED: boolean FF_WORKSPACES_MULTI_REGION_ENABLED: boolean + FF_WORKSPACES_MULTI_REGION_BLOB_STORAGE_ENABLED: boolean FF_FILEIMPORT_IFC_DOTNET_ENABLED: boolean } { if (!parsedFlags) parsedFlags = parseFeatureFlags() diff --git a/packages/webhook-service/.vscode/launch.json b/packages/webhook-service/.vscode/launch.json index 9f22179fdb..801e351a0a 100644 --- a/packages/webhook-service/.vscode/launch.json +++ b/packages/webhook-service/.vscode/launch.json @@ -9,7 +9,8 @@ "runtimeArgs": ["dev"], "skipFiles": ["/**"], "env": { - "FF_WORKSPACES_MULTI_REGION_ENABLED": "false" + "FF_WORKSPACES_MULTI_REGION_ENABLED": "false", + "FF_WORKSPACES_MULTI_REGION_BLOB_STORAGE_ENABLED": "false" } } ] diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index 532f331047..3b883fe984 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -589,6 +589,9 @@ Generate the environment variables for Speckle server and Speckle objects deploy - name: FF_WORKSPACES_MULTI_REGION_ENABLED value: {{ .Values.featureFlags.workspacesMultiRegionEnabled | quote }} +- name: FF_WORKSPACES_MULTI_REGION_BLOB_STORAGE_ENABLED + value: {{ .Values.featureFlags.workspacesMultiRegionBlobStorageEnabled | quote }} + {{- if .Values.featureFlags.billingIntegrationEnabled }} - name: STRIPE_API_KEY valueFrom: diff --git a/utils/helm/speckle-server/templates/frontend_2/deployment.yml b/utils/helm/speckle-server/templates/frontend_2/deployment.yml index 00809b8ba7..1b4e3f31ae 100644 --- a/utils/helm/speckle-server/templates/frontend_2/deployment.yml +++ b/utils/helm/speckle-server/templates/frontend_2/deployment.yml @@ -129,6 +129,8 @@ spec: value: {{ .Values.featureFlags.billingIntegrationEnabled | quote }} - name: NUXT_PUBLIC_FF_WORKSPACES_MULTI_REGION_ENABLED value: {{ .Values.featureFlags.workspacesMultiRegionEnabled | quote }} + - name: NUXT_PUBLIC_FF_WORKSPACES_MULTI_REGION_BLOB_STORAGE_ENABLED + value: {{ .Values.featureFlags.workspacesMultiRegionBlobStorageEnabled | quote }} - name: NUXT_PUBLIC_FF_GENDOAI_MODULE_ENABLED value: {{ .Values.featureFlags.gendoAIModuleEnabled | quote }} {{- if .Values.analytics.survicate_workspace_key }} From 8927490797e587f441a924bd41083798a3a73393 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Thu, 5 Dec 2024 13:30:01 +0000 Subject: [PATCH 06/68] chore(automate): track redirects from beta site (#3633) * chore(automate): track redirects from beta site * fix(automate): clear param after processing * fix(automate): clear specific query param --- .../components/automate/functions/page/Header.vue | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/frontend-2/components/automate/functions/page/Header.vue b/packages/frontend-2/components/automate/functions/page/Header.vue index 1cded91745..5115d4b752 100644 --- a/packages/frontend-2/components/automate/functions/page/Header.vue +++ b/packages/frontend-2/components/automate/functions/page/Header.vue @@ -49,6 +49,7 @@ import type { Workspace } from '~/lib/common/generated/gql/graphql' import { workspaceFunctionsRoute, workspaceRoute } from '~/lib/common/helpers/route' +import { useMixpanel } from '~/lib/core/composables/mp' graphql(` fragment AutomateFunctionsPageHeader_Query on Query { @@ -81,6 +82,7 @@ const { on, bind } = useDebouncedTextInput({ model: search }) const { triggerNotification } = useGlobalToast() const route = useRoute() const router = useRouter() +const mixpanel = useMixpanel() const createDialogOpen = ref(false) @@ -126,5 +128,15 @@ if (import.meta.client) { }, { immediate: true } ) + watch( + () => route.query['automateBetaRedirect'] as Nullable, + (isRedirect) => { + if (!isRedirect?.length) return + mixpanel.track('Automate Beta Visit Redirected') + const { automateBetaRedirect, ...query } = route.query + void router.replace({ query }) + }, + { immediate: true } + ) } From dab1bc758cb2966d7fd46223aec76f0ab52ab976 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Thu, 5 Dec 2024 13:30:17 +0000 Subject: [PATCH 07/68] fix(automate): update empty state (#3642) * fix(automate): wip onboarding copy * fix(automate): conditional onboarding buttons --- .../frontend-2/components/common/Card.vue | 27 ++-- .../project/page/automations/EmptyState.vue | 138 ++++++++++++++---- .../project/page/automations/Tab.vue | 87 ++++++++--- .../lib/common/generated/gql/gql.ts | 4 +- .../lib/common/generated/gql/graphql.ts | 4 +- .../lib/projects/graphql/queries.ts | 4 + .../src/helpers/layout/components.ts | 1 + 7 files changed, 204 insertions(+), 61 deletions(-) diff --git a/packages/frontend-2/components/common/Card.vue b/packages/frontend-2/components/common/Card.vue index b697d8b082..7045d794f1 100644 --- a/packages/frontend-2/components/common/Card.vue +++ b/packages/frontend-2/components/common/Card.vue @@ -21,20 +21,25 @@ v-if="buttons" class="flex flex-col flex-wrap md:flex-row gap-y-2 md:gap-x-2 gap-y-0 mt-3" > - - {{ button.text }} - + + {{ button.text }} + + diff --git a/packages/frontend-2/components/project/page/automations/EmptyState.vue b/packages/frontend-2/components/project/page/automations/EmptyState.vue index 40c511603f..d232675967 100644 --- a/packages/frontend-2/components/project/page/automations/EmptyState.vue +++ b/packages/frontend-2/components/project/page/automations/EmptyState.vue @@ -1,27 +1,18 @@ diff --git a/packages/frontend-2/components/project/page/automations/Tab.vue b/packages/frontend-2/components/project/page/automations/Tab.vue index 4d867651f2..ce8d835fc7 100644 --- a/packages/frontend-2/components/project/page/automations/Tab.vue +++ b/packages/frontend-2/components/project/page/automations/Tab.vue @@ -4,7 +4,7 @@ v-model:search="search" :workspace-slug="workspaceSlug" :show-empty-state="shouldShowEmptyState" - :creation-disabled-message="disableCreateMessage" + :creation-disabled-message="disableCreateAutomationMessage" @new-automation="onNewAutomation" /> + diff --git a/packages/frontend-2/components/onboarding/checklist/v1.vue b/packages/frontend-2/components/onboarding/checklist/v1.vue index 0dd619fb67..73e647a221 100644 --- a/packages/frontend-2/components/onboarding/checklist/v1.vue +++ b/packages/frontend-2/components/onboarding/checklist/v1.vue @@ -213,7 +213,7 @@ > - diff --git a/packages/frontend-2/components/settings/server/ActiveUsers.vue b/packages/frontend-2/components/settings/server/ActiveUsers.vue index 3d9150edc9..da61c5a232 100644 --- a/packages/frontend-2/components/settings/server/ActiveUsers.vue +++ b/packages/frontend-2/components/settings/server/ActiveUsers.vue @@ -98,7 +98,7 @@ :user="userToModify" /> - + diff --git a/packages/frontend-2/components/settings/server/PendingInvitations.vue b/packages/frontend-2/components/settings/server/PendingInvitations.vue index 2644c01560..844e2801be 100644 --- a/packages/frontend-2/components/settings/server/PendingInvitations.vue +++ b/packages/frontend-2/components/settings/server/PendingInvitations.vue @@ -70,7 +70,7 @@ @infinite="onInfiniteLoad" /> - + diff --git a/packages/frontend-2/components/settings/server/user/InviteDialog.vue b/packages/frontend-2/components/settings/server/user/InviteDialog.vue deleted file mode 100644 index b8e38bf716..0000000000 --- a/packages/frontend-2/components/settings/server/user/InviteDialog.vue +++ /dev/null @@ -1,136 +0,0 @@ - - diff --git a/packages/frontend-2/components/singleton/ToastManager.vue b/packages/frontend-2/components/singleton/ToastManager.vue index 43499b8f70..616aea4125 100644 --- a/packages/frontend-2/components/singleton/ToastManager.vue +++ b/packages/frontend-2/components/singleton/ToastManager.vue @@ -1,6 +1,6 @@ @@ -8,13 +8,13 @@ import { useGlobalToastManager } from '~~/lib/common/composables/toast' import { GlobalToastRenderer } from '@speckle/ui-components' -const { currentNotification, dismiss } = useGlobalToastManager() +const { currentNotifications, dismissAll, dismiss } = useGlobalToastManager() -const notification = computed({ - get: () => currentNotification.value, +const notifications = computed({ + get: () => currentNotifications.value, set: (newVal) => { if (!newVal) { - dismiss() + dismissAll() } } }) diff --git a/packages/frontend-2/lib/common/composables/toast.ts b/packages/frontend-2/lib/common/composables/toast.ts index 657afacac7..a2b5911c65 100644 --- a/packages/frontend-2/lib/common/composables/toast.ts +++ b/packages/frontend-2/lib/common/composables/toast.ts @@ -1,60 +1,93 @@ -import { useTimeoutFn } from '@vueuse/core' import type { Optional } from '@speckle/shared' import type { ToastNotification } from '@speckle/ui-components' +import { useTimeoutFn } from '@vueuse/core' import { ToastNotificationType } from '@speckle/ui-components' import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie' +import { nanoid } from 'nanoid' /** * Persisting toast state between reqs and between CSR & SSR loads so that we can trigger * toasts anywhere and anytime */ const useGlobalToastState = () => - useSynchronizedCookie>('global-toast-state') + useSynchronizedCookie>('global-toast-state') /** * Set up a new global toast manager/renderer (don't use this in multiple components that live at the same time) */ export function useGlobalToastManager() { + type Timeout = { + id: string + stop: () => void + } + const stateNotification = useGlobalToastState() - const currentNotification = ref(stateNotification.value) - const readOnlyNotification = computed(() => currentNotification.value) + const timeouts = ref([]) + const currentNotifications = ref( + Array.isArray(stateNotification.value) ? stateNotification.value : [] + ) + const readOnlyNotification = computed(() => currentNotifications.value) - const dismiss = () => { - currentNotification.value = undefined - stateNotification.value = undefined + // Remove a specific notification from the state + const removeNotification = (id: string) => { + const index = currentNotifications.value.findIndex((n) => n.id === id) + if (index !== -1) { + currentNotifications.value.splice(index, 1) + // Clean up timeout + timeouts.value = timeouts.value.filter((t) => t.id !== id) + } } - const { start, stop } = useTimeoutFn(() => { - dismiss() - }, 4000) + // Create a timeout for a notification + const createTimeout = (notification: ToastNotification) => { + const { stop } = useTimeoutFn(() => { + if (notification.id) { + removeNotification(notification.id) + } + }, 4000) + return stop + } watch( stateNotification, (newVal) => { if (!newVal) return - if (import.meta.server) { - currentNotification.value = newVal - return - } + currentNotifications.value = newVal - // First dismiss old notification, then set a new one on next tick - // this is so that the old one actually disappears from the screen for the user, - // instead of just having its contents replaced - dismiss() + // Create timeout for the new notification + const index = currentNotifications.value.length - 1 + const lastNotification = newVal[index] - nextTick(() => { - currentNotification.value = newVal - - // (re-)init timeout - stop() - if (newVal.autoClose !== false) start() - }) + if (lastNotification && !lastNotification.autoClose) { + timeouts.value.push({ + id: lastNotification.id as string, + stop: createTimeout(lastNotification) + }) + } }, { deep: true, immediate: true } ) - return { currentNotification: readOnlyNotification, dismiss } + // Function to dismiss a specific notification + const dismiss = (notification: ToastNotification) => { + if (!notification.id) return + + const targetTimeout = timeouts.value.find((t) => t.id === notification.id) + if (targetTimeout) { + targetTimeout.stop() + } + removeNotification(notification.id as string) + } + + // Dismiss all notifications + const dismissAll = () => { + timeouts.value.forEach((timeout) => timeout.stop()) + timeouts.value = [] + currentNotifications.value = [] + } + + return { currentNotifications: readOnlyNotification, dismiss, dismissAll } } /** @@ -68,7 +101,11 @@ export function useGlobalToast() { * Trigger a new toast notification */ const triggerNotification = (notification: ToastNotification) => { - stateNotification.value = notification + const newNotification = { ...notification, id: nanoid() } + + stateNotification.value + ? stateNotification.value.push(newNotification) + : (stateNotification.value = [newNotification]) if (import.meta.server) { logger.info('Queued SSR toast notification', notification) diff --git a/packages/frontend-2/lib/invites/helpers/constants.ts b/packages/frontend-2/lib/invites/helpers/constants.ts new file mode 100644 index 0000000000..d31c358f68 --- /dev/null +++ b/packages/frontend-2/lib/invites/helpers/constants.ts @@ -0,0 +1,8 @@ +import type { InviteServerItem } from '~~/lib/invites/helpers/types' +import { Roles } from '@speckle/shared' + +export const emptyInviteServerItem: InviteServerItem = { + email: '', + serverRole: Roles.Server.User, + project: undefined +} diff --git a/packages/frontend-2/lib/invites/helpers/types.ts b/packages/frontend-2/lib/invites/helpers/types.ts new file mode 100644 index 0000000000..aeae0c16ad --- /dev/null +++ b/packages/frontend-2/lib/invites/helpers/types.ts @@ -0,0 +1,12 @@ +import type { ServerRoles } from '@speckle/shared' +import type { FormSelectProjects_ProjectFragment } from '~~/lib/common/generated/gql/graphql' + +export type InviteServerItem = { + email: string + serverRole: ServerRoles + project?: FormSelectProjects_ProjectFragment +} + +export interface InviteServerForm { + fields: InviteServerItem[] +} diff --git a/packages/frontend-2/lib/projects/composables/projectManagement.ts b/packages/frontend-2/lib/projects/composables/projectManagement.ts index d4520d4677..3049df31f2 100644 --- a/packages/frontend-2/lib/projects/composables/projectManagement.ts +++ b/packages/frontend-2/lib/projects/composables/projectManagement.ts @@ -308,13 +308,19 @@ export function useInviteUserToProject() { if (err) { triggerNotification({ type: ToastNotificationType.Danger, - title: 'Invitation failed', + title: + input.length > 1 + ? "Couldn't send invites" + : `Coudldn't send invite to ${input[0].email}`, description: err }) } else { triggerNotification({ type: ToastNotificationType.Success, - title: 'Invite successfully sent' + title: + input.length > 1 + ? 'Invites successfully send' + : `Invite successfully sent to ${input[0].email}` }) } diff --git a/packages/frontend-2/lib/server/composables/invites.ts b/packages/frontend-2/lib/server/composables/invites.ts index c0b73713dc..cd18c32226 100644 --- a/packages/frontend-2/lib/server/composables/invites.ts +++ b/packages/frontend-2/lib/server/composables/invites.ts @@ -55,13 +55,19 @@ export function useInviteUserToServer() { if (res?.data?.serverInviteBatchCreate) { triggerNotification({ type: ToastNotificationType.Success, - title: `Server invite${finalInput.length > 1 ? 's' : ''} sent` + title: + finalInput.length > 1 + ? 'Server invites sent' + : `Server invite sent to ${finalInput[0].email}` }) } else { const errMsg = getFirstErrorMessage(res?.errors) triggerNotification({ type: ToastNotificationType.Danger, - title: `Couldn't send invite${finalInput.length > 1 ? 's' : ''}`, + title: + finalInput.length > 1 + ? "Couldn't send invites" + : `Couldn't send invite to ${finalInput[0].email}`, description: errMsg }) } diff --git a/packages/ui-components/src/components/form/select/Base.vue b/packages/ui-components/src/components/form/select/Base.vue index 3f988eb635..5c72ffc5f2 100644 --- a/packages/ui-components/src/components/form/select/Base.vue +++ b/packages/ui-components/src/components/form/select/Base.vue @@ -127,7 +127,7 @@ ref="searchInput" v-model="searchValue" type="text" - class="py-1 pl-7 w-full bg-foundation placeholder:font-normal normal placeholder:text-foreground-2 text-[13px]" + class="py-1 pl-7 w-full bg-foundation placeholder:font-normal normal placeholder:text-foreground-2 text-[13px] focus-visible:[box-shadow:none] rounded-md hover:border-outline-5 focus-visible:border-outline-4" :placeholder="searchPlaceholder" @keydown.stop /> diff --git a/packages/ui-components/src/components/global/ToastRenderer.stories.ts b/packages/ui-components/src/components/global/ToastRenderer.stories.ts index ce1e1fe540..fe7b692197 100644 --- a/packages/ui-components/src/components/global/ToastRenderer.stories.ts +++ b/packages/ui-components/src/components/global/ToastRenderer.stories.ts @@ -7,7 +7,7 @@ import { ToastNotificationType } from '~~/src/helpers/global/toast' import type { ToastNotification } from '~~/src/helpers/global/toast' import { useGlobalToast } from '~~/src/stories/composables/toast' -type StoryType = StoryObj<{ notification: ToastNotification }> +type StoryType = StoryObj<{ notifications: ToastNotification[] }> export default { component: ToastRenderer, @@ -20,12 +20,11 @@ export default { } }, argTypes: { - notification: { - description: 'ToastNotification type object, nullable' + notifications: { + description: 'ToastNotification array, nullable' }, - 'update:notification': { - description: - "Notification prop update event. Enables two-way binding through 'v-model:notification'" + dismiss: { + description: 'Dismiss event for a notification' } } } as Meta @@ -35,11 +34,11 @@ export const Default: StoryType = { components: { ToastRenderer, FormButton }, setup() { const { triggerNotification } = useGlobalToast() - const notification = ref(null as Nullable) + const notifications = ref(null as Nullable) const onClick = () => { - triggerNotification(args.notification) + triggerNotification(args.notifications[0]) } - return { args, onClick, notification } + return { args, onClick, notifications } }, template: `
@@ -50,30 +49,34 @@ export const Default: StoryType = { parameters: { docs: { source: { - code: '' + code: '' } } }, args: { - notification: { - type: ToastNotificationType.Info, - title: 'Title', - description: 'Description', + notifications: [ + { + type: ToastNotificationType.Info, + title: 'Title', + description: 'Description', - cta: { - title: 'CTA' + cta: { + title: 'CTA' + } } - } + ] } } export const WithManualClose: StoryType = { ...Default, args: { - notification: { - ...Default.args!.notification!, - autoClose: false - } + notifications: [ + { + ...Default.args!.notifications![0], + autoClose: false + } + ] } } @@ -81,23 +84,20 @@ export const NoCtaOrDescription: StoryObj = { render: (args) => ({ components: { ToastRenderer, FormButton }, setup() { - const notification = ref(null as Nullable) + const { triggerNotification } = useGlobalToast() + const notifications = ref(null as Nullable) const onClick = () => { - // Update notification without cta or description - notification.value = { + triggerNotification({ type: ToastNotificationType.Info, title: 'Displays a toast notification' - } - - // Clear after 2s - setTimeout(() => (notification.value = null), 2000) + }) } - return { args, onClick, notification } + return { args, onClick, notifications } }, template: `
Trigger Title Only - +
` }), @@ -108,8 +108,8 @@ export const NoCtaOrDescription: StoryObj = { }, source: { code: ` -Trigger Title Only - + Trigger Title Only + ` } } diff --git a/packages/ui-components/src/components/global/ToastRenderer.vue b/packages/ui-components/src/components/global/ToastRenderer.vue index 01bdc1a82f..41d68a5016 100644 --- a/packages/ui-components/src/components/global/ToastRenderer.vue +++ b/packages/ui-components/src/components/global/ToastRenderer.vue @@ -5,7 +5,7 @@ >
-
@@ -60,7 +61,7 @@ color="subtle" :to="notification.cta.url" size="sm" - @click="onCtaClick" + @click="(e: MouseEvent) => onCtaClick(notification, e)" > {{ notification.cta.title }} @@ -70,7 +71,7 @@
-
+
@@ -91,29 +92,24 @@ import { InformationCircleIcon, XMarkIcon } from '@heroicons/vue/20/solid' -import { computed } from 'vue' import type { MaybeNullOrUndefined } from '@speckle/shared' import { ToastNotificationType } from '~~/src/helpers/global/toast' import type { ToastNotification } from '~~/src/helpers/global/toast' const emit = defineEmits<{ - (e: 'update:notification', val: MaybeNullOrUndefined): void + (e: 'dismiss', val: ToastNotification): void }>() -const props = defineProps<{ - notification: MaybeNullOrUndefined +defineProps<{ + notifications: MaybeNullOrUndefined }>() -const isTitleOnly = computed( - () => !props.notification?.description && !props.notification?.cta -) - -const dismiss = () => { - emit('update:notification', null) +const dismiss = (notification: ToastNotification) => { + emit('dismiss', notification) } -const onCtaClick = (e: MouseEvent) => { - props.notification?.cta?.onClick?.(e) - dismiss() +const onCtaClick = (notification: ToastNotification, e: MouseEvent) => { + notification.cta?.onClick?.(e) + dismiss(notification) } diff --git a/packages/ui-components/src/helpers/global/toast.ts b/packages/ui-components/src/helpers/global/toast.ts index 7052f86a22..4c070cf69c 100644 --- a/packages/ui-components/src/helpers/global/toast.ts +++ b/packages/ui-components/src/helpers/global/toast.ts @@ -25,4 +25,5 @@ export type ToastNotification = { * Defaults to true */ autoClose?: boolean + id?: string } diff --git a/packages/ui-components/src/stories/components/GlobalToast.vue b/packages/ui-components/src/stories/components/GlobalToast.vue index 38b795ea8e..401583d092 100644 --- a/packages/ui-components/src/stories/components/GlobalToast.vue +++ b/packages/ui-components/src/stories/components/GlobalToast.vue @@ -1,18 +1,18 @@ diff --git a/packages/frontend-2/components/viewer/explode/Menu.vue b/packages/frontend-2/components/viewer/explode/Menu.vue index 494ddc143d..c8112352fb 100644 --- a/packages/frontend-2/components/viewer/explode/Menu.vue +++ b/packages/frontend-2/components/viewer/explode/Menu.vue @@ -1,53 +1,46 @@ + diff --git a/packages/frontend-2/components/viewer/menu/Menu.vue b/packages/frontend-2/components/viewer/menu/Menu.vue new file mode 100644 index 0000000000..23cdb98d1c --- /dev/null +++ b/packages/frontend-2/components/viewer/menu/Menu.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/frontend-2/components/viewer/sun/Menu.vue b/packages/frontend-2/components/viewer/sun/Menu.vue index a7029b3445..8ae8aed371 100644 --- a/packages/frontend-2/components/viewer/sun/Menu.vue +++ b/packages/frontend-2/components/viewer/sun/Menu.vue @@ -1,105 +1,111 @@ + diff --git a/packages/frontend-2/components/viewer/views/Menu.vue b/packages/frontend-2/components/viewer/views/Menu.vue index 3280bebad8..5c6bbfdcb4 100644 --- a/packages/frontend-2/components/viewer/views/Menu.vue +++ b/packages/frontend-2/components/viewer/views/Menu.vue @@ -1,52 +1,48 @@ + From 3b67a51f21bdda6f250f0776ac4c5dc05cdad87e Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:12:57 +0000 Subject: [PATCH 55/68] fix(server): correct type in notifications helper (#3694) --- packages/server/test/notificationsHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/test/notificationsHelper.ts b/packages/server/test/notificationsHelper.ts index 2d70d10aee..fb2d7d6518 100644 --- a/packages/server/test/notificationsHelper.ts +++ b/packages/server/test/notificationsHelper.ts @@ -89,7 +89,7 @@ export function buildNotificationsStateTracker() { * otherwise it might get processed so fast that you miss it */ waitForAck: async (predicate?: (e: AckEvent) => boolean, timeout = 3000) => { - let timeoutRef: NodeJS.Timer + let timeoutRef: NodeJS.Timeout let promiseAckTracker: (e: AckEvent) => void // We start tracking even before promise is created so that we can't possibly miss it From 6d3ba0de221989d87825e44e3ed1be39fe0a7b0f Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:13:20 +0000 Subject: [PATCH 56/68] chore(server): errors for database append additional context (#3698) * chore(server): errors for database append additional context * Refactor --- packages/server/db/migrations.ts | 2 +- .../modules/multiregion/utils/dbSelector.ts | 4 ++ packages/server/modules/shared/errors/base.ts | 5 ++- .../modules/shared/errors/databaseMetadata.ts | 45 +++++++++++++++++++ .../server/modules/shared/errors/index.ts | 26 ++++++++++- .../modules/shared/repositories/roles.ts | 1 + 6 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 packages/server/modules/shared/errors/databaseMetadata.ts diff --git a/packages/server/db/migrations.ts b/packages/server/db/migrations.ts index d2af94d5aa..16b0292f91 100644 --- a/packages/server/db/migrations.ts +++ b/packages/server/db/migrations.ts @@ -7,7 +7,7 @@ export const migrateDbToLatest = async (params: { db: Knex; region: string }) => try { await db.migrate.latest() } catch (err: unknown) { - throw new DatabaseError('Error migrating db to latest for region "{region}".', { + throw new DatabaseError('Error migrating db to latest for region "{region}".', db, { cause: ensureError(err, 'Unknown postgres error'), info: { region } }) diff --git a/packages/server/modules/multiregion/utils/dbSelector.ts b/packages/server/modules/multiregion/utils/dbSelector.ts index 341ccce663..28079219cf 100644 --- a/packages/server/modules/multiregion/utils/dbSelector.ts +++ b/packages/server/modules/multiregion/utils/dbSelector.ts @@ -190,6 +190,7 @@ const setUpUserReplication = async ({ if (!(err instanceof Error)) throw new DatabaseError( 'Could not create publication {pubName} when setting up user replication for region {regionName}', + from.public, { cause: ensureError(err, 'Unknown database error when creating publication'), info: { pubName, regionName } @@ -225,6 +226,7 @@ const setUpUserReplication = async ({ if (!(err instanceof Error)) throw new DatabaseError( 'Could not create subscription {subName} to {pubName} when setting up user replication for region {regionName}', + to.public, { cause: ensureError(err, 'Unknown database error when creating subscription'), info: { subName, pubName, regionName } @@ -249,6 +251,7 @@ const setUpProjectReplication = async ({ if (!(err instanceof Error)) throw new DatabaseError( 'Could not create publication {pubName} when setting up project replication for region {regionName}', + from.public, { cause: ensureError(err, 'Unknown database error when creating publication'), info: { pubName, regionName } @@ -284,6 +287,7 @@ const setUpProjectReplication = async ({ if (!(err instanceof Error)) throw new DatabaseError( 'Could not create subscription {subName} to {pubName} when setting up project replication for region {regionName}', + to.public, { cause: ensureError(err, 'Unknown database error when creating subscription'), info: { subName, pubName, regionName } diff --git a/packages/server/modules/shared/errors/base.ts b/packages/server/modules/shared/errors/base.ts index af9b4ee763..929741c0a5 100644 --- a/packages/server/modules/shared/errors/base.ts +++ b/packages/server/modules/shared/errors/base.ts @@ -1,7 +1,10 @@ import { Merge } from 'type-fest' import { VError, Options, Info } from 'verror' -type ExtendedOptions = Merge }> & { +export type ExtendedOptions = Merge< + Options, + { info?: Partial } +> & { statusCode?: number } diff --git a/packages/server/modules/shared/errors/databaseMetadata.ts b/packages/server/modules/shared/errors/databaseMetadata.ts new file mode 100644 index 0000000000..d69d273ac8 --- /dev/null +++ b/packages/server/modules/shared/errors/databaseMetadata.ts @@ -0,0 +1,45 @@ +import type { Knex } from 'knex' +import { numberOfFreeConnections } from '@/modules/shared/helpers/dbHelper' + +export const retrieveMetadataFromDatabaseClient = ( + dbClient: Knex | undefined +): Record => { + const additionalInfo: Record = {} + if (!dbClient) { + return additionalInfo + } + + const dbClientClient = dbClient.client + + // attempt to get more info about the connection string (without exposing the password!) + try { + const connectionURL = new URL(dbClientClient.config?.connection?.connectionString) + additionalInfo.databaseHost = connectionURL.hostname + additionalInfo.databasePort = connectionURL.port + additionalInfo.databaseUser = connectionURL.username + additionalInfo.databaseOrConnectionPoolName = connectionURL.pathname + .split('/') + .pop() + } catch { + // ignore problems and move on + } + + // attempt to get more info about the state of the connection pool + try { + const connPool = dbClientClient?.pool + additionalInfo.databasePoolConnectionsFree = connPool.numFree() + additionalInfo.databasePoolConnectionsUsed = connPool.numUsed() + additionalInfo.databasePoolConnectionsPendingAcquires = + connPool.numPendingAcquires() + additionalInfo.databasePoolConnectionsPendingCreates = connPool.numPendingCreates() + additionalInfo.databasePoolConnectionsPendingValidations = + connPool.numPendingValidations() + additionalInfo.databasePoolConnectionsRemainingCapacity = numberOfFreeConnections( + dbClient.client + ) + } catch { + // ignore problems and move on + } + + return additionalInfo +} diff --git a/packages/server/modules/shared/errors/index.ts b/packages/server/modules/shared/errors/index.ts index 2fdad32a19..109c629316 100644 --- a/packages/server/modules/shared/errors/index.ts +++ b/packages/server/modules/shared/errors/index.ts @@ -1,4 +1,10 @@ -import { BaseError, Info } from '@/modules/shared/errors/base' +import { + BaseError, + type ExtendedOptions, + type Info +} from '@/modules/shared/errors/base' +import type { Knex } from 'knex' +import { retrieveMetadataFromDatabaseClient } from '@/modules/shared/errors/databaseMetadata' /** * Use this to throw when the request has auth credentials, but they are not sufficient @@ -106,10 +112,26 @@ export class EnvironmentResourceError extends BaseError { static statusCode = 502 } -export class DatabaseError extends EnvironmentResourceError { +export class DatabaseError extends EnvironmentResourceError { static code = 'DATABASE_ERROR' static defaultMessage = 'An error occurred while trying to access the database.' static statusCode = 502 + + constructor( + message?: string | null | undefined, + dbClient?: Knex | undefined, + options: ExtendedOptions | Error | undefined = undefined + ) { + const additionalInfo = retrieveMetadataFromDatabaseClient(dbClient) + + super(message, { + ...options, + info: + options && 'info' in options + ? { ...options?.info, ...additionalInfo } + : additionalInfo + }) + } } export { BaseError } diff --git a/packages/server/modules/shared/repositories/roles.ts b/packages/server/modules/shared/repositories/roles.ts index 26aa1bd729..22223b3408 100644 --- a/packages/server/modules/shared/repositories/roles.ts +++ b/packages/server/modules/shared/repositories/roles.ts @@ -20,6 +20,7 @@ export const getRolesFactory = if (e instanceof Error) throw new DatabaseError( 'Database error occurred while attempting to get Roles', + db, { cause: e } ) throw e From 89caea32ebc4c6008a1c005a38c2acdebdf79b92 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:14:14 +0000 Subject: [PATCH 57/68] chore(knex): adds comments around config choices. No code changes (#3696) --- packages/shared/src/environment/multiRegionConfig.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/environment/multiRegionConfig.ts b/packages/shared/src/environment/multiRegionConfig.ts index 27c504165a..7774126bdc 100644 --- a/packages/shared/src/environment/multiRegionConfig.ts +++ b/packages/shared/src/environment/multiRegionConfig.ts @@ -141,8 +141,10 @@ export const createKnexConfig = ({ pool: { min: 0, max: maxConnections, - acquireTimeoutMillis: 16000, //allows for 3x creation attempts plus idle time between attempts - createTimeoutMillis: 5000 + acquireTimeoutMillis: 16000, // If the maximum number of connections is reached, it wait for 16 seconds trying to acquire an existing connection before throwing a timeout error. + createTimeoutMillis: 5000 // If no existing connection is available and the maximum number of connections is not yet reached, the pool will try to create a new connection for 5 seconds before throwing a timeout error. + // createRetryIntervalMillis: 200, // Irrelevant & ignored because propogateCreateError is true. + // propagateCreateError: true // The propagateCreateError is set to true by default in Knex and throws a TimeoutError if the first create connection to the database fails. Knex recommends that this value is NOT set to false, despite what 'helpful' people on Stackoverflow tell you: https://github.com/knex/knex/issues/3455#issuecomment-535554401 } } } From 663ee0b5a94ddda1a16cf11e9399358147633431 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:40:27 +0000 Subject: [PATCH 58/68] fix(server): app initialization starts metrics after multiregion (#3697) --- packages/server/app.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/server/app.ts b/packages/server/app.ts index c6d69f1301..cd9fb7d80d 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -11,7 +11,7 @@ import compression from 'compression' import cookieParser from 'cookie-parser' import { createTerminus } from '@godaddy/terminus' -import Logging from '@/logging' +import Metrics from '@/logging' import { startupLogger, shutdownLogger, @@ -403,9 +403,6 @@ export async function init() { // Should perhaps be done manually? await migrateDbToLatest({ region: 'main', db: knex }) - // Logging relies on 'regions' table in the database, so much be initialized after migrations - await Logging(app) - app.use(cookieParser()) app.use(DetermineRequestIdMiddleware) app.use(determineClientIpAddressMiddleware) @@ -453,6 +450,11 @@ export async function init() { // Initialize healthchecks const healthchecks = await healthchecksInitFactory()(app, true) + // Metrics relies on 'regions' table in the database, so much be initialized after migrations in the main database ("migrateDbToLatest({ region: 'main'," etc..) + // It also relies on the regional knex clients, which will initialize and run migrations in the respective regions. + // It must be initialized after the multiregion module is initialized in ModulesSetup.init + await Metrics(app) + // Init HTTP server & subscription server const server = http.createServer(app) const subscriptionServer = buildApolloSubscriptionServer(server) From c4def81ae229c355d8d4c70840760a590100c1dc Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:39:40 +0000 Subject: [PATCH 59/68] feat(server options): allow connection timeouts to be configured (#3701) * feat(server options): allow connection timeouts to be configured * feat(postgres config): allow connection parameters to be configured --- packages/fileimport-service/knex.js | 15 +++++-- packages/preview-service/src/clients/knex.ts | 21 +++++++--- packages/preview-service/src/utils/env.ts | 41 ++++++++++++------- packages/server/knexfile.ts | 8 +++- .../modules/shared/helpers/envHelper.ts | 8 ++++ .../src/environment/multiRegionConfig.ts | 10 +++-- packages/webhook-service/src/knex.js | 15 +++++-- .../src/observability/prometheusMetrics.js | 5 ++- .../speckle-server/templates/_helpers.tpl | 4 ++ .../fileimport_service/deployment.yml | 8 ++++ .../templates/preview_service/deployment.yml | 8 ++++ .../templates/webhook_service/deployment.yml | 8 ++++ utils/helm/speckle-server/values.schema.json | 25 +++++++++++ utils/helm/speckle-server/values.yaml | 18 ++++++++ 14 files changed, 160 insertions(+), 34 deletions(-) diff --git a/packages/fileimport-service/knex.js b/packages/fileimport-service/knex.js index aa1729c9a1..ff8285fefb 100644 --- a/packages/fileimport-service/knex.js +++ b/packages/fileimport-service/knex.js @@ -14,8 +14,15 @@ const isDevEnv = process.env.NODE_ENV !== 'production' let dbClients const getDbClients = async () => { if (dbClients) return dbClients - const maxConnections = - parseInt(process.env.POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE) || 1 + const maxConnections = parseInt( + process.env['POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE'] || '1' + ) + const connectionAcquireTimeoutMillis = parseInt( + process.env['POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS'] || '16000' + ) + const connectionCreateTimeoutMillis = parseInt( + process.env['POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS'] || '5000' + ) const configArgs = { migrationDirs: [], @@ -23,7 +30,9 @@ const getDbClients = async () => { isDevOrTestEnv: isDevEnv, logger, maxConnections, - applicationName: 'speckle_fileimport_service' + applicationName: 'speckle_fileimport_service', + connectionAcquireTimeoutMillis, + connectionCreateTimeoutMillis } if (!FF_WORKSPACES_MULTI_REGION_ENABLED) { const mainClient = configureKnexClient( diff --git a/packages/preview-service/src/clients/knex.ts b/packages/preview-service/src/clients/knex.ts index 33dc042362..c66c226aae 100644 --- a/packages/preview-service/src/clients/knex.ts +++ b/packages/preview-service/src/clients/knex.ts @@ -1,5 +1,12 @@ import { knexLogger as logger } from '@/observability/logging.js' -import { getPostgresConnectionString, getPostgresMaxConnections } from '@/utils/env.js' +import { + getConnectionAcquireTimeoutMillis, + getConnectionCreateTimeoutMillis, + getPostgresConnectionString, + getPostgresMaxConnections, + isDevOrTestEnv, + isTest +} from '@/utils/env.js' import Environment from '@speckle/shared/dist/commonjs/environment/index.js' import { loadMultiRegionsConfig, @@ -14,19 +21,21 @@ export type DbClients = Record<'main', ConfiguredKnexClient> & Record let dbClients: DbClients -const isDevEnv = process.env.NODE_ENV === 'development' - export const getDbClients = async () => { if (dbClients) return dbClients const maxConnections = getPostgresMaxConnections() + const connectionAcquireTimeoutMillis = getConnectionAcquireTimeoutMillis() + const connectionCreateTimeoutMillis = getConnectionCreateTimeoutMillis() const configArgs = { migrationDirs: [], - isTestEnv: isDevEnv, - isDevOrTestEnv: isDevEnv, + isTestEnv: isTest(), + isDevOrTestEnv: isDevOrTestEnv(), logger, maxConnections, - applicationName: 'speckle_fileimport_service' + applicationName: 'speckle_fileimport_service', + connectionAcquireTimeoutMillis, + connectionCreateTimeoutMillis } if (!FF_WORKSPACES_MULTI_REGION_ENABLED) { const mainClient = configureKnexClient( diff --git a/packages/preview-service/src/utils/env.ts b/packages/preview-service/src/utils/env.ts index 102cfd5952..6553efe928 100644 --- a/packages/preview-service/src/utils/env.ts +++ b/packages/preview-service/src/utils/env.ts @@ -1,29 +1,40 @@ -export const getAppPort = () => process.env.PORT || '3001' +function getIntFromEnv(envVarKey: string, aDefault = '0'): number { + return parseInt(process.env[envVarKey] || aDefault) +} +function getBooleanFromEnv(envVarKey: string, aDefault = false): boolean { + return ['1', 'true', true].includes(process.env[envVarKey] || aDefault.toString()) +} + +export const getAppPort = () => process.env['PORT'] || '3001' export const getChromiumExecutablePath = () => { if (isDevelopment()) return undefined // use default - return process.env.CHROMIUM_EXECUTABLE_PATH || '/usr/bin/google-chrome-stable' + return process.env['CHROMIUM_EXECUTABLE_PATH'] || '/usr/bin/google-chrome-stable' } export const getHealthCheckFilePath = () => - process.env.HEALTHCHECK_FILE_PATH || '/tmp/last_successful_query' -export const getHost = () => process.env.HOST || '127.0.0.1' -export const getMetricsHost = () => process.env.METRICS_HOST || '127.0.0.1' -export const getLogLevel = () => process.env.LOG_LEVEL || 'info' -export const getMetricsPort = () => process.env.PROMETHEUS_METRICS_PORT || '9094' -export const getNodeEnv = () => process.env.NODE_ENV || 'production' + process.env['HEALTHCHECK_FILE_PATH'] || '/tmp/last_successful_query' +export const getHost = () => process.env['HOST'] || '127.0.0.1' +export const getMetricsHost = () => process.env['METRICS_HOST'] || '127.0.0.1' +export const getLogLevel = () => process.env['LOG_LEVEL'] || 'info' +export const getMetricsPort = () => process.env['PROMETHEUS_METRICS_PORT'] || '9094' +export const getNodeEnv = () => process.env['NODE_ENV'] || 'production' export const getPostgresConnectionString = () => - process.env.PG_CONNECTION_STRING || 'postgres://speckle:speckle@127.0.0.1/speckle' + process.env['PG_CONNECTION_STRING'] || 'postgres://speckle:speckle@127.0.0.1/speckle' export const getPostgresMaxConnections = () => - parseInt(process.env.POSTGRES_MAX_CONNECTIONS_PREVIEW_SERVICE || '2') -export const getPreviewTimeout = () => - parseInt(process.env.PREVIEW_TIMEOUT || '3600000') + getIntFromEnv('POSTGRES_MAX_CONNECTIONS_PREVIEW_SERVICE', '2') +export const getConnectionAcquireTimeoutMillis = () => + getIntFromEnv('POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS', '16000') +export const getConnectionCreateTimeoutMillis = () => + getIntFromEnv('POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS', '5000') +export const getPreviewTimeout = () => getIntFromEnv('PREVIEW_TIMEOUT', '3600000') export const getPuppeteerUserDataDir = () => { if (isDevelopment()) return undefined // use default - return process.env.USER_DATA_DIR || '/tmp/puppeteer' + return process.env['USER_DATA_DIR'] || '/tmp/puppeteer' } export const isDevelopment = () => getNodeEnv() === 'development' || getNodeEnv() === 'dev' -export const isLogPretty = () => process.env.LOG_PRETTY?.toLocaleLowerCase() === 'true' +export const isLogPretty = () => getBooleanFromEnv('LOG_PRETTY') export const isProduction = () => getNodeEnv() === 'production' export const isTest = () => getNodeEnv() === 'test' +export const isDevOrTestEnv = () => isDevelopment() || isTest() export const serviceOrigin = () => `http://${getHost()}:${getAppPort()}` -export const shouldBeHeadless = () => process.env.PREVIEWS_HEADED !== 'true' +export const shouldBeHeadless = () => !getBooleanFromEnv('PREVIEWS_HEADED') diff --git a/packages/server/knexfile.ts b/packages/server/knexfile.ts index 1da749fc1d..2bd579393e 100644 --- a/packages/server/knexfile.ts +++ b/packages/server/knexfile.ts @@ -7,7 +7,9 @@ import { isTestEnv, ignoreMissingMigrations, postgresMaxConnections, - isDevOrTestEnv + isDevOrTestEnv, + postgresConnectionAcquireTimeoutMillis, + postgresConnectionCreateTimeoutMillis } from '@/modules/shared/helpers/envHelper' import { dbLogger as logger } from '@/logging/logging' import { Knex } from 'knex' @@ -80,7 +82,9 @@ const configArgs: KnexConfigArgs = { isDevOrTestEnv: isDevOrTestEnv(), applicationName: 'speckle_server', logger, - maxConnections: postgresMaxConnections() + maxConnections: postgresMaxConnections(), + connectionAcquireTimeoutMillis: postgresConnectionAcquireTimeoutMillis(), + connectionCreateTimeoutMillis: postgresConnectionCreateTimeoutMillis() } const config: Record = { diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index c279a695af..09fb36a24d 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -363,6 +363,14 @@ export function postgresMaxConnections() { return getIntFromEnv('POSTGRES_MAX_CONNECTIONS_SERVER', '4') } +export function postgresConnectionAcquireTimeoutMillis() { + return getIntFromEnv('POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS', '16000') +} + +export function postgresConnectionCreateTimeoutMillis() { + return getIntFromEnv('POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS', '5000') +} + export function highFrequencyMetricsCollectionPeriodMs() { return getIntFromEnv('HIGH_FREQUENCY_METRICS_COLLECTION_PERIOD_MS', '100') } diff --git a/packages/shared/src/environment/multiRegionConfig.ts b/packages/shared/src/environment/multiRegionConfig.ts index 7774126bdc..480d34d702 100644 --- a/packages/shared/src/environment/multiRegionConfig.ts +++ b/packages/shared/src/environment/multiRegionConfig.ts @@ -94,6 +94,8 @@ export type KnexConfigArgs = { logger: Logger maxConnections: number applicationName: string + connectionAcquireTimeoutMillis: number + connectionCreateTimeoutMillis: number } export const createKnexConfig = ({ @@ -103,7 +105,9 @@ export const createKnexConfig = ({ isDevOrTestEnv, logger, maxConnections, - caCertificate + caCertificate, + connectionAcquireTimeoutMillis, + connectionCreateTimeoutMillis }: { connectionString?: string | undefined caCertificate?: string | undefined @@ -141,8 +145,8 @@ export const createKnexConfig = ({ pool: { min: 0, max: maxConnections, - acquireTimeoutMillis: 16000, // If the maximum number of connections is reached, it wait for 16 seconds trying to acquire an existing connection before throwing a timeout error. - createTimeoutMillis: 5000 // If no existing connection is available and the maximum number of connections is not yet reached, the pool will try to create a new connection for 5 seconds before throwing a timeout error. + acquireTimeoutMillis: connectionAcquireTimeoutMillis, // If the maximum number of connections is reached, it wait for 16 seconds trying to acquire an existing connection before throwing a timeout error. + createTimeoutMillis: connectionCreateTimeoutMillis // If no existing connection is available and the maximum number of connections is not yet reached, the pool will try to create a new connection for 5 seconds before throwing a timeout error. // createRetryIntervalMillis: 200, // Irrelevant & ignored because propogateCreateError is true. // propagateCreateError: true // The propagateCreateError is set to true by default in Knex and throws a TimeoutError if the first create connection to the database fails. Knex recommends that this value is NOT set to false, despite what 'helpful' people on Stackoverflow tell you: https://github.com/knex/knex/issues/3455#issuecomment-535554401 } diff --git a/packages/webhook-service/src/knex.js b/packages/webhook-service/src/knex.js index 9daade342e..6d36cbadba 100644 --- a/packages/webhook-service/src/knex.js +++ b/packages/webhook-service/src/knex.js @@ -13,8 +13,15 @@ const isDevEnv = process.env.NODE_ENV !== 'production' let dbClients const getDbClients = async () => { if (dbClients) return dbClients - const maxConnections = - parseInt(process.env.POSTGRES_MAX_CONNECTIONS_WEBHOOK_SERVICE) || 1 + const maxConnections = parseInt( + process.env.POSTGRES_MAX_CONNECTIONS_WEBHOOK_SERVICE || '1' + ) + const connectionAcquireTimeoutMillis = parseInt( + process.env['POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS'] || '16000' + ) + const connectionCreateTimeoutMillis = parseInt( + process.env['POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS'] || '5000' + ) const configArgs = { migrationDirs: [], @@ -22,7 +29,9 @@ const getDbClients = async () => { isDevOrTestEnv: isDevEnv, logger, maxConnections, - applicationName: 'speckle_webhook_service' + applicationName: 'speckle_webhook_service', + connectionAcquireTimeoutMillis, + connectionCreateTimeoutMillis } if (!FF_WORKSPACES_MULTI_REGION_ENABLED) { const mainClient = configureKnexClient( diff --git a/packages/webhook-service/src/observability/prometheusMetrics.js b/packages/webhook-service/src/observability/prometheusMetrics.js index 51094dbcc4..6a260c04a1 100644 --- a/packages/webhook-service/src/observability/prometheusMetrics.js +++ b/packages/webhook-service/src/observability/prometheusMetrics.js @@ -70,8 +70,9 @@ async function initKnexPrometheusMetrics() { name: 'speckle_server_knex_remaining_capacity', help: 'Remaining capacity of the DB connection pool', collect() { - const postgresMaxConnections = - parseInt(process.env.POSTGRES_MAX_CONNECTIONS_WEBHOOK_SERVICE) || 1 + const postgresMaxConnections = parseInt( + process.env.POSTGRES_MAX_CONNECTIONS_WEBHOOK_SERVICE || '1' + ) const demand = knex.client.pool.numUsed() + knex.client.pool.numPendingCreates() + diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index 3b883fe984..07add86cee 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -770,6 +770,10 @@ Generate the environment variables for Speckle server and Speckle objects deploy key: {{ default "postgres_url" .Values.db.connectionString.secretKey }} - name: POSTGRES_MAX_CONNECTIONS_SERVER value: {{ .Values.db.maxConnectionsServer | quote }} +- name: POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS + value: {{ .Values.db.connectionCreateTimeoutMillis | quote }} +- name: POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS + value: {{ .Values.db.connectionAcquireTimeoutMillis | quote }} - name: PGSSLMODE value: "{{ .Values.db.PGSSLMODE }}" diff --git a/utils/helm/speckle-server/templates/fileimport_service/deployment.yml b/utils/helm/speckle-server/templates/fileimport_service/deployment.yml index b655763a25..8d851f3372 100644 --- a/utils/helm/speckle-server/templates/fileimport_service/deployment.yml +++ b/utils/helm/speckle-server/templates/fileimport_service/deployment.yml @@ -80,6 +80,14 @@ spec: secretKeyRef: name: {{ default .Values.secretName .Values.db.connectionString.secretName }} key: {{ default "postgres_url" .Values.db.connectionString.secretKey }} + {{- if .Values.fileimport_service.postgresMaxConnections }} + - name: POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE + value: {{ .Values.fileimport_service.postgresMaxConnections | quote }} + {{- end }} + - name: POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS + value: {{ .Values.db.connectionCreateTimeoutMillis | quote }} + - name: POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS + value: {{ .Values.db.connectionAcquireTimeoutMillis | quote }} - name: LOG_LEVEL value: {{ .Values.fileimport_service.logLevel | quote }} diff --git a/utils/helm/speckle-server/templates/preview_service/deployment.yml b/utils/helm/speckle-server/templates/preview_service/deployment.yml index 043acdf76e..e62e2e3faf 100644 --- a/utils/helm/speckle-server/templates/preview_service/deployment.yml +++ b/utils/helm/speckle-server/templates/preview_service/deployment.yml @@ -82,6 +82,14 @@ spec: secretKeyRef: name: {{ default .Values.secretName .Values.db.connectionString.secretName }} key: {{ default "postgres_url" .Values.db.connectionString.secretKey }} + {{- if .Values.preview_service.postgresMaxConnections }} + - name: POSTGRES_MAX_CONNECTIONS_PREVIEW_SERVICE + value: {{ .Values.preview_service.postgresMaxConnections | quote }} + {{- end }} + - name: POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS + value: {{ .Values.db.connectionCreateTimeoutMillis | quote }} + - name: POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS + value: {{ .Values.db.connectionAcquireTimeoutMillis | quote }} - name: LOG_LEVEL value: {{ .Values.preview_service.logLevel | quote }} diff --git a/utils/helm/speckle-server/templates/webhook_service/deployment.yml b/utils/helm/speckle-server/templates/webhook_service/deployment.yml index 7637fa7ee7..b1e0e3b8e4 100644 --- a/utils/helm/speckle-server/templates/webhook_service/deployment.yml +++ b/utils/helm/speckle-server/templates/webhook_service/deployment.yml @@ -74,6 +74,14 @@ spec: secretKeyRef: name: {{ default .Values.secretName .Values.db.connectionString.secretName }} key: {{ default "postgres_url" .Values.db.connectionString.secretKey }} + {{- if .Values.webhook_service.postgresMaxConnections }} + - name: POSTGRES_MAX_CONNECTIONS_WEBHOOK_SERVICE + value: {{ .Values.webhook_service.postgresMaxConnections | quote }} + {{- end }} + - name: POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS + value: {{ .Values.db.connectionCreateTimeoutMillis | quote }} + - name: POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS + value: {{ .Values.db.connectionAcquireTimeoutMillis | quote }} - name: LOG_LEVEL value: {{ .Values.webhook_service.logLevel }} diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index dafd836a48..ac9a4959ff 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -236,6 +236,16 @@ "description": "This defines the level of security used when connecting to the Postgres database", "default": "require" }, + "connectionAcquisitionTimeoutMillis": { + "type": "number", + "description": "The maximum time in milliseconds to wait for a connection to be acquired from the connection pool.", + "default": 15000 + }, + "connectionCreationTimeoutMillis": { + "type": "number", + "description": "The maximum time in milliseconds to wait for a new connection to be created in the connection pool. Should be less than the acquisition timeout, as a new connection may need to be created then acquired.", + "default": 5000 + }, "connectionString": { "type": "object", "properties": { @@ -1881,6 +1891,11 @@ } } }, + "postgresMaxConnections": { + "type": "number", + "description": "The maximum number of connections that the Preview Service postgres client will make to the Postgres database.", + "default": 2 + }, "puppeteer": { "type": "object", "properties": { @@ -1993,6 +2008,11 @@ "description": "The Docker image to be used for the Speckle Webhook Service component. If blank, defaults to speckle/speckle-webhook-service:{{ .Values.docker_image_tag }}. If provided, this value should be the full path including tag. The docker_image_tag value will be ignored.", "default": "" }, + "postgresMaxConnections": { + "type": "number", + "description": "The maximum number of connections that the Webhook Service postgres client will make to the Postgres database.", + "default": 1 + }, "requests": { "type": "object", "properties": { @@ -2090,6 +2110,11 @@ "description": "The Docker image to be used for the Speckle FileImport Service component. If blank, defaults to speckle/speckle-fileimport-service:{{ .Values.docker_image_tag }}. If provided, this value should be the full path including tag. The docker_image_tag value will be ignored.", "default": "" }, + "postgresMaxConnections": { + "type": "number", + "description": "The maximum number of connections that the File Import Service postgres client will make to the Postgres database.", + "default": 1 + }, "requests": { "type": "object", "properties": { diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 2076ed2e38..6c07080994 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -190,6 +190,13 @@ db: ## ref: https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-PROTECTION ## PGSSLMODE: require + ## @param db.connectionAcquisitionTimeoutMillis The maximum time in milliseconds to wait for a connection to be acquired from the connection pool. + ## + connectionAcquisitionTimeoutMillis: 15000 + ## @param db.connectionCreationTimeoutMillis The maximum time in milliseconds to wait for a new connection to be created in the connection pool. Should be less than the acquisition timeout, as a new connection may need to be created then acquired. + ## + connectionCreationTimeoutMillis: 5000 + connectionString: ## @param db.connectionString.secretName Required. A secret containing the full connection string to the Postgres database (e.g. in format of `protocol://username:password@host:port/database`) stored within the Kubernetes cluster as an opaque Kubernetes Secret. Ref: https://kubernetes.io/docs/concepts/configuration/secret/#opaque-secrets ## @@ -1136,6 +1143,10 @@ preview_service: ## @param preview_service.monitoring.metricsPort The port on which the metrics server will be exposed. metricsPort: '9094' + ## @param preview_service.postgresMaxConnections The maximum number of connections that the Preview Service postgres client will make to the Postgres database. + ## + postgresMaxConnections: 2 + puppeteer: ## @param preview_service.puppeteer.userDataDirectory The path to the user data directory. If not set, defaults to '/tmp/puppeteer'. This is mounted in the deployment as a volume with read-write access. userDataDirectory: '' @@ -1211,6 +1222,9 @@ webhook_service: ## @param webhook_service.image The Docker image to be used for the Speckle Webhook Service component. If blank, defaults to speckle/speckle-webhook-service:{{ .Values.docker_image_tag }}. If provided, this value should be the full path including tag. The docker_image_tag value will be ignored. ## image: '' + ## @param webhook_service.postgresMaxConnections The maximum number of connections that the Webhook Service postgres client will make to the Postgres database. + ## + postgresMaxConnections: 1 requests: ## @param webhook_service.requests.cpu The CPU that should be available on a node when scheduling this pod. @@ -1283,6 +1297,10 @@ fileimport_service: ## image: '' + ## @param fileimport_service.postgresMaxConnections The maximum number of connections that the File Import Service postgres client will make to the Postgres database. + ## + postgresMaxConnections: 1 + requests: ## @param fileimport_service.requests.cpu The CPU that should be available on a node when scheduling this pod. ## ref: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ From 1f7620a2815e707717046a61b06f933d66384bfa Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:40:29 +0000 Subject: [PATCH 60/68] chore(logging): log migration of databases (#3700) - tidy up Database error handling context data --- packages/server/db/migrations.ts | 24 +++++++++++++++++++ .../modules/shared/errors/databaseMetadata.ts | 6 ++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/server/db/migrations.ts b/packages/server/db/migrations.ts index 16b0292f91..fee45792ff 100644 --- a/packages/server/db/migrations.ts +++ b/packages/server/db/migrations.ts @@ -1,11 +1,20 @@ import { Knex } from 'knex' import { DatabaseError } from '@/modules/shared/errors' import { ensureError } from '@speckle/shared' +import { logger } from '@/logging/logging' export const migrateDbToLatest = async (params: { db: Knex; region: string }) => { const { db, region } = params try { + const endStopWatch = stopWatch() + await db.migrate.latest() + + const durationMs = endStopWatch().milliseconds + logger.info( + { region, durationMs }, + 'Migrated db to latest for region "{region}" in {durationMs}ms.' + ) } catch (err: unknown) { throw new DatabaseError('Error migrating db to latest for region "{region}".', db, { cause: ensureError(err, 'Unknown postgres error'), @@ -13,3 +22,18 @@ export const migrateDbToLatest = async (params: { db: Knex; region: string }) => }) } } + +const stopWatch = () => { + const start = process.hrtime.bigint() + return () => { + const end = process.hrtime.bigint() + const elapsedNanoseconds = Number(end - start) + const elapsedMs = elapsedNanoseconds / 1e6 + const elapsedS = elapsedNanoseconds / 1e9 + return { + nanoSeconds: elapsedNanoseconds, + milliseconds: elapsedMs, + seconds: elapsedS + } + } +} diff --git a/packages/server/modules/shared/errors/databaseMetadata.ts b/packages/server/modules/shared/errors/databaseMetadata.ts index d69d273ac8..a3cbdd22c6 100644 --- a/packages/server/modules/shared/errors/databaseMetadata.ts +++ b/packages/server/modules/shared/errors/databaseMetadata.ts @@ -27,16 +27,14 @@ export const retrieveMetadataFromDatabaseClient = ( // attempt to get more info about the state of the connection pool try { const connPool = dbClientClient?.pool - additionalInfo.databasePoolConnectionsFree = connPool.numFree() additionalInfo.databasePoolConnectionsUsed = connPool.numUsed() additionalInfo.databasePoolConnectionsPendingAcquires = connPool.numPendingAcquires() additionalInfo.databasePoolConnectionsPendingCreates = connPool.numPendingCreates() additionalInfo.databasePoolConnectionsPendingValidations = connPool.numPendingValidations() - additionalInfo.databasePoolConnectionsRemainingCapacity = numberOfFreeConnections( - dbClient.client - ) + additionalInfo.databasePoolConnectionsRemainingCapacity = + numberOfFreeConnections(dbClient) } catch { // ignore problems and move on } From 0115e654b70859c1874b36798ab2dbeb95401b9a Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Tue, 17 Dec 2024 09:36:39 +0000 Subject: [PATCH 61/68] feat(database monitor): handles multi-region and connection pooling (#3685) --- .circleci/config.yml | 2 - .gitignore | 4 +- packages/fileimport-service/Dockerfile | 2 +- packages/monitor-deployment/.env.example | 16 ++ packages/monitor-deployment/Dockerfile | 51 +++++ packages/monitor-deployment/README.md | 23 ++ packages/monitor-deployment/bin/www.js | 2 + packages/monitor-deployment/eslint.config.mjs | 61 +++++ .../multiregion.example.json | 34 +++ packages/monitor-deployment/package.json | 67 ++++++ .../monitor-deployment/src/aliasLoader.ts | 6 + packages/monitor-deployment/src/bin.ts | 9 + packages/monitor-deployment/src/bootstrap.ts | 2 + .../monitor-deployment/src/clients/knex.ts | 95 ++++++++ .../monitor-deployment/src/domain/const.ts | 1 + .../src/observability/expressLogging.ts | 93 ++++++++ .../src/observability/logging.ts | 14 ++ .../src/observability/metrics/commits.ts | 37 +++ .../src/observability/metrics/dbSize.ts | 47 ++++ .../src/observability/metrics/dbWorkers.ts | 33 +++ .../metrics/dbWorkersAwaitingLocks.ts | 33 +++ .../src/observability/metrics/fileImports.ts | 90 ++++++++ .../src/observability/metrics/fileSize.ts | 41 ++++ .../metrics/inactiveReplicationSlots.ts | 35 +++ .../observability/metrics/maxConnections.ts | 35 +++ .../src/observability/metrics/objects.ts | 38 ++++ .../src/observability/metrics/previews.ts | 46 ++++ .../metrics/replicationSlotLag.ts | 38 ++++ .../metrics/replicationWorkerLag.ts | 109 +++++++++ .../src/observability/metrics/streams.ts | 29 +++ .../metrics/subscriptionsEnabled.ts | 65 ++++++ .../src/observability/metrics/tableSize.ts | 46 ++++ .../src/observability/metrics/users.ts | 28 +++ .../src/observability/metrics/webhooks.ts | 47 ++++ .../src/observability/metricsApp.ts | 29 +++ .../src/observability/metricsRoute.ts | 15 ++ .../src/observability/prometheusMetrics.ts | 164 ++++++++++++++ .../src/observability/types.ts | 13 ++ packages/monitor-deployment/src/root.ts | 21 ++ .../src/server/routes/index.ts | 13 ++ .../monitor-deployment/src/server/server.ts | 88 ++++++++ packages/monitor-deployment/src/utils/env.ts | 33 +++ .../src/utils/errorHandler.ts | 25 +++ .../monitor-deployment/tsconfig.build.json | 5 + packages/monitor-deployment/tsconfig.json | 108 +++++++++ packages/monitor-deployment/vitest.config.ts | 18 ++ packages/preview-service/src/clients/knex.ts | 2 +- .../src/environment/multiRegionConfig.ts | 6 + .../templates/monitoring/deployment.yml | 44 +++- .../templates/monitoring/service.yml | 2 +- utils/helm/speckle-server/values.schema.json | 20 ++ utils/helm/speckle-server/values.yaml | 14 ++ utils/monitor-deployment/Dockerfile | 33 --- utils/monitor-deployment/dev_run.sh | 10 - utils/monitor-deployment/requirements.txt | 3 - utils/monitor-deployment/src/run.py | 211 ------------------ yarn.lock | 48 +++- 57 files changed, 1939 insertions(+), 265 deletions(-) create mode 100644 packages/monitor-deployment/.env.example create mode 100644 packages/monitor-deployment/Dockerfile create mode 100644 packages/monitor-deployment/README.md create mode 100755 packages/monitor-deployment/bin/www.js create mode 100644 packages/monitor-deployment/eslint.config.mjs create mode 100644 packages/monitor-deployment/multiregion.example.json create mode 100644 packages/monitor-deployment/package.json create mode 100644 packages/monitor-deployment/src/aliasLoader.ts create mode 100644 packages/monitor-deployment/src/bin.ts create mode 100644 packages/monitor-deployment/src/bootstrap.ts create mode 100644 packages/monitor-deployment/src/clients/knex.ts create mode 100644 packages/monitor-deployment/src/domain/const.ts create mode 100644 packages/monitor-deployment/src/observability/expressLogging.ts create mode 100644 packages/monitor-deployment/src/observability/logging.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/commits.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/dbSize.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/dbWorkers.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/dbWorkersAwaitingLocks.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/fileImports.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/fileSize.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/inactiveReplicationSlots.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/maxConnections.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/objects.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/previews.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/replicationSlotLag.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/replicationWorkerLag.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/streams.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/subscriptionsEnabled.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/tableSize.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/users.ts create mode 100644 packages/monitor-deployment/src/observability/metrics/webhooks.ts create mode 100644 packages/monitor-deployment/src/observability/metricsApp.ts create mode 100644 packages/monitor-deployment/src/observability/metricsRoute.ts create mode 100644 packages/monitor-deployment/src/observability/prometheusMetrics.ts create mode 100644 packages/monitor-deployment/src/observability/types.ts create mode 100644 packages/monitor-deployment/src/root.ts create mode 100644 packages/monitor-deployment/src/server/routes/index.ts create mode 100644 packages/monitor-deployment/src/server/server.ts create mode 100644 packages/monitor-deployment/src/utils/env.ts create mode 100644 packages/monitor-deployment/src/utils/errorHandler.ts create mode 100644 packages/monitor-deployment/tsconfig.build.json create mode 100644 packages/monitor-deployment/tsconfig.json create mode 100644 packages/monitor-deployment/vitest.config.ts delete mode 100644 utils/monitor-deployment/Dockerfile delete mode 100755 utils/monitor-deployment/dev_run.sh delete mode 100644 utils/monitor-deployment/requirements.txt delete mode 100644 utils/monitor-deployment/src/run.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 9a7eda562c..78c50c415c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1049,7 +1049,6 @@ jobs: docker-build-monitor-container: <<: *build-job environment: - FOLDER: utils SPECKLE_SERVER_PACKAGE: monitor-deployment docker-build-docker-compose-ingress: @@ -1119,7 +1118,6 @@ jobs: docker-publish-monitor-container: <<: *publish-job environment: - FOLDER: utils SPECKLE_SERVER_PACKAGE: monitor-deployment docker-publish-docker-compose-ingress: diff --git a/.gitignore b/.gitignore index 8c373e06f6..449785fc3b 100644 --- a/.gitignore +++ b/.gitignore @@ -74,7 +74,9 @@ redis-data/ .tshy-build obj/ bin/ - +!packages/monitor-deployment/bin +!packages/preview-service/bin +!packages/server/bin # Server multiregion.json diff --git a/packages/fileimport-service/Dockerfile b/packages/fileimport-service/Dockerfile index 088f024c2e..2a893ef635 100644 --- a/packages/fileimport-service/Dockerfile +++ b/packages/fileimport-service/Dockerfile @@ -20,7 +20,7 @@ RUN chmod +x /usr/bin/tini RUN apt-get update -y \ && DEBIAN_FRONTEND=noninteractive apt-get install -y \ --no-install-recommends \ - curl=8.5.0-2ubuntu10.5 \ + curl=8.5.0-2ubuntu10.6 \ && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ && DEBIAN_FRONTEND=noninteractive apt-get install -y \ --no-install-recommends \ diff --git a/packages/monitor-deployment/.env.example b/packages/monitor-deployment/.env.example new file mode 100644 index 0000000000..920c795dee --- /dev/null +++ b/packages/monitor-deployment/.env.example @@ -0,0 +1,16 @@ +PG_CONNECTION_STRING='postgres://speckle:speckle@127.0.0.1/speckle' +POSTGRES_MAX_CONNECTIONS='2' +PROMETHEUS_METRICS_PORT='9092' +LOG_LEVEL='info' +LOG_PRETTY='true' +METRICS_COLLECTION_PERIOD_SECONDS='120' +FF_WORKSPACES_MULTI_REGION_ENABLED='false' + +# Enable this if you want to use a custom CA certificate for the connection to the database +# You also have to save it in a file and set NODE_EXTRA_CA_CERTS when running `yarn start` or `yarn dev`` +# POSTGRES_CA_CERTIFICATE='-----BEGIN CERTIFICATE----- +# XXXX +# -----END CERTIFICATE-----' + +# Used if the database name to be queried is different from the path in the connection string, which is the case for connection pools +# POSTGRES_DATABASE='speckle' diff --git a/packages/monitor-deployment/Dockerfile b/packages/monitor-deployment/Dockerfile new file mode 100644 index 0000000000..19e296d22d --- /dev/null +++ b/packages/monitor-deployment/Dockerfile @@ -0,0 +1,51 @@ +# NOTE: Docker context must be the git root directory, to include the shared directory +ARG NODE_ENV=production + +FROM node:18-bookworm-slim@sha256:408f8cbbb7b33a5bb94bdb8862795a94d2b64c2d516856824fd86c4a5594a443 AS build-stage + +WORKDIR /speckle-server + +# Download tini +ARG TINI_VERSION=v0.19.0 +ENV TINI_VERSION=${TINI_VERSION} +ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini +RUN chmod +x ./tini + +ARG NODE_ENV +ENV NODE_ENV=${NODE_ENV} + +WORKDIR /speckle-server + +COPY .yarnrc.yml . +COPY .yarn ./.yarn +COPY package.json yarn.lock ./ + +# Only copy in the relevant package.json files for the dependencies +COPY packages/shared/package.json ./packages/shared/ +COPY packages/monitor-deployment/package.json ./packages/monitor-deployment/ + +RUN yarn workspaces focus -A && yarn install + +# Only copy in the relevant source files for the dependencies +COPY packages/shared ./packages/shared/ +COPY packages/monitor-deployment ./packages/monitor-deployment/ + +RUN yarn workspaces foreach -W run build + +WORKDIR /speckle-server/packages/monitor-deployment +RUN yarn workspaces focus --production + +FROM gcr.io/distroless/nodejs18-debian12:nonroot@sha256:afdea027580f7afcaf1f316b2b3806690c297cb3ce6ddc5cf6a15804dc1c790f AS production-stage + +ARG NODE_ENV +ENV NODE_ENV=${NODE_ENV} + +WORKDIR /speckle-server +COPY --from=build-stage /speckle-server/tini /usr/bin/tini +COPY --from=build-stage /speckle-server/packages/shared ./packages/shared +COPY --from=build-stage /speckle-server/packages/monitor-deployment ./packages/monitor-deployment +COPY --from=build-stage /speckle-server/node_modules ./node_modules + +WORKDIR /speckle-server/packages/monitor-deployment + +ENTRYPOINT [ "tini", "--", "/nodejs/bin/node", "--loader=./dist/src/aliasLoader.js", "bin/www.js" ] diff --git a/packages/monitor-deployment/README.md b/packages/monitor-deployment/README.md new file mode 100644 index 0000000000..7cb8e3b861 --- /dev/null +++ b/packages/monitor-deployment/README.md @@ -0,0 +1,23 @@ +# Database Monitor + +Responsible for querying all databases and generating metrics. + +Metrics are available at `/metrics` endpoint and are in Prometheus format. + +## Development + +```bash +yarn dev +``` + +## Databases with self-signed certificates + +Add the self-signed CA certificate to a file at `packages/monitor-deployment/ca-certificate.crt` + +Run `NODE_EXTRA_CA_CERTS=./ca-certificate.crt yarn dev` or `NODE_EXTRA_CA_CERTS=./ca-certificate.crt yarn start` + +## Production + +```bash +yarn start +``` diff --git a/packages/monitor-deployment/bin/www.js b/packages/monitor-deployment/bin/www.js new file mode 100755 index 0000000000..5bbe596274 --- /dev/null +++ b/packages/monitor-deployment/bin/www.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/src/bin.js' diff --git a/packages/monitor-deployment/eslint.config.mjs b/packages/monitor-deployment/eslint.config.mjs new file mode 100644 index 0000000000..884f851ba5 --- /dev/null +++ b/packages/monitor-deployment/eslint.config.mjs @@ -0,0 +1,61 @@ +import tseslint from 'typescript-eslint' +import { + baseConfigs, + getESMDirname, + globals, + prettierConfig +} from '../../eslint.config.mjs' + +const configs = [ + ...baseConfigs, + { + ignores: ['dist', 'public', 'docs'] + }, + { + files: ['**/*.js'], + ignores: ['**/*.mjs'], + languageOptions: { + sourceType: 'module', + globals: { + ...globals.node + } + } + }, + { + files: ['bin/www'], + languageOptions: { + sourceType: 'module', + globals: { + ...globals.node + } + } + }, + ...tseslint.configs.recommendedTypeChecked.map((c) => ({ + ...c, + files: [...(c.files || []), '**/*.ts', '**/*.d.ts'] + })), + { + files: ['**/*.ts', '**/*.d.ts'], + languageOptions: { + parserOptions: { + tsconfigRootDir: getESMDirname(import.meta.url), + project: './tsconfig.json' + } + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-return': 'error' + } + }, + { + files: ['**/*.spec.{js,ts}'], + languageOptions: { + globals: { + ...globals.node + } + } + }, + prettierConfig +] + +export default configs diff --git a/packages/monitor-deployment/multiregion.example.json b/packages/monitor-deployment/multiregion.example.json new file mode 100644 index 0000000000..61bd76bf9a --- /dev/null +++ b/packages/monitor-deployment/multiregion.example.json @@ -0,0 +1,34 @@ +{ + "main": { + "postgres": { + "connectionUri": "postgresql://speckle:speckle@127.0.0.1:5432/speckle", + "privateConnectionUri": "postgresql://speckle:speckle@postgres:5432/speckle", + "databaseName": "speckle" + }, + "blobStorage": { + "accessKey": "minioadmin", + "secretKey": "minioadmin", + "bucket": "speckle-server", + "createBucketIfNotExists": true, + "endpoint": "http://127.0.0.1:9000", + "s3Region": "us-east-1" + } + }, + "regions": { + "region1": { + "postgres": { + "connectionUri": "postgresql://speckle:speckle@127.0.0.1:5401/speckle", + "privateConnectionUri": "postgresql://speckle:speckle@postgres-region1:5432/speckle", + "databaseName": "speckle" + }, + "blobStorage": { + "accessKey": "minioadmin", + "secretKey": "minioadmin", + "bucket": "speckle-server", + "createBucketIfNotExists": true, + "endpoint": "http://127.0.0.1:9020", + "s3Region": "us-east-1" + } + } + } +} diff --git a/packages/monitor-deployment/package.json b/packages/monitor-deployment/package.json new file mode 100644 index 0000000000..ae6fbc60bc --- /dev/null +++ b/packages/monitor-deployment/package.json @@ -0,0 +1,67 @@ +{ + "name": "@speckle/monitor-deployment", + "private": true, + "version": "2.5.4", + "description": "Query connected databases and generate metrics.", + "main": "bin/www", + "homepage": "https://speckle.systems", + "repository": { + "type": "git", + "url": "https://github.com/specklesystems/speckle-server.git", + "directory": "packages/monitor-deployment" + }, + "type": "module", + "engines": { + "node": "^18.19.0" + }, + "scripts": { + "build:tsc:watch": "tsc -p ./tsconfig.build.json --watch", + "run:watch": "NODE_ENV=development LOG_PRETTY=true LOG_LEVEL=debug nodemon --exec \"yarn start\" --trace-deprecation --watch ./bin/www.js --watch ./dist", + "dev": "concurrently \"npm:build:tsc:watch\" \"npm:run:watch\"", + "dev:headed": "yarn dev", + "build:tsc": "rimraf ./dist/src && tsc -p ./tsconfig.build.json", + "build": "yarn build:tsc", + "lint": "yarn lint:tsc && yarn lint:eslint", + "lint:ci": "yarn lint:tsc", + "lint:tsc": "tsc --noEmit", + "lint:eslint": "eslint .", + "start": "node --loader=./dist/src/aliasLoader.js ./bin/www.js", + "test": "NODE_ENV=test LOG_LEVEL=silent LOG_PRETTY=true vitest run --sequence.shuffle" + }, + "dependencies": { + "@speckle/shared": "workspace:^", + "crypto": "^1.0.1", + "dotenv": "^16.4.5", + "esm-module-alias": "^2.2.0", + "express": "^4.19.2", + "http-errors": "~1.6.3", + "knex": "^2.4.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "pg": "^8.7.3", + "pino": "^8.7.0", + "pino-http": "^8.2.1", + "pino-pretty": "^9.1.1", + "prom-client": "^14.0.1", + "znv": "^0.4.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/express": "^4.17.13", + "@types/http-errors": "^2.0.4", + "@types/lodash-es": "^4.17.6", + "@types/node": "^18.19.38", + "@vitest/coverage-istanbul": "^1.6.0", + "concurrently": "^8.2.2", + "crypto-random-string": "^5.0.0", + "eslint": "^9.4.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-vitest": "^0.5.4", + "nodemon": "^2.0.20", + "prettier": "^2.5.1", + "rimraf": "^5.0.7", + "typescript": "^4.6.4", + "typescript-eslint": "^7.12.0", + "vitest": "^1.6.0" + } +} diff --git a/packages/monitor-deployment/src/aliasLoader.ts b/packages/monitor-deployment/src/aliasLoader.ts new file mode 100644 index 0000000000..d77980f8da --- /dev/null +++ b/packages/monitor-deployment/src/aliasLoader.ts @@ -0,0 +1,6 @@ +import generateAliasesResolver from 'esm-module-alias' +import { srcRoot } from './root.js' + +export const resolve = generateAliasesResolver({ + '@': srcRoot +}) diff --git a/packages/monitor-deployment/src/bin.ts b/packages/monitor-deployment/src/bin.ts new file mode 100644 index 0000000000..2ec8a8f77b --- /dev/null +++ b/packages/monitor-deployment/src/bin.ts @@ -0,0 +1,9 @@ +import '@/bootstrap.js' // This has side-effects and has to be imported first + +import { startServer } from '@/server/server.js' + +const start = () => { + startServer() +} + +start() diff --git a/packages/monitor-deployment/src/bootstrap.ts b/packages/monitor-deployment/src/bootstrap.ts new file mode 100644 index 0000000000..50c8721e6a --- /dev/null +++ b/packages/monitor-deployment/src/bootstrap.ts @@ -0,0 +1,2 @@ +import dotenv from 'dotenv' +dotenv.config() diff --git a/packages/monitor-deployment/src/clients/knex.ts b/packages/monitor-deployment/src/clients/knex.ts new file mode 100644 index 0000000000..45430f330f --- /dev/null +++ b/packages/monitor-deployment/src/clients/knex.ts @@ -0,0 +1,95 @@ +import { knexLogger as logger } from '@/observability/logging.js' +import { + getConnectionAcquireTimeoutMillis, + getConnectionCreateTimeoutMillis, + getDatabaseName, + getPostgresCACertificate, + getPostgresConnectionString, + getPostgresMaxConnections, + isDevOrTestEnv, + isTest +} from '@/utils/env.js' +import Environment from '@speckle/shared/dist/commonjs/environment/index.js' +import { + loadMultiRegionsConfig, + configureKnexClient +} from '@speckle/shared/dist/commonjs/environment/multiRegionConfig.js' +import { Knex } from 'knex' + +const { FF_WORKSPACES_MULTI_REGION_ENABLED } = Environment.getFeatureFlags() + +export type DbClient = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: Knex + isMain: boolean + regionKey: string + databaseName?: string +} + +let dbClients: DbClient[] + +export const getDbClients = async () => { + if (dbClients) return dbClients + const maxConnections = getPostgresMaxConnections() + + const configArgs = { + migrationDirs: [], + isTestEnv: isTest(), + isDevOrTestEnv: isDevOrTestEnv(), + logger, + maxConnections, + applicationName: 'speckle_database_monitor', + connectionAcquireTimeoutMillis: getConnectionAcquireTimeoutMillis(), + connectionCreateTimeoutMillis: getConnectionCreateTimeoutMillis() + } + if (!FF_WORKSPACES_MULTI_REGION_ENABLED) { + const mainClient = configureKnexClient( + { + postgres: { + connectionUri: getPostgresConnectionString(), + publicTlsCertificate: getPostgresCACertificate() + } + }, + configArgs + ) + const databaseName = + // try to get the database name from the environment variable, if not default to parsing the connection string + getDatabaseName() || + new URL(getPostgresConnectionString()).pathname.split('/').pop() + dbClients = [ + { client: mainClient.public, regionKey: 'main', isMain: true, databaseName } + ] + } else { + const configPath = process.env.MULTI_REGION_CONFIG_PATH || 'multiregion.json' + const config = await loadMultiRegionsConfig({ path: configPath }) + const clients: [string, { databaseName?: string; public: Knex; private?: Knex }][] = + [ + [ + 'main', + { + ...configureKnexClient(config.main, configArgs), + databaseName: config.main.postgres.databaseName + } + ] + ] + Object.entries(config.regions).map(([key, config]) => { + clients.push([ + key, + { + ...configureKnexClient(config, configArgs), + databaseName: config.postgres.databaseName + } + ]) + }) + + dbClients = [ + ...clients.map(([regionKey, c]) => ({ + client: c.public, + isMain: regionKey === 'main', + regionKey, + databaseName: c.databaseName + })) + ] + } + return dbClients +} diff --git a/packages/monitor-deployment/src/domain/const.ts b/packages/monitor-deployment/src/domain/const.ts new file mode 100644 index 0000000000..898869b284 --- /dev/null +++ b/packages/monitor-deployment/src/domain/const.ts @@ -0,0 +1 @@ +export const REQUEST_ID_HEADER = 'x-request-id' diff --git a/packages/monitor-deployment/src/observability/expressLogging.ts b/packages/monitor-deployment/src/observability/expressLogging.ts new file mode 100644 index 0000000000..0060defb88 --- /dev/null +++ b/packages/monitor-deployment/src/observability/expressLogging.ts @@ -0,0 +1,93 @@ +import { REQUEST_ID_HEADER } from '@/domain/const.js' +import { logger } from '@/observability/logging.js' +import { randomUUID } from 'crypto' +import type { Request } from 'express' +import type { IncomingHttpHeaders, IncomingMessage } from 'http' +import { get } from 'lodash-es' +import { pinoHttp } from 'pino-http' +import pino from 'pino' +import { parse } from 'url' + +function determineRequestId(headers: IncomingHttpHeaders, uuidGenerator = randomUUID) { + const idHeader = headers[REQUEST_ID_HEADER] + if (!idHeader) return uuidGenerator() + if (Array.isArray(idHeader)) return idHeader[0] ?? uuidGenerator() + return idHeader +} + +const generateReqId = (req: IncomingMessage) => determineRequestId(req.headers) + +export const loggingExpressMiddleware = pinoHttp({ + genReqId: generateReqId, + logger, + autoLogging: true, + // this is here, to force logging 500 responses as errors in the final log + // and we don't really care about 3xx stuff + // all the user related 4xx responses are treated as info + customLogLevel: (req, res, error) => { + if (res.statusCode >= 400 && res.statusCode < 500) { + return 'info' + } else if (res.statusCode >= 500 || error) { + return 'error' + } else if (res.statusCode >= 300 && res.statusCode < 400) { + return 'silent' + } + + return 'debug' + }, + + // we need to redact any potential sensitive data from being logged. + // as we do not know what headers may be sent in a request by a user or client + // we have to allow list selected headers + serializers: { + req: pino.stdSerializers.wrapRequestSerializer((req) => { + return { + id: req.raw.id, + method: req.raw.method, + path: getRequestPath(req.raw), + // Denylist potentially sensitive query parameters + pathParameters: sanitizeQueryParams(getRequestParameters(req.raw)), + // Denylist potentially sensitive headers + headers: sanitizeHeaders(req.raw.headers) + } + }) + } +}) + +const getRequestPath = (req: IncomingMessage | Request) => { + const path = ((get(req, 'originalUrl') || get(req, 'url') || '') as string).split( + '?' + )[0] + return path?.length ? path : null +} + +const getRequestParameters = (req: IncomingMessage | Request) => { + const maybeUrl = (get(req, 'originalUrl') as string) || get(req, 'url') || '' + const url = parse(maybeUrl, true) + return url.query || {} +} + +const sanitizeHeaders = (headers: Record) => + Object.fromEntries( + Object.entries(headers).filter( + ([key]) => + ![ + 'cookie', + 'authorization', + 'cf-connecting-ip', + 'true-client-ip', + 'x-real-ip', + 'x-forwarded-for', + 'x-original-forwarded-for' + ].includes(key.toLocaleLowerCase()) + ) + ) + +const sanitizeQueryParams = (query: Record) => { + Object.keys(query).forEach(function (key) { + if (['code', 'state'].includes(key.toLocaleLowerCase())) { + query[key] = '******' + } + }) + return query +} diff --git a/packages/monitor-deployment/src/observability/logging.ts b/packages/monitor-deployment/src/observability/logging.ts new file mode 100644 index 0000000000..b947653829 --- /dev/null +++ b/packages/monitor-deployment/src/observability/logging.ts @@ -0,0 +1,14 @@ +import { getLogLevel, isLogPretty } from '@/utils/env.js' +import { + extendLoggerComponent as elc, + getLogger +} from '@speckle/shared/dist/commonjs/observability/index.js' +export const extendLoggerComponent = elc + +export const logger = extendLoggerComponent( + getLogger(getLogLevel(), isLogPretty()), + 'monitor-deployment' +) +export const serverLogger = extendLoggerComponent(logger, 'server') +export const testLogger = getLogger(getLogLevel(), isLogPretty()) +export const knexLogger = extendLoggerComponent(logger, 'knex') diff --git a/packages/monitor-deployment/src/observability/metrics/commits.ts b/packages/monitor-deployment/src/observability/metrics/commits.ts new file mode 100644 index 0000000000..7e05c96cd9 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/commits.ts @@ -0,0 +1,37 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricConfig, MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config: MetricConfig) => { + const { labelNames, namePrefix, logger } = config + const commits = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_commits'], '_'), + help: 'Number of commits/versions', + labelNames: [...labelNames, 'region'] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + try { + const commitsEstimate = await client.raw<{ + rows: [{ estimate: number }] + }>( + "SELECT reltuples AS estimate FROM pg_class WHERE relname = 'commits' LIMIT 1;" + ) + if (commitsEstimate.rows.length) { + commits.set( + { ...labels, region: regionKey }, + Math.max(commitsEstimate.rows[0]?.estimate) + ) + } + } catch (err) { + logger.warn( + { err, region: regionKey }, + "Failed to collect commits metrics from region '{region}'. This may be because the region is not yet registered and has no 'commits' table." + ) + } + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/dbSize.ts b/packages/monitor-deployment/src/observability/metrics/dbSize.ts new file mode 100644 index 0000000000..bcbe7a16dd --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/dbSize.ts @@ -0,0 +1,47 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const dbSize = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_size'], '_'), + help: 'Size of the entire database (in bytes)', + labelNames: ['region', ...labelNames] + }) + + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey, databaseName }) => { + if (!databaseName) { + logger.warn( + { region: regionKey }, + "Could not get database name from client config for region '{region}'" + ) + return + } + + logger.info( + { region: regionKey, databaseName }, + "Collecting database size for region '{region}' from database '{databaseName}'" + ) + + const dbSizeResult = await client.raw<{ + rows: [{ pg_database_size: string }] //bigints are returned as strings + }>('SELECT pg_database_size(?) LIMIT 1', [databaseName]) + if (!dbSizeResult.rows.length) { + logger.error( + { region: regionKey }, + "No database size found for region '{region}'. This is odd." + ) + return + } + dbSize.set( + { ...labels, region: regionKey }, + parseInt(dbSizeResult.rows[0].pg_database_size) //NOTE risk this bigint being too big for JS, but that would be a very large database! + ) + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/dbWorkers.ts b/packages/monitor-deployment/src/observability/metrics/dbWorkers.ts new file mode 100644 index 0000000000..f62770ef56 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/dbWorkers.ts @@ -0,0 +1,33 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const dbWorkers = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_workers'], '_'), + help: 'Number of database workers', + labelNames: ['region', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + const connectionResults = await client.raw<{ + rows: [{ worker_count: string }] + }>(`SELECT COUNT(*) AS worker_count FROM pg_stat_activity;`) + if (!connectionResults.rows.length) { + logger.error( + { region: regionKey }, + "No database workers found for region '{region}'. This is odd." + ) + return + } + dbWorkers.set( + { ...labels, region: regionKey }, + parseInt(connectionResults.rows[0].worker_count) + ) + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/dbWorkersAwaitingLocks.ts b/packages/monitor-deployment/src/observability/metrics/dbWorkersAwaitingLocks.ts new file mode 100644 index 0000000000..c15809b2cf --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/dbWorkersAwaitingLocks.ts @@ -0,0 +1,33 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const promMetric = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_workers_awaiting_locks'], '_'), + help: 'Number of database workers awaiting locks', + labelNames: ['region', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + const queryResults = await client.raw<{ + rows: [{ count: string }] + }>(`SELECT COUNT(*) FROM pg_stat_activity WHERE wait_event = 'Lock';`) + if (!queryResults.rows.length) { + logger.error( + { region: regionKey }, + "No database workers found for region '{region}'. This is odd." + ) + return + } + promMetric.set( + { ...labels, region: regionKey }, + parseInt(queryResults.rows[0].count) + ) + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/fileImports.ts b/packages/monitor-deployment/src/observability/metrics/fileImports.ts new file mode 100644 index 0000000000..28a10b0175 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/fileImports.ts @@ -0,0 +1,90 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const fileimports = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_fileimports'], '_'), + help: 'Number of imported files, by type and status', + labelNames: ['filetype', 'status', 'region', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + try { + const importedFiles = await client.raw<{ + rows: [{ fileType: string; convertedStatus: number; count: string }] + }>( + ` + SELECT LOWER("fileType") AS "fileType", "convertedStatus", count(*) + FROM file_uploads + GROUP BY (LOWER("fileType"), "convertedStatus"); + ` + ) + + // Get the set of all unique file types and converted statuses in the database + const allFileImportConvertedStatusAndFileTypes = importedFiles.rows.reduce( + (acc, row) => { + acc.convertedStatus.add(row.convertedStatus) + acc.fileType.add(row.fileType) + acc.presentConvertedStatusAndFileType.add( + `${row.convertedStatus}:::${row.fileType}` + ) + return acc + }, + { + convertedStatus: new Set(), + fileType: new Set(), + presentConvertedStatusAndFileType: new Set() + } + ) + + // now calculate the combinatorial set of all possible file types and statuses + const remainingConvertedStatusAndFileTypes = new Set() + allFileImportConvertedStatusAndFileTypes.convertedStatus.forEach((status) => { + allFileImportConvertedStatusAndFileTypes.fileType.forEach((fileType) => { + remainingConvertedStatusAndFileTypes.add(`${status}:::${fileType}`) + }) + }) + + // now set the counts for the file types and statuses that are in the database + for (const row of importedFiles.rows) { + remainingConvertedStatusAndFileTypes.delete( + `${row.convertedStatus}:::${row.fileType}` + ) + + fileimports.set( + { + ...labels, + filetype: row.fileType, + status: row.convertedStatus.toString(), + region: regionKey + }, + parseInt(row.count) + ) + } + // zero-values for all remaining file types and statuses + remainingConvertedStatusAndFileTypes.forEach((formattedStatusAndFileType) => { + const [status, fileType] = formattedStatusAndFileType.split(':::') + fileimports.set( + { + ...labels, + filetype: fileType, + status, + region: regionKey + }, + 0 + ) + }) + } catch (err) { + logger.warn( + { err, region: regionKey }, + "Failed to collect file import status metrics from region '{region}'. This may be because the region is not yet registered and has no file_uploads table." + ) + } + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/fileSize.ts b/packages/monitor-deployment/src/observability/metrics/fileSize.ts new file mode 100644 index 0000000000..ac76cc8782 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/fileSize.ts @@ -0,0 +1,41 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const filesize = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_filesize'], '_'), + help: 'Size of imported files, by type (in bytes)', + labelNames: ['filetype', 'region', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + try { + const fileSizeResults = await client.raw<{ + rows: [{ filetype: string; filesize: string }] + }>( + ` + SELECT LOWER("fileType") AS fileType, SUM("fileSize") AS fileSize + FROM file_uploads + GROUP BY LOWER("fileType"); + ` + ) + for (const row of fileSizeResults.rows) { + filesize.set( + { ...labels, filetype: row.filetype, region: regionKey }, + parseInt(row.filesize) + ) + } + } catch (err) { + logger.warn( + { err, region: regionKey }, + "Failed to collect file upload metrics from region '{region}'. This may be because the region is not yet registered and has no 'file_uploads' table." + ) + } + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/inactiveReplicationSlots.ts b/packages/monitor-deployment/src/observability/metrics/inactiveReplicationSlots.ts new file mode 100644 index 0000000000..1a849948fc --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/inactiveReplicationSlots.ts @@ -0,0 +1,35 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const connections = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_inactive_replication_slots'], '_'), + help: 'Number of inactive database replication slots', + labelNames: ['region', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + const connectionResults = await client.raw<{ + rows: [{ inactive_replication_slots: string }] + }>( + `SELECT count(*) AS inactive_replication_slots FROM pg_replication_slots WHERE NOT active;` + ) + if (!connectionResults.rows.length) { + logger.error( + { region: regionKey }, + "No data related to replication slots found for region '{region}'. This is odd." + ) + return + } + connections.set( + { ...labels, region: regionKey }, + parseInt(connectionResults.rows[0].inactive_replication_slots) + ) + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/maxConnections.ts b/packages/monitor-deployment/src/observability/metrics/maxConnections.ts new file mode 100644 index 0000000000..b74142a359 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/maxConnections.ts @@ -0,0 +1,35 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const totalConnections = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_max_connections'], '_'), + help: 'Maximum number of database connections allowed by the server', + labelNames: ['region', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + const connectionResults = await client.raw<{ + rows: [{ maximum_connections: number }] + }>( + `SELECT setting::int AS maximum_connections FROM pg_settings WHERE name=$$max_connections$$;` + ) + if (!connectionResults.rows.length) { + logger.error( + { region: regionKey }, + "No maximum connections found for region '{region}'. This is odd." + ) + return + } + totalConnections.set( + { ...labels, region: regionKey }, + connectionResults.rows[0].maximum_connections + ) + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/objects.ts b/packages/monitor-deployment/src/observability/metrics/objects.ts new file mode 100644 index 0000000000..a05eaed63d --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/objects.ts @@ -0,0 +1,38 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const objects = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_objects'], '_'), + help: 'Number of objects', + labelNames: [...labelNames, 'region'] + }) + + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + try { + const objectsEstimate = await client.raw<{ + rows: [{ estimate: number }] + }>( + "SELECT reltuples AS estimate FROM pg_class WHERE relname = 'objects' LIMIT 1;" + ) + if (objectsEstimate.rows.length) { + objects.set( + { ...labels, region: regionKey }, + Math.max(objectsEstimate.rows[0]?.estimate, 0) + ) + } + } catch (err) { + logger.warn( + { err, region: regionKey }, + "Failed to collect objects from region '{region}'. This may be because the region is not yet registered and has no 'objects' table." + ) + } + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/previews.ts b/packages/monitor-deployment/src/observability/metrics/previews.ts new file mode 100644 index 0000000000..dfb07afb96 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/previews.ts @@ -0,0 +1,46 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const previews = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_previews'], '_'), + help: 'Number of previews, by status', + labelNames: ['status', 'region', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + try { + const previewStatusResults = await client.raw<{ + rows: [{ previewStatus: number; count: string }] + }>(` + SELECT "previewStatus", count(*) + FROM object_preview + GROUP BY "previewStatus"; + `) + + const remainingPreviewStatus = new Set(Array(4).keys()) + for (const row of previewStatusResults.rows) { + remainingPreviewStatus.delete(row.previewStatus) + previews.set( + { ...labels, region: regionKey, status: row.previewStatus.toString() }, + parseInt(row.count) + ) + } + // zero-values for all remaining preview statuses + remainingPreviewStatus.forEach((status) => { + previews.set({ ...labels, region: regionKey, status: status.toString() }, 0) + }) + } catch (err) { + logger.warn( + { err, region: regionKey }, + "Failed to collect private status metrics from region '{region}'. This may be because the region is not yet registered and has no 'object_preview' table." + ) + } + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/replicationSlotLag.ts b/packages/monitor-deployment/src/observability/metrics/replicationSlotLag.ts new file mode 100644 index 0000000000..9d70fb4953 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/replicationSlotLag.ts @@ -0,0 +1,38 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const promMetric = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_replication_slot_lag'], '_'), + help: 'Lag of replication slots in bytes', + labelNames: ['region', 'slotname', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + const queryResults = await client.raw<{ + rows: [{ slot_name: string; slot_lag_bytes: string }] + }>(` + SELECT slot_name, pg_current_wal_lsn() - confirmed_flush_lsn AS slot_lag_bytes + FROM pg_replication_slots WHERE slot_type='logical'; + `) + if (!queryResults.rows.length) { + logger.error( + { region: regionKey }, + "No database replication slots found for region '{region}'. This is odd." + ) + return + } + for (const row of queryResults.rows) { + promMetric.set( + { ...labels, region: regionKey, slotname: row.slot_name }, + parseInt(row.slot_lag_bytes) + ) + } + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/replicationWorkerLag.ts b/packages/monitor-deployment/src/observability/metrics/replicationWorkerLag.ts new file mode 100644 index 0000000000..e3feb62e06 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/replicationWorkerLag.ts @@ -0,0 +1,109 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' +import Environment from '@speckle/shared/dist/commonjs/environment/index.js' + +const { FF_WORKSPACES_MULTI_REGION_ENABLED } = Environment.getFeatureFlags() + +type QueryResponseSchema = { + rows: [ + { + write_lag: string + flush_lag: string + replay_lag: string + application_name: string + } + ] +} + +export const init: MetricInitializer = (config) => { + if (!FF_WORKSPACES_MULTI_REGION_ENABLED) { + return async () => { + // Do nothing + } + } + + const { labelNames, namePrefix, logger } = config + const promMetric = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_replication_worker_lag'], '_'), + help: 'Lag of replication workers, by type of lag', + labelNames: ['region', 'lagtype', 'name', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + let queryResults: QueryResponseSchema | undefined = undefined + try { + queryResults = await client.raw(` + SELECT write_lsn - sent_lsn AS write_lag, + flush_lsn - write_lsn AS flush_lag, + replay_lsn - flush_lsn AS replay_lag, + application_name + FROM aiven_extras.pg_stat_replication_list(); + `) + } catch (err) { + if ( + err instanceof Error && + err.message.includes('schema "aiven_extras" does not exist') + ) { + logger.warn( + { err, region: regionKey }, + "'aiven_extras' extension is not yet enabled for region '{region}'." + ) + return // continue to next region + } + + //else rethrow + throw err + } + + if (!queryResults?.rows.length) { + logger.error( + { region: regionKey }, + "No database workers found for region '{region}'. This is odd." + ) + return + } + for (const row of queryResults.rows) { + const writeLag = parseInt(row.write_lag) + if (!isNaN(writeLag)) { + promMetric.set( + { + ...labels, + region: regionKey, + lagtype: 'write', + name: row.application_name + }, + parseInt(row.write_lag) + ) + } + const flushLag = parseInt(row.flush_lag) + if (!isNaN(flushLag)) { + promMetric.set( + { + ...labels, + region: regionKey, + lagtype: 'flush', + name: row.application_name + }, + parseInt(row.flush_lag) + ) + } + const replayLag = parseInt(row.replay_lag) + if (!isNaN(replayLag)) { + promMetric.set( + { + ...labels, + region: regionKey, + lagtype: 'replay', + name: row.application_name + }, + parseInt(row.replay_lag) + ) + } + } + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/streams.ts b/packages/monitor-deployment/src/observability/metrics/streams.ts new file mode 100644 index 0000000000..811c586d04 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/streams.ts @@ -0,0 +1,29 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const streams = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_streams'], '_'), + help: 'Number of streams/projects', + labelNames + }) + + return async (params) => { + const { mainDbClient, labels } = params + try { + const streamsEstimate = await mainDbClient.raw<{ rows: [{ estimate: number }] }>( + "SELECT reltuples AS estimate FROM pg_class WHERE relname = 'streams' LIMIT 1;" + ) + if (streamsEstimate.rows.length) { + streams.set({ ...labels }, Math.max(streamsEstimate.rows[0]?.estimate)) + } + } catch (err) { + logger.warn( + err, + 'Failed to collect streams metrics. This may be because the main database is not yet migrated.' + ) + } + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/subscriptionsEnabled.ts b/packages/monitor-deployment/src/observability/metrics/subscriptionsEnabled.ts new file mode 100644 index 0000000000..fb8c195fc5 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/subscriptionsEnabled.ts @@ -0,0 +1,65 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' +import Environment from '@speckle/shared/dist/commonjs/environment/index.js' + +type QueryResponseSchema = { + rows: [{ subname: string; subenabled: boolean }] +} + +const { FF_WORKSPACES_MULTI_REGION_ENABLED } = Environment.getFeatureFlags() + +export const init: MetricInitializer = (config) => { + if (!FF_WORKSPACES_MULTI_REGION_ENABLED) { + return async () => { + // Do nothing + } + } + + const { labelNames, namePrefix, logger } = config + const promMetric = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_subscriptions_enabled'], '_'), + help: 'Enabled subscriptions to other databases', + labelNames: ['region', 'subscriptionname', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + let queryResults: QueryResponseSchema | undefined = undefined + try { + queryResults = await client.raw(` + SELECT subname, subenabled FROM aiven_extras.pg_list_all_subscriptions(); + `) + } catch (err) { + if ( + err instanceof Error && + err.message.includes('schema "aiven_extras" does not exist') + ) { + logger.warn( + { err, region: regionKey }, + "'aiven_extras' extension is not yet enabled for region '{region}'." + ) + return // continue to next region + } + + //else rethrow + throw err + } + if (!queryResults?.rows.length) { + logger.error( + { region: regionKey }, + "No database replication slots found for region '{region}'. This is odd." + ) + return + } + for (const row of queryResults.rows) { + promMetric.set( + { ...labels, region: regionKey, subscriptionname: row.subname }, + row.subenabled ? 1 : 0 + ) + } + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/tableSize.ts b/packages/monitor-deployment/src/observability/metrics/tableSize.ts new file mode 100644 index 0000000000..2aadf6f884 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/tableSize.ts @@ -0,0 +1,46 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix } = config + const tablesize = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_tablesize'], '_'), + help: 'Size of tables in the database, by table (in bytes)', + labelNames: ['table', 'region', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + const tableSizeResults = await client.raw<{ + rows: [{ table_name: string; table_size: string }] //bigints are returned as strings + }>( + ` + SELECT + table_name, + table_size + + FROM ( + SELECT + pg_catalog.pg_namespace.nspname AS schema_name, + relname AS table_name, + pg_relation_size(pg_catalog.pg_class.oid) AS table_size + + FROM pg_catalog.pg_class + JOIN pg_catalog.pg_namespace ON relnamespace = pg_catalog.pg_namespace.oid + ) t + WHERE schema_name = 'public' + ORDER BY table_size DESC; + ` + ) + for (const row of tableSizeResults.rows) { + tablesize.set( + { ...labels, table: row.table_name, region: regionKey }, + parseInt(row.table_size) //NOTE risk this bigint being too big for JS, but that would be a very large table! + ) + } + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/users.ts b/packages/monitor-deployment/src/observability/metrics/users.ts new file mode 100644 index 0000000000..a49ac237c4 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/users.ts @@ -0,0 +1,28 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const users = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_users'], '_'), + help: 'Number of users', + labelNames + }) + return async (params) => { + const { mainDbClient, labels } = params + try { + const usersEstimate = await mainDbClient.raw<{ rows: [{ estimate: number }] }>( + "SELECT reltuples AS estimate FROM pg_class WHERE relname = 'users' LIMIT 1;" + ) + if (usersEstimate.rows.length) { + users.set({ ...labels }, Math.max(usersEstimate.rows[0]?.estimate)) + } + } catch (err) { + logger.warn( + err, + "Failed to collect users metrics. This may be because the migrations have not yet occcurred and has no 'users' table." + ) + } + } +} diff --git a/packages/monitor-deployment/src/observability/metrics/webhooks.ts b/packages/monitor-deployment/src/observability/metrics/webhooks.ts new file mode 100644 index 0000000000..b3d496a184 --- /dev/null +++ b/packages/monitor-deployment/src/observability/metrics/webhooks.ts @@ -0,0 +1,47 @@ +import prometheusClient from 'prom-client' +import { join } from 'lodash-es' +import type { MetricInitializer } from '@/observability/types.js' + +export const init: MetricInitializer = (config) => { + const { labelNames, namePrefix, logger } = config + const webhooks = new prometheusClient.Gauge({ + name: join([namePrefix, 'db_webhooks'], '_'), + help: 'Number of webhook calls, by status', + labelNames: ['status', 'region', ...labelNames] + }) + return async (params) => { + const { dbClients, labels } = params + await Promise.all( + dbClients.map(async ({ client, regionKey }) => { + try { + const webhookResults = await client.raw<{ + rows: [{ status: number; count: string }] + }>( + ` + SELECT status, count(*) + FROM webhooks_events + GROUP BY status; + ` + ) + const remainingWebhookStatus = new Set(Array(4).keys()) + for (const row of webhookResults.rows) { + remainingWebhookStatus.delete(row.status) + webhooks.set( + { ...labels, status: row.status.toString(), region: regionKey }, + parseInt(row.count) //NOTE risk this bigint being too big for JS, but that would be a very large number of webhooks + ) + } + // zero-values for all remaining webhook statuses + remainingWebhookStatus.forEach((status) => { + webhooks.set({ ...labels, status: status.toString(), region: regionKey }, 0) + }) + } catch (err) { + logger.warn( + { err, region: regionKey }, + "Failed to collect webhook metrics from region '{region}'. This may be because the region is not yet registered and has no webhooks_events table." + ) + } + }) + ) + } +} diff --git a/packages/monitor-deployment/src/observability/metricsApp.ts b/packages/monitor-deployment/src/observability/metricsApp.ts new file mode 100644 index 0000000000..8f6b30f1fb --- /dev/null +++ b/packages/monitor-deployment/src/observability/metricsApp.ts @@ -0,0 +1,29 @@ +import { loggingExpressMiddleware } from '@/observability/expressLogging.js' +import { metricsRouterFactory } from '@/observability/metricsRoute.js' +import { initPrometheusMetrics } from '@/observability/prometheusMetrics.js' +import indexRouterFactory from '@/server/routes/index.js' +import { errorHandler } from '@/utils/errorHandler.js' +import express from 'express' +import createError from 'http-errors' + +export const appFactory = () => { + initPrometheusMetrics() + const app = express() + + app.disable('x-powered-by') + app.use(loggingExpressMiddleware) + app.use(express.json({ limit: '100mb' })) + app.use(express.urlencoded({ limit: '100mb', extended: false })) + + app.use('/', indexRouterFactory()) + app.use('/metrics', metricsRouterFactory()) + + // catch 404 and forward to error handler + app.use(function (req, _res, next) { + next(createError(404, `Not Found: ${req.url}`)) + }) + app.set('json spaces', 2) // pretty print json + + app.use(errorHandler) + return app +} diff --git a/packages/monitor-deployment/src/observability/metricsRoute.ts b/packages/monitor-deployment/src/observability/metricsRoute.ts new file mode 100644 index 0000000000..3b661adaef --- /dev/null +++ b/packages/monitor-deployment/src/observability/metricsRoute.ts @@ -0,0 +1,15 @@ +import express, { RequestHandler } from 'express' +import prometheusClient from 'prom-client' + +export const metricsRouterFactory = () => { + const metricsRouter = express.Router() + + metricsRouter.get( + '/', //root path of the sub-path to which this router is attached (should be `/metrics`) + (async (_req, res) => { + res.setHeader('Content-Type', prometheusClient.register.contentType) + res.end(await prometheusClient.register.metrics()) + }) as RequestHandler //FIXME: this works around a type error with async, which is resolved in express 5 + ) + return metricsRouter +} diff --git a/packages/monitor-deployment/src/observability/prometheusMetrics.ts b/packages/monitor-deployment/src/observability/prometheusMetrics.ts new file mode 100644 index 0000000000..4023e6d5ed --- /dev/null +++ b/packages/monitor-deployment/src/observability/prometheusMetrics.ts @@ -0,0 +1,164 @@ +import { DbClient, getDbClients } from '@/clients/knex.js' +import { logger } from '@/observability/logging.js' +import { databaseMonitorCollectionPeriodSeconds } from '@/utils/env.js' +import { join } from 'lodash-es' +import { Counter, Histogram, Registry } from 'prom-client' +import prometheusClient from 'prom-client' +import { init as commits } from '@/observability/metrics/commits.js' +import { init as dbSize } from '@/observability/metrics/dbSize.js' +import { init as dbWorkers } from '@/observability/metrics/dbWorkers.js' +import { init as dbWorkersAwaitingLocks } from '@/observability/metrics/dbWorkersAwaitingLocks.js' +import { init as fileImports } from '@/observability/metrics/fileImports.js' +import { init as fileSize } from '@/observability/metrics/fileSize.js' +import { init as inactiveReplicationSlots } from '@/observability/metrics/inactiveReplicationSlots.js' +import { init as maxConnections } from '@/observability/metrics/maxConnections.js' +import { init as objects } from '@/observability/metrics/objects.js' +import { init as previews } from '@/observability/metrics/previews.js' +import { init as replicationSlotLag } from '@/observability/metrics/replicationSlotLag.js' +import { init as replicationWorkerLag } from '@/observability/metrics/replicationWorkerLag.js' +import { init as streams } from '@/observability/metrics/streams.js' +import { init as subscriptionsEnabled } from '@/observability/metrics/subscriptionsEnabled.js' +import { init as tablesize } from '@/observability/metrics/tableSize.js' +import { init as users } from '@/observability/metrics/users.js' +import { init as webhooks } from '@/observability/metrics/webhooks.js' + +let prometheusInitialized = false + +function isPrometheusInitialized() { + return prometheusInitialized +} + +type MetricConfig = { + prefix?: string + labels?: Record + buckets?: Record + getDbClients: () => Promise +} + +type MetricsMonitor = { + start: () => () => void +} + +function initMonitoringMetrics(params: { + register: Registry + collectionPeriodMilliseconds: number + config: MetricConfig +}): MetricsMonitor { + logger.info('Initializing monitoring metrics...') + const { register, collectionPeriodMilliseconds, config } = params + const registers = register ? [register] : undefined + const namePrefix = config.prefix ?? '' + const labels = config.labels ?? {} + const labelNames = Object.keys(labels) + const getDbClients = config.getDbClients + + const metricsToInitialize = [ + commits, + dbWorkers, + dbWorkersAwaitingLocks, + dbSize, + fileImports, + fileSize, + inactiveReplicationSlots, + maxConnections, + objects, + previews, + replicationSlotLag, + replicationWorkerLag, + streams, + subscriptionsEnabled, + tablesize, + users, + webhooks + ] + + const metricsToCollect = metricsToInitialize.map((metricToInitialize) => + metricToInitialize({ labelNames, namePrefix, logger }) + ) + + const selfMonitor = new Histogram({ + name: join([namePrefix, 'self_monitor_time_monitoring_metrics'], '_'), + help: 'The time taken to collect all of the database monitoring metrics, seconds.', + registers, + buckets: [0, 0.1, 0.25, 0.5, 1, 2, 5, 10], + labelNames + }) + + const selfMonitorErrors = new Counter({ + name: join([namePrefix, 'self_monitor_errors_monitoring_metrics'], '_'), + help: 'The number of errors encountered while collecting monitoring metrics.', + registers, + labelNames + }) + + const collect = async () => { + const dbClients = await getDbClients() + + const mainDbClient = dbClients.find((c) => c.isMain)?.client + if (!mainDbClient) { + logger.warn('Could not find main database client') + return + } + + await Promise.all( + metricsToCollect.map(async (collectMetric) => { + try { + await collectMetric({ dbClients, mainDbClient, labels }) + } catch (err) { + selfMonitorErrors.inc(labels) + logger.error({ err }, 'Error encountered while collecting a metric') + // Continue collecting other metrics + } + }) + ) + } + + return { + start: () => { + const intervalId = setInterval(() => { + void (async () => { + const end = selfMonitor.startTimer() + await collect() + const duration = end() + logger.info( + { metricsCollectionDurationSeconds: duration }, + 'Collected monitoring metrics in {metricsCollectionDurationSeconds} seconds' + ) + })() + }, collectionPeriodMilliseconds) + return () => clearInterval(intervalId) // returns a handle which can be called to stop the monitoring + } + } +} + +export function initPrometheusMetrics() { + logger.info('Initializing Prometheus metrics...') + if (isPrometheusInitialized()) { + logger.info('Prometheus metrics already initialized') + return + } + + prometheusInitialized = true + + prometheusClient.register.clear() + prometheusClient.register.setDefaultLabels({ + project: 'speckle-server', + app: 'monitor-deployment' + }) + + try { + prometheusClient.collectDefaultMetrics() + const monitoringMetrics = initMonitoringMetrics({ + register: prometheusClient.register, + collectionPeriodMilliseconds: databaseMonitorCollectionPeriodSeconds() * 1000, + config: { + getDbClients, + prefix: 'speckle' + } + }) + monitoringMetrics.start() + } catch (e) { + logger.error(e, 'Failed to initialize Prometheus metrics.') + prometheusInitialized = false + } +} diff --git a/packages/monitor-deployment/src/observability/types.ts b/packages/monitor-deployment/src/observability/types.ts new file mode 100644 index 0000000000..031155476c --- /dev/null +++ b/packages/monitor-deployment/src/observability/types.ts @@ -0,0 +1,13 @@ +import type { DbClient } from '@/clients/knex.js' +import type { Knex } from 'knex' +import type { Logger } from 'pino' + +export type MetricConfig = { labelNames: string[]; namePrefix: string; logger: Logger } +export type MetricCollectionParameters = { + dbClients: DbClient[] + mainDbClient: Knex + labels: Record +} +export type MetricInitializer = ( + config: MetricConfig +) => (params: MetricCollectionParameters) => Promise diff --git a/packages/monitor-deployment/src/root.ts b/packages/monitor-deployment/src/root.ts new file mode 100644 index 0000000000..13b51d800f --- /dev/null +++ b/packages/monitor-deployment/src/root.ts @@ -0,0 +1,21 @@ +import path from 'node:path' +import fs from 'node:fs' +import { fileURLToPath } from 'url' + +/** + * Singleton module for src root and package root directory resolution + */ + +const __filename = fileURLToPath(import.meta.url) +const srcRoot = path.dirname(__filename) + +// Recursively walk back from __dirname till we find our package.json +let packageRoot = srcRoot +while (packageRoot !== '/') { + if (fs.readdirSync(packageRoot).includes('package.json')) { + break + } + packageRoot = path.resolve(packageRoot, '..') +} + +export { srcRoot, packageRoot } diff --git a/packages/monitor-deployment/src/server/routes/index.ts b/packages/monitor-deployment/src/server/routes/index.ts new file mode 100644 index 0000000000..4faee34c04 --- /dev/null +++ b/packages/monitor-deployment/src/server/routes/index.ts @@ -0,0 +1,13 @@ +import express from 'express' + +const indexRouterFactory = () => { + const indexRouter = express.Router() + + indexRouter.get('/', (_req, res) => { + res.send('Speckle database monitoring, at your service.') + }) + + return indexRouter +} + +export default indexRouterFactory diff --git a/packages/monitor-deployment/src/server/server.ts b/packages/monitor-deployment/src/server/server.ts new file mode 100644 index 0000000000..4737e2f1da --- /dev/null +++ b/packages/monitor-deployment/src/server/server.ts @@ -0,0 +1,88 @@ +import { serverLogger } from '@/observability/logging.js' +import { appFactory as metricsAppFactory } from '@/observability/metricsApp.js' +import { getMetricsHost, getMetricsPort } from '@/utils/env.js' +import http from 'http' +import { isNaN, isString, toNumber } from 'lodash-es' + +export const startServer = (params?: { serveOnRandomPort?: boolean }) => { + // we place the metrics on a separate port as we wish to expose it to external monitoring tools, but do not wish to expose other routes (for now) + const inputMetricsPort = params?.serveOnRandomPort + ? 0 + : normalizePort(getMetricsPort()) + const metricsApp = metricsAppFactory() + metricsApp.set('port', inputMetricsPort) + + /** + * Create HTTP server. + */ + const metricsServer = http.createServer(metricsApp) + + const metricsHost = getMetricsHost() + metricsServer.on('error', onErrorFactory(inputMetricsPort)) + metricsServer.on('listening', () => { + serverLogger.info('📊 Started Database monitor server') + onListening(metricsServer) + }) + metricsServer.listen(inputMetricsPort, metricsHost) + + return { metricsServer } +} + +export const stopServer = (params: { server: http.Server }) => { + const { server } = params + server.close() +} + +/** + * Normalize a port into a number, string, or false. + */ +function normalizePort(val: string | number) { + const port = toNumber(val) + if (!isNaN(port) && port >= 0) return port + + throw new Error('Invalid port; port must be a positive integer.') +} + +/** + * Event listener for HTTP server "error" event. + */ + +const onErrorFactory = (port: string | number | false) => (error: Error) => { + if ('syscall' in error && error.syscall !== 'listen') { + throw error + } + + const bind = isString(port) ? 'Pipe ' + port : 'Port ' + port + + if (!('code' in error)) throw error + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + serverLogger.error(error, bind + ' requires elevated privileges') + process.exit(1) + case 'EADDRINUSE': + serverLogger.error(error, bind + ' is already in use') + process.exit(1) + default: + throw error + } +} + +/** + * Event listener for HTTP server "listening" event. + */ + +function onListening(referenceServer: http.Server) { + const addr = referenceServer.address() + if (!addr) throw new Error('Server address is not defined') + + switch (typeof addr) { + case 'string': + serverLogger.info(`Listening on pipe ${addr}`) + return addr + default: + serverLogger.info(`Listening on port ${addr.port}`) + return addr.port + } +} diff --git a/packages/monitor-deployment/src/utils/env.ts b/packages/monitor-deployment/src/utils/env.ts new file mode 100644 index 0000000000..2450162bac --- /dev/null +++ b/packages/monitor-deployment/src/utils/env.ts @@ -0,0 +1,33 @@ +export function getIntFromEnv(envVarKey: string, aDefault = '0'): number { + return parseInt(process.env[envVarKey] || aDefault) +} +function getBooleanFromEnv(envVarKey: string, aDefault = false): boolean { + return ['1', 'true', true].includes( + process.env[envVarKey]?.toLocaleLowerCase() || aDefault.toString() + ) +} + +export const getMetricsHost = () => process.env['METRICS_HOST'] || '127.0.0.1' +export const getLogLevel = () => process.env['LOG_LEVEL'] || 'info' +export const getMetricsPort = () => process.env['PROMETHEUS_METRICS_PORT'] || '9092' +export const getNodeEnv = () => process.env['NODE_ENV'] || 'production' +export const getPostgresConnectionString = () => + process.env['PG_CONNECTION_STRING'] || 'postgres://speckle:speckle@127.0.0.1/speckle' +export const getPostgresCACertificate = () => process.env['POSTGRES_CA_CERTIFICATE'] +export const getPostgresMaxConnections = () => + getIntFromEnv('POSTGRES_MAX_CONNECTIONS_DATABASE_MONITOR', '2') +export const getConnectionAcquireTimeoutMillis = () => + getIntFromEnv('POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS', '16000') +export const getConnectionCreateTimeoutMillis = () => + getIntFromEnv('POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS', '5000') +export function databaseMonitorCollectionPeriodSeconds() { + return getIntFromEnv('METRICS_COLLECTION_PERIOD_SECONDS', '120') +} +export const getDatabaseName = () => process.env['POSTGRES_DATABASE'] + +export const isDevelopment = () => + getNodeEnv() === 'development' || getNodeEnv() === 'dev' +export const isLogPretty = () => getBooleanFromEnv('LOG_PRETTY', false) +export const isProduction = () => getNodeEnv() === 'production' +export const isTest = () => getNodeEnv() === 'test' +export const isDevOrTestEnv = () => isDevelopment() || isTest() diff --git a/packages/monitor-deployment/src/utils/errorHandler.ts b/packages/monitor-deployment/src/utils/errorHandler.ts new file mode 100644 index 0000000000..626c7b1106 --- /dev/null +++ b/packages/monitor-deployment/src/utils/errorHandler.ts @@ -0,0 +1,25 @@ +import { ErrorRequestHandler } from 'express' +import { isNaN, isObject, isString } from 'lodash-es' + +export const errorHandler: ErrorRequestHandler = (err, req, res) => { + if ( + isObject(err) && + 'status' in err && + typeof err.status === 'number' && + !isNaN(err.status) + ) { + res.status(err?.status) + } else { + res.status(500) + } + + res.setHeader('Content-Type', 'application/json') + + if (req.app.get('env') === 'development') { + res.send(JSON.stringify(err, undefined, 2)) + } else if (isObject(err) && 'message' in err && isString(err.message)) { + res.send(JSON.stringify({ message: err.message })) + } else { + res.send(JSON.stringify({ message: 'Internal Server Error' })) + } +} diff --git a/packages/monitor-deployment/tsconfig.build.json b/packages/monitor-deployment/tsconfig.build.json new file mode 100644 index 0000000000..8e1bc28f9b --- /dev/null +++ b/packages/monitor-deployment/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["**/*.spec.js", "**/*.spec.ts"] +} diff --git a/packages/monitor-deployment/tsconfig.json b/packages/monitor-deployment/tsconfig.json new file mode 100644 index 0000000000..4351d814b7 --- /dev/null +++ b/packages/monitor-deployment/tsconfig.json @@ -0,0 +1,108 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Projects */ + // "incremental": true, /* Enable incremental compilation */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ + // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + + /* Modules */ + "module": "node16" /* Specify what module code is generated. */, + "rootDir": "./" /* Specify the root folder within your source files. */, + "moduleResolution": "node16" /* Specify how TypeScript looks up a file from a given module specifier. */, + "baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */, + "paths": { + "@/*": ["./src/*"] + }, + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "resolveJsonModule": true, /* Enable importing .json files */ + // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, + "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + "sourceMap": true /* Create source map files for emitted JavaScript files. */, + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist" /* Specify an output folder for all emitted files. */, + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ + // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ + // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "ts-node": { + "swc": true + }, + "include": ["src/**/*", "vitest.config.ts"], + "exclude": ["node_modules", "coverage", "reports"] +} diff --git a/packages/monitor-deployment/vitest.config.ts b/packages/monitor-deployment/vitest.config.ts new file mode 100644 index 0000000000..dbf4dedd5b --- /dev/null +++ b/packages/monitor-deployment/vitest.config.ts @@ -0,0 +1,18 @@ +import path from 'path' +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + exclude: [...configDefaults.exclude], + // reporters: ['verbose', 'hanging-process'] //uncomment to debug hanging processes etc. + sequence: { + shuffle: true, + concurrent: true + } + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + } +}) diff --git a/packages/preview-service/src/clients/knex.ts b/packages/preview-service/src/clients/knex.ts index c66c226aae..ad4e2e50ca 100644 --- a/packages/preview-service/src/clients/knex.ts +++ b/packages/preview-service/src/clients/knex.ts @@ -33,7 +33,7 @@ export const getDbClients = async () => { isDevOrTestEnv: isDevOrTestEnv(), logger, maxConnections, - applicationName: 'speckle_fileimport_service', + applicationName: 'speckle_preview_service', connectionAcquireTimeoutMillis, connectionCreateTimeoutMillis } diff --git a/packages/shared/src/environment/multiRegionConfig.ts b/packages/shared/src/environment/multiRegionConfig.ts index 480d34d702..1ff21eaf2d 100644 --- a/packages/shared/src/environment/multiRegionConfig.ts +++ b/packages/shared/src/environment/multiRegionConfig.ts @@ -13,6 +13,12 @@ const regionConfigSchemaV1 = z.object({ .describe( 'Full Postgres connection URI (e.g. "postgres://user:password@host:port/dbname")' ), + databaseName: z + .string() + .describe( + 'Name of the database to connect to. Used where the connection string is to a connection pool, and does not include the database name.' + ) + .optional(), privateConnectionUri: z .string() .describe( diff --git a/utils/helm/speckle-server/templates/monitoring/deployment.yml b/utils/helm/speckle-server/templates/monitoring/deployment.yml index 725edf3772..c635241879 100644 --- a/utils/helm/speckle-server/templates/monitoring/deployment.yml +++ b/utils/helm/speckle-server/templates/monitoring/deployment.yml @@ -25,7 +25,7 @@ spec: ports: - name: metrics - containerPort: 9092 + containerPort: {{ .Values.monitoring.port }} protocol: TCP resources: @@ -63,6 +63,23 @@ spec: secretKeyRef: name: {{ default .Values.secretName .Values.db.connectionString.secretName }} key: {{ default "postgres_url" .Values.db.connectionString.secretKey }} + - name: POSTGRES_MAX_CONNECTIONS + value: {{ .Values.monitoring.maximumPostgresConnections | quote }} + - name: POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS + value: {{ .Values.db.connectionCreateTimeoutMillis | quote }} + - name: POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS + value: {{ .Values.db.connectionAcquireTimeoutMillis | quote }} + - name: METRICS_HOST + value: '0.0.0.0' # bind to all interfaces, not just localhost. Required to allow prometheus to scrape metrics, and healthchecks to work. + - name: PROMETHEUS_METRICS_PORT + value: {{ .Values.monitoring.port | quote }} + - name: METRICS_COLLECTION_PERIOD_SECONDS + value: {{ .Values.monitoring.metricsCollectionPeriodSeconds | quote }} + + {{- if .Values.db.databaseName }} + - name: POSTGRES_DATABASE + value: {{ .Values.db.databaseName | quote }} + {{- end }} {{- if .Values.db.useCertificate }} - name: NODE_EXTRA_CA_CERTS @@ -80,6 +97,31 @@ spec: - name: LOG_PRETTY value: {{ .Values.monitoring.logPretty | quote }} + startupProbe: + periodSeconds: 10 + failureThreshold: 60 # 10*60 = 600s; allows for long-running db migrations + timeoutSeconds: 3 + httpGet: + path: / + port: {{ .Values.monitoring.port }} + + livenessProbe: + periodSeconds: 60 + timeoutSeconds: 3 + failureThreshold: 3 + httpGet: + path: / + port: {{ .Values.monitoring.port }} + + readinessProbe: + initialDelaySeconds: 5 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + httpGet: + path: /metrics + port: {{ .Values.monitoring.port }} + priorityClassName: low-priority {{- if .Values.monitoring.serviceAccount.create }} serviceAccountName: {{ include "monitoring.name" $ }} diff --git a/utils/helm/speckle-server/templates/monitoring/service.yml b/utils/helm/speckle-server/templates/monitoring/service.yml index 0beec6abb0..b27e0ac33c 100644 --- a/utils/helm/speckle-server/templates/monitoring/service.yml +++ b/utils/helm/speckle-server/templates/monitoring/service.yml @@ -12,5 +12,5 @@ spec: ports: - protocol: TCP name: web - port: 9092 + port: {{ .Values.monitoring.port }} targetPort: metrics diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index ac9a4959ff..5b00fc0ed5 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -246,6 +246,11 @@ "description": "The maximum time in milliseconds to wait for a new connection to be created in the connection pool. Should be less than the acquisition timeout, as a new connection may need to be created then acquired.", "default": 5000 }, + "databaseName": { + "type": "string", + "description": "(Optional) The name of the Postgres database to which Speckle will connect. Only required for the Database Monitoring utility when the connection string is to a database connection pool and multi-region is disabled, otherwise this value is ignored.", + "default": "" + }, "connectionString": { "type": "object", "properties": { @@ -2212,6 +2217,21 @@ "description": "If enabled, will output logs in a human-readable format. Otherwise, logs will be output in JSON format.", "default": false }, + "port": { + "type": "number", + "description": "The port on which the Monitoring Service will run.", + "default": 9092 + }, + "maximumPostgresConnections": { + "type": "number", + "description": "The maximum number of connections that the Monitoring Service will allow to the Postgres database. A connection pool exists to manage access to the connections.", + "default": 2 + }, + "metricsCollectionPeriodSeconds": { + "type": "number", + "description": "The period in seconds at which the Monitoring Service will query the Postgres database for metrics. Unlike typical Prometheus metrics, the data from the database is not collected in real-time when /metrics is accessed, and is instead done out-of-band on a timed interval.", + "default": 120 + }, "image": { "type": "string", "description": "The Docker image to be used for the Speckle Monitoring component. If blank, defaults to speckle/speckle-monitoring-deployment:{{ .Values.docker_image_tag }}. If provided, this value should be the full path including tag. The docker_image_tag value will be ignored.", diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 6c07080994..ca9b0f8500 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -197,6 +197,9 @@ db: ## connectionCreationTimeoutMillis: 5000 + ## @param db.databaseName (Optional) The name of the Postgres database to which Speckle will connect. Only required for the Database Monitoring utility when the connection string is to a database connection pool and multi-region is disabled, otherwise this value is ignored. + databaseName: '' + connectionString: ## @param db.connectionString.secretName Required. A secret containing the full connection string to the Postgres database (e.g. in format of `protocol://username:password@host:port/database`) stored within the Kubernetes cluster as an opaque Kubernetes Secret. Ref: https://kubernetes.io/docs/concepts/configuration/secret/#opaque-secrets ## @@ -1374,6 +1377,17 @@ monitoring: ## logPretty: false + ## @param monitoring.port The port on which the Monitoring Service will run. + ## + port: 9092 + + ## @param monitoring.maximumPostgresConnections The maximum number of connections that the Monitoring Service will allow to the Postgres database. A connection pool exists to manage access to the connections. + ## + maximumPostgresConnections: 2 + + ## @param monitoring.metricsCollectionPeriodSeconds The period in seconds at which the Monitoring Service will query the Postgres database for metrics. Unlike typical Prometheus metrics, the data from the database is not collected in real-time when /metrics is accessed, and is instead done out-of-band on a timed interval. + metricsCollectionPeriodSeconds: 120 + ## @param monitoring.image The Docker image to be used for the Speckle Monitoring component. If blank, defaults to speckle/speckle-monitoring-deployment:{{ .Values.docker_image_tag }}. If provided, this value should be the full path including tag. The docker_image_tag value will be ignored. ## image: '' diff --git a/utils/monitor-deployment/Dockerfile b/utils/monitor-deployment/Dockerfile deleted file mode 100644 index 361fd25be3..0000000000 --- a/utils/monitor-deployment/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -FROM debian:12-slim@sha256:67f3931ad8cb1967beec602d8c0506af1e37e8d73c2a0b38b181ec5d8560d395 AS build-stage - -WORKDIR /build - -# install tini -ARG TINI_VERSION=v0.19.0 -ENV TINI_VERSION=${TINI_VERSION} -ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini -RUN chmod +x ./tini - -# Add python virtual env -WORKDIR /venv -RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install \ - --no-install-suggests --no-install-recommends --yes \ - python3-venv=3.11.2-1+b1 && \ - python3 -m venv /venv - -COPY utils/monitor-deployment/requirements.txt /requirements.txt -RUN /venv/bin/pip install --disable-pip-version-check --requirement /requirements.txt - -FROM gcr.io/distroless/python3-debian12:nonroot@sha256:14c62b8925d3bb30319de2f346bde203fe18103a68898284a62db9d4aa54c794 as production-stage -ARG PG_CONNECTION_STRING -ARG NODE_EXTRA_CA_CERTS -ENV PG_CONNECTION_STRING=${PG_CONNECTION_STRING} \ - NODE_EXTRA_CA_CERTS=${NODE_EXTRA_CA_CERTS} - -COPY --from=build-stage /venv /venv -COPY --from=build-stage /build/tini /usr/bin/tini -WORKDIR /app -COPY utils/monitor-deployment . - -ENTRYPOINT [ "tini", "--", "/venv/bin/python3", "-u", "src/run.py"] diff --git a/utils/monitor-deployment/dev_run.sh b/utils/monitor-deployment/dev_run.sh deleted file mode 100755 index 1cfafe16e1..0000000000 --- a/utils/monitor-deployment/dev_run.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail - -GIT_ROOT="$(git rev-parse --show-toplevel)" - -export PG_CONNECTION_STRING=postgres://speckle:speckle@localhost/speckle -pushd "${GIT_ROOT}/utils/monitor-deployment" -trap popd EXIT -pip install --disable-pip-version-check --requirement ./requirements.txt -python3 -u src/run.py diff --git a/utils/monitor-deployment/requirements.txt b/utils/monitor-deployment/requirements.txt deleted file mode 100644 index a6ea698bd5..0000000000 --- a/utils/monitor-deployment/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -psycopg2-binary==2.9.9 -prometheus-client==0.19.0 -structlog==23.3.0 diff --git a/utils/monitor-deployment/src/run.py b/utils/monitor-deployment/src/run.py deleted file mode 100644 index c7c34a73ec..0000000000 --- a/utils/monitor-deployment/src/run.py +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -import psycopg2 -from prometheus_client import start_http_server, Gauge -import time -import structlog -from logging import INFO, basicConfig - -basicConfig(format="%(message)s", stream=sys.stdout, level=INFO) - -structlog.configure( - processors=[ - structlog.stdlib.filter_by_level, - structlog.contextvars.merge_contextvars, - structlog.processors.add_log_level, - structlog.processors.StackInfoRenderer(), - structlog.processors.format_exc_info, - structlog.processors.TimeStamper(fmt="iso"), - structlog.stdlib.PositionalArgumentsFormatter(), - structlog.processors.UnicodeDecoder(), - structlog.processors.CallsiteParameterAdder( - { - structlog.processors.CallsiteParameter.FILENAME, - structlog.processors.CallsiteParameter.FUNC_NAME, - structlog.processors.CallsiteParameter.LINENO, - } - ), - structlog.processors.EventRenamer("msg"), - structlog.processors.JSONRenderer(), - ], - wrapper_class=structlog.make_filtering_bound_logger(INFO), - logger_factory=structlog.stdlib.LoggerFactory(), - cache_logger_on_first_use=True, -) -LOG = structlog.get_logger() -PG_CONNECTION_STRING = os.environ["PG_CONNECTION_STRING"] - -PROM = { - "db_size": Gauge("speckle_db_size", "Size of the entire database (in bytes)"), - "objects": Gauge("speckle_db_objects", "Number of objects"), - "streams": Gauge("speckle_db_streams", "Number of streams"), - "commits": Gauge("speckle_db_commits", "Number of commits"), - "users": Gauge("speckle_db_users", "Number of users"), - "fileimports": Gauge( - "speckle_db_fileimports", - "Number of imported files, by type and status", - labelnames=("filetype", "status"), - ), - "webhooks": Gauge( - "speckle_db_webhooks", - "Number of webhook calls, by status", - labelnames=("status",), - ), - "previews": Gauge( - "speckle_db_previews", "Number of previews, by status", labelnames=("status",) - ), - "filesize": Gauge( - "speckle_db_filesize", - "Size of imported files, by type (in bytes)", - labelnames=("filetype",), - ), - "tablesize": Gauge( - "speckle_db_tablesize", - "Size of tables in the database, by table (in bytes)", - labelnames=("table",), - ), -} - - -def tick(cur): - # Total DB size - cur.execute("SELECT pg_database_size(%s)", (cur.connection.info.dbname,)) - PROM["db_size"].set(cur.fetchone()[0]) - - # Counts for users, streams, commits, objects - cur.execute("SELECT reltuples AS estimate FROM pg_class WHERE relname = 'objects';") - PROM["objects"].set(cur.fetchone()[0]) - cur.execute("SELECT reltuples AS estimate FROM pg_class WHERE relname = 'streams';") - PROM["streams"].set(cur.fetchone()[0]) - cur.execute("SELECT reltuples AS estimate FROM pg_class WHERE relname = 'commits';") - PROM["commits"].set(cur.fetchone()[0]) - cur.execute("SELECT reltuples AS estimate FROM pg_class WHERE relname = 'users';") - PROM["users"].set(cur.fetchone()[0]) - - # File Imports - cur.execute( - """ - SELECT LOWER("fileType"), "convertedStatus", count(*) - FROM file_uploads - GROUP BY (LOWER("fileType"), "convertedStatus"); - """ - ) - # put in a dictionary so we fill non-existing statuses with zeroes - # (query can return PENDING files, then the next query will not return any PENDING rows. -> need to reset the metric to 0) - used_labels = {} - for row in cur: - if row[0] not in used_labels: - used_labels[row[0]] = {} - used_labels[row[0]][str(row[1])] = row[2] - for file_type in used_labels: - for status in range(4): - if str(status) in used_labels[file_type]: - PROM["fileimports"].labels(file_type, str(status)).set( - used_labels[file_type][str(status)] - ) - else: - PROM["fileimports"].labels(file_type, str(status)).set(0) - - cur.execute( - """ - SELECT LOWER("fileType") AS fileType, SUM("fileSize") AS fileSize - FROM file_uploads - GROUP BY LOWER("fileType"); - """ - ) - for row in cur: - PROM["filesize"].labels(row[0]).set(row[1]) - - # Webhooks - cur.execute( - """ - SELECT status, count(*) - FROM webhooks_events - GROUP BY status - """ - ) - values = {} - for row in cur: - values[str(row[0])] = row[1] - for status in range(4): - if str(status) in values: - PROM["webhooks"].labels(str(status)).set(values[str(status)]) - else: - PROM["webhooks"].labels(str(status)).set(0) - - # Previews - cur.execute( - """ - SELECT "previewStatus", count(*) - FROM object_preview - GROUP BY "previewStatus" - """ - ) - values = {} - for row in cur: - values[str(row[0])] = row[1] - for status in range(4): - if str(status) in values: - PROM["previews"].labels(str(status)).set(values[str(status)]) - else: - PROM["previews"].labels(str(status)).set(0) - - # Table sizes - cur.execute( - """ - SELECT - relname, - table_size - - FROM ( - SELECT - pg_catalog.pg_namespace.nspname AS schema_name, - relname, - pg_relation_size(pg_catalog.pg_class.oid) AS table_size - - FROM pg_catalog.pg_class - JOIN pg_catalog.pg_namespace ON relnamespace = pg_catalog.pg_namespace.oid - ) t - WHERE schema_name = 'public' - ORDER BY table_size DESC; - """ - ) - values = {} - for row in cur: - PROM["tablesize"].labels(row[0]).set(row[1]) - - -def main(): - start_http_server(9092) - - while True: - conn = None - cur = None - try: - t0 = time.time() - conn = psycopg2.connect( - PG_CONNECTION_STRING, - application_name="speckle_monitor_deployment", - ) - cur = conn.cursor() - t1 = time.time() - tick(cur) - t2 = time.time() - LOG.info( - "Updated metrics.", connection_period=(t1 - t0), query_period=(t2 - t1) - ) - except Exception as ex: - LOG.exception(ex) - finally: - if cur: - cur.close() - if conn: - conn.close() - - time.sleep(120) - - -if __name__ == "__main__": - main() diff --git a/yarn.lock b/yarn.lock index 6b9a839592..1726c53f86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16910,6 +16910,45 @@ __metadata: languageName: unknown linkType: soft +"@speckle/monitor-deployment@workspace:packages/monitor-deployment": + version: 0.0.0-use.local + resolution: "@speckle/monitor-deployment@workspace:packages/monitor-deployment" + dependencies: + "@speckle/shared": "workspace:^" + "@types/express": "npm:^4.17.13" + "@types/http-errors": "npm:^2.0.4" + "@types/lodash-es": "npm:^4.17.6" + "@types/node": "npm:^18.19.38" + "@vitest/coverage-istanbul": "npm:^1.6.0" + concurrently: "npm:^8.2.2" + crypto: "npm:^1.0.1" + crypto-random-string: "npm:^5.0.0" + dotenv: "npm:^16.4.5" + eslint: "npm:^9.4.0" + eslint-config-prettier: "npm:^9.1.0" + eslint-plugin-vitest: "npm:^0.5.4" + esm-module-alias: "npm:^2.2.0" + express: "npm:^4.19.2" + http-errors: "npm:~1.6.3" + knex: "npm:^2.4.1" + lodash: "npm:^4.17.21" + lodash-es: "npm:^4.17.21" + nodemon: "npm:^2.0.20" + pg: "npm:^8.7.3" + pino: "npm:^8.7.0" + pino-http: "npm:^8.2.1" + pino-pretty: "npm:^9.1.1" + prettier: "npm:^2.5.1" + prom-client: "npm:^14.0.1" + rimraf: "npm:^5.0.7" + typescript: "npm:^4.6.4" + typescript-eslint: "npm:^7.12.0" + vitest: "npm:^1.6.0" + znv: "npm:^0.4.0" + zod: "npm:^3.24.1" + languageName: unknown + linkType: soft + "@speckle/objectloader@npm:^2.17.8": version: 2.17.9 resolution: "@speckle/objectloader@npm:2.17.9" @@ -19961,7 +20000,7 @@ __metadata: languageName: node linkType: hard -"@types/http-errors@npm:*": +"@types/http-errors@npm:*, @types/http-errors@npm:^2.0.4": version: 2.0.4 resolution: "@types/http-errors@npm:2.0.4" checksum: 10/1f3d7c3b32c7524811a45690881736b3ef741bf9849ae03d32ad1ab7062608454b150a4e7f1351f83d26a418b2d65af9bdc06198f1c079d75578282884c4e8e3 @@ -54875,6 +54914,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^3.24.1": + version: 3.24.1 + resolution: "zod@npm:3.24.1" + checksum: 10/54e25956495dec22acb9399c168c6ba657ff279801a7fcd0530c414d867f1dcca279335e160af9b138dd70c332e17d548be4bc4d2f7eaf627dead50d914fec27 + languageName: node + linkType: hard + "zx@npm:^8.1.2": version: 8.1.2 resolution: "zx@npm:8.1.2" From cb134f3b14f4e97024cfeb720117669bd11fd2b1 Mon Sep 17 00:00:00 2001 From: Mike Date: Tue, 17 Dec 2024 13:10:00 +0100 Subject: [PATCH 62/68] Fix: Invite banner mixpanel event (#3704) --- packages/frontend-2/components/invite/Banner.vue | 7 ------- .../frontend-2/components/workspace/invite/Banner.vue | 9 +++++++++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/frontend-2/components/invite/Banner.vue b/packages/frontend-2/components/invite/Banner.vue index 6b6ddb1694..7701c21264 100644 --- a/packages/frontend-2/components/invite/Banner.vue +++ b/packages/frontend-2/components/invite/Banner.vue @@ -149,7 +149,6 @@ const onDeclineClick = (token?: string) => { mixpanel.track('Invite Action', { accepted: false, type: 'workspace invite', - location: 'invite banner', // eslint-disable-next-line camelcase workspace_id: props.invite.workspace.id }) @@ -159,12 +158,6 @@ const onDeclineClick = (token?: string) => { const onAcceptClick = (token?: string) => { emit('processed', true, token) if (props.invite.workspace) { - mixpanel.track('Workspace Joined', { - location: 'invite banner', - // eslint-disable-next-line camelcase - workspace_id: props.invite.workspace.id - }) - mixpanel.track('Invite Action', { accepted: true, type: 'workspace invite', diff --git a/packages/frontend-2/components/workspace/invite/Banner.vue b/packages/frontend-2/components/workspace/invite/Banner.vue index 4f9067381f..1481c657b5 100644 --- a/packages/frontend-2/components/workspace/invite/Banner.vue +++ b/packages/frontend-2/components/workspace/invite/Banner.vue @@ -16,6 +16,7 @@ import type { Optional } from '@speckle/shared' import type { WorkspaceInviteBanner_PendingWorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql' import { useWorkspaceInviteManager } from '~/lib/workspaces/composables/management' import { graphql } from '~~/lib/common/generated/gql' +import { useMixpanel } from '~~/lib/core/composables/mp' graphql(` fragment WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator { @@ -51,11 +52,19 @@ const { loading, accept, decline } = useWorkspaceInviteManager( } ) +const mixpanel = useMixpanel() + const processInvite = async (shouldAccept: boolean, token: Optional) => { if (!token) return if (shouldAccept) { await accept() + + mixpanel.track('Workspace Joined', { + location: 'invite banner', + // eslint-disable-next-line camelcase + workspace_id: props.invite.workspaceId + }) } else { await decline() } From 09e93562d1b1bb17d35f2d08bc7dc646fbf10ba0 Mon Sep 17 00:00:00 2001 From: Mike Date: Tue, 17 Dec 2024 13:10:30 +0100 Subject: [PATCH 63/68] Fix: Various workspace wizard fixes (#3705) --- .../frontend-2/components/workspace/CreatePage.vue | 10 ++++++++-- .../components/workspace/wizard/step/Invites.vue | 6 ++++-- .../lib/projects/composables/projectManagement.ts | 5 ----- .../src/components/global/ToastRenderer.vue | 9 ++++----- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/frontend-2/components/workspace/CreatePage.vue b/packages/frontend-2/components/workspace/CreatePage.vue index e66b9c0846..f2cee08472 100644 --- a/packages/frontend-2/components/workspace/CreatePage.vue +++ b/packages/frontend-2/components/workspace/CreatePage.vue @@ -2,7 +2,12 @@
@@ -31,7 +36,7 @@ defineProps<{ workspaceId?: string }>() -const { currentStep } = useWorkspacesWizard() +const { currentStep, resetWizardState } = useWorkspacesWizard() const isCancelDialogOpen = ref(false) @@ -40,6 +45,7 @@ const isFirstStep = computed(() => currentStep.value === WizardSteps.Details) const onCancelClick = () => { if (isFirstStep.value) { navigateTo(workspacesRoute) + resetWizardState() } else { isCancelDialogOpen.value = true } diff --git a/packages/frontend-2/components/workspace/wizard/step/Invites.vue b/packages/frontend-2/components/workspace/wizard/step/Invites.vue index 87a8bdd25c..0cd1545697 100644 --- a/packages/frontend-2/components/workspace/wizard/step/Invites.vue +++ b/packages/frontend-2/components/workspace/wizard/step/Invites.vue @@ -70,12 +70,14 @@ const onAddInvite = () => { } const onSubmit = handleSubmit(() => { - state.value.invites = fields.value + const validInvites = fields.value .filter((field) => !!field) .map((field) => field.value) + state.value.invites = validInvites + mixpanel.track('Workspace Invites Step Completed', { - inviteCount: state.value.invites.length + inviteCount: validInvites }) goToNextStep() diff --git a/packages/frontend-2/lib/projects/composables/projectManagement.ts b/packages/frontend-2/lib/projects/composables/projectManagement.ts index 3049df31f2..bfd2805799 100644 --- a/packages/frontend-2/lib/projects/composables/projectManagement.ts +++ b/packages/frontend-2/lib/projects/composables/projectManagement.ts @@ -176,11 +176,6 @@ export function useCreateProject() { title: 'Project creation failed', description: err }) - } else { - triggerNotification({ - type: ToastNotificationType.Success, - title: 'Project successfully created' - }) } return newProject diff --git a/packages/ui-components/src/components/global/ToastRenderer.vue b/packages/ui-components/src/components/global/ToastRenderer.vue index 41d68a5016..4d039008e9 100644 --- a/packages/ui-components/src/components/global/ToastRenderer.vue +++ b/packages/ui-components/src/components/global/ToastRenderer.vue @@ -56,15 +56,14 @@ {{ notification.description }}

- {{ notification.cta.title }} - +
@@ -84,7 +83,7 @@
diff --git a/packages/frontend-2/components/workspace/wizard/step/Invites.vue b/packages/frontend-2/components/workspace/wizard/step/Invites.vue index 0cd1545697..16741df550 100644 --- a/packages/frontend-2/components/workspace/wizard/step/Invites.vue +++ b/packages/frontend-2/components/workspace/wizard/step/Invites.vue @@ -82,4 +82,8 @@ const onSubmit = handleSubmit(() => { goToNextStep() }) + +onMounted(() => { + mixpanel.track('Workspace Invites Step Viewed') +}) diff --git a/packages/frontend-2/components/workspace/wizard/step/Pricing.vue b/packages/frontend-2/components/workspace/wizard/step/Pricing.vue index 5ab8d5d1f8..c76dc404ed 100644 --- a/packages/frontend-2/components/workspace/wizard/step/Pricing.vue +++ b/packages/frontend-2/components/workspace/wizard/step/Pricing.vue @@ -80,4 +80,8 @@ watch( }, { immediate: true } ) + +onMounted(() => { + mixpanel.track('Workspace Pricing Step Viewed') +}) diff --git a/packages/frontend-2/components/workspace/wizard/step/Region.vue b/packages/frontend-2/components/workspace/wizard/step/Region.vue index bc95128140..0163d9b1bf 100644 --- a/packages/frontend-2/components/workspace/wizard/step/Region.vue +++ b/packages/frontend-2/components/workspace/wizard/step/Region.vue @@ -83,4 +83,8 @@ watch( }, { immediate: true } ) + +onMounted(() => { + mixpanel.track('Workspace Region Step Viewed') +}) From 866e31a3e057d42a14567aa016dffd6c3bfb3330 Mon Sep 17 00:00:00 2001 From: Mike Date: Wed, 18 Dec 2024 10:38:32 +0100 Subject: [PATCH 65/68] Feat: Add seat info to invite modal (#3710) --- .../settings/shared/DeleteUserDialog.vue | 80 ++++++++++++++++--- .../settings/workspaces/Billing.vue | 4 +- .../workspaces/members/GuestsTable.vue | 2 + .../workspaces/members/MembersTable.vue | 5 +- .../lib/common/generated/gql/gql.ts | 13 ++- .../lib/common/generated/gql/graphql.ts | 15 ++-- 6 files changed, 95 insertions(+), 24 deletions(-) diff --git a/packages/frontend-2/components/settings/shared/DeleteUserDialog.vue b/packages/frontend-2/components/settings/shared/DeleteUserDialog.vue index 8ef50054c0..e8d0bc57f7 100644 --- a/packages/frontend-2/components/settings/shared/DeleteUserDialog.vue +++ b/packages/frontend-2/components/settings/shared/DeleteUserDialog.vue @@ -1,32 +1,94 @@ diff --git a/packages/frontend-2/components/projects/DeleteDialog.vue b/packages/frontend-2/components/projects/DeleteDialog.vue new file mode 100644 index 0000000000..d254e12d61 --- /dev/null +++ b/packages/frontend-2/components/projects/DeleteDialog.vue @@ -0,0 +1,144 @@ + + + diff --git a/packages/frontend-2/components/projects/NewSpeckle/Banner.vue b/packages/frontend-2/components/projects/NewSpeckle/Banner.vue deleted file mode 100644 index 262b39b1dd..0000000000 --- a/packages/frontend-2/components/projects/NewSpeckle/Banner.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/packages/frontend-2/components/projects/NewSpeckle/Dialog.vue b/packages/frontend-2/components/projects/NewSpeckle/Dialog.vue deleted file mode 100644 index cee0c2fa12..0000000000 --- a/packages/frontend-2/components/projects/NewSpeckle/Dialog.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/packages/frontend-2/components/settings/shared/projects/DeleteDialog.vue b/packages/frontend-2/components/settings/shared/projects/DeleteDialog.vue deleted file mode 100644 index 056db158f6..0000000000 --- a/packages/frontend-2/components/settings/shared/projects/DeleteDialog.vue +++ /dev/null @@ -1,137 +0,0 @@ - - - diff --git a/packages/frontend-2/components/settings/shared/projects/index.vue b/packages/frontend-2/components/settings/shared/projects/index.vue index 17d1dd2377..d6f35c79c9 100644 --- a/packages/frontend-2/components/settings/shared/projects/index.vue +++ b/packages/frontend-2/components/settings/shared/projects/index.vue @@ -89,7 +89,8 @@ - @@ -99,10 +100,11 @@ diff --git a/packages/frontend-2/components/dashboard/Blog/Wrapper.vue b/packages/frontend-2/components/dashboard/tutorials/Wrapper.vue similarity index 88% rename from packages/frontend-2/components/dashboard/Blog/Wrapper.vue rename to packages/frontend-2/components/dashboard/tutorials/Wrapper.vue index c92f35eacc..df5700ad8e 100644 --- a/packages/frontend-2/components/dashboard/Blog/Wrapper.vue +++ b/packages/frontend-2/components/dashboard/tutorials/Wrapper.vue @@ -1,8 +1,8 @@ - { // Allow role change if the active user is an admin if (isWorkspaceAdmin.value && !isActiveUserCurrentUser.value(user)) { - baseItems.push([{ title: 'Update role...', id: ActionTypes.ChangeRole }]) + baseItems.push([{ title: 'Change role...', id: ActionTypes.ChangeRole }]) } // Allow the current user to leave the workspace diff --git a/packages/frontend-2/lib/common/generated/gql/gql.ts b/packages/frontend-2/lib/common/generated/gql/gql.ts index e0dfcbe5e1..4b668e1a46 100644 --- a/packages/frontend-2/lib/common/generated/gql/gql.ts +++ b/packages/frontend-2/lib/common/generated/gql/gql.ts @@ -131,12 +131,13 @@ const documents = { "\n fragment SettingsWorkspacesRegions_Workspace on Workspace {\n id\n role\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n hasAccessToMultiRegion: hasAccessToFeature(\n featureName: workspaceDataRegionSpecificity\n )\n hasProjects: projects(limit: 0) {\n totalCount\n }\n }\n": types.SettingsWorkspacesRegions_WorkspaceFragmentDoc, "\n fragment SettingsWorkspacesRegions_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": types.SettingsWorkspacesRegions_ServerInfoFragmentDoc, "\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n slug\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n\n fragment SettingsWorkspacesSecurity_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n": types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc, + "\n fragment SettingsWorkspacesMembersChangeRoleDialog_Workspace on Workspace {\n id\n plan {\n status\n name\n }\n subscription {\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n }\n": types.SettingsWorkspacesMembersChangeRoleDialog_WorkspaceFragmentDoc, "\n fragment SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n projectRoles {\n role\n project {\n id\n name\n }\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragmentDoc, - "\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n ...SettingsSharedDeleteUserDialog_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceFragmentDoc, + "\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n ...SettingsSharedDeleteUserDialog_Workspace\n ...SettingsWorkspacesMembersChangeRoleDialog_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceFragmentDoc, "\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n inviteId\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaboratorFragmentDoc, "\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam(filter: $invitesFilter) {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_WorkspaceFragmentDoc, "\n fragment SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n workspaceDomainPolicyCompliant(workspaceId: $workspaceId)\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceCollaboratorFragmentDoc, - "\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n name\n ...SettingsSharedDeleteUserDialog_Workspace\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceFragmentDoc, + "\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n name\n ...SettingsSharedDeleteUserDialog_Workspace\n ...SettingsWorkspacesMembersTableHeader_Workspace\n ...SettingsWorkspacesMembersChangeRoleDialog_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceFragmentDoc, "\n fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n role\n ...WorkspaceInviteDialog_Workspace\n }\n": types.SettingsWorkspacesMembersTableHeader_WorkspaceFragmentDoc, "\n fragment SettingsWorkspacesRegionsSelect_ServerRegionItem on ServerRegionItem {\n id\n key\n name\n description\n }\n": types.SettingsWorkspacesRegionsSelect_ServerRegionItemFragmentDoc, "\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {\n id\n domain\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragmentDoc, @@ -865,6 +866,10 @@ export function graphql(source: "\n fragment SettingsWorkspacesRegions_ServerIn * 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 SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n slug\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n\n fragment SettingsWorkspacesSecurity_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n slug\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n\n fragment SettingsWorkspacesSecurity_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n fragment SettingsWorkspacesMembersChangeRoleDialog_Workspace on Workspace {\n id\n plan {\n status\n name\n }\n subscription {\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersChangeRoleDialog_Workspace on Workspace {\n id\n plan {\n status\n name\n }\n subscription {\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -872,7 +877,7 @@ export function graphql(source: "\n fragment SettingsWorkspacesMembersGuestsTab /** * 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 SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n ...SettingsSharedDeleteUserDialog_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n ...SettingsSharedDeleteUserDialog_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n }\n"]; +export function graphql(source: "\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n ...SettingsSharedDeleteUserDialog_Workspace\n ...SettingsWorkspacesMembersChangeRoleDialog_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n ...SettingsSharedDeleteUserDialog_Workspace\n ...SettingsWorkspacesMembersChangeRoleDialog_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -888,7 +893,7 @@ export function graphql(source: "\n fragment SettingsWorkspacesMembersMembersTa /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n name\n ...SettingsSharedDeleteUserDialog_Workspace\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n name\n ...SettingsSharedDeleteUserDialog_Workspace\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n"]; +export function graphql(source: "\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n name\n ...SettingsSharedDeleteUserDialog_Workspace\n ...SettingsWorkspacesMembersTableHeader_Workspace\n ...SettingsWorkspacesMembersChangeRoleDialog_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n name\n ...SettingsSharedDeleteUserDialog_Workspace\n ...SettingsWorkspacesMembersTableHeader_Workspace\n ...SettingsWorkspacesMembersChangeRoleDialog_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index b8857009a4..b7c998d511 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -4868,6 +4868,8 @@ export type SettingsWorkspacesSecurity_WorkspaceFragment = { __typename?: 'Works export type SettingsWorkspacesSecurity_UserFragment = { __typename?: 'User', id: string, emails: Array<{ __typename?: 'UserEmail', id: string, email: string, verified: boolean }> }; +export type SettingsWorkspacesMembersChangeRoleDialog_WorkspaceFragment = { __typename?: 'Workspace', id: string, plan?: { __typename?: 'WorkspacePlan', status: WorkspacePlanStatuses, name: WorkspacePlans } | null, subscription?: { __typename?: 'WorkspaceSubscription', currentBillingCycleEnd: string, seats: { __typename?: 'WorkspaceSubscriptionSeats', guest: number, plan: number } } | null }; + export type SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragment = { __typename?: 'WorkspaceCollaborator', id: string, role: string, user: { __typename?: 'LimitedUser', id: string, avatar?: string | null, name: string, company?: string | null, verified?: boolean | null }, projectRoles: Array<{ __typename?: 'ProjectRole', role: string, project: { __typename?: 'Project', id: string, name: string } }> }; export type SettingsWorkspacesMembersGuestsTable_WorkspaceFragment = { __typename?: 'Workspace', id: string, role?: string | null, domainBasedMembershipProtectionEnabled: boolean, team: { __typename?: 'WorkspaceCollaboratorCollection', items: Array<{ __typename?: 'WorkspaceCollaborator', id: string, role: string, user: { __typename?: 'LimitedUser', id: string, role?: string | null, avatar?: string | null, name: string, company?: string | null, verified?: boolean | null }, projectRoles: Array<{ __typename?: 'ProjectRole', role: string, project: { __typename?: 'Project', id: string, name: string } }> }> }, plan?: { __typename?: 'WorkspacePlan', status: WorkspacePlanStatuses, name: WorkspacePlans } | null, subscription?: { __typename?: 'WorkspaceSubscription', currentBillingCycleEnd: string, seats: { __typename?: 'WorkspaceSubscriptionSeats', guest: number, plan: number } } | null, domains?: Array<{ __typename?: 'WorkspaceDomain', domain: string, id: string }> | null, invitedTeam?: Array<{ __typename?: 'PendingWorkspaceCollaborator', title: string, user?: { __typename?: 'LimitedUser', id: string } | null }> | null }; @@ -6472,12 +6474,13 @@ export const SettingsWorkspacesSecurity_UserFragmentDoc = {"kind":"Document","de export const WorkspaceInviteDialog_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesMembersTableHeader_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const SettingsSharedDeleteUserDialog_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}}]}}]} as unknown as DocumentNode; +export const SettingsWorkspacesMembersChangeRoleDialog_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersChangeRoleDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; -export const SettingsWorkspacesMembersGuestsTable_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const SettingsWorkspacesMembersGuestsTable_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersChangeRoleDialog_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersChangeRoleDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesMembersInvitesTable_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesMembersMembersTable_WorkspaceCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDomainPolicyCompliant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}]}]}}]}}]} as unknown as DocumentNode; -export const SettingsWorkspacesMembersMembersTable_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDomainPolicyCompliant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}]}]}}]}}]} as unknown as DocumentNode; +export const SettingsWorkspacesMembersMembersTable_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersChangeRoleDialog_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersChangeRoleDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDomainPolicyCompliant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}]}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDomain"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}}]} as unknown as DocumentNode; export const ModelPageProjectFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ModelPageProject"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const ViewerModelVersionCardItemFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewerModelVersionCardItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Version"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"sourceApplication"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"previewUrl"}},{"kind":"Field","name":{"kind":"Name","value":"authorUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]} as unknown as DocumentNode; @@ -6672,7 +6675,7 @@ export const SettingsWorkspaceGeneralDocument = {"kind":"Document","definitions" export const SettingsWorkspaceBillingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspaceBilling"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesBilling_Workspace"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingAlert_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billingInterval"}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesBilling_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingAlert_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billingInterval"}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspaceBillingCustomerPortalDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspaceBillingCustomerPortal"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customerPortalUrl"}}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspaceRegionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspaceRegions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesRegions_Workspace"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesRegions_ServerInfo"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesRegionsSelect_ServerRegionItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerRegionItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesRegions_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"defaultRegion"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesRegionsSelect_ServerRegionItem"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"hasAccessToMultiRegion"},"name":{"kind":"Name","value":"hasAccessToFeature"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"featureName"},"value":{"kind":"EnumValue","value":"workspaceDataRegionSpecificity"}}]},{"kind":"Field","alias":{"kind":"Name","value":"hasProjects"},"name":{"kind":"Name","value":"projects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesRegions_ServerInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"multiRegion"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesRegionsSelect_ServerRegionItem"}}]}}]}}]}}]} as unknown as DocumentNode; -export const SettingsWorkspacesMembersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspacesMembers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaboratorsFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembers_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_Workspace"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDomainPolicyCompliant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}]}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembers_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator"}}]}}]}}]} as unknown as DocumentNode; +export const SettingsWorkspacesMembersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspacesMembers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaboratorsFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembers_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_Workspace"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersChangeRoleDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guest"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDomainPolicyCompliant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}]}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembers_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersChangeRoleDialog_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsSharedDeleteUserDialog_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersChangeRoleDialog_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator"}}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesMembersSearchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspacesMembersSearch"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceTeamFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDomainPolicyCompliant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}]}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesInvitesSearchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspacesInvitesSearch"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaboratorsFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_Workspace"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator"}}]}}]}}]} as unknown as DocumentNode; export const SettingsUserEmailsQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsUserEmailsQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsUserEmails_User"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsUserEmailCards_UserEmail"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserEmail"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"primary"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsUserEmails_User"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"emails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsUserEmailCards_UserEmail"}}]}}]}}]} as unknown as DocumentNode;