mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-16 05:13:06 +00:00
UBER-62: Maintenance warnings (#3210)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
parent
39e5dd5142
commit
f9d2ce4a1e
@ -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
|
||||
|
@ -14,6 +14,7 @@
|
||||
//
|
||||
|
||||
import core, {
|
||||
Account,
|
||||
Class,
|
||||
ClientConnection,
|
||||
Doc,
|
||||
@ -65,6 +66,10 @@ class ServerStorageWrapper implements ClientConnection {
|
||||
})
|
||||
}
|
||||
|
||||
async getAccount (): Promise<Account> {
|
||||
return (await this.storage.findAll(this.measureCtx, core.class.Account, {}))[0]
|
||||
}
|
||||
|
||||
async tx (tx: Tx): Promise<TxResult> {
|
||||
const _tx = protoDeserialize(protoSerialize(tx, false), false)
|
||||
const [result, derived] = await this.storage.tx(this.measureCtx, _tx)
|
||||
|
@ -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<Client> => {
|
||||
GetClient: async (): Promise<AccountClient> => {
|
||||
if (client === undefined) {
|
||||
client = await createClient(connect)
|
||||
for (const op of migrateOperations) {
|
||||
|
@ -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 <email> <role>')
|
||||
.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 <name>')
|
||||
.description('upgrade workspace')
|
||||
|
@ -662,7 +662,7 @@ export function createModel (builder: Builder, options = { addApplication: true
|
||||
},
|
||||
templates: {
|
||||
textTemplate: '{sender} mentioned you in {doc} {data}',
|
||||
htmlTemplate: '<p><b>{sender}</b> mentioned you in {doc}</p> {data}',
|
||||
htmlTemplate: '<p>–{sender}</b> mentioned you in {doc}</p> {data}',
|
||||
subjectTemplate: 'You were mentioned in {doc}'
|
||||
}
|
||||
},
|
||||
|
@ -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<Doc>[]) => [],
|
||||
upload: async (domain: Domain, docs: Doc[]) => {},
|
||||
clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
|
||||
loadModel: async (last: Timestamp) => txes
|
||||
loadModel: async (last: Timestamp) => txes,
|
||||
getAccount: async () => null as unknown as Account
|
||||
}
|
||||
}
|
||||
const spyCreate = jest.spyOn(TxProcessor, 'createDoc2Doc')
|
||||
|
@ -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<ClientConnect
|
||||
loadDocs: async (domain: Domain, docs: Ref<Doc>[]) => [],
|
||||
upload: async (domain: Domain, docs: Doc[]) => {},
|
||||
clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
|
||||
loadModel: async (last: Timestamp) => txes
|
||||
loadModel: async (last: Timestamp) => txes,
|
||||
getAccount: async () => null as unknown as Account
|
||||
}
|
||||
}
|
||||
|
@ -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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface AccountClient extends Client {
|
||||
getAccount: () => Promise<Account>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@ -54,9 +61,10 @@ export interface ClientConnection extends Storage, BackupClient {
|
||||
close: () => Promise<void>
|
||||
onConnect?: (apply: boolean) => Promise<void>
|
||||
loadModel: (last: Timestamp) => Promise<Tx[]>
|
||||
getAccount: () => Promise<Account>
|
||||
}
|
||||
|
||||
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<Doc>[]): Promise<void> {
|
||||
return await this.conn.clean(domain, docs)
|
||||
}
|
||||
|
||||
async getAccount (): Promise<Account> {
|
||||
return await this.conn.getAccount()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -153,7 +165,7 @@ export async function createClient (
|
||||
connect: (txHandler: TxHandler) => Promise<ClientConnection>,
|
||||
// If set will build model with only allowed plugins.
|
||||
allowedPlugins?: Plugin[]
|
||||
): Promise<Client> {
|
||||
): Promise<AccountClient> {
|
||||
let client: ClientImpl | null = null
|
||||
|
||||
// Temporal buffer, while we apply model
|
||||
|
@ -50,7 +50,8 @@ export enum WorkspaceEvent {
|
||||
UpgradeScheduled,
|
||||
Upgrade,
|
||||
IndexingUpdate,
|
||||
SecurityChange
|
||||
SecurityChange,
|
||||
MaintenanceNotification
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -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<IntlString
|
||||
export async function translate<P extends Record<string, any>> (message: IntlString<P>, params: P): Promise<string> {
|
||||
const locale = getMetadata(platform.metadata.locale) ?? 'en'
|
||||
const compiled = cache.get(message)
|
||||
|
||||
if (compiled !== undefined) {
|
||||
if (compiled instanceof Status) {
|
||||
return message
|
||||
|
@ -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<URL>
|
||||
|
||||
export { default } from './platform'
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"status": {
|
||||
"LoadingPlugin": "Loading plugin '<b>'{plugin}'</b>'...",
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"status": {
|
||||
"LoadingPlugin": "Загрузка плагина '<b>'{plugin}'</b>'...",
|
||||
"LoadingPlugin": "Загрузка плагина {plugin}...",
|
||||
"UnknownError": "Неизвестная ошибка: {message}",
|
||||
"InvalidId": "Некорректный Id: {id}",
|
||||
"BadRequest": "Некорректный запрос",
|
||||
@ -8,6 +8,7 @@
|
||||
"ExpiredLink": "Ссылка истекла",
|
||||
"Unauthorized": "Неавторизован",
|
||||
"UnknownMethod": "Неизвестный метод: {method}",
|
||||
"InternalServerError": "Внутренняя ошибка сервера"
|
||||
"InternalServerError": "Внутренняя ошибка сервера",
|
||||
"MaintenanceWarning": "Серверные работы запланированы через {time} минут"
|
||||
}
|
||||
}
|
||||
|
@ -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<string>
|
||||
|
@ -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<Tx[]>
|
||||
}
|
||||
@ -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<TxResult> => {
|
||||
if (tx.objectSpace === core.space.Model) {
|
||||
hierarchy.tx(tx)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { addEventListener, getMetadata, OK, PlatformEvent } from '@hcengineering/platform'
|
||||
import platform, { addEventListener, getMetadata, OK, PlatformEvent, Status } from '@hcengineering/platform'
|
||||
import { onDestroy } from 'svelte'
|
||||
import type { AnyComponent } from '../../types'
|
||||
// import { applicationShortcutKey } from '../../utils'
|
||||
@ -13,6 +13,7 @@
|
||||
// import Mute from './icons/Mute.svelte'
|
||||
import { checkMobile, deviceOptionsStore as deviceInfo, networkStatus } from '../../'
|
||||
import uiPlugin from '../../plugin'
|
||||
import Label from '../Label.svelte'
|
||||
import FontSizeSelector from './FontSizeSelector.svelte'
|
||||
import Computer from './icons/Computer.svelte'
|
||||
import Phone from './icons/Phone.svelte'
|
||||
@ -53,9 +54,14 @@
|
||||
)
|
||||
|
||||
let status = OK
|
||||
let maintenanceTime = -1
|
||||
|
||||
addEventListener(PlatformEvent, async (_event, _status) => {
|
||||
status = _status
|
||||
addEventListener(PlatformEvent, async (_event, _status: Status) => {
|
||||
if (_status.code === platform.status.MaintenanceWarning) {
|
||||
maintenanceTime = (_status.params as any).time
|
||||
} else {
|
||||
status = _status
|
||||
}
|
||||
})
|
||||
|
||||
let docWidth: number = window.innerWidth
|
||||
@ -99,7 +105,14 @@
|
||||
class="status-info"
|
||||
style:margin-left={(isPortrait && docWidth <= 480) || (!isPortrait && docHeight <= 480) ? '1.5rem' : '0'}
|
||||
>
|
||||
<StatusComponent {status} />
|
||||
<div class="flex flex-row-center flex-center">
|
||||
{#if maintenanceTime > 0}
|
||||
<div class="flex flex-grow flex-center flex-row-center" class:maintenanceScheduled={maintenanceTime > 0}>
|
||||
<Label label={platform.status.MaintenanceWarning} params={{ time: maintenanceTime }} />
|
||||
</div>
|
||||
{/if}
|
||||
<StatusComponent {status} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-row-reverse">
|
||||
<div class="clock">
|
||||
@ -181,6 +194,17 @@
|
||||
line-height: 150%;
|
||||
background-color: var(--theme-statusbar-color);
|
||||
|
||||
.maintenanceScheduled {
|
||||
background-color: var(--highlight-red);
|
||||
color: var(--tooltip-bg-color);
|
||||
border-radius: 10px;
|
||||
padding: 0 1rem 0 1rem;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
|
@ -16,6 +16,7 @@
|
||||
|
||||
import client, { ClientSocket, ClientSocketReadyState } from '@hcengineering/client'
|
||||
import core, {
|
||||
Account,
|
||||
Class,
|
||||
ClientConnection,
|
||||
Doc,
|
||||
@ -340,6 +341,10 @@ class Connection implements ClientConnection {
|
||||
return await this.sendRequest({ method: 'loadModel', params: [lastTxTime] })
|
||||
}
|
||||
|
||||
async getAccount (): Promise<Account> {
|
||||
return await this.sendRequest({ method: 'getAccount', params: [] })
|
||||
}
|
||||
|
||||
findAll<T extends Doc>(
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
|
@ -14,8 +14,15 @@
|
||||
//
|
||||
|
||||
import clientPlugin from '@hcengineering/client'
|
||||
import core, { Client, createClient, TxHandler, TxWorkspaceEvent, WorkspaceEvent } from '@hcengineering/core'
|
||||
import { getMetadata, getPlugins, getResource } from '@hcengineering/platform'
|
||||
import core, { AccountClient, TxHandler, TxWorkspaceEvent, WorkspaceEvent, createClient } from '@hcengineering/core'
|
||||
import platform, {
|
||||
Severity,
|
||||
Status,
|
||||
getMetadata,
|
||||
getPlugins,
|
||||
getResource,
|
||||
setPlatformStatus
|
||||
} from '@hcengineering/platform'
|
||||
import { connect } from './connection'
|
||||
|
||||
export { connect }
|
||||
@ -30,7 +37,7 @@ export default async () => {
|
||||
onUpgrade?: () => void,
|
||||
onUnauthorized?: () => void,
|
||||
onConnect?: (apply: boolean) => void
|
||||
): Promise<Client> => {
|
||||
): Promise<AccountClient> => {
|
||||
const filterModel = getMetadata(clientPlugin.metadata.FilterModel) ?? false
|
||||
|
||||
let client = createClient(
|
||||
@ -39,8 +46,13 @@ export default async () => {
|
||||
console.log('connecting to', url.href)
|
||||
const upgradeHandler: TxHandler = (tx) => {
|
||||
if (tx?._class === core.class.TxWorkspaceEvent) {
|
||||
if ((tx as TxWorkspaceEvent).event === WorkspaceEvent.Upgrade) {
|
||||
const event = tx as TxWorkspaceEvent
|
||||
if (event.event === WorkspaceEvent.Upgrade) {
|
||||
onUpgrade?.()
|
||||
} else if (event.event === WorkspaceEvent.MaintenanceNotification) {
|
||||
void setPlatformStatus(
|
||||
new Status(Severity.WARNING, platform.status.MaintenanceWarning, { time: event.params.timeMinutes })
|
||||
)
|
||||
}
|
||||
}
|
||||
handler(tx)
|
||||
@ -57,7 +69,7 @@ export default async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
async function hookClient (client: Promise<Client>): Promise<Client> {
|
||||
async function hookClient (client: Promise<AccountClient>): Promise<AccountClient> {
|
||||
const hook = getMetadata(clientPlugin.metadata.ClientHook)
|
||||
if (hook !== undefined) {
|
||||
const hookProc = await getResource(hook)
|
||||
|
@ -13,9 +13,9 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Metadata, plugin } from '@hcengineering/platform'
|
||||
import type { AccountClient } from '@hcengineering/core'
|
||||
import type { Plugin, Resource } from '@hcengineering/platform'
|
||||
import type { Client } from '@hcengineering/core'
|
||||
import { Metadata, plugin } from '@hcengineering/platform'
|
||||
// import type { LiveQuery } from '@hcengineering/query'
|
||||
|
||||
// export type Connection = Client & LiveQuery & TxOperations
|
||||
@ -28,7 +28,7 @@ export const clientId = 'client' as Plugin
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type ClientHook = (client: Client) => Promise<Client>
|
||||
export type ClientHook = (client: AccountClient) => Promise<AccountClient>
|
||||
|
||||
/**
|
||||
* @public
|
||||
@ -70,7 +70,7 @@ export type ClientFactory = (
|
||||
onUpgrade?: () => void,
|
||||
onUnauthorized?: () => void,
|
||||
onConnect?: (apply: boolean) => void
|
||||
) => Promise<Client>
|
||||
) => Promise<AccountClient>
|
||||
|
||||
export default plugin(clientId, {
|
||||
metadata: {
|
||||
|
@ -14,6 +14,8 @@
|
||||
//
|
||||
|
||||
import core, {
|
||||
Account,
|
||||
AccountClient,
|
||||
Class,
|
||||
Client,
|
||||
Doc,
|
||||
@ -29,7 +31,7 @@ import core, {
|
||||
} from '@hcengineering/core'
|
||||
import { devModelId } from '@hcengineering/devmodel'
|
||||
import { Builder } from '@hcengineering/model'
|
||||
import { getMetadata, IntlString, Resources } from '@hcengineering/platform'
|
||||
import { IntlString, Resources, getMetadata } from '@hcengineering/platform'
|
||||
import view from '@hcengineering/view'
|
||||
import workbench from '@hcengineering/workbench'
|
||||
import ModelView from './components/ModelView.svelte'
|
||||
@ -50,9 +52,9 @@ export interface QueryWithResult {
|
||||
|
||||
export const transactions: TxWitHResult[] = []
|
||||
|
||||
class ModelClient implements Client {
|
||||
class ModelClient implements AccountClient {
|
||||
notifyEnabled = true
|
||||
constructor (readonly client: Client) {
|
||||
constructor (readonly client: AccountClient) {
|
||||
this.notifyEnabled = (localStorage.getItem('#platform.notification.logging') ?? 'true') === 'true'
|
||||
|
||||
client.notify = (tx) => {
|
||||
@ -73,6 +75,10 @@ class ModelClient implements Client {
|
||||
return this.client.getModel()
|
||||
}
|
||||
|
||||
async getAccount (): Promise<Account> {
|
||||
return await this.client.getAccount()
|
||||
}
|
||||
|
||||
async findOne<T extends Doc>(
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
@ -133,7 +139,7 @@ class ModelClient implements Client {
|
||||
await this.client.close()
|
||||
}
|
||||
}
|
||||
export async function Hook (client: Client): Promise<Client> {
|
||||
export async function Hook (client: AccountClient): Promise<Client> {
|
||||
console.debug('devmodel# Client HOOKED by DevModel')
|
||||
|
||||
// Client is alive here, we could hook with some model extensions special for DevModel plugin.
|
||||
|
@ -15,19 +15,20 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { OK, Severity, Status } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import {
|
||||
Button,
|
||||
getCurrentLocation,
|
||||
Label,
|
||||
navigate,
|
||||
setMetadataLocalStorage,
|
||||
Scroller,
|
||||
deviceOptionsStore as deviceInfo,
|
||||
Scroller
|
||||
getCurrentLocation,
|
||||
navigate,
|
||||
setMetadataLocalStorage
|
||||
} from '@hcengineering/ui'
|
||||
import login from '../plugin'
|
||||
import { getWorkspaces, selectWorkspace, Workspace, navigateToWorkspace } from '../utils'
|
||||
import { getWorkspaces, navigateToWorkspace, selectWorkspace } from '../utils'
|
||||
import StatusControl from './StatusControl.svelte'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import { Workspace } from '@hcengineering/login'
|
||||
|
||||
export let navigateUrl: string | undefined = undefined
|
||||
|
||||
|
193
plugins/workbench-resources/src/components/ServerManager.svelte
Normal file
193
plugins/workbench-resources/src/components/ServerManager.svelte
Normal file
@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import contact, { EmployeeAccount } from '@hcengineering/contact'
|
||||
import { metricsToRows } from '@hcengineering/core'
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import { createQuery } from '@hcengineering/presentation'
|
||||
import { Button, IconArrowRight, Loading, Panel, Scroller, TabItem, TabList, ticker } from '@hcengineering/ui'
|
||||
import EditBox from '@hcengineering/ui/src/components/EditBox.svelte'
|
||||
import { ObjectPresenter } from '@hcengineering/view-resources'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
export let endpoint: string
|
||||
export let token: string
|
||||
|
||||
let data: any
|
||||
|
||||
onDestroy(
|
||||
ticker.subscribe(() => {
|
||||
fetch(endpoint + `/api/v1/statistics?token=${token}`, {}).then(async (json) => {
|
||||
data = await json.json()
|
||||
})
|
||||
})
|
||||
)
|
||||
const tabs: TabItem[] = [
|
||||
{
|
||||
id: 'general',
|
||||
labelIntl: getEmbeddedLabel('General')
|
||||
},
|
||||
{
|
||||
id: 'statistics',
|
||||
labelIntl: getEmbeddedLabel('Statistics')
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
labelIntl: getEmbeddedLabel('Users')
|
||||
}
|
||||
]
|
||||
let selectedTab: string = tabs[0].id
|
||||
|
||||
interface StatisticsElement {
|
||||
find: number
|
||||
tx: number
|
||||
}
|
||||
|
||||
$: activeSessions =
|
||||
(data?.statistics?.activeSessions as Record<
|
||||
string,
|
||||
{
|
||||
userId: string
|
||||
total: StatisticsElement
|
||||
mins5: StatisticsElement
|
||||
current: StatisticsElement
|
||||
}[]
|
||||
>) ?? {}
|
||||
|
||||
const employeeQuery = createQuery()
|
||||
|
||||
let employees: Map<string, EmployeeAccount> = new Map()
|
||||
|
||||
employeeQuery.query(contact.class.EmployeeAccount, {}, (res) => {
|
||||
const emp: Map<string, EmployeeAccount> = new Map()
|
||||
for (const r of res) {
|
||||
emp.set(r.email, r)
|
||||
}
|
||||
employees = emp
|
||||
})
|
||||
const toNum = (value: any) => value as number
|
||||
|
||||
let warningTimeout = 15
|
||||
</script>
|
||||
|
||||
<Panel on:close isFullSize useMaxWidth={true}>
|
||||
<svelte:fragment slot="header">
|
||||
{#if data}
|
||||
Mem: {data.statistics.memoryUsed} / {data.statistics.memoryTotal} CPU: {data.statistics.cpuUsage}
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="navigator">
|
||||
<span class="p-3"> Server manager </span>
|
||||
<TabList
|
||||
items={tabs}
|
||||
bind:selected={selectedTab}
|
||||
kind={'separated'}
|
||||
on:select={(result) => {
|
||||
selectedTab = result.detail.id
|
||||
}}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
{#if data}
|
||||
{#if selectedTab === 'general'}
|
||||
<Scroller>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex-row-center p-1">
|
||||
<div class="p-3">1.</div>
|
||||
<Button
|
||||
icon={IconArrowRight}
|
||||
label={getEmbeddedLabel('Set maintenance warning')}
|
||||
on:click={() => {
|
||||
fetch(endpoint + `/api/v1/manage?token=${token}&operation=maintenance&timeout=${warningTimeout}`, {
|
||||
method: 'PUT'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div class="flex-col p-1">
|
||||
<div class="flex-row-center p-1">
|
||||
<EditBox kind={'underline'} format={'number'} bind:value={warningTimeout} /> min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-row-center p-1">
|
||||
<div class="p-3">2.</div>
|
||||
<Button
|
||||
icon={IconArrowRight}
|
||||
label={getEmbeddedLabel('Reboot server')}
|
||||
on:click={() => {
|
||||
fetch(endpoint + `/api/v1/manage?token=${token}&operation=reboot`, {
|
||||
method: 'PUT'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Scroller>
|
||||
{:else if selectedTab === 'users'}
|
||||
<div class="flex-column p-3">
|
||||
<Scroller>
|
||||
{#each Object.entries(activeSessions) as act}
|
||||
<span class="flex-col">
|
||||
<div class="fs-title">
|
||||
Workspace: {act[0]}: {act[1].length}
|
||||
</div>
|
||||
|
||||
<div class="flex-col">
|
||||
{#each act[1] as user}
|
||||
{@const employee = employees.get(user.userId)}
|
||||
<div class="p-1 flex-row-center">
|
||||
{#if employee}
|
||||
<ObjectPresenter
|
||||
_class={contact.class.Employee}
|
||||
objectId={employee.employee}
|
||||
props={{ shouldShowAvatar: true }}
|
||||
/>
|
||||
{:else}
|
||||
{user.userId}
|
||||
{/if}
|
||||
<div class="p-1">
|
||||
Total: {user.total.find}/{user.total.tx}
|
||||
</div>
|
||||
<div class="p-1">
|
||||
Previous 5 mins: {user.mins5.find}/{user.mins5.tx}
|
||||
</div>
|
||||
<div class="p-1">
|
||||
Current 5 mins: {user.current.find}/{user.current.tx}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</span>
|
||||
{/each}
|
||||
</Scroller>
|
||||
</div>
|
||||
{:else if selectedTab === 'statistics'}
|
||||
<Scroller>
|
||||
<table class="antiTable" class:highlightRows={true}>
|
||||
<thead class="scroller-thead">
|
||||
<tr>
|
||||
<th><div class="p-1">Name</div> </th>
|
||||
<th>Average</th>
|
||||
<th>Total</th>
|
||||
<th>Ops</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each metricsToRows(data.metrics, 'System') as row}
|
||||
<tr class="antiTable-body__row">
|
||||
<td>
|
||||
<span style={`padding-left: ${toNum(row[0]) + 0.5}rem;`}>
|
||||
{row[1]}
|
||||
</span>
|
||||
</td>
|
||||
<td>{row[2]}</td>
|
||||
<td>{row[3]}</td>
|
||||
<td>{row[4]}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Scroller>
|
||||
{/if}
|
||||
{:else}
|
||||
<Loading />
|
||||
{/if}
|
||||
</Panel>
|
@ -1,70 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { metricsToRows } from '@hcengineering/core'
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import { Card } from '@hcengineering/presentation'
|
||||
import { ticker } from '@hcengineering/ui'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
export let endpoint: string
|
||||
|
||||
let data: any
|
||||
|
||||
onDestroy(
|
||||
ticker.subscribe(() => {
|
||||
fetch(endpoint, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(async (json) => {
|
||||
data = await json.json()
|
||||
})
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<Card on:close fullSize label={getEmbeddedLabel('Statistics')} okAction={() => {}} okLabel={getEmbeddedLabel('Ok')}>
|
||||
{#if data}
|
||||
<div class="flex-column">
|
||||
{#each Object.entries(data.statistics?.activeSessions) as act}
|
||||
<span class="flex-row-center">
|
||||
{act[0]}: {act[1]}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<span class="fs-title flex-row-center">
|
||||
Memory usage: {data.statistics.memoryUsed} / {data.statistics.memoryTotal}
|
||||
</span>
|
||||
<span class="fs-title flex-row-center">
|
||||
CPU: {data.statistics.cpuUsage}
|
||||
</span>
|
||||
<span class="fs-title flex-row-center">
|
||||
Mem: {data.statistics.freeMem} / {data.statistics.totalMem}
|
||||
</span>
|
||||
|
||||
<table class="antiTable" class:highlightRows={true}>
|
||||
<thead class="scroller-thead">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Average</th>
|
||||
<th>Total</th>
|
||||
<th>Ops</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each metricsToRows(data.metrics, 'System') as row}
|
||||
<tr class="antiTable-body__row">
|
||||
<td>
|
||||
<span style={`padding-left: ${row[0]}rem;`}>
|
||||
{row[1]}
|
||||
</span>
|
||||
</td>
|
||||
<td>{row[2]}</td>
|
||||
<td>{row[3]}</td>
|
||||
<td>{row[4]}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</Card>
|
@ -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
|
||||
</script>
|
||||
|
||||
{#if employee?.active === true}
|
||||
{#if employee?.active === true || accountId === core.account.System}
|
||||
<ActionHandler />
|
||||
<svg class="svg-mask">
|
||||
<clipPath id="notify-normal">
|
||||
@ -637,7 +641,7 @@
|
||||
showPopup(AccountPopup, {}, popupPosition)
|
||||
}}
|
||||
>
|
||||
<Component is={contact.component.Avatar} props={{ avatar: employee.avatar, size: 'small' }} />
|
||||
<Component is={contact.component.Avatar} props={{ avatar: employee?.avatar, size: 'small' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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<Client | undefined> {
|
||||
)
|
||||
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<Client | undefined> {
|
||||
|
||||
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'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
@ -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<Class<Obj>>): Asset | undefined {
|
||||
return client.getHierarchy().getClass(_class).icon
|
||||
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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<Account | null
|
||||
return await db.collection(ACCOUNT_COLLECTION).findOne<Account>({ email })
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function setAccountAdmin (db: Db, email: string, admin: boolean): Promise<void> {
|
||||
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<Workspace>): Filter<Workspace> {
|
||||
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<LoginInfo> {
|
||||
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<string, string>
|
||||
): Record<string, string> | 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<Workspace[]> {
|
||||
return (await db.collection<Workspace>(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray()).map(
|
||||
(it) => ({ ...it, productId })
|
||||
)
|
||||
export async function listWorkspaces (db: Db, productId: string): Promise<WorkspaceInfoOnly[]> {
|
||||
return (await db.collection<Workspace>(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<Workspace[]> {
|
||||
export type WorkspaceInfoOnly = Omit<Workspace, '_id' | 'accounts'>
|
||||
|
||||
function trimWorkspace (ws: Workspace): WorkspaceInfoOnly {
|
||||
const { _id, accounts, ...data } = ws
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function getUserWorkspaces (db: Db, productId: string, token: string): Promise<WorkspaceInfoOnly[]> {
|
||||
const { email } = decodeToken(token)
|
||||
const account = await getAccount(db, email)
|
||||
if (account === null) return []
|
||||
return await db
|
||||
.collection<Workspace>(WORKSPACE_COLLECTION)
|
||||
.find(withProductId(productId, { _id: { $in: account.workspaces } }))
|
||||
.toArray()
|
||||
return (
|
||||
await db
|
||||
.collection<Workspace>(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}`)
|
||||
|
||||
|
@ -168,7 +168,8 @@ describe('mongo operations', () => {
|
||||
loadDocs: async (domain: Domain, docs: Ref<Doc>[]) => [],
|
||||
upload: async (domain: Domain, docs: Doc[]) => {},
|
||||
clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
|
||||
loadModel: async () => txes
|
||||
loadModel: async () => txes,
|
||||
getAccount: async () => ({} as any)
|
||||
}
|
||||
return st
|
||||
})
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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<Account> {
|
||||
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<Class<Account>>,
|
||||
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<T extends Doc>(
|
||||
ctx: MeasureContext,
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
): Promise<FindResult<T>> {
|
||||
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<TxResult> {
|
||||
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)
|
||||
|
@ -19,6 +19,8 @@ import core, {
|
||||
Space,
|
||||
Tx,
|
||||
TxFactory,
|
||||
TxWorkspaceEvent,
|
||||
WorkspaceEvent,
|
||||
WorkspaceId,
|
||||
generateId,
|
||||
toWorkspaceString
|
||||
@ -52,6 +54,9 @@ class TSessionManager implements SessionManager {
|
||||
|
||||
sessions: Map<string, { session: Session, socket: ConnectionSocket }> = 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 {
|
||||
|
@ -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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -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<string, any> = {
|
||||
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
|
||||
|
@ -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<void>
|
||||
|
||||
broadcast: (from: Session | null, workspaceId: WorkspaceId, resp: Response<any>, target?: string[]) => void
|
||||
|
||||
scheduleMaintenance: (timeMinutes: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user