UBER-62: Maintenance warnings (#3210)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-05-19 18:46:05 +07:00 committed by GitHub
parent 39e5dd5142
commit f9d2ce4a1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 688 additions and 221 deletions

View File

@ -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

View File

@ -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)

View File

@ -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) {

View File

@ -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')

View File

@ -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}'
}
},

View File

@ -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')

View File

@ -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
}
}

View File

@ -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

View File

@ -50,7 +50,8 @@ export enum WorkspaceEvent {
UpgradeScheduled,
Upgrade,
IndexingUpdate,
SecurityChange
SecurityChange,
MaintenanceNotification
}
/**

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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}}"
}
}

View File

@ -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} минут"
}
}

View File

@ -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>

View File

@ -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)

View File

@ -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
}

View File

@ -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;

View File

@ -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>,

View File

@ -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)

View File

@ -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: {

View File

@ -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.

View File

@ -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

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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'
)
})
}

View File

@ -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

View File

@ -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)
)
}
}
})

View File

@ -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}`)

View File

@ -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
})

View File

@ -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"
}
}

View File

@ -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)

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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

View File

@ -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
}
/**