From f9d2ce4a1e8b5192c55b9e41878f8e87f0c76459 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Fri, 19 May 2023 18:46:05 +0700 Subject: [PATCH] UBER-62: Maintenance warnings (#3210) Signed-off-by: Andrey Sobolev --- common/config/rush/pnpm-lock.yaml | 14 +- dev/client-resources/src/connection.ts | 5 + dev/client-resources/src/index.ts | 12 +- dev/tool/src/index.ts | 12 ++ models/chunter/src/index.ts | 2 +- packages/core/src/__tests__/client.test.ts | 5 +- packages/core/src/__tests__/connection.ts | 5 +- packages/core/src/client.ts | 18 +- packages/core/src/tx.ts | 3 +- packages/panel/src/components/Panel.svelte | 3 + packages/platform/src/i18n.ts | 11 +- packages/platform/src/index.ts | 11 +- packages/platform/src/lang/en.json | 5 +- packages/platform/src/lang/ru.json | 5 +- packages/platform/src/platform.ts | 3 +- packages/query/src/__tests__/connection.ts | 5 +- packages/ui/src/components/Label.svelte | 10 +- .../ui/src/components/internal/Root.svelte | 32 ++- plugins/client-resources/src/connection.ts | 5 + plugins/client-resources/src/index.ts | 22 +- plugins/client/src/index.ts | 8 +- plugins/devmodel-resources/src/index.ts | 14 +- .../src/components/SelectWorkspace.svelte | 13 +- .../src/components/ServerManager.svelte | 193 ++++++++++++++++++ .../src/components/ServerStatistics.svelte | 70 ------- .../src/components/Workbench.svelte | 22 +- plugins/workbench-resources/src/connect.ts | 27 ++- plugins/workbench-resources/src/utils.ts | 12 +- server-plugins/contact-resources/src/index.ts | 14 +- server/account/src/index.ts | 86 ++++++-- server/mongo/src/__tests__/storage.test.ts | 3 +- server/ws/package.json | 10 +- server/ws/src/client.ts | 36 +++- server/ws/src/server.ts | 74 ++++++- server/ws/src/server_http.ts | 113 +++++++--- server/ws/src/stats.ts | 13 +- server/ws/src/types.ts | 13 ++ 37 files changed, 688 insertions(+), 221 deletions(-) create mode 100644 plugins/workbench-resources/src/components/ServerManager.svelte delete mode 100644 plugins/workbench-resources/src/components/ServerStatistics.svelte diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index e2d2753dd9..192a859ec6 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -327,7 +327,6 @@ specifiers: lexorank: ~1.0.4 lib0: ~0.2.52 libphonenumber-js: ^1.9.46 - lodash.debounce: ~4.0.8 mime-types: ~2.1.34 mini-css-extract-plugin: ^2.2.0 minio: ^7.0.26 @@ -716,7 +715,6 @@ dependencies: lexorank: 1.0.5 lib0: 0.2.53 libphonenumber-js: 1.10.14 - lodash.debounce: 4.0.8 mime-types: 2.1.35 mini-css-extract-plugin: 2.6.1_webpack@5.75.0 minio: 7.0.32 @@ -19070,7 +19068,7 @@ packages: dev: false file:projects/model-notification.tgz_typescript@4.8.4: - resolution: {integrity: sha512-czp+Fb3hiQDqWgNIiEBZeEThWoi62Q/rTszn832gf8ZtwX7A7WAlPfe98hoVrREiyJa7t8J+T31DPvuzo8jX1w==, tarball: file:projects/model-notification.tgz} + resolution: {integrity: sha512-jirL85hH/HPJT14qMFn4MTOg2rk1/69RElcvra03egNKUmllc0ktQvgsgUJRu70kK+LKgAP4oyTjIir6slDlwg==, tarball: file:projects/model-notification.tgz} id: file:projects/model-notification.tgz name: '@rush-temp/model-notification' version: 0.0.0 @@ -20118,7 +20116,7 @@ packages: dev: false file:projects/pod-collaborator.tgz_bufferutil@4.0.7: - resolution: {integrity: sha512-4EwuJc74XPA1LMocihMIFQmZr/hC1XTu+RhsePPTO4AUJO88V77tXVt9YlE2m/6V8m5iYqiWOW6Q1PVOylidjw==, tarball: file:projects/pod-collaborator.tgz} + resolution: {integrity: sha512-/bUkdWq43OqZGj1+cDLUlsRwUocv5/lpAzh8Mo2QgBHiwFZ9l1UD/l4k3gwweviHHKsJytR3mDieqND5DCng6Q==, tarball: file:projects/pod-collaborator.tgz} id: file:projects/pod-collaborator.tgz name: '@rush-temp/pod-collaborator' version: 0.0.0 @@ -21395,22 +21393,28 @@ packages: dev: false file:projects/server-ws.tgz: - resolution: {integrity: sha512-RtEpUiRRZIRNoECGLQzD/fb9VLJNKh9eZ33oSBnMPcxe9bAxbpQxJ1WqV1EkQKQmCvD1W5bwEIMTjNatV4Ab+Q==, tarball: file:projects/server-ws.tgz} + resolution: {integrity: sha512-HYy/1R9Fu1ntumpyHkmZ0Fu+eTrXJdzoY8CSXC1Yr8WEEUy3yw75zN/LmWhEs6YIC6skcBWEK9qb0gue9kXDGA==, tarball: file:projects/server-ws.tgz} name: '@rush-temp/server-ws' version: 0.0.0 dependencies: '@rushstack/heft': 0.47.11 + '@types/compression': 1.7.2 + '@types/cors': 2.8.12 + '@types/express': 4.17.14 '@types/heft-jest': 1.0.3 '@types/node': 16.11.68 '@types/ws': 8.5.3 '@typescript-eslint/eslint-plugin': 5.42.1_d506b9be61cb4ac2646ecbc6e0680464 '@typescript-eslint/parser': 5.42.1_eslint@8.27.0+typescript@4.8.4 bufferutil: 4.0.7 + compression: 1.7.4 + cors: 2.8.5 eslint: 8.27.0 eslint-config-standard-with-typescript: 23.0.0_c9fe9619f50f4e82337a86c3af25e566 eslint-plugin-import: 2.26.0_eslint@8.27.0 eslint-plugin-n: 15.5.1_eslint@8.27.0 eslint-plugin-promise: 6.1.1_eslint@8.27.0 + express: 4.18.2 prettier: 2.8.8 typescript: 4.8.4 ws: 8.11.0_bufferutil@4.0.7 diff --git a/dev/client-resources/src/connection.ts b/dev/client-resources/src/connection.ts index f43c4c74d3..06393ef186 100644 --- a/dev/client-resources/src/connection.ts +++ b/dev/client-resources/src/connection.ts @@ -14,6 +14,7 @@ // import core, { + Account, Class, ClientConnection, Doc, @@ -65,6 +66,10 @@ class ServerStorageWrapper implements ClientConnection { }) } + async getAccount (): Promise { + return (await this.storage.findAll(this.measureCtx, core.class.Account, {}))[0] + } + async tx (tx: Tx): Promise { const _tx = protoDeserialize(protoSerialize(tx, false), false) const [result, derived] = await this.storage.tx(this.measureCtx, _tx) diff --git a/dev/client-resources/src/index.ts b/dev/client-resources/src/index.ts index dda50fb3d7..129179c994 100644 --- a/dev/client-resources/src/index.ts +++ b/dev/client-resources/src/index.ts @@ -13,18 +13,18 @@ // limitations under the License. // -import { createClient, Client } from '@hcengineering/core' -import { getMetadata, getResource } from '@hcengineering/platform' -import { migrateOperations } from '@hcengineering/model-all' -import { connect } from './connection' import clientPlugin from '@hcengineering/client' +import { AccountClient, createClient } from '@hcengineering/core' +import { migrateOperations } from '@hcengineering/model-all' +import { getMetadata, getResource } from '@hcengineering/platform' +import { connect } from './connection' -let client: Client +let client: AccountClient // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export default async () => { return { function: { - GetClient: async (): Promise => { + GetClient: async (): Promise => { if (client === undefined) { client = await createClient(connect) for (const op of migrateOperations) { diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index 2a2e8a567f..ccf2644aa5 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -26,6 +26,7 @@ import { listAccounts, listWorkspaces, replacePassword, + setAccountAdmin, setRole, upgradeWorkspace } from '@hcengineering/account' @@ -195,6 +196,17 @@ export function devTool ( await setRole(email, workspace, productId, role) }) + program + .command('set-user-admin ') + .description('set user role') + .action(async (email: string, role: string) => { + const { mongodbUri } = prepareTools() + console.log(`set user ${email} admin...`) + return await withDatabase(mongodbUri, async (db) => { + await setAccountAdmin(db, email, role === 'true') + }) + }) + program .command('upgrade-workspace ') .description('upgrade workspace') diff --git a/models/chunter/src/index.ts b/models/chunter/src/index.ts index 7d7b8003a4..7e3c65bacd 100644 --- a/models/chunter/src/index.ts +++ b/models/chunter/src/index.ts @@ -662,7 +662,7 @@ export function createModel (builder: Builder, options = { addApplication: true }, templates: { textTemplate: '{sender} mentioned you in {doc} {data}', - htmlTemplate: '

{sender} mentioned you in {doc}

{data}', + htmlTemplate: '

–{sender} mentioned you in {doc}

{data}', subjectTemplate: 'You were mentioned in {doc}' } }, diff --git a/packages/core/src/__tests__/client.test.ts b/packages/core/src/__tests__/client.test.ts index b915893d7f..80cbf41626 100644 --- a/packages/core/src/__tests__/client.test.ts +++ b/packages/core/src/__tests__/client.test.ts @@ -14,7 +14,7 @@ // limitations under the License. // import { Plugin, IntlString } from '@hcengineering/platform' -import type { Class, Data, Doc, Domain, PluginConfiguration, Ref, Timestamp } from '../classes' +import type { Account, Class, Data, Doc, Domain, PluginConfiguration, Ref, Timestamp } from '../classes' import { Space, ClassifierKind, DOMAIN_MODEL } from '../classes' import { createClient, ClientConnection } from '../client' import core from '../component' @@ -113,7 +113,8 @@ describe('client', () => { loadDocs: async (domain: Domain, docs: Ref[]) => [], upload: async (domain: Domain, docs: Doc[]) => {}, clean: async (domain: Domain, docs: Ref[]) => {}, - loadModel: async (last: Timestamp) => txes + loadModel: async (last: Timestamp) => txes, + getAccount: async () => null as unknown as Account } } const spyCreate = jest.spyOn(TxProcessor, 'createDoc2Doc') diff --git a/packages/core/src/__tests__/connection.ts b/packages/core/src/__tests__/connection.ts index 5fda539e99..cd74509974 100644 --- a/packages/core/src/__tests__/connection.ts +++ b/packages/core/src/__tests__/connection.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import type { Class, Doc, Domain, Ref, Timestamp } from '../classes' +import type { Account, Class, Doc, Domain, Ref, Timestamp } from '../classes' import { ClientConnection } from '../client' import core from '../component' import { Hierarchy } from '../hierarchy' @@ -65,6 +65,7 @@ export async function connect (handler: (tx: Tx) => void): Promise[]) => [], upload: async (domain: Domain, docs: Doc[]) => {}, clean: async (domain: Domain, docs: Ref[]) => {}, - loadModel: async (last: Timestamp) => txes + loadModel: async (last: Timestamp) => txes, + getAccount: async () => null as unknown as Account } } diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 03bc193f49..f6ba56dab5 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -15,7 +15,7 @@ import { Plugin } from '@hcengineering/platform' import { BackupClient, DocChunk } from './backup' -import { AttachedDoc, Class, DOMAIN_MODEL, Doc, Domain, PluginConfiguration, Ref, Timestamp } from './classes' +import { Account, AttachedDoc, Class, DOMAIN_MODEL, Doc, Domain, PluginConfiguration, Ref, Timestamp } from './classes' import core from './component' import { Hierarchy } from './hierarchy' import { ModelDb } from './memdb' @@ -47,6 +47,13 @@ export interface Client extends Storage { close: () => Promise } +/** + * @public + */ +export interface AccountClient extends Client { + getAccount: () => Promise +} + /** * @public */ @@ -54,9 +61,10 @@ export interface ClientConnection extends Storage, BackupClient { close: () => Promise onConnect?: (apply: boolean) => Promise loadModel: (last: Timestamp) => Promise + getAccount: () => Promise } -class ClientImpl implements Client, BackupClient { +class ClientImpl implements AccountClient, BackupClient { notify?: (tx: Tx) => void constructor ( @@ -144,6 +152,10 @@ class ClientImpl implements Client, BackupClient { async clean (domain: Domain, docs: Ref[]): Promise { return await this.conn.clean(domain, docs) } + + async getAccount (): Promise { + return await this.conn.getAccount() + } } /** @@ -153,7 +165,7 @@ export async function createClient ( connect: (txHandler: TxHandler) => Promise, // If set will build model with only allowed plugins. allowedPlugins?: Plugin[] -): Promise { +): Promise { let client: ClientImpl | null = null // Temporal buffer, while we apply model diff --git a/packages/core/src/tx.ts b/packages/core/src/tx.ts index 26ca038095..115f1a95c1 100644 --- a/packages/core/src/tx.ts +++ b/packages/core/src/tx.ts @@ -50,7 +50,8 @@ export enum WorkspaceEvent { UpgradeScheduled, Upgrade, IndexingUpdate, - SecurityChange + SecurityChange, + MaintenanceNotification } /** diff --git a/packages/panel/src/components/Panel.svelte b/packages/panel/src/components/Panel.svelte index 0e82bffe1f..2c2ed5369d 100644 --- a/packages/panel/src/components/Panel.svelte +++ b/packages/panel/src/components/Panel.svelte @@ -62,6 +62,9 @@ const startScrollHeightCheck = () => { clearTimeout(timer) timer = setTimeout(() => { + if (scroll == null) { + return + } if (lastScrollHeight <= scroll.scrollHeight && count <= waitCount) { count = lastScrollHeight < scroll.scrollHeight ? 0 : count + 1 lastScrollHeight = scroll.scrollHeight diff --git a/packages/platform/src/i18n.ts b/packages/platform/src/i18n.ts index 93530b753f..81ec45310b 100644 --- a/packages/platform/src/i18n.ts +++ b/packages/platform/src/i18n.ts @@ -14,14 +14,14 @@ // limitations under the License. // -import type { Plugin, IntlString } from './platform' -import { Status, Severity, unknownError } from './status' -import { _IdInfo, _parseId } from './ident' -import { setPlatformStatus } from './event' import { IntlMessageFormat } from 'intl-messageformat' +import { setPlatformStatus } from './event' +import { _IdInfo, _parseId } from './ident' +import type { IntlString, Plugin } from './platform' +import { Severity, Status, unknownError } from './status' -import platform from './platform' import { getMetadata } from './metadata' +import platform from './platform' /** * @public @@ -103,6 +103,7 @@ async function getTranslation (id: _IdInfo, locale: string): Promise> (message: IntlString

, params: P): Promise { const locale = getMetadata(platform.metadata.locale) ?? 'en' const compiled = cache.get(message) + if (compiled !== undefined) { if (compiled instanceof Status) { return message diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 0fc544f85d..6a6f3efd0c 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -15,15 +15,16 @@ // import { addStringsLoader } from './i18n' -import { platformId } from './platform' import type { Metadata } from './metadata' +import { platformId } from './platform' -export * from './platform' -export * from './status' export * from './event' -export * from './resource' export * from './i18n' export * from './metadata' +export * from './platform' +export { default } from './platform' +export * from './resource' +export * from './status' export * from './testUtils' addStringsLoader(platformId, async (lang: string) => { @@ -39,5 +40,3 @@ export type URL = string * @public */ export type Asset = Metadata - -export { default } from './platform' diff --git a/packages/platform/src/lang/en.json b/packages/platform/src/lang/en.json index ec50a8dd85..a0278d058f 100644 --- a/packages/platform/src/lang/en.json +++ b/packages/platform/src/lang/en.json @@ -1,6 +1,6 @@ { "status": { - "LoadingPlugin": "Loading plugin ''{plugin}''...", + "LoadingPlugin": "Loading plugin {plugin}...", "UnknownError": "Unknown error: {message}", "InvalidId": "Invalid Id: {id}", "BadRequest": "Bad request", @@ -8,6 +8,7 @@ "ExpiredLink": "This invite link is expired", "Unauthorized": "Unauthorized", "UnknownMethod": "Unknown method: {method}", - "InternalServerError": "Internal server error" + "InternalServerError": "Internal server error", + "MaintenanceWarning": "Maintenance Scheduled in {time, plural, =1 {less a minute} other {# minutes}}" } } diff --git a/packages/platform/src/lang/ru.json b/packages/platform/src/lang/ru.json index e66fa0aaa9..67f6f0d3f1 100644 --- a/packages/platform/src/lang/ru.json +++ b/packages/platform/src/lang/ru.json @@ -1,6 +1,6 @@ { "status": { - "LoadingPlugin": "Загрузка плагина ''{plugin}''...", + "LoadingPlugin": "Загрузка плагина {plugin}...", "UnknownError": "Неизвестная ошибка: {message}", "InvalidId": "Некорректный Id: {id}", "BadRequest": "Некорректный запрос", @@ -8,6 +8,7 @@ "ExpiredLink": "Ссылка истекла", "Unauthorized": "Неавторизован", "UnknownMethod": "Неизвестный метод: {method}", - "InternalServerError": "Внутренняя ошибка сервера" + "InternalServerError": "Внутренняя ошибка сервера", + "MaintenanceWarning": "Серверные работы запланированы через {time} минут" } } diff --git a/packages/platform/src/platform.ts b/packages/platform/src/platform.ts index 6123954054..40ab14c8da 100644 --- a/packages/platform/src/platform.ts +++ b/packages/platform/src/platform.ts @@ -143,7 +143,8 @@ export default plugin(platformId, { Unauthorized: '' as StatusCode, ExpiredLink: '' as StatusCode, UnknownMethod: '' as StatusCode<{ method: string }>, - InternalServerError: '' as StatusCode + InternalServerError: '' as StatusCode, + MaintenanceWarning: '' as StatusCode<{ time: number }> }, metadata: { locale: '' as Metadata diff --git a/packages/query/src/__tests__/connection.ts b/packages/query/src/__tests__/connection.ts index 29e41ea8ae..9e92c2b634 100644 --- a/packages/query/src/__tests__/connection.ts +++ b/packages/query/src/__tests__/connection.ts @@ -14,9 +14,9 @@ // import type { + AccountClient, BackupClient, Class, - Client, Doc, DocumentQuery, Domain, @@ -31,7 +31,7 @@ import core, { DOMAIN_TX, Hierarchy, ModelDb, TxDb } from '@hcengineering/core' import { genMinModel } from './minmodel' export async function connect (handler: (tx: Tx) => void): Promise< -Client & +AccountClient & BackupClient & { loadModel: (lastTxTime: Timestamp) => Promise } @@ -63,6 +63,7 @@ BackupClient & { findOne: async (_class, query, options) => (await findAll(_class, query, { ...options, limit: 1 })).shift(), getHierarchy: () => hierarchy, getModel: () => model, + getAccount: async () => ({} as unknown as any), tx: async (tx: Tx): Promise => { if (tx.objectSpace === core.space.Model) { hierarchy.tx(tx) diff --git a/packages/ui/src/components/Label.svelte b/packages/ui/src/components/Label.svelte index 5682d0f5a0..18667733a8 100644 --- a/packages/ui/src/components/Label.svelte +++ b/packages/ui/src/components/Label.svelte @@ -22,9 +22,13 @@ let _value: string | undefined = undefined $: if (label !== undefined) { - translate(label, params ?? {}).then((r) => { - _value = r - }) + translate(label, params ?? {}) + .then((r) => { + _value = r + }) + .catch((err) => { + console.error(err) + }) } else { _value = label } diff --git a/packages/ui/src/components/internal/Root.svelte b/packages/ui/src/components/internal/Root.svelte index ac06281dd0..1b4ca43f0d 100644 --- a/packages/ui/src/components/internal/Root.svelte +++ b/packages/ui/src/components/internal/Root.svelte @@ -1,5 +1,5 @@ + + + + {#if data} + Mem: {data.statistics.memoryUsed} / {data.statistics.memoryTotal} CPU: {data.statistics.cpuUsage} + {/if} + + + Server manager + { + selectedTab = result.detail.id + }} + /> + + {#if data} + {#if selectedTab === 'general'} + +

+
+
1.
+
+ +
+
2.
+
+
+ + {:else if selectedTab === 'users'} +
+ + {#each Object.entries(activeSessions) as act} + +
+ Workspace: {act[0]}: {act[1].length} +
+ +
+ {#each act[1] as user} + {@const employee = employees.get(user.userId)} +
+ {#if employee} + + {:else} + {user.userId} + {/if} +
+ Total: {user.total.find}/{user.total.tx} +
+
+ Previous 5 mins: {user.mins5.find}/{user.mins5.tx} +
+
+ Current 5 mins: {user.current.find}/{user.current.tx} +
+
+ {/each} +
+
+ {/each} +
+
+ {:else if selectedTab === 'statistics'} + + + + + + + + + + + + {#each metricsToRows(data.metrics, 'System') as row} + + + + + + + {/each} + +
Name
AverageTotalOps
+ + {row[1]} + + {row[2]}{row[3]}{row[4]}
+
+ {/if} + {:else} + + {/if} + diff --git a/plugins/workbench-resources/src/components/ServerStatistics.svelte b/plugins/workbench-resources/src/components/ServerStatistics.svelte deleted file mode 100644 index 6e6480431b..0000000000 --- a/plugins/workbench-resources/src/components/ServerStatistics.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - - {}} okLabel={getEmbeddedLabel('Ok')}> - {#if data} -
- {#each Object.entries(data.statistics?.activeSessions) as act} - - {act[0]}: {act[1]} - - {/each} -
- - - Memory usage: {data.statistics.memoryUsed} / {data.statistics.memoryTotal} - - - CPU: {data.statistics.cpuUsage} - - - Mem: {data.statistics.freeMem} / {data.statistics.totalMem} - - - - - - - - - - - - - {#each metricsToRows(data.metrics, 'System') as row} - - - - - - - {/each} - -
NameAverageTotalOps
- - {row[1]} - - {row[2]}{row[3]}{row[4]}
- {/if} -
diff --git a/plugins/workbench-resources/src/components/Workbench.svelte b/plugins/workbench-resources/src/components/Workbench.svelte index faa435048e..6bfb19973f 100644 --- a/plugins/workbench-resources/src/components/Workbench.svelte +++ b/plugins/workbench-resources/src/components/Workbench.svelte @@ -122,16 +122,20 @@ }) }) - let account = getCurrentAccount() as EmployeeAccount + const accountId = (getCurrentAccount() as EmployeeAccount)._id + + let account: EmployeeAccount | undefined const accountQ = createQuery() accountQ.query( contact.class.EmployeeAccount, { - _id: account._id + _id: accountId }, (res) => { - account = res[0] - setCurrentAccount(account) + if (res.length > 0) { + account = res[0] + setCurrentAccount(account) + } }, { limit: 1 } ) @@ -139,10 +143,10 @@ let employee: Employee | undefined const employeeQ = createQuery() - employeeQ.query( + $: employeeQ.query( contact.class.Employee, { - _id: account.employee + _id: account?.employee }, (res) => { employee = res[0] @@ -156,7 +160,7 @@ notificationQuery.query( notification.class.DocUpdates, { - user: account._id, + user: accountId, hidden: false }, (res) => { @@ -529,7 +533,7 @@ let lastLoc: Location | undefined = undefined -{#if employee?.active === true} +{#if employee?.active === true || accountId === core.account.System} @@ -637,7 +641,7 @@ showPopup(AccountPopup, {}, popupPosition) }} > - + diff --git a/plugins/workbench-resources/src/connect.ts b/plugins/workbench-resources/src/connect.ts index 03c00d2cab..bd69a5c6c5 100644 --- a/plugins/workbench-resources/src/connect.ts +++ b/plugins/workbench-resources/src/connect.ts @@ -1,6 +1,12 @@ import client from '@hcengineering/client' -import contact from '@hcengineering/contact' -import core, { AccountRole, Client, setCurrentAccount, Version, versionToString } from '@hcengineering/core' +import core, { + AccountRole, + Client, + AccountClient, + setCurrentAccount, + Version, + versionToString +} from '@hcengineering/core' import login, { loginId } from '@hcengineering/login' import { addEventListener, getMetadata, getResource, setMetadata } from '@hcengineering/platform' import presentation, { refreshClient, setClient } from '@hcengineering/presentation' @@ -12,12 +18,12 @@ import ui, { setMetadataLocalStorage, showPopup } from '@hcengineering/ui' -import ServerStatistics from './components/ServerStatistics.svelte' +import ServerManager from './components/ServerManager.svelte' export let versionError: string | undefined = '' let _token: string | undefined -let _client: Client | undefined +let _client: AccountClient | undefined addEventListener(client.event.NetworkRequests, async (event: string, val: number) => { networkStatus.set(val) @@ -95,7 +101,7 @@ export async function connect (title: string): Promise { ) console.log('logging in as', email) - const me = await _client.findOne(contact.class.EmployeeAccount, { email }) + const me = await _client?.getAccount() if (me !== undefined) { console.log('login: employee account', me) setCurrentAccount(me) @@ -143,16 +149,17 @@ export async function connect (title: string): Promise { if (me.role === AccountRole.Owner) { let ep = endpoint.replace(/^ws/g, 'http') - if (!ep.endsWith('/')) { - ep += '/' + if (ep.endsWith('/')) { + ep = ep.substring(0, ep.length - 1) } setMetadata(ui.metadata.ShowNetwork, (evt: MouseEvent) => { showPopup( - ServerStatistics, + ServerManager, { - endpoint: ep + token + endpoint: ep, + token }, - 'top' + 'content' ) }) } diff --git a/plugins/workbench-resources/src/utils.ts b/plugins/workbench-resources/src/utils.ts index 252af2a03a..f8e1fbe05b 100644 --- a/plugins/workbench-resources/src/utils.ts +++ b/plugins/workbench-resources/src/utils.ts @@ -16,16 +16,16 @@ import type { Class, Client, Doc, Obj, Ref, Space } from '@hcengineering/core' import core from '@hcengineering/core' +import type { Workspace } from '@hcengineering/login' import type { Asset } from '@hcengineering/platform' import { getResource } from '@hcengineering/platform' -import workbench, { NavigatorModel } from '@hcengineering/workbench' -import view from '@hcengineering/view' -import { closePanel, getCurrentLocation, navigate } from '@hcengineering/ui' -import { getClient } from '@hcengineering/presentation' -import type { Application } from '@hcengineering/workbench' import preference from '@hcengineering/preference' +import { getClient } from '@hcengineering/presentation' +import { closePanel, getCurrentLocation, navigate } from '@hcengineering/ui' +import view from '@hcengineering/view' +import type { Application } from '@hcengineering/workbench' +import workbench, { NavigatorModel } from '@hcengineering/workbench' import { writable } from 'svelte/store' -import type { Workspace } from '@hcengineering/login' export function classIcon (client: Client, _class: Ref>): Asset | undefined { return client.getHierarchy().getClass(_class).icon diff --git a/server-plugins/contact-resources/src/index.ts b/server-plugins/contact-resources/src/index.ts index b116eb4bd2..623257cfd4 100644 --- a/server-plugins/contact-resources/src/index.ts +++ b/server-plugins/contact-resources/src/index.ts @@ -74,12 +74,14 @@ export async function OnContactDelete ( storageFx(async (adapter, bucket) => { await adapter.remove(bucket, [avatar]) - const extra = await adapter.list(bucket, avatar) - if (extra.length > 0) { - await adapter.remove( - bucket, - Array.from(extra.entries()).map((it) => it[1].name) - ) + if (avatar != null) { + const extra = await adapter.list(bucket, avatar) + if (extra.length > 0) { + await adapter.remove( + bucket, + Array.from(extra.entries()).map((it) => it[1].name) + ) + } } }) diff --git a/server/account/src/index.ts b/server/account/src/index.ts index 5d3c6e8ecb..b4bdaef2ce 100644 --- a/server/account/src/index.ts +++ b/server/account/src/index.ts @@ -110,6 +110,8 @@ export interface Account { hash: Binary salt: Binary workspaces: ObjectId[] + // Defined for server admins only + admin?: boolean } /** @@ -171,6 +173,18 @@ export async function getAccount (db: Db, email: string): Promise({ email }) } +/** + * @public + */ +export async function setAccountAdmin (db: Db, email: string, admin: boolean): Promise { + const account = await getAccount(db, email) + if (account === null) { + return + } + // Add workspace to account + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { admin } }) +} + function withProductId (productId: string, query: Filter): Filter { return productId === '' ? { @@ -218,15 +232,25 @@ async function getAccountInfo (db: Db, email: string, password: string): Promise */ export async function login (db: Db, productId: string, email: string, password: string): Promise { console.log(`login attempt:${email}`) - await getAccountInfo(db, email, password) + const info = await getAccountInfo(db, email, password) const result = { endpoint: getEndpoint(), email, - token: generateToken(email, getWorkspaceId('', productId)) + token: generateToken(email, getWorkspaceId('', productId), getAdminExtra(info)) } return result } +/** + * Will add admin=='true' in case of user is server admin + */ +function getAdminExtra ( + info: Account | AccountInfo | null, + rec?: Record +): Record | undefined { + return info?.admin === true ? { ...rec, admin: 'true' } : rec +} + /** * @public */ @@ -241,6 +265,17 @@ export async function selectWorkspace ( if (accountInfo === null) { throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.AccountNotFound, { account: email })) } + + if (accountInfo.admin === true) { + return { + endpoint: getEndpoint(), + email, + token: generateToken(email, getWorkspaceId(workspace, productId), getAdminExtra(accountInfo)), + workspace, + productId + } + } + const workspaceInfo = await getWorkspace(db, productId, workspace) if (workspaceInfo !== null) { @@ -251,7 +286,7 @@ export async function selectWorkspace ( const result = { endpoint: getEndpoint(), email, - token: generateToken(email, getWorkspaceId(workspace, productId)), + token: generateToken(email, getWorkspaceId(workspace, productId), getAdminExtra(accountInfo)), workspace, productId } @@ -373,7 +408,7 @@ export async function createAccount ( const result = { endpoint: getEndpoint(), email, - token: generateToken(email, getWorkspaceId('', productId)) + token: generateToken(email, getWorkspaceId('', productId), getAdminExtra(account)) } return result } @@ -381,10 +416,10 @@ export async function createAccount ( /** * @public */ -export async function listWorkspaces (db: Db, productId: string): Promise { - return (await db.collection(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray()).map( - (it) => ({ ...it, productId }) - ) +export async function listWorkspaces (db: Db, productId: string): Promise { + return (await db.collection(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray()) + .map((it) => ({ ...it, productId })) + .map(trimWorkspace) } /** @@ -462,10 +497,11 @@ export const createUserWorkspace = await createWorkspace(version, txes, migrationOperation, db, productId, workspace, '') await assignWorkspace(db, productId, email, workspace) await setRole(email, workspace, productId, AccountRole.Owner) + const info = await getAccount(db, email) const result = { endpoint: getEndpoint(), email, - token: generateToken(email, getWorkspaceId(workspace, productId)), + token: generateToken(email, getWorkspaceId(workspace, productId), getAdminExtra(info)), productId } return result @@ -501,14 +537,26 @@ export async function getInviteLink ( /** * @public */ -export async function getUserWorkspaces (db: Db, productId: string, token: string): Promise { +export type WorkspaceInfoOnly = Omit + +function trimWorkspace (ws: Workspace): WorkspaceInfoOnly { + const { _id, accounts, ...data } = ws + return data +} + +/** + * @public + */ +export async function getUserWorkspaces (db: Db, productId: string, token: string): Promise { const { email } = decodeToken(token) const account = await getAccount(db, email) if (account === null) return [] - return await db - .collection(WORKSPACE_COLLECTION) - .find(withProductId(productId, { _id: { $in: account.workspaces } })) - .toArray() + return ( + await db + .collection(WORKSPACE_COLLECTION) + .find(withProductId(productId, account.admin === true ? {} : { _id: { $in: account.workspaces } })) + .toArray() + ).map(trimWorkspace) } async function getWorkspaceAndAccount ( @@ -686,9 +734,13 @@ export async function requestPassword (db: Db, productId: string, email: string) throw new Error('Please provide front url') } - const token = generateToken('@restore', getWorkspaceId('', productId), { - restore: email - }) + const token = generateToken( + '@restore', + getWorkspaceId('', productId), + getAdminExtra(account, { + restore: email + }) + ) const link = concatLink(front, `/login/recovery?id=${token}`) diff --git a/server/mongo/src/__tests__/storage.test.ts b/server/mongo/src/__tests__/storage.test.ts index 78b364f9f3..1993636699 100644 --- a/server/mongo/src/__tests__/storage.test.ts +++ b/server/mongo/src/__tests__/storage.test.ts @@ -168,7 +168,8 @@ describe('mongo operations', () => { loadDocs: async (domain: Domain, docs: Ref[]) => [], upload: async (domain: Domain, docs: Doc[]) => {}, clean: async (domain: Domain, docs: Ref[]) => {}, - loadModel: async () => txes + loadModel: async () => txes, + getAccount: async () => ({} as any) } return st }) diff --git a/server/ws/package.json b/server/ws/package.json index 44bca340d3..5add640361 100644 --- a/server/ws/package.json +++ b/server/ws/package.json @@ -26,7 +26,10 @@ "@typescript-eslint/parser": "^5.41.0", "eslint-config-standard-with-typescript": "^23.0.0", "prettier": "^2.7.1", - "typescript": "^4.3.5" + "typescript": "^4.3.5", + "@types/express": "^4.17.13", + "@types/cors": "^2.8.12", + "@types/compression": "~1.7.2" }, "dependencies": { "ws": "^8.10.0", @@ -35,6 +38,9 @@ "@hcengineering/server-core": "^0.6.1", "@hcengineering/server-token": "^0.6.4", "@hcengineering/rpc": "^0.6.0", - "bufferutil": "^4.0.7" + "bufferutil": "^4.0.7", + "express": "^4.17.1", + "compression": "~1.7.4", + "cors": "^2.8.5" } } diff --git a/server/ws/src/client.ts b/server/ws/src/client.ts index 13c9c3826e..60647a92da 100644 --- a/server/ws/src/client.ts +++ b/server/ws/src/client.ts @@ -13,7 +13,9 @@ // limitations under the License. // -import { +import core, { + Account, + AccountRole, Class, Doc, DocumentQuery, @@ -27,7 +29,7 @@ import { } from '@hcengineering/core' import { Pipeline, SessionContext } from '@hcengineering/server-core' import { Token } from '@hcengineering/server-token' -import { BroadcastCall, Session, SessionRequest } from './types' +import { BroadcastCall, Session, SessionRequest, StatisticsElement } from './types' /** * @public @@ -37,6 +39,11 @@ export class ClientSession implements Session { binaryResponseMode: boolean = false useCompression: boolean = true sessionId = '' + + total: StatisticsElement = { find: 0, tx: 0 } + current: StatisticsElement = { find: 0, tx: 0 } + mins5: StatisticsElement = { find: 0, tx: 0 } + constructor ( protected readonly broadcast: BroadcastCall, protected readonly token: Token, @@ -60,18 +67,43 @@ export class ClientSession implements Session { return await this._pipeline.storage.loadModel(lastModelTx) } + async getAccount (ctx: MeasureContext): Promise { + const account = await this._pipeline.modelDb.findAll(core.class.Account, { email: this.token.email }) + if (account.length === 0 && this.token.extra?.admin === 'true') { + // Generate fake account for admin user + const account = { + _id: core.account.System, + _class: 'contact:class:EmployeeAccount' as Ref>, + name: 'System,Ghost', + email: this.token.email, + space: core.space.Model, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + role: AccountRole.Owner + } + // Add for other services to work properly + this._pipeline.modelDb.addDoc(account) + return account + } + return account[0] + } + async findAll( ctx: MeasureContext, _class: Ref>, query: DocumentQuery, options?: FindOptions ): Promise> { + this.total.find++ + this.current.find++ const context = ctx as SessionContext context.userEmail = this.token.email return await this._pipeline.findAll(context, _class, query, options) } async tx (ctx: MeasureContext, tx: Tx): Promise { + this.total.tx++ + this.current.tx++ const context = ctx as SessionContext context.userEmail = this.token.email const [result, derived, target] = await this._pipeline.tx(context, tx) diff --git a/server/ws/src/server.ts b/server/ws/src/server.ts index fcbc785787..4402b2238e 100644 --- a/server/ws/src/server.ts +++ b/server/ws/src/server.ts @@ -19,6 +19,8 @@ import core, { Space, Tx, TxFactory, + TxWorkspaceEvent, + WorkspaceEvent, WorkspaceId, generateId, toWorkspaceString @@ -52,6 +54,9 @@ class TSessionManager implements SessionManager { sessions: Map = new Map() + maintenanceTimer: any + timeMinutes = 0 + constructor ( readonly ctx: MeasureContext, readonly sessionFactory: (token: Token, pipeline: Pipeline, broadcast: BroadcastCall) => Session @@ -59,9 +64,66 @@ class TSessionManager implements SessionManager { this.checkInterval = setInterval(() => this.handleInterval(), 1000) } + scheduleMaintenance (timeMinutes: number): void { + this.timeMinutes = timeMinutes + + this.sendMaintenanceWarning() + + const nextTime = (): number => (this.timeMinutes > 1 ? 60 * 1000 : this.timeMinutes * 60 * 1000) + + const showMaintenance = (): void => { + if (this.timeMinutes > 1) { + this.timeMinutes -= 1 + clearTimeout(this.maintenanceTimer) + this.maintenanceTimer = setTimeout(showMaintenance, nextTime()) + } else { + this.timeMinutes = 0 + } + + this.sendMaintenanceWarning() + } + + clearTimeout(this.maintenanceTimer) + this.maintenanceTimer = setTimeout(showMaintenance, nextTime()) + } + + private sendMaintenanceWarning (): void { + if (this.timeMinutes === 0) { + return + } + const event: TxWorkspaceEvent = this.createMaintenanceWarning() + for (const ws of this.workspaces.values()) { + this.broadcastAll(ws, [event]) + } + } + + private createMaintenanceWarning (): TxWorkspaceEvent { + return { + _id: generateId(), + _class: core.class.TxWorkspaceEvent, + event: WorkspaceEvent.MaintenanceNotification, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + objectSpace: core.space.DerivedTx, + space: core.space.DerivedTx, + createdBy: core.account.System, + params: { + timeMinutes: this.timeMinutes + } + } + } + + ticks = 0 + handleInterval (): void { for (const h of this.workspaces.entries()) { for (const s of h[1].sessions) { + if (this.ticks % (5 * 60) === 0) { + s[1].session.mins5.find = s[1].session.current.find + s[1].session.mins5.tx = s[1].session.current.tx + + s[1].session.current = { find: 0, tx: 0 } + } for (const r of s[1].session.requests.values()) { const ed = Date.now() @@ -71,6 +133,7 @@ class TSessionManager implements SessionManager { } } } + this.ticks++ } createSession (token: Token, pipeline: Pipeline): Session { @@ -121,6 +184,15 @@ class TSessionManager implements SessionManager { // We need to delete previous session with Id if found. workspace.sessions.set(session.sessionId, { session, socket: ws }) await ctx.with('set-status', {}, () => this.setStatus(ctx, session, true)) + + if (this.timeMinutes > 0) { + void ws.send( + ctx, + { result: this.createMaintenanceWarning() }, + session.binaryResponseMode, + session.useCompression + ) + } return session }) } @@ -461,7 +533,7 @@ class TSessionManager implements SessionManager { id: request.id, error: unknownError(err) } - await ws.send(ctx, resp, false, false) + await ws.send(ctx, resp, service.binaryResponseMode, service.useCompression) } }) } finally { diff --git a/server/ws/src/server_http.ts b/server/ws/src/server_http.ts index 68f3f1e1e0..e578f3dc9a 100644 --- a/server/ws/src/server_http.ts +++ b/server/ws/src/server_http.ts @@ -17,10 +17,14 @@ import { MeasureContext, generateId } from '@hcengineering/core' import { UNAUTHORIZED } from '@hcengineering/platform' import { Response, serialize } from '@hcengineering/rpc' import { Token, decodeToken } from '@hcengineering/server-token' -import { IncomingMessage, ServerResponse, createServer } from 'http' +import compression from 'compression' +import cors from 'cors' +import express from 'express' +import http, { IncomingMessage } from 'http' import { RawData, WebSocket, WebSocketServer } from 'ws' import { getStatistics } from './stats' import { ConnectionSocket, HandleRequestFunction, LOGGING_ENABLED, PipelineFactory, SessionManager } from './types' + /** * @public * @param sessionFactory - @@ -38,6 +42,80 @@ export function startHttpServer ( ): () => Promise { if (LOGGING_ENABLED) console.log(`starting server on port ${port} ...`) + const app = express() + app.use(cors()) + app.use( + compression({ + filter: (req, res) => { + if (req.headers['x-no-compression'] != null) { + // don't compress responses with this request header + return false + } + + // fallback to standard filter function + return compression.filter(req, res) + }, + level: 6 + }) + ) + + const getUsers = (): any => Array.from(sessions.sessions.entries()).map(([k, v]) => v.session.getUser()) + + app.get('/api/v1/statistics', (req, res) => { + try { + const token = req.query.token as string + const payload = decodeToken(token) + const admin = payload.extra?.admin === 'true' + res.writeHead(200) + const json = JSON.stringify({ + ...getStatistics(ctx, sessions, admin), + users: getUsers, + admin + }) + res.end(json) + } catch (err) { + console.error(err) + res.writeHead(404, {}) + res.end() + } + }) + app.put('/api/v1/manage', (req, res) => { + try { + const token = req.query.token as string + const payload = decodeToken(token) + if (payload.extra?.admin !== 'true') { + res.writeHead(404, {}) + res.end() + return + } + + const operation = req.query.operation + + switch (operation) { + case 'maintenance': { + const timeMinutes = parseInt((req.query.timeout as string) ?? '5') + sessions.scheduleMaintenance(timeMinutes) + + res.writeHead(200) + res.end() + break + } + case 'reboot': { + process.exit(0) + } + } + + res.writeHead(404, {}) + res.end() + } catch (err) { + console.error(err) + res.writeHead(404, {}) + res.end() + } + }) + + const httpServer = http.createServer(app) + const wss = new WebSocketServer({ noServer: true, perMessageDeflate: enableCompression @@ -121,32 +199,7 @@ export function startHttpServer ( } }) - const server = createServer() - - server.on('request', (request: IncomingMessage, response: ServerResponse) => { - const url = new URL('http://localhost' + (request.url ?? '')) - - const token = url.pathname.substring(1) - try { - const payload = decodeToken(token ?? '') - console.log(payload.workspace, 'statistics request') - - response.writeHead(200, { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' - }) - const json = JSON.stringify(getStatistics(ctx, sessions)) - response.end(json) - } catch (err) { - console.error(err) - response.writeHead(404, {}) - response.end() - } - }) - - server.on('upgrade', (request: IncomingMessage, socket: any, head: Buffer) => { + httpServer.on('upgrade', (request: IncomingMessage, socket: any, head: Buffer) => { const url = new URL('http://localhost' + (request.url ?? '')) const token = url.pathname.substring(1) @@ -178,13 +231,13 @@ export function startHttpServer ( }) } }) - server.on('error', (err) => { + httpServer.on('error', (err) => { if (LOGGING_ENABLED) console.error('server error', err) }) - server.listen(port) + httpServer.listen(port) return async () => { - server.close() + httpServer.close() await sessions.closeWorkspaces(ctx) } } diff --git a/server/ws/src/stats.ts b/server/ws/src/stats.ts index b09d12b2f9..9d4e0d4c7f 100644 --- a/server/ws/src/stats.ts +++ b/server/ws/src/stats.ts @@ -5,7 +5,7 @@ import { SessionManager } from './types' /** * @public */ -export function getStatistics (ctx: MeasureContext, sessions: SessionManager): any { +export function getStatistics (ctx: MeasureContext, sessions: SessionManager, admin: boolean): any { const data: Record = { metrics: metricsAggregate((ctx as any).metrics), statistics: { @@ -13,8 +13,15 @@ export function getStatistics (ctx: MeasureContext, sessions: SessionManager): a } } data.statistics.totalClients = sessions.sessions.size - for (const [k, v] of sessions.workspaces) { - data.statistics.activeSessions[k] = v.sessions.size + if (admin) { + for (const [k, v] of sessions.workspaces) { + data.statistics.activeSessions[k] = Array.from(v.sessions.entries()).map(([k, v]) => ({ + userId: v.session.getUser(), + mins5: v.session.mins5, + total: v.session.total, + current: v.session.current + })) + } } data.statistics.memoryUsed = Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 100) / 100 diff --git a/server/ws/src/types.ts b/server/ws/src/types.ts index df1be22121..5ee51c7826 100644 --- a/server/ws/src/types.ts +++ b/server/ws/src/types.ts @@ -23,6 +23,13 @@ export interface SessionRequest { start: number } +/** + * @public + */ +export interface StatisticsElement { + find: number + tx: number +} /** * @public */ @@ -48,6 +55,10 @@ export interface Session { binaryResponseMode: boolean useCompression: boolean + + total: StatisticsElement + current: StatisticsElement + mins5: StatisticsElement } /** @@ -141,6 +152,8 @@ export interface SessionManager { closeWorkspaces: (ctx: MeasureContext) => Promise broadcast: (from: Session | null, workspaceId: WorkspaceId, resp: Response, target?: string[]) => void + + scheduleMaintenance: (timeMinutes: number) => void } /**