diff --git a/packages/core/src/measurements/metrics.ts b/packages/core/src/measurements/metrics.ts index 0fe3267f3d..e83d559852 100644 --- a/packages/core/src/measurements/metrics.ts +++ b/packages/core/src/measurements/metrics.ts @@ -267,6 +267,10 @@ export function metricsToJson (metrics: Metrics): any { return toJson(metricsAggregate(metrics)) } +export function metricsToJson (metrics: Metrics): any { + return toJson(metricsAggregate(metrics)) +} + function printMetricsParamsRows ( params: Record>, offset: number diff --git a/plugins/login-resources/src/components/Join.svelte b/plugins/login-resources/src/components/Join.svelte index 73b11ea837..60052bb562 100644 --- a/plugins/login-resources/src/components/Join.svelte +++ b/plugins/login-resources/src/components/Join.svelte @@ -28,7 +28,7 @@ const location = getCurrentLocation() Analytics.handleEvent('invite_link_activated') - let page = 'login' + let page = 'signUp' $: fields = page === 'login' @@ -60,7 +60,7 @@ let status = OK $: action = { - i18n: login.string.Join, + i18n: page === 'login' ? login.string.Join : login.string.SignUp, func: async () => { status = new Status(Severity.INFO, login.status.ConnectingToServer, {}) @@ -97,23 +97,15 @@ } } - const signUpAction: BottomAction = { - caption: login.string.DoNotHaveAnAccount, - i18n: login.string.SignUp, - func: () => (page = 'signUp') - } - - const loginJoinAction: BottomAction = { - caption: login.string.HaveAccount, - i18n: login.string.LogIn, - func: () => (page = 'login') - } - - $: bottom = page === 'login' ? [signUpAction] : [loginJoinAction] - $: secondaryButtonLabel = page === 'login' ? login.string.SignUp : undefined - $: secondaryButtonAction = () => { - page = 'signUp' - } + $: secondaryButtonLabel = page === 'login' ? login.string.SignUp : login.string.Join + $: secondaryButtonAction = + page === 'login' + ? () => { + page = 'signUp' + } + : () => { + page = 'login' + } onMount(() => { void check() @@ -151,6 +143,6 @@ {action} {secondaryButtonLabel} {secondaryButtonAction} - bottomActions={[...bottom, loginAction, recoveryAction]} + bottomActions={[loginAction, recoveryAction]} withProviders /> diff --git a/plugins/login-resources/src/components/SelectWorkspace.svelte b/plugins/login-resources/src/components/SelectWorkspace.svelte index 6762741362..8d28fbfb79 100644 --- a/plugins/login-resources/src/components/SelectWorkspace.svelte +++ b/plugins/login-resources/src/components/SelectWorkspace.svelte @@ -35,7 +35,6 @@ import { fetchWorkspace, getAccount, - getHref, getWorkspaces, goTo, navigateToWorkspace, @@ -43,9 +42,6 @@ getAccountDisplayName } from '../utils' import StatusControl from './StatusControl.svelte' - - export let navigateUrl: string | undefined = undefined - let workspaces: WorkspaceInfoWithStatus[] = [] let status = OK let accountPromise: Promise diff --git a/server-plugins/controlled-documents-resources/src/index.ts b/server-plugins/controlled-documents-resources/src/index.ts index e5720b567c..764c81b452 100644 --- a/server-plugins/controlled-documents-resources/src/index.ts +++ b/server-plugins/controlled-documents-resources/src/index.ts @@ -30,7 +30,6 @@ import documents, { DocumentApprovalRequest, DocumentState, DocumentTemplate, - getDocumentId, getEffectiveDocUpdate, type DocumentRequest, type DocumentTraining @@ -80,7 +79,7 @@ function archiveDocs (docs: ControlledDocument[], txFactory: TxFactory): Tx[] { function updateMeta (doc: ControlledDocument, txFactory: TxFactory): Tx[] { return [ txFactory.createTxUpdateDoc(doc.attachedToClass, doc.space, doc.attachedTo, { - title: `${getDocumentId(doc)} ${doc.title}` + title: `${doc.code} ${doc.title}` }) ] } diff --git a/server/middleware/src/model.ts b/server/middleware/src/model.ts index 19f2c14525..5f9b96261f 100644 --- a/server/middleware/src/model.ts +++ b/server/middleware/src/model.ts @@ -101,6 +101,19 @@ export class ModelMiddleware extends BaseMiddleware implements Middleware { return this.provideFindAll(ctx, _class, query, options) } + findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + const d = this.context.hierarchy.findDomain(_class) + if (d === DOMAIN_MODEL) { + return this.context.modelDb.findAll(_class, query, options) + } + return this.provideFindAll(ctx, _class, query, options) + } + async init (ctx: MeasureContext): Promise { if (this.context.adapterManager == null) { throw new PlatformError(unknownError('Adapter manager should be configured')) diff --git a/server/workspace-service/src/service.ts b/server/workspace-service/src/service.ts index 40fc0dae06..4e539ea35f 100644 --- a/server/workspace-service/src/service.ts +++ b/server/workspace-service/src/service.ts @@ -68,6 +68,8 @@ import { import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage' import { createWorkspace, upgradeWorkspace } from './ws-operations' +const dbCleanTreshold = 256 // Cleanup workspaces if less 256mb + export interface WorkspaceOptions { errorHandler: (workspace: WorkspaceInfoWithStatus, error: any) => Promise force: boolean @@ -395,18 +397,18 @@ export class WorkspaceWorker { /** * If onlyDrop is true, will drop workspace from database, overwize remove only indexes and do full reindex. */ - async doCleanup (ctx: MeasureContext, workspace: WorkspaceInfoWithStatus, onlyDrop: boolean): Promise { + async doCleanup (ctx: MeasureContext, workspace: WorkspaceInfoWithStatus, cleanIndexes: boolean): Promise { const { dbUrl } = prepareTools([]) const adapter = getWorkspaceDestroyAdapter(dbUrl) await adapter.deleteWorkspace(ctx, sharedPipelineContextVars, workspace.uuid, workspace.dataId) - await this.doReindexFulltext(ctx, workspace, onlyDrop) + await this.doReindexFulltext(ctx, workspace, cleanIndexes) } private async doReindexFulltext ( ctx: MeasureContext, workspace: WorkspaceInfoWithStatus, - onlyDrop: boolean + cleanIndexes: boolean ): Promise { if (this.fulltextUrl !== undefined) { const token = generateToken(systemAccountUuid, workspace.uuid, { service: 'workspace' }) @@ -417,7 +419,7 @@ export class WorkspaceWorker { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, onlyDrop }) + body: JSON.stringify({ token, onlyDrop: cleanIndexes }) }) if (!res.ok) { throw new Error(`HTTP Error ${res.status} ${res.statusText}`) @@ -523,12 +525,16 @@ export class WorkspaceWorker { case 'migration-clean': { // We should remove DB, not storages. await sendEvent('migrate-clean-started', 0) - await this.sendTransactorMaitenance(token, workspace.uuid) - try { - await this.doCleanup(ctx, workspace, false) - } catch (err: any) { - Analytics.handleError(err) - return + await this.sendTransactorMaitenance(token, { name: workspace.uuid }) + + const sz = workspace.backupInfo?.backupSize ?? 0 + if (sz <= dbCleanTreshold) { + try { + await this.doCleanup(ctx, workspace, false) + } catch (err: any) { + Analytics.handleError(err) + return + } } await sendEvent('migrate-clean-done', 0) break diff --git a/server/ws/src/__tests__/rest.test.ts b/server/ws/src/__tests__/rest.test.ts new file mode 100644 index 0000000000..edcca6a125 --- /dev/null +++ b/server/ws/src/__tests__/rest.test.ts @@ -0,0 +1,240 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { generateToken } from '@hcengineering/server-token' + +import { createRestClient, createRestTxOperations, type RestClient } from '@hcengineering/api-client' +import core, { + generateId, + getWorkspaceId, + Hierarchy, + MeasureMetricsContext, + ModelDb, + toFindResult, + type Class, + type Doc, + type DocumentQuery, + type Domain, + type FindOptions, + type FindResult, + type MeasureContext, + type Ref, + type Space, + type Tx, + type TxCreateDoc, + type TxOperations, + type TxResult +} from '@hcengineering/core' +import { ClientSession, startSessionManager, type TSessionManager } from '@hcengineering/server' +import { createDummyStorageAdapter, type SessionManager, type WorkspaceLoginInfo } from '@hcengineering/server-core' +import { startHttpServer } from '../server_http' +import { genMinModel, test } from './minmodel' + +describe('rest-server', () => { + async function getModelDb (): Promise<{ modelDb: ModelDb, hierarchy: Hierarchy, txes: Tx[] }> { + const txes = genMinModel() + const hierarchy = new Hierarchy() + for (const tx of txes) { + hierarchy.tx(tx) + } + const modelDb = new ModelDb(hierarchy) + for (const tx of txes) { + await modelDb.tx(tx) + } + return { modelDb, hierarchy, txes } + } + + let shutdown: () => Promise + let sessionManager: SessionManager + const port: number = 11000 + + beforeAll(async () => { + ;({ shutdown, sessionManager } = startSessionManager(new MeasureMetricsContext('test', {}), { + pipelineFactory: async () => { + const { modelDb, hierarchy, txes } = await getModelDb() + return { + hierarchy, + modelDb, + context: { + workspace: { + name: 'test-ws', + workspaceName: 'test-ws', + workspaceUrl: 'test-ws' + }, + hierarchy, + modelDb, + lastTx: generateId(), + lastHash: generateId(), + contextVars: {}, + branding: null + }, + handleBroadcast: async (ctx) => {}, + findAll: async ( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> => toFindResult(await modelDb.findAll(_class, query, options)), + tx: async (ctx: MeasureContext, tx: Tx[]): Promise<[TxResult, Tx[], string[] | undefined]> => [ + await modelDb.tx(...tx), + [], + undefined + ], + close: async () => {}, + domains: async () => hierarchy.domains(), + groupBy: async () => new Map(), + find: (ctx: MeasureContext, domain: Domain) => ({ + next: async (ctx: MeasureContext) => undefined, + close: async (ctx: MeasureContext) => {} + }), + load: async (ctx: MeasureContext, domain: Domain, docs: Ref[]) => [], + upload: async (ctx: MeasureContext, domain: Domain, docs: Doc[]) => {}, + clean: async (ctx: MeasureContext, domain: Domain, docs: Ref[]) => {}, + searchFulltext: async (ctx, query, options) => { + return { docs: [] } + }, + loadModel: async (ctx, lastModelTx, hash) => ({ + full: true, + hash: generateId(), + transactions: txes + }) + } + }, + sessionFactory: (token, workspace) => new ClientSession(token, workspace, true), + port, + brandingMap: {}, + serverFactory: startHttpServer, + accountsUrl: '', + externalStorage: createDummyStorageAdapter() + })) + jest + .spyOn(sessionManager as TSessionManager, 'getWorkspaceInfo') + .mockImplementation(async (ctx: MeasureContext, token: string): Promise => { + return { + workspaceId: 'test-ws', + workspaceUrl: 'test-ws', + workspaceName: 'Test Workspace', + uuid: 'test-ws', + createdBy: 'test-owner', + mode: 'active', + createdOn: Date.now(), + lastVisit: Date.now(), + disabled: false, + endpoint: `http://localhost:${port}`, + region: 'test-region', + targetRegion: 'test-region', + backupInfo: { + dataSize: 0, + blobsSize: 0, + backupSize: 0, + lastBackup: 0, + backups: 0 + } + } + }) + }) + afterAll(async () => { + await shutdown() + }) + + function connect (): RestClient { + const token: string = generateToken('user1@site.com', getWorkspaceId('test-ws')) + return createRestClient(`http://localhost:${port}`, 'test-ws', token) + } + + async function connectTx (): Promise { + const token: string = generateToken('user1@site.com', getWorkspaceId('test-ws')) + return await createRestTxOperations(`http://localhost:${port}`, 'test-ws', token) + } + + it('get account', async () => { + const conn = connect() + const account = await conn.getAccount() + + expect(account.email).toBe('user1@site.com') + expect(account.role).toBe('OWNER') + expect(account._id).toBe('User1') + expect(account._class).toBe('core:class:Account') + expect(account.space).toBe('core:space:Model') + expect(account.modifiedBy).toBe('core:account:System') + expect(account.createdBy).toBe('core:account:System') + expect(typeof account.modifiedOn).toBe('number') + expect(typeof account.createdOn).toBe('number') + }) + + it('find spaces', async () => { + const conn = connect() + const spaces = await conn.findAll(core.class.Space, {}) + expect(spaces.length).toBe(2) + expect(spaces[0].name).toBe('Sp1') + expect(spaces[1].name).toBe('Sp2') + }) + + it('find avg', async () => { + const conn = connect() + let ops = 0 + let total = 0 + const attempts = 1000 + for (let i = 0; i < attempts; i++) { + const st = performance.now() + const spaces = await conn.findAll(core.class.Space, {}) + expect(spaces.length).toBe(2) + expect(spaces[0].name).toBe('Sp1') + expect(spaces[1].name).toBe('Sp2') + const ed = performance.now() + ops++ + total += ed - st + } + const avg = total / ops + // console.log('ops:', ops, 'total:', total, 'avg:', ) + expect(ops).toEqual(attempts) + expect(avg).toBeLessThan(5) // 5ms max per operation + }) + + it('add space', async () => { + const conn = connect() + const account = await conn.getAccount() + const tx: TxCreateDoc = { + _class: core.class.TxCreateDoc, + space: core.space.Tx, + _id: generateId(), + objectSpace: core.space.Model, + modifiedBy: account._id, + modifiedOn: Date.now(), + attributes: { + name: 'Sp3', + description: '', + private: false, + archived: false, + members: [], + autoJoin: false + }, + objectClass: core.class.Space, + objectId: generateId() + } + await conn.tx(tx) + const spaces = await conn.findAll(core.class.Space, {}) + expect(spaces.length).toBe(3) + }) + + it('check-model-operations', async () => { + const conn = await connectTx() + const h = conn.getHierarchy() + const domains = h.domains() + expect(domains.length).toBe(2) + + expect(h.isDerived(test.class.TestComment, core.class.AttachedDoc)).toBe(true) + }) +}) diff --git a/services/github/pod-github/src/platform.ts b/services/github/pod-github/src/platform.ts index 05d05bbb11..ff79522745 100644 --- a/services/github/pod-github/src/platform.ts +++ b/services/github/pod-github/src/platform.ts @@ -9,6 +9,7 @@ import core, { BrandingMap, Client, ClientConnectEvent, + ClientWorkspaceInfo, DocumentUpdate, isActiveMode, isDeletingMode, diff --git a/tests/sanity/tests/model/signin-page.ts b/tests/sanity/tests/model/signin-page.ts index f72f3036bb..e2beb145f5 100644 --- a/tests/sanity/tests/model/signin-page.ts +++ b/tests/sanity/tests/model/signin-page.ts @@ -15,6 +15,7 @@ export class SignInJoinPage extends CommonPage { buttonJoin = (): Locator => this.page.locator('button', { hasText: 'Join' }) async join (data: Pick): Promise { + await this.buttonJoin().click() await this.inputEmail().fill(data.email) await this.inputPassword().fill(data.password) expect(await this.buttonJoin().isEnabled()).toBe(true) diff --git a/tests/sanity/tests/model/signup-page.ts b/tests/sanity/tests/model/signup-page.ts index 0bf56dcbbe..69c0049775 100644 --- a/tests/sanity/tests/model/signup-page.ts +++ b/tests/sanity/tests/model/signup-page.ts @@ -17,7 +17,6 @@ export class SignUpPage extends CommonPage { inputNewPassword = (): Locator => this.page.locator('//div[text()="Password"]/../input') inputRepeatPassword = (): Locator => this.page.locator('//div[text()="Repeat password"]/../input') buttonSignUp = (): Locator => this.page.locator('button', { hasText: 'Sign Up' }) - buttonJoin = (): Locator => this.page.locator('button', { hasText: 'Join' }) async enterFirstName (firstName: string): Promise { await this.inputFirstName().fill(firstName) @@ -43,25 +42,14 @@ export class SignUpPage extends CommonPage { await this.buttonSignUp().click() } - async signUpPwd (data: SignUpData, mode: 'join' | 'signup' = 'signup'): Promise { - const isOtp = await this.signUpPasswordBtn().isVisible() - if (isOtp) { - await this.signUpPasswordBtn().click() - } + async signUp (data: SignUpData): Promise { await this.enterFirstName(data.firstName) await this.enterLastName(data.lastName) await this.enterEmail(data.email) await this.enterPassword(data.password) await this.enterRepeatPassword(data.password) - switch (mode) { - case 'join': - expect(await this.buttonJoin().isEnabled()).toBe(true) - await this.buttonJoin().click() - break - case 'signup': - expect(await this.buttonSignUp().isEnabled()).toBe(true) - await this.buttonSignUp().click() - } + expect(await this.buttonSignUp().isEnabled()).toBe(true) + await this.buttonSignUp().click() } async checkIfSignUpButtonIsDisabled (): Promise { diff --git a/tests/sanity/tests/workspace/create.spec.ts b/tests/sanity/tests/workspace/create.spec.ts index e3697fa72b..a4e5d7a3a1 100644 --- a/tests/sanity/tests/workspace/create.spec.ts +++ b/tests/sanity/tests/workspace/create.spec.ts @@ -140,9 +140,8 @@ test.describe('Workspace tests', () => { password: '1234' } - await page2.getByRole('link', { name: 'Sign Up' }).click() const signUpPage2 = new SignUpPage(page2) - await signUpPage2.signUpPwd(newUser2, 'join') + await signUpPage2.signUp(newUser2) const leftSideMenuPage2 = new LeftSideMenuPage(page2) await leftSideMenuPage2.clickTracker() diff --git a/ws-tests/sanity/tests/workspace/create.spec.ts b/ws-tests/sanity/tests/workspace/create.spec.ts index 2bb64ebb7d..552f94fc22 100644 --- a/ws-tests/sanity/tests/workspace/create.spec.ts +++ b/ws-tests/sanity/tests/workspace/create.spec.ts @@ -145,9 +145,8 @@ test.describe('Workspace tests', () => { password: '1234' } - await page2.getByRole('link', { name: 'Sign Up' }).click() const signUpPage2 = new SignUpPage(page2) - await signUpPage2.signUpPwd(newUser2, 'join') + await signUpPage2.signUp(newUser2) const leftSideMenuPage2 = new LeftSideMenuPage(page2) await leftSideMenuPage2.clickTracker()