From 8197bb74a373f343ef585b3e91ecf58f63a37d44 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 12 Dec 2024 10:03:25 +0000 Subject: [PATCH 01/15] feat(multi-region): metrics for knex for all regional databases (#3580) * feat(multi-region): metrics for knex for all regional databases * improve typing in knex monitoring * error logging around migrations * await async calls for db connections - add 'region' label * add missing 'await' statements * more missing 'await' * guard against re-adding listeners * It was possible for update to be called before initialize - this change collapses both into initialize, and adds checks to ensure initialization is done before being updated for new regions * separate back into non-exported const and rename * align with main * Amend order at which metrics is enabled --- packages/server/app.ts | 5 +- .../highfrequencyMonitoring.ts | 4 +- .../knexConnectionPool.ts | 32 +- packages/server/logging/index.ts | 10 +- packages/server/logging/knexMonitoring.ts | 331 +++++++++++------- 5 files changed, 239 insertions(+), 143 deletions(-) diff --git a/packages/server/app.ts b/packages/server/app.ts index eb398e768..c6d69f130 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -399,12 +399,13 @@ export async function init() { const app = express() app.disable('x-powered-by') - Logging(app) - // Moves things along automatically on restart. // 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) diff --git a/packages/server/logging/highFrequencyMetrics/highfrequencyMonitoring.ts b/packages/server/logging/highFrequencyMetrics/highfrequencyMonitoring.ts index a38c80bb5..b11bfe6cd 100644 --- a/packages/server/logging/highFrequencyMetrics/highfrequencyMonitoring.ts +++ b/packages/server/logging/highFrequencyMetrics/highfrequencyMonitoring.ts @@ -14,7 +14,9 @@ type MetricConfig = { prefix?: string labels?: Record buckets?: Record - knex: Knex + getDbClients: () => Promise< + Array<{ client: Knex; isMain: boolean; regionKey: string }> + > } type HighFrequencyMonitor = { diff --git a/packages/server/logging/highFrequencyMetrics/knexConnectionPool.ts b/packages/server/logging/highFrequencyMetrics/knexConnectionPool.ts index 72cf4b89c..6a6afc5fd 100644 --- a/packages/server/logging/highFrequencyMetrics/knexConnectionPool.ts +++ b/packages/server/logging/highFrequencyMetrics/knexConnectionPool.ts @@ -31,16 +31,17 @@ type MetricConfig = { prefix?: string labels?: Record buckets?: Record - knex: Knex + getDbClients: () => Promise< + Array<{ client: Knex; isMain: boolean; regionKey: string }> + > } export const knexConnections = (registry: Registry, config: MetricConfig): Metric => { const registers = registry ? [registry] : undefined const namePrefix = config.prefix ?? '' const labels = config.labels ?? {} - const labelNames = Object.keys(labels) + const labelNames = [...Object.keys(labels), 'region'] const buckets = { ...DEFAULT_KNEX_TOTAL_BUCKETS, ...config.buckets } - const knex = config.knex const knexConnectionsFree = new Histogram({ name: namePrefix + KNEX_CONNECTIONS_FREE, @@ -91,15 +92,24 @@ export const knexConnections = (registry: Registry, config: MetricConfig): Metri }) return { - collect: () => { - const connPool = knex.client.pool + collect: async () => { + for (const dbClient of await config.getDbClients()) { + const labelsAndRegion = { ...labels, region: dbClient.regionKey } + const connPool = dbClient.client.client.pool - knexConnectionsFree.observe(labels, connPool.numFree()) - knexConnectionsUsed.observe(labels, connPool.numUsed()) - knexPendingAcquires.observe(labels, connPool.numPendingAcquires()) - knexPendingCreates.observe(labels, connPool.numPendingCreates()) - knexPendingValidations.observe(labels, connPool.numPendingValidations()) - knexRemainingCapacity.observe(labels, numberOfFreeConnections(knex)) + knexConnectionsFree.observe(labelsAndRegion, connPool.numFree()) + knexConnectionsUsed.observe(labelsAndRegion, connPool.numUsed()) + knexPendingAcquires.observe(labelsAndRegion, connPool.numPendingAcquires()) + knexPendingCreates.observe(labelsAndRegion, connPool.numPendingCreates()) + knexPendingValidations.observe( + labelsAndRegion, + connPool.numPendingValidations() + ) + knexRemainingCapacity.observe( + labelsAndRegion, + numberOfFreeConnections(dbClient.client) + ) + } } } } diff --git a/packages/server/logging/index.ts b/packages/server/logging/index.ts index 6489ae634..c1020813e 100644 --- a/packages/server/logging/index.ts +++ b/packages/server/logging/index.ts @@ -4,14 +4,14 @@ import promBundle from 'express-prom-bundle' import { initKnexPrometheusMetrics } from '@/logging/knexMonitoring' import { initHighFrequencyMonitoring } from '@/logging/highFrequencyMetrics/highfrequencyMonitoring' -import knex from '@/db/knex' import { highFrequencyMetricsCollectionPeriodMs } from '@/modules/shared/helpers/envHelper' import { startupLogger as logger } from '@/logging/logging' import type express from 'express' +import { getAllRegisteredDbClients } from '@/modules/multiregion/utils/dbSelector' let prometheusInitialized = false -export default function (app: express.Express) { +export default async function (app: express.Express) { if (!prometheusInitialized) { prometheusInitialized = true prometheusClient.register.clear() @@ -24,14 +24,14 @@ export default function (app: express.Express) { register: prometheusClient.register, collectionPeriodMilliseconds: highFrequencyMetricsCollectionPeriodMs(), config: { - knex + getDbClients: getAllRegisteredDbClients } }) highfrequencyMonitoring.start() - initKnexPrometheusMetrics({ + await initKnexPrometheusMetrics({ register: prometheusClient.register, - db: knex, + getAllDbClients: getAllRegisteredDbClients, logger }) const expressMetricsMiddleware = promBundle({ diff --git a/packages/server/logging/knexMonitoring.ts b/packages/server/logging/knexMonitoring.ts index 5d327ca42..f2d087b90 100644 --- a/packages/server/logging/knexMonitoring.ts +++ b/packages/server/logging/knexMonitoring.ts @@ -5,150 +5,228 @@ import { Logger } from 'pino' import { toNDecimalPlaces } from '@/modules/core/utils/formatting' import { omit } from 'lodash' -export const initKnexPrometheusMetrics = (params: { - db: Knex +let metricQueryDuration: prometheusClient.Summary +let metricQueryErrors: prometheusClient.Counter +let metricConnectionAcquisitionDuration: prometheusClient.Histogram +let metricConnectionPoolErrors: prometheusClient.Counter +let metricConnectionInUseDuration: prometheusClient.Histogram +let metricConnectionPoolReapingDuration: prometheusClient.Histogram +const initializedRegions: string[] = [] +let initializedPollingMetrics = false + +export const initKnexPrometheusMetrics = async (params: { + getAllDbClients: () => Promise< + Array<{ client: Knex; isMain: boolean; regionKey: string }> + > register: Registry logger: Logger }) => { - const normalizeSqlMethod = (sqlMethod: string) => { - if (!sqlMethod) return 'unknown' - switch (sqlMethod.toLocaleLowerCase()) { - case 'first': - return 'select' - default: - return sqlMethod.toLocaleLowerCase() - } + if (!initializedPollingMetrics) { + initializedPollingMetrics = true + new prometheusClient.Gauge({ + registers: [params.register], + name: 'speckle_server_knex_free', + labelNames: ['region'], + help: 'Number of free DB connections', + async collect() { + for (const dbClient of await params.getAllDbClients()) { + this.set( + { region: dbClient.regionKey }, + dbClient.client.client.pool.numFree() + ) + } + } + }) + + new prometheusClient.Gauge({ + registers: [params.register], + name: 'speckle_server_knex_used', + labelNames: ['region'], + help: 'Number of used DB connections', + async collect() { + for (const dbClient of await params.getAllDbClients()) { + this.set( + { region: dbClient.regionKey }, + dbClient.client.client.pool.numUsed() + ) + } + } + }) + + new prometheusClient.Gauge({ + registers: [params.register], + name: 'speckle_server_knex_pending', + labelNames: ['region'], + help: 'Number of pending DB connection aquires', + async collect() { + for (const dbClient of await params.getAllDbClients()) { + this.set( + { region: dbClient.regionKey }, + dbClient.client.client.pool.numPendingAcquires() + ) + } + } + }) + + new prometheusClient.Gauge({ + registers: [params.register], + name: 'speckle_server_knex_pending_creates', + labelNames: ['region'], + help: 'Number of pending DB connection creates', + async collect() { + for (const dbClient of await params.getAllDbClients()) { + this.set( + { region: dbClient.regionKey }, + dbClient.client.client.pool.numPendingCreates() + ) + } + } + }) + + new prometheusClient.Gauge({ + registers: [params.register], + name: 'speckle_server_knex_pending_validations', + labelNames: ['region'], + help: 'Number of pending DB connection validations. This is a state between pending acquisition and acquiring a connection.', + async collect() { + for (const dbClient of await params.getAllDbClients()) { + this.set( + { region: dbClient.regionKey }, + dbClient.client.client.pool.numPendingValidations() + ) + } + } + }) + + new prometheusClient.Gauge({ + registers: [params.register], + name: 'speckle_server_knex_remaining_capacity', + labelNames: ['region'], + help: 'Remaining capacity of the DB connection pool', + async collect() { + for (const dbClient of await params.getAllDbClients()) { + this.set( + { region: dbClient.regionKey }, + numberOfFreeConnections(dbClient.client) + ) + } + } + }) + + metricQueryDuration = new prometheusClient.Summary({ + registers: [params.register], + labelNames: ['sqlMethod', 'sqlNumberBindings', 'region'], + name: 'speckle_server_knex_query_duration', + help: 'Summary of the DB query durations in seconds' + }) + + metricQueryErrors = new prometheusClient.Counter({ + registers: [params.register], + labelNames: ['sqlMethod', 'sqlNumberBindings', 'region'], + name: 'speckle_server_knex_query_errors', + help: 'Number of DB queries with errors' + }) + + metricConnectionAcquisitionDuration = new prometheusClient.Histogram({ + registers: [params.register], + name: 'speckle_server_knex_connection_acquisition_duration', + labelNames: ['region'], + help: 'Summary of the DB connection acquisition duration, from request to acquire connection from pool until successfully acquired, in seconds' + }) + + metricConnectionPoolErrors = new prometheusClient.Counter({ + registers: [params.register], + name: 'speckle_server_knex_connection_acquisition_errors', + labelNames: ['region'], + help: 'Number of DB connection pool acquisition errors' + }) + + metricConnectionInUseDuration = new prometheusClient.Histogram({ + registers: [params.register], + name: 'speckle_server_knex_connection_usage_duration', + labelNames: ['region'], + help: 'Summary of the DB connection duration, from successful acquisition of connection from pool until release back to pool, in seconds' + }) + + metricConnectionPoolReapingDuration = new prometheusClient.Histogram({ + registers: [params.register], + name: 'speckle_server_knex_connection_pool_reaping_duration', + labelNames: ['region'], + help: 'Summary of the DB connection pool reaping duration, in seconds. Reaping is the process of removing idle connections from the pool.' + }) } + // configure hooks on knex + for (const dbClient of await params.getAllDbClients()) { + if (initializedRegions.includes(dbClient.regionKey)) continue + initKnexPrometheusMetricsForRegionEvents({ + logger: params.logger, + region: dbClient.regionKey, + db: dbClient.client + }) + initializedRegions.push(dbClient.regionKey) + } +} + +const normalizeSqlMethod = (sqlMethod: string) => { + if (!sqlMethod) return 'unknown' + switch (sqlMethod.toLocaleLowerCase()) { + case 'first': + return 'select' + default: + return sqlMethod.toLocaleLowerCase() + } +} + +interface QueryEvent extends Knex.Sql { + __knexUid: string + __knexTxId: string + __knexQueryUid: string +} + +const initKnexPrometheusMetricsForRegionEvents = async (params: { + region: string + db: Knex + logger: Logger +}) => { + const { region, db } = params const queryStartTime: Record = {} const connectionAcquisitionStartTime: Record = {} const connectionInUseStartTime: Record = {} - new prometheusClient.Gauge({ - registers: [params.register], - name: 'speckle_server_knex_free', - help: 'Number of free DB connections', - collect() { - this.set(params.db.client.pool.numFree()) - } - }) - - new prometheusClient.Gauge({ - registers: [params.register], - name: 'speckle_server_knex_used', - help: 'Number of used DB connections', - collect() { - this.set(params.db.client.pool.numUsed()) - } - }) - - new prometheusClient.Gauge({ - registers: [params.register], - name: 'speckle_server_knex_pending', - help: 'Number of pending DB connection aquires', - collect() { - this.set(params.db.client.pool.numPendingAcquires()) - } - }) - - new prometheusClient.Gauge({ - registers: [params.register], - name: 'speckle_server_knex_pending_creates', - help: 'Number of pending DB connection creates', - collect() { - this.set(params.db.client.pool.numPendingCreates()) - } - }) - - new prometheusClient.Gauge({ - registers: [params.register], - name: 'speckle_server_knex_pending_validations', - help: 'Number of pending DB connection validations. This is a state between pending acquisition and acquiring a connection.', - collect() { - this.set(params.db.client.pool.numPendingValidations()) - } - }) - - new prometheusClient.Gauge({ - registers: [params.register], - name: 'speckle_server_knex_remaining_capacity', - help: 'Remaining capacity of the DB connection pool', - collect() { - this.set(numberOfFreeConnections(params.db)) - } - }) - - const metricQueryDuration = new prometheusClient.Summary({ - registers: [params.register], - labelNames: ['sqlMethod', 'sqlNumberBindings'], - name: 'speckle_server_knex_query_duration', - help: 'Summary of the DB query durations in seconds' - }) - - const metricQueryErrors = new prometheusClient.Counter({ - registers: [params.register], - labelNames: ['sqlMethod', 'sqlNumberBindings'], - name: 'speckle_server_knex_query_errors', - help: 'Number of DB queries with errors' - }) - - const metricConnectionAcquisitionDuration = new prometheusClient.Histogram({ - registers: [params.register], - name: 'speckle_server_knex_connection_acquisition_duration', - help: 'Summary of the DB connection acquisition duration, from request to acquire connection from pool until successfully acquired, in seconds' - }) - - const metricConnectionPoolErrors = new prometheusClient.Counter({ - registers: [params.register], - name: 'speckle_server_knex_connection_acquisition_errors', - help: 'Number of DB connection pool acquisition errors' - }) - - const metricConnectionInUseDuration = new prometheusClient.Histogram({ - registers: [params.register], - name: 'speckle_server_knex_connection_usage_duration', - help: 'Summary of the DB connection duration, from successful acquisition of connection from pool until release back to pool, in seconds' - }) - - const metricConnectionPoolReapingDuration = new prometheusClient.Histogram({ - registers: [params.register], - name: 'speckle_server_knex_connection_pool_reaping_duration', - help: 'Summary of the DB connection pool reaping duration, in seconds. Reaping is the process of removing idle connections from the pool.' - }) - - // configure hooks on knex - - params.db.on('query', (data) => { + db.on('query', (data: QueryEvent) => { const queryId = data.__knexQueryUid + '' queryStartTime[queryId] = performance.now() }) - params.db.on('query-response', (_data, querySpec) => { - const queryId = querySpec.__knexQueryUid + '' + db.on('query-response', (_response: unknown, data: QueryEvent) => { + const queryId = data.__knexQueryUid + '' const durationMs = performance.now() - queryStartTime[queryId] const durationSec = toNDecimalPlaces(durationMs / 1000, 2) delete queryStartTime[queryId] if (!isNaN(durationSec)) metricQueryDuration .labels({ - sqlMethod: normalizeSqlMethod(querySpec.method), - sqlNumberBindings: querySpec.bindings?.length || -1 + region, + sqlMethod: normalizeSqlMethod(data.method), + sqlNumberBindings: data.bindings?.length || -1 }) .observe(durationSec) params.logger.debug( { - sql: querySpec.sql, - sqlMethod: normalizeSqlMethod(querySpec.method), + region, + sql: data.sql, + sqlMethod: normalizeSqlMethod(data.method), sqlQueryId: queryId, sqlQueryDurationMs: toNDecimalPlaces(durationMs, 0), - sqlNumberBindings: querySpec.bindings?.length || -1 + sqlNumberBindings: data.bindings?.length || -1 }, "DB query successfully completed, for method '{sqlMethod}', after {sqlQueryDurationMs}ms" ) }) - params.db.on('query-error', (err, querySpec) => { - const queryId = querySpec.__knexQueryUid + '' + db.on('query-error', (err: unknown, data: QueryEvent) => { + const queryId = data.__knexQueryUid + '' const durationMs = performance.now() - queryStartTime[queryId] const durationSec = toNDecimalPlaces(durationMs / 1000, 2) delete queryStartTime[queryId] @@ -156,25 +234,27 @@ export const initKnexPrometheusMetrics = (params: { if (!isNaN(durationSec)) metricQueryDuration .labels({ - sqlMethod: normalizeSqlMethod(querySpec.method), - sqlNumberBindings: querySpec.bindings?.length || -1 + region, + sqlMethod: normalizeSqlMethod(data.method), + sqlNumberBindings: data.bindings?.length || -1 }) .observe(durationSec) metricQueryErrors.inc() params.logger.warn( { - err: omit(err, 'detail'), - sql: querySpec.sql, - sqlMethod: normalizeSqlMethod(querySpec.method), + err: typeof err === 'object' ? omit(err, 'detail') : err, + region, + sql: data.sql, + sqlMethod: normalizeSqlMethod(data.method), sqlQueryId: queryId, sqlQueryDurationMs: toNDecimalPlaces(durationMs, 0), - sqlNumberBindings: querySpec.bindings?.length || -1 + sqlNumberBindings: data.bindings?.length || -1 }, 'DB query errored for {sqlMethod} after {sqlQueryDurationMs}ms' ) }) - const pool = params.db.client.pool + const pool = db.client.pool // configure hooks on knex connection pool pool.on('acquireRequest', (eventId: number) => { @@ -190,7 +270,8 @@ export const initKnexPrometheusMetrics = (params: { const now = performance.now() const durationMs = now - connectionAcquisitionStartTime[eventId] delete connectionAcquisitionStartTime[eventId] - if (!isNaN(durationMs)) metricConnectionAcquisitionDuration.observe(durationMs) + if (!isNaN(durationMs)) + metricConnectionAcquisitionDuration.labels({ region }).observe(durationMs) // successful acquisition is the start of usage, so record that start time let knexUid: string | undefined = undefined @@ -234,7 +315,8 @@ export const initKnexPrometheusMetrics = (params: { const now = performance.now() const durationMs = now - connectionInUseStartTime[knexUid] - if (!isNaN(durationMs)) metricConnectionInUseDuration.observe(durationMs) + if (!isNaN(durationMs)) + metricConnectionInUseDuration.labels({ region }).observe(durationMs) // params.logger.debug( // { // knexUid, @@ -263,7 +345,8 @@ export const initKnexPrometheusMetrics = (params: { pool.on('stopReaping', () => { if (!reapingStartTime) return const durationMs = performance.now() - reapingStartTime - if (!isNaN(durationMs)) metricConnectionPoolReapingDuration.observe(durationMs) + if (!isNaN(durationMs)) + metricConnectionPoolReapingDuration.labels({ region }).observe(durationMs) reapingStartTime = undefined }) From c6301f8fc2ab865f5d37859a754aee2763050c93 Mon Sep 17 00:00:00 2001 From: Mike Date: Thu, 12 Dec 2024 13:10:28 +0100 Subject: [PATCH 02/15] Fix: Hide seat counts and pricing for academia and unlimited plans (#3682) --- .../settings/workspaces/Billing.vue | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/frontend-2/components/settings/workspaces/Billing.vue b/packages/frontend-2/components/settings/workspaces/Billing.vue index 73074c6b6..dbd2838ac 100644 --- a/packages/frontend-2/components/settings/workspaces/Billing.vue +++ b/packages/frontend-2/components/settings/workspaces/Billing.vue @@ -49,30 +49,38 @@

-

- {{ - statusIsTrial - ? 'Expected bill' - : subscription?.billingInterval === BillingInterval.Yearly - ? 'Annual bill' - : 'Monthly bill' - }} -

-

- {{ billValue }} per - {{ - subscription?.billingInterval === BillingInterval.Yearly - ? 'year' - : 'month' - }} -

-

- {{ billDescription }} - -

+ +

@@ -123,6 +131,7 @@ class="pt-4" /> currentPlan.value?.name === WorkspacePlans.Academia +) const isPurchasablePlan = computed(() => isPaidPlan(currentPlan.value?.name)) const seatPrice = computed(() => currentPlan.value && subscription.value From 9e0e6a9c0d8b1b200897cc2347312af58e33588d Mon Sep 17 00:00:00 2001 From: Alexandru Popovici Date: Thu, 12 Dec 2024 14:37:39 +0200 Subject: [PATCH 03/15] SpeckleInstancedMeshes now recrete their shadow depth material whenever the instance configuration changes (#3681) --- .../src/Extensions/ExtendedSelection.ts | 2 +- packages/viewer-sandbox/src/main.ts | 7 +++++-- .../shaders/speckle-standard-colored-vert.ts | 4 ++++ .../src/modules/objects/SpeckleInstancedMesh.ts | 11 +++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/viewer-sandbox/src/Extensions/ExtendedSelection.ts b/packages/viewer-sandbox/src/Extensions/ExtendedSelection.ts index 4e2fdbcbc..a7966d342 100644 --- a/packages/viewer-sandbox/src/Extensions/ExtendedSelection.ts +++ b/packages/viewer-sandbox/src/Extensions/ExtendedSelection.ts @@ -138,6 +138,6 @@ export class ExtendedSelection extends SelectionExtension { ) } this.lastGizmoTranslation.copy(this.dummyAnchor.position) - this.viewer.requestRender(UpdateFlags.RENDER | UpdateFlags.SHADOWS) + this.viewer.requestRender(UpdateFlags.RENDER_RESET | UpdateFlags.SHADOWS) } } diff --git a/packages/viewer-sandbox/src/main.ts b/packages/viewer-sandbox/src/main.ts index a473e0716..afe349945 100644 --- a/packages/viewer-sandbox/src/main.ts +++ b/packages/viewer-sandbox/src/main.ts @@ -21,6 +21,7 @@ import { SectionTool } from '@speckle/viewer' import { SectionOutlines } from '@speckle/viewer' import { ViewModesKeys } from './Extensions/ViewModesKeys' import { BoxSelection } from './Extensions/BoxSelection' +import { ExtendedSelection } from './Extensions/ExtendedSelection' const createViewer = async (containerName: string, stream: string) => { const container = document.querySelector(containerName) @@ -45,7 +46,8 @@ const createViewer = async (containerName: string, stream: string) => { await viewer.init() const cameraController = viewer.createExtension(CameraController) - const selection = viewer.createExtension(SelectionExtension) + const selection = viewer.createExtension(ExtendedSelection) + selection.init() const sections = viewer.createExtension(SectionTool) viewer.createExtension(SectionOutlines) const measurements = viewer.createExtension(MeasurementsExtension) @@ -115,7 +117,7 @@ const getStream = () => { // 'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8' // 'https://latest.speckle.systems/streams/58b5648c4d/commits/60371ecb2d' // 'Super' heavy revit shit - 'https://app.speckle.systems/streams/e6f9156405/commits/0694d53bb5' + // 'https://app.speckle.systems/streams/e6f9156405/commits/0694d53bb5' // IFC building (good for a tree based structure) // 'https://latest.speckle.systems/streams/92b620fb17/commits/2ebd336223' // IFC story, a subtree of the above @@ -454,6 +456,7 @@ const getStream = () => { // 'https://app.speckle.systems/projects/344f803f81/models/5582ab673e' // 'https://speckle.xyz/streams/27e89d0ad6/commits/5ed4b74252' + 'https://app.speckle.systems/projects/e89b61b65c/models/2a0995f124' ) } diff --git a/packages/viewer/src/modules/materials/shaders/speckle-standard-colored-vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-standard-colored-vert.ts index e48e38e97..c08fd8630 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-standard-colored-vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-standard-colored-vert.ts @@ -226,6 +226,10 @@ void main() { vec4 rtePivotShadow = computeRelativePositionSeparate(tPivotLow.xyz, tPivotHigh.xyz, uShadowViewer_low, uShadowViewer_high); shadowPosition.xyz = rotate_vertex_position((shadowPosition - rtePivotShadow).xyz, tQuaternion) * tScale.xyz + rtePivotShadow.xyz + tTranslation.xyz; #endif + #ifdef USE_INSTANCING + vec4 rtePivotShadow = computeRelativePositionSeparate(ZERO3, ZERO3, uShadowViewer_low, uShadowViewer_high); + shadowPosition.xyz = (mat3(instanceMatrix) * (shadowPosition - rtePivotShadow).xyz) + rtePivotShadow.xyz + instanceMatrix[3].xyz; + #endif shadowWorldPosition = modelMatrix * shadowPosition + vec4( shadowWorldNormal * directionalLightShadows[ i ].shadowNormalBias, 0 ); vDirectionalShadowCoord[ i ] = shadowMatrix * shadowWorldPosition; diff --git a/packages/viewer/src/modules/objects/SpeckleInstancedMesh.ts b/packages/viewer/src/modules/objects/SpeckleInstancedMesh.ts index d890f67d7..8c03f99f1 100644 --- a/packages/viewer/src/modules/objects/SpeckleInstancedMesh.ts +++ b/packages/viewer/src/modules/objects/SpeckleInstancedMesh.ts @@ -11,6 +11,7 @@ import { Object3D, Ray, Raycaster, + RGBADepthPacking, SkinnedMesh, Sphere, Triangle, @@ -29,6 +30,7 @@ import { } from '../batching/Batch.js' import { SpeckleRaycaster } from './SpeckleRaycaster.js' import Logger from '../utils/Logger.js' +import SpeckleDepthMaterial from '../materials/SpeckleDepthMaterial.js' const _inverseMatrix = new Matrix4() const _ray = new Ray() @@ -183,6 +185,7 @@ export default class SpeckleInstancedMesh extends Group { public updateDrawGroups(transformBuffer: Float32Array, gradientBuffer: Float32Array) { this.instances.forEach((value: InstancedMesh) => { this.remove(value) + value.customDepthMaterial?.dispose() value.dispose() }) this.instances.length = 0 @@ -218,6 +221,14 @@ export default class SpeckleInstancedMesh extends Group { group.instanceMatrix.needsUpdate = true group.layers.set(ObjectLayers.STREAM_CONTENT_MESH) group.frustumCulled = false + group.customDepthMaterial = new SpeckleDepthMaterial( + { + depthPacking: RGBADepthPacking + }, + ['USE_RTE', 'ALPHATEST_REJECTION'] + ) + group.castShadow = !material.transparent + group.receiveShadow = !material.transparent this.instances.push(group) this.add(group) From 60ff23d73d8d09c05df2689c8a096decc53a79a3 Mon Sep 17 00:00:00 2001 From: Alexandru Popovici Date: Thu, 12 Dec 2024 15:42:10 +0200 Subject: [PATCH 04/15] Toggle-able Basit Mode (#3672) * Some updates to Basit Mode. Added it to the 6 key * Renamed BasitMode to ColorsMode --- .../src/Extensions/ViewModesKeys.ts | 3 ++ packages/viewer-sandbox/src/main.ts | 8 +++- packages/viewer/src/index.ts | 2 + .../src/modules/extensions/ViewModes.ts | 7 +++- .../src/modules/pipeline/Passes/BasitPass.ts | 7 ++-- .../pipeline/Pipelines/BasitViewPipeline.ts | 39 ++++++++++++++++--- 6 files changed, 54 insertions(+), 12 deletions(-) diff --git a/packages/viewer-sandbox/src/Extensions/ViewModesKeys.ts b/packages/viewer-sandbox/src/Extensions/ViewModesKeys.ts index 34eb55f13..ef97376fe 100644 --- a/packages/viewer-sandbox/src/Extensions/ViewModesKeys.ts +++ b/packages/viewer-sandbox/src/Extensions/ViewModesKeys.ts @@ -31,6 +31,9 @@ export class ViewModesKeys extends Extension { case '5': viewModes.setViewMode(ViewMode.ARCTIC) break + case '6': + viewModes.setViewMode(ViewMode.COLORS) + break } }) } diff --git a/packages/viewer-sandbox/src/main.ts b/packages/viewer-sandbox/src/main.ts index afe349945..310360f41 100644 --- a/packages/viewer-sandbox/src/main.ts +++ b/packages/viewer-sandbox/src/main.ts @@ -11,7 +11,6 @@ import { import './style.css' import Sandbox from './Sandbox' import { - SelectionExtension, MeasurementsExtension, ExplodeExtension, DiffExtension, @@ -456,7 +455,12 @@ const getStream = () => { // 'https://app.speckle.systems/projects/344f803f81/models/5582ab673e' // 'https://speckle.xyz/streams/27e89d0ad6/commits/5ed4b74252' - 'https://app.speckle.systems/projects/e89b61b65c/models/2a0995f124' + + // DUI3 Mesh Colors + 'https://app.speckle.systems/projects/93200a735d/models/cbacd3eaeb@344a397239' + + // Instance toilets + // 'https://app.speckle.systems/projects/e89b61b65c/models/2a0995f124' ) } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 0a558915b..668ebbe82 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -131,6 +131,7 @@ import { } from './modules/materials/Materials.js' import { AccelerationStructure } from './modules/objects/AccelerationStructure.js' import { TopLevelAccelerationStructure } from './modules/objects/TopLevelAccelerationStructure.js' +import { BasitPipeline } from './modules/pipeline/Pipelines/BasitViewPipeline.js' export { Viewer, @@ -214,6 +215,7 @@ export { MRTEdgesPipeline, MRTShadedViewPipeline, MRTPenViewPipeline, + BasitPipeline, ViewModes, ViewMode, FilterMaterial, diff --git a/packages/viewer/src/modules/extensions/ViewModes.ts b/packages/viewer/src/modules/extensions/ViewModes.ts index c1e2a8281..f4c84824c 100644 --- a/packages/viewer/src/modules/extensions/ViewModes.ts +++ b/packages/viewer/src/modules/extensions/ViewModes.ts @@ -1,5 +1,6 @@ import { UpdateFlags } from '../../IViewer.js' import { ArcticViewPipeline } from '../pipeline/Pipelines/ArcticViewPipeline.js' +import { BasitPipeline } from '../pipeline/Pipelines/BasitViewPipeline.js' import { DefaultPipeline } from '../pipeline/Pipelines/DefaultPipeline.js' import { EdgesPipeline } from '../pipeline/Pipelines/EdgesPipeline.js' import { MRTEdgesPipeline } from '../pipeline/Pipelines/MRT/MRTEdgesPipeline.js' @@ -14,7 +15,8 @@ export enum ViewMode { DEFAULT_EDGES, SHADED, PEN, - ARCTIC + ARCTIC, + COLORS } export class ViewModes extends Extension { @@ -46,6 +48,9 @@ export class ViewModes extends Extension { case ViewMode.ARCTIC: renderer.pipeline = new ArcticViewPipeline(renderer) break + case ViewMode.COLORS: + renderer.pipeline = new BasitPipeline(renderer, this.viewer.getWorldTree()) + break } this.viewer.requestRender(UpdateFlags.RENDER_RESET) } diff --git a/packages/viewer/src/modules/pipeline/Passes/BasitPass.ts b/packages/viewer/src/modules/pipeline/Passes/BasitPass.ts index 37cc61718..2bdf9f89b 100644 --- a/packages/viewer/src/modules/pipeline/Passes/BasitPass.ts +++ b/packages/viewer/src/modules/pipeline/Passes/BasitPass.ts @@ -32,13 +32,14 @@ export class BasitPass extends BaseGPass { super() this.tree = tree this.speckleRenderer = renderer + this.buildMaterials() } public get displayName(): string { return 'BASIT' } - onBeforeRender = () => { + protected buildMaterials() { const batches: MeshBatch[] = this.speckleRenderer.batcher.getBatches( undefined, GeometryType.MESH @@ -112,14 +113,14 @@ export class BasitPass extends BaseGPass { protected overrideMaterials() { for (const k in this.materialMap) { const tuple = this.materialMap[k] - ;(tuple[0].renderObject as SpeckleMesh).setOverrideMaterial(tuple[2]) + ;(tuple[0].renderObject as SpeckleMesh).setOverrideBatchMaterial(tuple[2]) } } protected restoreMaterials() { for (const k in this.materialMap) { const tuple = this.materialMap[k] - ;(tuple[0].renderObject as SpeckleMesh).restoreMaterial() + ;(tuple[0].renderObject as SpeckleMesh).restoreBatchMaterial() } } diff --git a/packages/viewer/src/modules/pipeline/Pipelines/BasitViewPipeline.ts b/packages/viewer/src/modules/pipeline/Pipelines/BasitViewPipeline.ts index 559e0cae8..b93b22c62 100644 --- a/packages/viewer/src/modules/pipeline/Pipelines/BasitViewPipeline.ts +++ b/packages/viewer/src/modules/pipeline/Pipelines/BasitViewPipeline.ts @@ -3,7 +3,9 @@ import SpeckleRenderer from '../../SpeckleRenderer.js' import { GeometryPass } from '../Passes/GeometryPass.js' import { Pipeline } from './Pipeline.js' import { BasitPass } from '../Passes/BasitPass.js' -import { ClearFlags } from '../Passes/GPass.js' +import { ClearFlags, ObjectVisibility } from '../Passes/GPass.js' +import { StencilPass } from '../Passes/StencilPass.js' +import { StencilMaskPass } from '../Passes/StencilMaskPass.js' export class BasitPipeline extends Pipeline { constructor(speckleRenderer: SpeckleRenderer, tree: WorldTree) { @@ -12,13 +14,38 @@ export class BasitPipeline extends Pipeline { const basitPass = new BasitPass(tree, speckleRenderer) basitPass.setLayers([ObjectLayers.STREAM_CONTENT_MESH]) basitPass.setClearColor(0x000000, 0) - basitPass.setClearFlags(ClearFlags.COLOR | ClearFlags.DEPTH | ClearFlags.STENCIL) + basitPass.setClearFlags(ClearFlags.COLOR) basitPass.outputTarget = null - const transparentColorPass = new GeometryPass() - transparentColorPass.setLayers([ObjectLayers.SHADOWCATCHER]) - transparentColorPass.outputTarget = null + const nonMeshPass = new GeometryPass() + nonMeshPass.setLayers([ + ObjectLayers.STREAM_CONTENT_LINE, + ObjectLayers.STREAM_CONTENT_POINT, + ObjectLayers.STREAM_CONTENT_POINT_CLOUD, + ObjectLayers.STREAM_CONTENT_TEXT + ]) + const stencilPass = new StencilPass() + stencilPass.setVisibility(ObjectVisibility.STENCIL) + stencilPass.setLayers([ObjectLayers.STREAM_CONTENT_MESH]) - this.passList.push(basitPass, transparentColorPass) + const stencilMaskPass = new StencilMaskPass() + stencilMaskPass.setVisibility(ObjectVisibility.STENCIL) + stencilMaskPass.setLayers([ObjectLayers.STREAM_CONTENT_MESH]) + stencilMaskPass.setClearFlags(ClearFlags.DEPTH) + + const overlayPass = new GeometryPass() + overlayPass.setLayers([ + ObjectLayers.PROPS, + ObjectLayers.OVERLAY, + ObjectLayers.MEASUREMENTS + ]) + + this.passList.push( + stencilPass, + basitPass, + nonMeshPass, + stencilMaskPass, + overlayPass + ) } } From 222f3ddb5b323f5e0ce829918efcae9da732da4e Mon Sep 17 00:00:00 2001 From: Mike Date: Thu, 12 Dec 2024 15:44:42 +0100 Subject: [PATCH 05/15] Feat: Add upgraded and failed event (#3683) --- .../frontend-2/lib/billing/composables/actions.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/frontend-2/lib/billing/composables/actions.ts b/packages/frontend-2/lib/billing/composables/actions.ts index b8b230d4f..fed3dbd58 100644 --- a/packages/frontend-2/lib/billing/composables/actions.ts +++ b/packages/frontend-2/lib/billing/composables/actions.ts @@ -199,12 +199,24 @@ export const useBillingActions = () => { type: ToastNotificationType.Danger, title: 'Your payment was canceled' }) + + mixpanel.track('Workspace Upgrade Cancelled', { + // eslint-disable-next-line camelcase + workspace_id: workspace.id + }) } else { triggerNotification({ type: ToastNotificationType.Success, title: 'Your workspace plan was successfully updated' }) + mixpanel.track('Workspace Upgraded', { + plan: workspace.plan?.name, + cycle: workspace.subscription?.billingInterval, + // eslint-disable-next-line camelcase + workspace_id: workspace.id + }) + if (import.meta.server) { await sendWebhook(defaultZapierWebhookUrl, { workspaceId: workspace.id, From ad61f1d885d06fc97c682c8cf9b0e7002e065300 Mon Sep 17 00:00:00 2001 From: Mike Date: Thu, 12 Dec 2024 16:12:32 +0100 Subject: [PATCH 06/15] Feat: Change server invite to new design (#3676) --- .../components/form/select/ServerRoles.vue | 5 +- .../components/header/NavUserMenu.vue | 2 +- .../components/invite/dialog/Server.vue | 176 ++++++++++++++++++ .../components/onboarding/checklist/v1.vue | 2 +- .../settings/server/ActiveUsers.vue | 2 +- .../settings/server/PendingInvitations.vue | 2 +- .../settings/server/user/InviteDialog.vue | 136 -------------- .../components/singleton/ToastManager.vue | 10 +- .../lib/common/composables/toast.ts | 99 +++++++--- .../lib/invites/helpers/constants.ts | 8 + .../frontend-2/lib/invites/helpers/types.ts | 12 ++ .../projects/composables/projectManagement.ts | 10 +- .../lib/server/composables/invites.ts | 10 +- .../src/components/form/select/Base.vue | 2 +- .../global/ToastRenderer.stories.ts | 64 +++---- .../src/components/global/ToastRenderer.vue | 34 ++-- .../ui-components/src/helpers/global/toast.ts | 1 + .../src/stories/components/GlobalToast.vue | 10 +- .../src/stories/composables/toast.ts | 97 +++++++--- 19 files changed, 415 insertions(+), 267 deletions(-) create mode 100644 packages/frontend-2/components/invite/dialog/Server.vue delete mode 100644 packages/frontend-2/components/settings/server/user/InviteDialog.vue create mode 100644 packages/frontend-2/lib/invites/helpers/constants.ts create mode 100644 packages/frontend-2/lib/invites/helpers/types.ts diff --git a/packages/frontend-2/components/form/select/ServerRoles.vue b/packages/frontend-2/components/form/select/ServerRoles.vue index 25028ebd8..9aa4c2bce 100644 --- a/packages/frontend-2/components/form/select/ServerRoles.vue +++ b/packages/frontend-2/components/form/select/ServerRoles.vue @@ -7,7 +7,7 @@ :disabled-item-tooltip=" !allowGuest ? 'The Guest role isn\'t enabled on the server' : '' " - name="serverRoles" + :name="name ?? 'serverRoles'" label="Role" :show-label="showLabel" class="min-w-[110px]" @@ -75,7 +75,8 @@ const props = defineProps({ allowAdmin: Boolean, allowArchived: Boolean, fullyControlValue: Boolean, - showLabel: Boolean + showLabel: Boolean, + name: String }) const elementToWatchForChanges = ref(null as Nullable) diff --git a/packages/frontend-2/components/header/NavUserMenu.vue b/packages/frontend-2/components/header/NavUserMenu.vue index 04a59f813..4083b7035 100644 --- a/packages/frontend-2/components/header/NavUserMenu.vue +++ b/packages/frontend-2/components/header/NavUserMenu.vue @@ -124,7 +124,7 @@ - + + + +
+
+
+
+
+
+
+ +
+ +
+ +
+
+ + + +
+
+ + Invite another user + +
+
+
+ + diff --git a/packages/frontend-2/components/onboarding/checklist/v1.vue b/packages/frontend-2/components/onboarding/checklist/v1.vue index 0dd619fb6..73e647a22 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 3d9150edc..da61c5a23 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 2644c0156..844e2801b 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 b8e38bf71..000000000 --- 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 43499b8f7..616aea412 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 657afacac..a2b5911c6 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() { - const stateNotification = useGlobalToastState() - - const currentNotification = ref(stateNotification.value) - const readOnlyNotification = computed(() => currentNotification.value) - - const dismiss = () => { - currentNotification.value = undefined - stateNotification.value = undefined + type Timeout = { + id: string + stop: () => void } - const { start, stop } = useTimeoutFn(() => { - dismiss() - }, 4000) + const stateNotification = useGlobalToastState() + + const timeouts = ref([]) + const currentNotifications = ref( + Array.isArray(stateNotification.value) ? stateNotification.value : [] + ) + const readOnlyNotification = computed(() => currentNotifications.value) + + // 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) + } + } + + // 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 + + // Create timeout for the new notification + const index = currentNotifications.value.length - 1 + const lastNotification = newVal[index] + + if (lastNotification && !lastNotification.autoClose) { + timeouts.value.push({ + id: lastNotification.id as string, + stop: createTimeout(lastNotification) + }) } - - // 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() - - nextTick(() => { - currentNotification.value = newVal - - // (re-)init timeout - stop() - if (newVal.autoClose !== false) start() - }) }, { 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 000000000..d31c358f6 --- /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 000000000..aeae0c16a --- /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 d4520d467..3049df31f 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 c0b73713d..cd18c3222 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 3f988eb63..5c72ffc5f 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 ce1e1fe54..fe7b69219 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 01bdc1a82..41d68a501 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 7052f86a2..4c070cf69 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 38b795ea8..401583d09 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 494ddc143..c8112352f 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 000000000..23cdb98d1 --- /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 a7029b344..8ae8aed37 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 3280bebad..5c6bbfdcb 100644 --- a/packages/frontend-2/components/viewer/views/Menu.vue +++ b/packages/frontend-2/components/viewer/views/Menu.vue @@ -1,52 +1,48 @@ +