mirror of
https://github.com/hcengineering/platform.git
synced 2025-01-03 00:43:59 +03:00
UBERF-8553: Stats as separate service (#7054)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
parent
3a37a16fc8
commit
7c69f6f35a
1
.gitignore
vendored
1
.gitignore
vendored
@ -101,6 +101,5 @@ dev/tool/report.csv
|
||||
bundle/*
|
||||
bundle.js.map
|
||||
tests/profiles
|
||||
_*
|
||||
**/bundle/model.json
|
||||
.wrangler
|
||||
|
17
.vscode/launch.json
vendored
17
.vscode/launch.json
vendored
@ -94,6 +94,7 @@
|
||||
"REGION_INFO": "|Mongo;pg|Postgree",
|
||||
"ACCOUNT_PORT": "3000",
|
||||
"FRONT_URL": "http://localhost:8080",
|
||||
"STATS_URL": "http://host.docker.internal:4900",
|
||||
"SES_URL": "",
|
||||
// "DB_NS": "account-2",
|
||||
// "WS_LIVENESS_DAYS": "1",
|
||||
@ -111,6 +112,22 @@
|
||||
"cwd": "${workspaceRoot}/pods/account",
|
||||
"protocol": "inspector"
|
||||
},
|
||||
{
|
||||
"name": "Debug Stats",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"args": ["src/__start.ts"],
|
||||
"env": {
|
||||
"PORT": "4900",
|
||||
"SERVER_SECRET": "secret",
|
||||
},
|
||||
"runtimeVersion": "20",
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
"sourceMaps": true,
|
||||
"outputCapture": "std",
|
||||
"cwd": "${workspaceRoot}/pods/stats",
|
||||
"protocol": "inspector"
|
||||
},
|
||||
{
|
||||
"name": "Debug Workspace",
|
||||
"type": "node",
|
||||
|
@ -238,7 +238,7 @@
|
||||
"summary": "Build docker with platform",
|
||||
"description": "use to build all docker containers required for platform",
|
||||
"safeForSimultaneousRushProcesses": true,
|
||||
"shellCommand": "rush docker:build -p 20 --to @hcengineering/pod-server --to @hcengineering/pod-front --to @hcengineering/prod --to @hcengineering/pod-account --to @hcengineering/pod-workspace --to @hcengineering/pod-collaborator --to @hcengineering/tool --to @hcengineering/pod-print --to @hcengineering/pod-sign --to @hcengineering/pod-analytics-collector --to @hcengineering/rekoni-service --to @hcengineering/pod-ai-bot --to @hcengineering/import-tool"
|
||||
"shellCommand": "rush docker:build -p 20 --to @hcengineering/pod-server --to @hcengineering/pod-front --to @hcengineering/prod --to @hcengineering/pod-account --to @hcengineering/pod-workspace --to @hcengineering/pod-collaborator --to @hcengineering/tool --to @hcengineering/pod-print --to @hcengineering/pod-sign --to @hcengineering/pod-analytics-collector --to @hcengineering/rekoni-service --to @hcengineering/pod-ai-bot --to @hcengineering/import-tool --to @hcengineering/pod-stats"
|
||||
},
|
||||
{
|
||||
"commandKind": "global",
|
||||
|
@ -632,6 +632,9 @@ dependencies:
|
||||
'@rush-temp/pod-sign':
|
||||
specifier: file:./projects/pod-sign.tgz
|
||||
version: file:projects/pod-sign.tgz
|
||||
'@rush-temp/pod-stats':
|
||||
specifier: file:./projects/pod-stats.tgz
|
||||
version: file:projects/pod-stats.tgz
|
||||
'@rush-temp/pod-telegram':
|
||||
specifier: file:./projects/pod-telegram.tgz
|
||||
version: file:projects/pod-telegram.tgz(bufferutil@4.0.8)(ts-node@10.9.2)(utf-8-validate@6.0.4)
|
||||
@ -31005,7 +31008,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/pod-collaborator.tgz:
|
||||
resolution: {integrity: sha512-ayRiefa0hytLnCUUVac5/YEwko1XSxpudTToOamTBxzFfXlwGdlK6D2gQMN/dNkbTG4Wc9KLzjlwDb4VXUkLmw==, tarball: file:projects/pod-collaborator.tgz}
|
||||
resolution: {integrity: sha512-AJhluNv49NI/ktWVKeHToGIi3DnJ419Qmsa8PFeR148h3X7uvmlIbPGM5bMQoVfJ1xiR8BpRIFVtu6EYzrs0iA==, tarball: file:projects/pod-collaborator.tgz}
|
||||
name: '@rush-temp/pod-collaborator'
|
||||
version: 0.0.0
|
||||
dependencies:
|
||||
@ -31458,6 +31461,46 @@ packages:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
file:projects/pod-stats.tgz:
|
||||
resolution: {integrity: sha512-vS1oRj3hDBzCAI0SagseVBqSDxd1puF9OQJRbCcn1V0MTYC5MwW8ndQGGG1W45jMK37vgHvgFveUn7FEEhIXOw==, tarball: file:projects/pod-stats.tgz}
|
||||
name: '@rush-temp/pod-stats'
|
||||
version: 0.0.0
|
||||
dependencies:
|
||||
'@koa/cors': 5.0.0
|
||||
'@types/jest': 29.5.12
|
||||
'@types/koa': 2.15.0
|
||||
'@types/koa-bodyparser': 4.3.12
|
||||
'@types/koa-router': 7.4.8
|
||||
'@types/koa__cors': 5.0.0
|
||||
'@types/node': 20.11.19
|
||||
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.6.2)
|
||||
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.6.2)
|
||||
cross-env: 7.0.3
|
||||
esbuild: 0.20.1
|
||||
eslint: 8.56.0
|
||||
eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.6.2)
|
||||
eslint-plugin-import: 2.29.1(eslint@8.56.0)
|
||||
eslint-plugin-n: 15.7.0(eslint@8.56.0)
|
||||
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
|
||||
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2)
|
||||
koa: 2.15.3
|
||||
koa-bodyparser: 4.4.1
|
||||
koa-router: 12.0.1
|
||||
prettier: 3.2.5
|
||||
ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.6.2)
|
||||
ts-node: 10.9.2(@types/node@20.11.19)(typescript@5.6.2)
|
||||
typescript: 5.6.2
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@jest/types'
|
||||
- '@swc/core'
|
||||
- '@swc/wasm'
|
||||
- babel-jest
|
||||
- babel-plugin-macros
|
||||
- node-notifier
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
file:projects/pod-telegram-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4):
|
||||
resolution: {integrity: sha512-Bq0gNcjaTU0PEYhroGlOPGUD1UsNhYswf32XQ5fgz//09GK9gEBx6qBknRNYGvbN5558bUyyzKyp0KsWVfySkQ==, tarball: file:projects/pod-telegram-bot.tgz}
|
||||
id: file:projects/pod-telegram-bot.tgz
|
||||
@ -32395,7 +32438,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/rekoni-service.tgz(webpack@5.90.3):
|
||||
resolution: {integrity: sha512-Q0Kc1tYShHVdu2vY3Exnvm9Lvj6lKr5AdUNFpMAVP6qMB0DZPK51hWqEt0BRDSmVLfKGN8g3zWhbG3FSxFs30w==, tarball: file:projects/rekoni-service.tgz}
|
||||
resolution: {integrity: sha512-z8lv7tJyejsIhl5tVjot97nBl0FX6dtUAAfLbEtfPwmiZR/uw6IpvgkfXsVJ0RJwsBMgwD0SpHBwqoxpVrgHww==, tarball: file:projects/rekoni-service.tgz}
|
||||
id: file:projects/rekoni-service.tgz
|
||||
name: '@rush-temp/rekoni-service'
|
||||
version: 0.0.0
|
||||
|
@ -208,6 +208,7 @@ export async function configurePlatform (): Promise<void> {
|
||||
setMetadata(presentation.metadata.PreviewConfig, parsePreviewConfig(config.PREVIEW_CONFIG))
|
||||
setMetadata(presentation.metadata.UploadConfig, parseUploadConfig(config.UPLOAD_CONFIG, config.UPLOAD_URL))
|
||||
setMetadata(presentation.metadata.FrontUrl, config.FRONT_URL)
|
||||
setMetadata(presentation.metadata.StatsUrl, config.STATS_URL)
|
||||
|
||||
setMetadata(textEditor.metadata.Collaborator, config.COLLABORATOR ?? '')
|
||||
|
||||
|
@ -35,6 +35,8 @@ export interface Config {
|
||||
DESKTOP_UPDATES_URL?: string
|
||||
DESKTOP_UPDATES_CHANNEL?: string
|
||||
TELEGRAM_BOT_URL?: string
|
||||
|
||||
STATS_URL?: string
|
||||
}
|
||||
|
||||
export interface Branding {
|
||||
|
@ -66,6 +66,7 @@ services:
|
||||
links:
|
||||
- mongodb
|
||||
- minio
|
||||
- stats
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
@ -73,6 +74,7 @@ services:
|
||||
environment:
|
||||
- ACCOUNT_PORT=3000
|
||||
- SERVER_SECRET=secret
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
# - DB_URL=postgresql://postgres:example@postgres:5432
|
||||
- DB_URL=${MONGO_URL}
|
||||
# - DB_NS=account-2
|
||||
@ -91,6 +93,18 @@ services:
|
||||
# - INIT_SCRIPT_URL=https://raw.githubusercontent.com/hcengineering/init/main/script.yaml
|
||||
# - INIT_WORKSPACE=onboarding
|
||||
restart: unless-stopped
|
||||
stats:
|
||||
image: hardcoreeng/stats
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- 4900:4900
|
||||
volumes:
|
||||
- ./branding.json:/var/cfg/branding.json
|
||||
environment:
|
||||
- PORT=4900
|
||||
- SERVER_SECRET=secret
|
||||
restart: unless-stopped
|
||||
workspace:
|
||||
image: hardcoreeng/workspace
|
||||
extra_hosts:
|
||||
@ -101,12 +115,14 @@ services:
|
||||
links:
|
||||
- mongodb
|
||||
- minio
|
||||
- stats
|
||||
volumes:
|
||||
- ./branding.json:/var/cfg/branding.json
|
||||
environment:
|
||||
# - WS_OPERATION=create
|
||||
- SERVER_SECRET=secret
|
||||
- DB_URL=${MONGO_URL}
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
# - DB_URL=postgresql://postgres:example@postgres:5432
|
||||
- SES_URL=
|
||||
- STORAGE_CONFIG=${STORAGE_CONFIG}
|
||||
@ -126,12 +142,14 @@ services:
|
||||
links:
|
||||
- postgres
|
||||
- minio
|
||||
- stats
|
||||
volumes:
|
||||
- ./branding.json:/var/cfg/branding.json
|
||||
environment:
|
||||
# - WS_OPERATION=create
|
||||
- SERVER_SECRET=secret
|
||||
- DB_URL=postgresql://postgres:example@postgres:5432
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
- SES_URL=
|
||||
- REGION=pg
|
||||
- STORAGE_CONFIG=${STORAGE_CONFIG}
|
||||
@ -152,6 +170,7 @@ services:
|
||||
- mongodb
|
||||
- minio
|
||||
- transactor
|
||||
- stats
|
||||
ports:
|
||||
- 3078:3078
|
||||
environment:
|
||||
@ -159,6 +178,7 @@ services:
|
||||
- SECRET=secret
|
||||
- ACCOUNTS_URL=http://host.docker.internal:3000
|
||||
- STORAGE_CONFIG=${STORAGE_CONFIG}
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
restart: unless-stopped
|
||||
front:
|
||||
image: hardcoreeng/front
|
||||
@ -170,6 +190,7 @@ services:
|
||||
- elastic
|
||||
- transactor
|
||||
- collaborator
|
||||
- stats
|
||||
ports:
|
||||
- 8087:8080
|
||||
- 8088:8080
|
||||
@ -177,6 +198,7 @@ services:
|
||||
- SERVER_PORT=8080
|
||||
- SERVER_SECRET=secret
|
||||
- ACCOUNTS_URL=http://host.docker.internal:3000
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
- UPLOAD_URL=/files
|
||||
- ELASTIC_URL=http://host.docker.internal:9200
|
||||
- GMAIL_URL=http://host.docker.internal:8088
|
||||
@ -204,6 +226,7 @@ services:
|
||||
- minio
|
||||
- rekoni
|
||||
- account
|
||||
- stats
|
||||
# - apm-server
|
||||
ports:
|
||||
- 3333:3333
|
||||
@ -216,6 +239,7 @@ services:
|
||||
- SERVER_PORT=3333
|
||||
- SERVER_SECRET=secret
|
||||
- ENABLE_COMPRESSION=true
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
- ELASTIC_URL=http://host.docker.internal:9200
|
||||
# - DB_URL=postgresql://postgres:example@postgres:5432
|
||||
- DB_URL=${MONGO_URL}
|
||||
@ -244,6 +268,7 @@ services:
|
||||
- minio
|
||||
- rekoni
|
||||
- account
|
||||
- stats
|
||||
# - apm-server
|
||||
ports:
|
||||
- 3331:3331
|
||||
@ -257,6 +282,7 @@ services:
|
||||
- SERVER_SECRET=secret
|
||||
- ENABLE_COMPRESSION=true
|
||||
- ELASTIC_URL=http://host.docker.internal:9200
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
- DB_URL=postgresql://postgres:example@postgres:5432
|
||||
- MONGO_URL=${MONGO_URL}
|
||||
- 'MONGO_OPTIONS={"appName": "transactor-pg", "maxPoolSize": 1}'
|
||||
@ -291,6 +317,7 @@ services:
|
||||
environment:
|
||||
- SECRET=secret
|
||||
- STORAGE_CONFIG=${STORAGE_CONFIG}
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@ -314,6 +341,7 @@ services:
|
||||
- CERTIFICATE_PATH=/var/cfg/certificate.p12
|
||||
- SERVICE_ID=sign-service
|
||||
- BRANDING_PATH=/var/cfg/branding.json
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@ -333,6 +361,7 @@ services:
|
||||
- SERVICE_ID=analytics-collector-service
|
||||
- ACCOUNTS_URL=http://host.docker.internal:3000
|
||||
- SUPPORT_WORKSPACE=support
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@ -354,6 +383,7 @@ services:
|
||||
- PASSWORD=password
|
||||
- AVATAR_PATH=./avatar.png
|
||||
- AVATAR_CONTENT_TYPE=.png
|
||||
- STATS_URL=http://host.docker.internal:4900
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@ -372,6 +402,7 @@ services:
|
||||
# - DOMAIN=domain
|
||||
# - ACCOUNTS_URL=http://host.docker.internal:3000
|
||||
# - SERVICE_ID=telegram-bot-service
|
||||
# - STATS_URL=http://host.docker.internal:4900
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
|
@ -6,5 +6,6 @@
|
||||
"GMAIL_URL": "https://gmail.hc.engineering",
|
||||
"CALENDAR_URL": "https://calendar.hc.engineering",
|
||||
"REKONI_URL": "https://rekoni.hc.engineering",
|
||||
"COLLABORATOR_URL": "wss://collaborator.hc.engineering"
|
||||
"COLLABORATOR_URL": "wss://collaborator.hc.engineering",
|
||||
"STATS_URL": "https://stats.hc.engineering"
|
||||
}
|
@ -19,5 +19,6 @@
|
||||
"SIGN_URL": "https://sign.huly.app",
|
||||
"PRINT_URL": "https://print.huly.app",
|
||||
"DESKTOP_UPDATES_CHANNEL": "huly",
|
||||
"TELEGRAM_BOT_URL": "https://telegram-bot.huly.app"
|
||||
"TELEGRAM_BOT_URL": "https://telegram-bot.huly.app",
|
||||
"STATS_URL": "https://stats.huly.app"
|
||||
}
|
@ -16,5 +16,6 @@
|
||||
"AI_URL": "http://localhost:4010",
|
||||
"BRANDING_URL": "/branding.json",
|
||||
"VERSION": null,
|
||||
"MODEL_VERSION": null
|
||||
"MODEL_VERSION": null,
|
||||
"STATS_URL": "http://localhost:4900"
|
||||
}
|
@ -156,6 +156,7 @@ export interface Config {
|
||||
FRONT_URL?: string
|
||||
PREVIEW_CONFIG?: string
|
||||
UPLOAD_CONFIG?: string
|
||||
STATS_URL?: string
|
||||
}
|
||||
|
||||
export interface Branding {
|
||||
@ -300,6 +301,7 @@ export async function configurePlatform() {
|
||||
setMetadata(presentation.metadata.FrontUrl, config.FRONT_URL)
|
||||
setMetadata(presentation.metadata.PreviewConfig, parsePreviewConfig(config.PREVIEW_CONFIG))
|
||||
setMetadata(presentation.metadata.UploadConfig, parseUploadConfig(config.UPLOAD_CONFIG, config.UPLOAD_URL))
|
||||
setMetadata(presentation.metadata.StatsUrl, config.STATS_URL)
|
||||
|
||||
setMetadata(textEditor.metadata.Collaborator, config.COLLABORATOR)
|
||||
|
||||
|
@ -67,3 +67,4 @@ export * from './search'
|
||||
export * from './image'
|
||||
export * from './preview'
|
||||
export * from './sound'
|
||||
export * from './stats'
|
||||
|
@ -142,7 +142,8 @@ export default plugin(presentationId, {
|
||||
UploadConfig: '' as Metadata<UploadConfig>,
|
||||
PreviewConfig: '' as Metadata<PreviewConfig | undefined>,
|
||||
ClientHook: '' as Metadata<ClientHook>,
|
||||
SessionId: '' as Metadata<string>
|
||||
SessionId: '' as Metadata<string>,
|
||||
StatsUrl: '' as Metadata<string>
|
||||
},
|
||||
status: {
|
||||
FileTooLarge: '' as StatusCode
|
||||
|
58
packages/presentation/src/stats.ts
Normal file
58
packages/presentation/src/stats.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { Metrics } from '@hcengineering/core'
|
||||
|
||||
// Copy from server/core/stats.ts for UI usage.
|
||||
export interface MemoryStatistics {
|
||||
memoryUsed: number
|
||||
memoryTotal: number
|
||||
memoryRSS: number
|
||||
freeMem: number
|
||||
totalMem: number
|
||||
}
|
||||
export interface CPUStatistics {
|
||||
usage: number
|
||||
cores: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface StatisticsElement {
|
||||
find: number
|
||||
tx: number
|
||||
}
|
||||
|
||||
export interface UserStatistics {
|
||||
userId: string
|
||||
sessionId: string
|
||||
data: any
|
||||
mins5: StatisticsElement
|
||||
total: StatisticsElement
|
||||
current: StatisticsElement
|
||||
}
|
||||
|
||||
export interface WorkspaceStatistics {
|
||||
sessions: UserStatistics[]
|
||||
workspaceName: string
|
||||
wsId: string
|
||||
sessionsTotal: number
|
||||
clientsTotal: number
|
||||
|
||||
service?: string
|
||||
}
|
||||
export interface ServiceStatistics {
|
||||
serviceName: string // A service category
|
||||
memory: MemoryStatistics
|
||||
cpu: CPUStatistics
|
||||
stats?: Metrics
|
||||
workspaces?: WorkspaceStatistics[]
|
||||
}
|
||||
|
||||
export interface OverviewStatistics {
|
||||
memory: MemoryStatistics
|
||||
cpu: CPUStatistics
|
||||
data: Record<string, Omit<ServiceStatistics, 'stats' | 'workspaces'>>
|
||||
usersTotal: number
|
||||
connectionsTotal: number
|
||||
admin: boolean
|
||||
workspaces: WorkspaceStatistics[]
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation, { type ServiceStatistics } from '@hcengineering/presentation'
|
||||
import { ticker } from '@hcengineering/ui'
|
||||
import MetricsInfo from './statistics/MetricsInfo.svelte'
|
||||
|
||||
export let serviceName: string
|
||||
export let sortOrder: 'ops' | 'avg' | 'total'
|
||||
|
||||
const endpoint = getMetadata(presentation.metadata.StatsUrl)
|
||||
const token: string = getMetadata(presentation.metadata.Token) ?? ''
|
||||
|
||||
async function fetchStats (time: number): Promise<void> {
|
||||
await fetch(endpoint + `/api/v1/statistics?token=${token}&name=${serviceName}`, {})
|
||||
.then(async (json) => {
|
||||
data = await json.json()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
let data: ServiceStatistics | undefined
|
||||
|
||||
$: void fetchStats($ticker)
|
||||
$: metricsData = data?.stats
|
||||
</script>
|
||||
|
||||
<div class="flex-column p-3 h-full" style:overflow="auto">
|
||||
{#if metricsData !== undefined}
|
||||
<MetricsInfo metrics={metricsData} {sortOrder} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.greyed {
|
||||
color: rgba(black, 0.5);
|
||||
}
|
||||
</style>
|
@ -1,8 +1,4 @@
|
||||
<script lang="ts">
|
||||
import ServerManagerCollaboratorStatistics from './ServerManagerCollaboratorStatistics.svelte'
|
||||
|
||||
import ServerManagerFrontStatistics from './ServerManagerFrontStatistics.svelte'
|
||||
|
||||
import ServerManagerServerStatistics from './ServerManagerServerStatistics.svelte'
|
||||
|
||||
import ServerManagerUsers from './ServerManagerUsers.svelte'
|
||||
@ -10,10 +6,9 @@
|
||||
import ServerManagerGeneral from './ServerManagerGeneral.svelte'
|
||||
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import { Header, TabItem, Switcher, Breadcrumb, IconSettings, ButtonIcon, IconClose } from '@hcengineering/ui'
|
||||
import ServerManagerAccountStatistics from './ServerManagerAccountStatistics.svelte'
|
||||
import { Breadcrumb, ButtonIcon, Header, IconClose, IconSettings, Switcher, TabItem } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@ -22,21 +17,9 @@
|
||||
id: 'general',
|
||||
labelIntl: getEmbeddedLabel('General')
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
labelIntl: getEmbeddedLabel('Account')
|
||||
},
|
||||
{
|
||||
id: 'statistics',
|
||||
labelIntl: getEmbeddedLabel('Server')
|
||||
},
|
||||
{
|
||||
id: 'statistics-front',
|
||||
labelIntl: getEmbeddedLabel('Front')
|
||||
},
|
||||
{
|
||||
id: 'statistics-collab',
|
||||
labelIntl: getEmbeddedLabel('Collaborator')
|
||||
labelIntl: getEmbeddedLabel('Servers')
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
@ -80,12 +63,6 @@
|
||||
<ServerManagerUsers />
|
||||
{:else if selectedTab === 'statistics'}
|
||||
<ServerManagerServerStatistics />
|
||||
{:else if selectedTab === 'statistics-front'}
|
||||
<ServerManagerFrontStatistics />
|
||||
{:else if selectedTab === 'statistics-collab'}
|
||||
<ServerManagerCollaboratorStatistics />
|
||||
{:else if selectedTab === 'account'}
|
||||
<ServerManagerAccountStatistics />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,65 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Metrics } from '@hcengineering/core'
|
||||
import login from '@hcengineering/login'
|
||||
import { getEmbeddedLabel, getMetadata } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import { Button, IconArrowRight, ticker } from '@hcengineering/ui'
|
||||
import MetricsInfo from './statistics/MetricsInfo.svelte'
|
||||
|
||||
const endpoint: string = getMetadata(login.metadata.AccountsUrl) ?? ''
|
||||
const token: string = getMetadata(presentation.metadata.Token) ?? ''
|
||||
|
||||
async function fetchStats (time: number): Promise<void> {
|
||||
await fetch(endpoint + `/api/v1/statistics?token=${token}`, {})
|
||||
.then(async (json) => {
|
||||
data = await json.json()
|
||||
admin = data?.admin ?? false
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
let data: any
|
||||
|
||||
let admin = false
|
||||
$: void fetchStats($ticker)
|
||||
$: metricsData = data?.metrics as Metrics | undefined
|
||||
</script>
|
||||
|
||||
{#if data}
|
||||
<div class="flex-col p-4">
|
||||
<span>
|
||||
Mem: {data?.statistics?.memoryUsed} / {data?.statistics?.memoryTotal} CPU: {data?.statistics?.cpuUsage}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if admin}
|
||||
<div class="flex flex-col">
|
||||
<div class="flex-row-center p-1">
|
||||
<div class="p-3">1.</div>
|
||||
<Button
|
||||
icon={IconArrowRight}
|
||||
label={getEmbeddedLabel('Wipe statistics')}
|
||||
on:click={() => {
|
||||
void fetch(endpoint + `/api/v1/manage?token=${token}&operation=wipe-statistics`, {
|
||||
method: 'PUT'
|
||||
}).then(async () => {
|
||||
await fetchStats(0)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-column p-3 h-full" style:overflow="auto">
|
||||
{#if metricsData !== undefined}
|
||||
<MetricsInfo metrics={metricsData} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.greyed {
|
||||
color: rgba(black, 0.5);
|
||||
}
|
||||
</style>
|
@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Metrics } from '@hcengineering/core'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import { ticker } from '@hcengineering/ui'
|
||||
import MetricsInfo from './statistics/MetricsInfo.svelte'
|
||||
|
||||
const token: string = getMetadata(presentation.metadata.Token) ?? ''
|
||||
|
||||
async function fetchCollabStats (tick: number): Promise<void> {
|
||||
const collaboratorUrl = getMetadata(presentation.metadata.CollaboratorUrl) ?? ''
|
||||
const collaboratorApiUrl = collaboratorUrl.replaceAll('wss://', 'https://').replace('ws://', 'http://')
|
||||
await fetch(collaboratorApiUrl + `/api/v1/statistics?token=${token}`, {})
|
||||
.then(async (json) => {
|
||||
dataCollab = await json.json()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
let dataCollab: any
|
||||
$: void fetchCollabStats($ticker)
|
||||
$: metricsDataCollab = dataCollab?.metrics as Metrics | undefined
|
||||
</script>
|
||||
|
||||
<div class="flex-column p-3 h-full" style:overflow="auto">
|
||||
{#if metricsDataCollab !== undefined}
|
||||
<MetricsInfo metrics={metricsDataCollab} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.greyed {
|
||||
color: rgba(black, 0.5);
|
||||
}
|
||||
</style>
|
@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Metrics } from '@hcengineering/core'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import { ticker } from '@hcengineering/ui'
|
||||
import MetricsInfo from './statistics/MetricsInfo.svelte'
|
||||
|
||||
const token: string = getMetadata(presentation.metadata.Token) ?? ''
|
||||
|
||||
async function fetchUIStats (time: number): Promise<void> {
|
||||
await fetch(`/api/v1/statistics?token=${token}`, {})
|
||||
.then(async (json) => {
|
||||
dataFront = await json.json()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
let dataFront: any
|
||||
$: void fetchUIStats($ticker)
|
||||
$: metricsDataFront = dataFront?.metrics as Metrics | undefined
|
||||
</script>
|
||||
|
||||
<div class="flex-column p-3 h-full" style:overflow="auto">
|
||||
{#if metricsDataFront !== undefined}
|
||||
<MetricsInfo metrics={metricsDataFront} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.greyed {
|
||||
color: rgba(black, 0.5);
|
||||
}
|
||||
</style>
|
@ -241,7 +241,7 @@
|
||||
{/if}
|
||||
|
||||
{#if metrics}
|
||||
<MetricsInfo {metrics} />
|
||||
<MetricsInfo {metrics} sortOrder={'avg'} />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -1,21 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Metrics } from '@hcengineering/core'
|
||||
import login from '@hcengineering/login'
|
||||
import { FixedColumn } from '@hcengineering/view-resources'
|
||||
import { getEmbeddedLabel, getMetadata } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import { Button, IconArrowRight, fetchMetadataLocalStorage, ticker } from '@hcengineering/ui'
|
||||
import MetricsInfo from './statistics/MetricsInfo.svelte'
|
||||
import presentation, { type OverviewStatistics } from '@hcengineering/presentation'
|
||||
import { Button, DropdownLabels, Expandable, IconArrowRight, ticker } from '@hcengineering/ui'
|
||||
import MetricsStats from './MetricsStats.svelte'
|
||||
import type { MetricsData } from '@hcengineering/core'
|
||||
|
||||
const _endpoint: string = fetchMetadataLocalStorage(login.metadata.LoginEndpoint) ?? ''
|
||||
const token: string = getMetadata(presentation.metadata.Token) ?? ''
|
||||
|
||||
let endpoint = _endpoint.replace(/^ws/g, 'http')
|
||||
if (endpoint.endsWith('/')) {
|
||||
endpoint = endpoint.substring(0, endpoint.length - 1)
|
||||
}
|
||||
const endpoint = getMetadata(presentation.metadata.StatsUrl)
|
||||
|
||||
async function fetchStats (time: number): Promise<void> {
|
||||
await fetch(endpoint + `/api/v1/statistics?token=${token}`, {})
|
||||
await fetch(endpoint + `/api/v1/overview?token=${token}`, {})
|
||||
.then(async (json) => {
|
||||
data = await json.json()
|
||||
admin = data?.admin ?? false
|
||||
@ -24,57 +20,26 @@
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
let data: any
|
||||
let data: OverviewStatistics | undefined
|
||||
|
||||
let admin = false
|
||||
$: void fetchStats($ticker)
|
||||
$: metricsData = data?.metrics as Metrics | undefined
|
||||
|
||||
interface StatisticsElement {
|
||||
find: number
|
||||
tx: number
|
||||
}
|
||||
|
||||
$: activeSessions =
|
||||
(data?.statistics?.activeSessions as Record<
|
||||
string,
|
||||
{
|
||||
sessions: Array<{
|
||||
userId: string
|
||||
data?: Record<string, any>
|
||||
total: StatisticsElement
|
||||
mins5: StatisticsElement
|
||||
current: StatisticsElement
|
||||
}>
|
||||
name: string
|
||||
wsId: string
|
||||
sessionsTotal: number
|
||||
upgrading: boolean
|
||||
closing: boolean
|
||||
}
|
||||
>) ?? {}
|
||||
|
||||
$: totalStats = Array.from(Object.entries(activeSessions).values()).reduce(
|
||||
(cur, it) => {
|
||||
const totalFind = it[1].sessions.reduce((it, itm) => itm.current.find + it, 0)
|
||||
const totalTx = it[1].sessions.reduce((it, itm) => itm.current.tx + it, 0)
|
||||
return {
|
||||
find: cur.find + totalFind,
|
||||
tx: cur.tx + totalTx
|
||||
}
|
||||
},
|
||||
{ find: 0, tx: 0 }
|
||||
)
|
||||
export let sortingOrder: 'avg' | 'ops' | 'total' = 'ops'
|
||||
const sortOrder = [
|
||||
{ id: 'avg', label: 'Average' },
|
||||
{ id: 'ops', label: 'Operations' },
|
||||
{ id: 'total', label: 'Total' }
|
||||
]
|
||||
</script>
|
||||
|
||||
{#if data}
|
||||
<div class="flex-col p-4">
|
||||
<span>
|
||||
Mem: {data.statistics.memoryUsed} / {data.statistics.memoryTotal} CPU: {data.statistics.cpuUsage}
|
||||
</span>
|
||||
<span>
|
||||
TotalFind: {totalStats.find} / Total Tx: {totalStats.tx}
|
||||
Connections: {data.connectionsTotal}
|
||||
Users: {data.usersTotal}
|
||||
</span>
|
||||
<span> </span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@ -96,11 +61,38 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-column p-3 h-full" style:overflow="auto">
|
||||
{#if metricsData !== undefined}
|
||||
<MetricsInfo metrics={metricsData} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if data}
|
||||
<div class="p-1 flex flex-grow flex-reverse">
|
||||
<DropdownLabels bind:selected={sortingOrder} items={sortOrder}></DropdownLabels>
|
||||
</div>
|
||||
<div class="flex-column p-3 h-full" style:overflow="auto">
|
||||
{#each Object.entries(data.data).sort((a, b) => a[1].serviceName.localeCompare(b[1].serviceName)) as kv}
|
||||
<Expandable bordered expandable showChevron>
|
||||
<svelte:fragment slot="title">
|
||||
<div class="ml-2">
|
||||
{kv[1].serviceName} - {kv[0]}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="tools">
|
||||
<div class="flex flex-between flex-grow">
|
||||
<FixedColumn key="mem-usage">
|
||||
{kv[1].memory.memoryUsed}/{kv[1].memory.memoryTotal} Mb
|
||||
</FixedColumn>
|
||||
<FixedColumn key="mem-rss">
|
||||
{kv[1].memory.memoryRSS} Mb
|
||||
</FixedColumn>
|
||||
<FixedColumn key="cpu-usage">
|
||||
<div class="ml-2">
|
||||
{kv[1].cpu.usage}%
|
||||
</div>
|
||||
</FixedColumn>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<MetricsStats serviceName={kv[0]} sortOrder={sortingOrder} />
|
||||
</Expandable>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.greyed {
|
||||
|
@ -1,24 +1,19 @@
|
||||
<script lang="ts">
|
||||
import contact, { PersonAccount } from '@hcengineering/contact'
|
||||
import { systemAccountEmail } from '@hcengineering/core'
|
||||
import login from '@hcengineering/login'
|
||||
import { groupByArray, systemAccountEmail } from '@hcengineering/core'
|
||||
import { getEmbeddedLabel, getMetadata } from '@hcengineering/platform'
|
||||
import presentation, { createQuery, isAdminUser } from '@hcengineering/presentation'
|
||||
import { Button, CheckBox, fetchMetadataLocalStorage, ticker } from '@hcengineering/ui'
|
||||
import presentation, { createQuery, isAdminUser, type OverviewStatistics } from '@hcengineering/presentation'
|
||||
import { Button, CheckBox, ticker } from '@hcengineering/ui'
|
||||
import Expandable from '@hcengineering/ui/src/components/Expandable.svelte'
|
||||
import { ObjectPresenter } from '@hcengineering/view-resources'
|
||||
import { workspacesStore } from '../utils'
|
||||
|
||||
const _endpoint: string = fetchMetadataLocalStorage(login.metadata.LoginEndpoint) ?? ''
|
||||
const token: string = getMetadata(presentation.metadata.Token) ?? ''
|
||||
|
||||
let endpoint = _endpoint.replace(/^ws/g, 'http')
|
||||
if (endpoint.endsWith('/')) {
|
||||
endpoint = endpoint.substring(0, endpoint.length - 1)
|
||||
}
|
||||
const endpoint = getMetadata(presentation.metadata.StatsUrl)
|
||||
|
||||
async function fetchStats (time: number): Promise<void> {
|
||||
await fetch(endpoint + `/api/v1/statistics?token=${token}`, {})
|
||||
await fetch(endpoint + `/api/v1/overview?token=${token}`, {})
|
||||
.then(async (json) => {
|
||||
data = await json.json()
|
||||
})
|
||||
@ -26,34 +21,9 @@
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
let data: any
|
||||
let data: OverviewStatistics | undefined
|
||||
$: void fetchStats($ticker)
|
||||
|
||||
interface StatisticsElement {
|
||||
find: number
|
||||
tx: number
|
||||
}
|
||||
|
||||
$: activeSessions =
|
||||
(data?.statistics?.activeSessions as Record<
|
||||
string,
|
||||
{
|
||||
sessions: Array<{
|
||||
userId: string
|
||||
data?: Record<string, any>
|
||||
total: StatisticsElement
|
||||
mins5: StatisticsElement
|
||||
current: StatisticsElement
|
||||
}>
|
||||
name: string
|
||||
wsId: string
|
||||
sessionsTotal: number
|
||||
upgrading: boolean
|
||||
closing: boolean
|
||||
}
|
||||
>) ?? {}
|
||||
|
||||
const employeeQuery = createQuery()
|
||||
|
||||
let employees = new Map<string, PersonAccount>()
|
||||
@ -66,11 +36,13 @@
|
||||
employees = emp
|
||||
})
|
||||
let realUsers: boolean
|
||||
|
||||
$: byService = groupByArray(data?.workspaces ?? [], (it) => it.service)
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="flex-row-center">
|
||||
Uniq users: {Object.keys(activeSessions).length} of {data?.statistics?.totalClients} connections
|
||||
Uniq users: {data?.usersTotal} of {data?.connectionsTotal} connections
|
||||
</div>
|
||||
<div class="flex-row-center">
|
||||
<CheckBox bind:checked={realUsers} />
|
||||
@ -78,103 +50,100 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-column p-3 h-full" style:overflow="auto">
|
||||
{#each Object.entries(activeSessions) as act}
|
||||
{@const wsInstance = $workspacesStore.find((it) => it.workspaceId === act[0])}
|
||||
{@const totalFind = act[1].sessions.reduce((it, itm) => itm.total.find + it, 0)}
|
||||
{@const totalTx = act[1].sessions.reduce((it, itm) => itm.total.tx + it, 0)}
|
||||
{#each byService.keys() as s}
|
||||
Service: {s}
|
||||
{#each byService.get(s) ?? [] as act}
|
||||
{@const wsInstance = $workspacesStore.find((it) => it.workspaceId === act.wsId)}
|
||||
{@const totalFind = act.sessions.reduce((it, itm) => itm.total.find + it, 0)}
|
||||
{@const totalTx = act.sessions.reduce((it, itm) => itm.total.tx + it, 0)}
|
||||
|
||||
{@const currentFind = act[1].sessions.reduce((it, itm) => itm.current.find + it, 0)}
|
||||
{@const currentTx = act[1].sessions.reduce((it, itm) => itm.current.tx + it, 0)}
|
||||
{@const employeeGroups = Array.from(new Set(act[1].sessions.map((it) => it.userId))).filter(
|
||||
(it) => systemAccountEmail !== it || !realUsers
|
||||
)}
|
||||
{@const realGroup = Array.from(new Set(act[1].sessions.map((it) => it.userId))).filter(
|
||||
(it) => systemAccountEmail !== it
|
||||
)}
|
||||
{#if employeeGroups.length > 0}
|
||||
<span class="flex-col">
|
||||
<Expandable contentColor expanded={false} expandable={true} bordered>
|
||||
<svelte:fragment slot="title">
|
||||
<div class="flex flex-row-center flex-between flex-grow p-1">
|
||||
<div class="fs-title" class:greyed={realGroup.length === 0}>
|
||||
Workspace: {wsInstance?.workspaceName ?? act[0]}: {employeeGroups.length} current 5 mins => {currentFind}/{currentTx},
|
||||
total => {totalFind}/{totalTx}
|
||||
{#if act[1].upgrading}
|
||||
(Upgrading)
|
||||
{/if}
|
||||
{#if act[1].closing}
|
||||
(Closing)
|
||||
{@const currentFind = act.sessions.reduce((it, itm) => itm.current.find + it, 0)}
|
||||
{@const currentTx = act.sessions.reduce((it, itm) => itm.current.tx + it, 0)}
|
||||
{@const employeeGroups = Array.from(new Set(act.sessions.map((it) => it.userId))).filter(
|
||||
(it) => systemAccountEmail !== it || !realUsers
|
||||
)}
|
||||
{@const realGroup = Array.from(new Set(act.sessions.map((it) => it.userId))).filter(
|
||||
(it) => systemAccountEmail !== it
|
||||
)}
|
||||
{#if employeeGroups.length > 0}
|
||||
<span class="flex-col">
|
||||
<Expandable contentColor expanded={false} expandable={true} bordered>
|
||||
<svelte:fragment slot="title">
|
||||
<div class="flex flex-row-center flex-between flex-grow p-1">
|
||||
<div class="fs-title" class:greyed={realGroup.length === 0}>
|
||||
Workspace: {wsInstance?.workspaceName ?? act.wsId}: {employeeGroups.length} current 5 mins => {currentFind}/{currentTx},
|
||||
total => {totalFind}/{totalTx}
|
||||
</div>
|
||||
{#if isAdminUser()}
|
||||
<Button
|
||||
label={getEmbeddedLabel('Force close')}
|
||||
size={'small'}
|
||||
kind={'ghost'}
|
||||
on:click={() => {
|
||||
void fetch(endpoint + `/api/v1/manage?token=${token}&operation=force-close&wsId=${act.wsId}`, {
|
||||
method: 'PUT'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isAdminUser()}
|
||||
<Button
|
||||
label={getEmbeddedLabel('Force close')}
|
||||
size={'small'}
|
||||
kind={'ghost'}
|
||||
on:click={() => {
|
||||
void fetch(endpoint + `/api/v1/manage?token=${token}&operation=force-close&wsId=${act[1].wsId}`, {
|
||||
method: 'PUT'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<div class="flex-col">
|
||||
{#each employeeGroups as employeeId}
|
||||
{@const employee = employees.get(employeeId)}
|
||||
{@const connections = act[1].sessions.filter((it) => it.userId === employeeId)}
|
||||
</svelte:fragment>
|
||||
<div class="flex-col">
|
||||
{#each employeeGroups as employeeId}
|
||||
{@const employee = employees.get(employeeId)}
|
||||
{@const connections = act.sessions.filter((it) => it.userId === employeeId)}
|
||||
|
||||
{@const find = connections.reduce((it, itm) => itm.current.find + it, 0)}
|
||||
{@const txes = connections.reduce((it, itm) => itm.current.tx + it, 0)}
|
||||
<div class="p-1 flex-col ml-4">
|
||||
<Expandable>
|
||||
<svelte:fragment slot="title">
|
||||
<div class="flex-row-center p-1">
|
||||
{#if employee}
|
||||
<ObjectPresenter
|
||||
_class={contact.mixin.Employee}
|
||||
objectId={employee.person}
|
||||
props={{ shouldShowAvatar: true, disabled: true }}
|
||||
/>
|
||||
{:else}
|
||||
{employeeId}
|
||||
{/if}
|
||||
: {connections.length}
|
||||
<div class="ml-4">
|
||||
<div class="ml-1">{find} rx/{txes} tx</div>
|
||||
</div>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
{#each connections as user, i}
|
||||
<div class="flex-row-center ml-10">
|
||||
#{i}
|
||||
{user.userId}
|
||||
<div class="p-1">
|
||||
Total: {user.total.find} rx/{user.total.tx} tx
|
||||
</div>
|
||||
<div class="p-1">
|
||||
Previous 5 mins: {user.mins5.find} rx/{user.mins5.tx} tx
|
||||
</div>
|
||||
<div class="p-1">
|
||||
Current 5 mins: {user.current.find} tx/{user.current.tx} tx
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-1 flex-col ml-10">
|
||||
{#each Object.entries(user.data ?? {}) as [k, v]}
|
||||
<div class="p-1">
|
||||
{k}: {JSON.stringify(v)}
|
||||
{@const find = connections.reduce((it, itm) => itm.current.find + it, 0)}
|
||||
{@const txes = connections.reduce((it, itm) => itm.current.tx + it, 0)}
|
||||
<div class="p-1 flex-col ml-4">
|
||||
<Expandable>
|
||||
<svelte:fragment slot="title">
|
||||
<div class="flex-row-center p-1">
|
||||
{#if employee}
|
||||
<ObjectPresenter
|
||||
_class={contact.mixin.Employee}
|
||||
objectId={employee.person}
|
||||
props={{ shouldShowAvatar: true, disabled: true }}
|
||||
/>
|
||||
{:else}
|
||||
{employeeId}
|
||||
{/if}
|
||||
: {connections.length}
|
||||
<div class="ml-4">
|
||||
<div class="ml-1">{find} rx/{txes} tx</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</Expandable>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Expandable>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
{#each connections as user, i}
|
||||
<div class="flex-row-center ml-10">
|
||||
#{i}
|
||||
{user.userId}
|
||||
<div class="p-1">
|
||||
Total: {user.total.find} rx/{user.total.tx} tx
|
||||
</div>
|
||||
<div class="p-1">
|
||||
Previous 5 mins: {user.mins5.find} rx/{user.mins5.tx} tx
|
||||
</div>
|
||||
<div class="p-1">
|
||||
Current 5 mins: {user.current.find} tx/{user.current.tx} tx
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-1 flex-col ml-10">
|
||||
{#each Object.entries(user.data ?? {}) as [k, v]}
|
||||
<div class="p-1">
|
||||
{k}: {JSON.stringify(v)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</Expandable>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Expandable>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
@ -2,13 +2,13 @@
|
||||
import { Metrics, type MetricsData } from '@hcengineering/core'
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import { Button, Expandable, showPopup } from '@hcengineering/ui'
|
||||
import DropdownLabels from '@hcengineering/ui/src/components/DropdownLabels.svelte'
|
||||
import { FixedColumn } from '@hcengineering/view-resources'
|
||||
import Params from './Params.svelte'
|
||||
|
||||
export let metrics: Metrics
|
||||
export let level = 0
|
||||
export let name: string = 'System'
|
||||
export let sortOrder: 'avg' | 'ops' | 'total'
|
||||
|
||||
$: haschilds =
|
||||
Object.keys(metrics.measurements).length > 0 ||
|
||||
@ -24,13 +24,6 @@
|
||||
}
|
||||
return `${Math.floor((time / ops) * 100) / 100}`
|
||||
}
|
||||
|
||||
export let sortingOrder: 'avg' | 'ops' | 'total' = 'ops'
|
||||
const sortOrder = [
|
||||
{ id: 'avg', label: 'Average' },
|
||||
{ id: 'ops', label: 'Operations' },
|
||||
{ id: 'total', label: 'Total' }
|
||||
]
|
||||
const getSorted = (v: Record<string, MetricsData>, sortingOrder: 'avg' | 'ops' | 'total') => {
|
||||
if (sortingOrder === 'avg') {
|
||||
return Object.entries(v).sort((a, b) => b[1].value / (b[1].operations + 1) - a[1].value / (a[1].operations + 1))
|
||||
@ -40,13 +33,23 @@
|
||||
return Object.entries(v).sort((a, b) => b[1].value - a[1].value)
|
||||
}
|
||||
}
|
||||
|
||||
function getSortedMeasurements (
|
||||
m: Record<string, Metrics>,
|
||||
sortingOrder: 'avg' | 'ops' | 'total'
|
||||
): [string, Metrics][] {
|
||||
const ms = [...Object.entries(m)]
|
||||
if (sortingOrder === 'avg') {
|
||||
ms.sort((a, b) => b[1].value / (b[1].operations + 1) - a[1].value / (a[1].operations + 1))
|
||||
} else if (sortingOrder === 'ops') {
|
||||
ms.sort((a, b) => b[1].operations + 1 - (a[1].operations + 1))
|
||||
} else {
|
||||
ms.sort((a, b) => b[1].value - a[1].value)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if level === 0}
|
||||
<div class="p-1 flex flex-grow flex-reverse">
|
||||
<DropdownLabels bind:selected={sortingOrder} items={sortOrder}></DropdownLabels>
|
||||
</div>
|
||||
{/if}
|
||||
<Expandable
|
||||
expanded={level === 0}
|
||||
expandable={level !== 0 && haschilds}
|
||||
@ -113,14 +116,14 @@
|
||||
</Expandable>
|
||||
</div>
|
||||
{/if}
|
||||
{#each Object.entries(metrics.measurements) as [k, v], i (k)}
|
||||
{#each getSortedMeasurements(metrics.measurements, sortOrder) as [k, v], i (k)}
|
||||
<div style:margin-left={`${level * 0.5}rem`}>
|
||||
<svelte:self metrics={v} name="{i}. {k}" level={level + 1} {sortingOrder} />
|
||||
<svelte:self metrics={v} name="{i}. {k}" level={level + 1} {sortOrder} />
|
||||
</div>
|
||||
{/each}
|
||||
{#each Object.entries(metrics.params) as [k, v], i}
|
||||
{#each Object.entries(metrics.params) as [k, v]}
|
||||
<div style:margin-left={`${level * 0.5}rem`}>
|
||||
{#each getSorted(v, sortingOrder) as [kk, vv]}
|
||||
{#each getSorted(v, sortOrder) as [kk, vv]}
|
||||
{@const childExpandable =
|
||||
vv.topResult !== undefined &&
|
||||
vv.topResult.length > 0 &&
|
||||
|
@ -1,42 +1,30 @@
|
||||
//
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { MeasureMetricsContext, metricsToString, newMetrics } from '@hcengineering/core'
|
||||
import { loadBrandingMap } from '@hcengineering/server-core'
|
||||
import { configureAnalytics, SplitLogger } from '@hcengineering/analytics-service'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { serveAccount } from '@hcengineering/account-service'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { configureAnalytics, SplitLogger } from '@hcengineering/analytics-service'
|
||||
import { MeasureMetricsContext, newMetrics } from '@hcengineering/core'
|
||||
import { initStatisticsContext, loadBrandingMap } from '@hcengineering/server-core'
|
||||
import { join } from 'path'
|
||||
|
||||
configureAnalytics(process.env.SENTRY_DSN, {})
|
||||
Analytics.setTag('application', 'account')
|
||||
|
||||
const metricsContext = new MeasureMetricsContext(
|
||||
'account',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('account', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
|
||||
let oldMetricsValue = ''
|
||||
|
||||
const intTimer = setInterval(() => {
|
||||
const val = metricsToString(metricsContext.metrics, 'Account', 140)
|
||||
if (val !== oldMetricsValue) {
|
||||
oldMetricsValue = val
|
||||
void writeFile('metrics.txt', val).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
}, 30000)
|
||||
const metricsContext = initStatisticsContext('account', {
|
||||
factory: () =>
|
||||
new MeasureMetricsContext(
|
||||
'account',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('account', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const brandingPath = process.env.BRANDING_PATH
|
||||
|
||||
serveAccount(metricsContext, loadBrandingMap(brandingPath), () => {
|
||||
clearInterval(intTimer)
|
||||
})
|
||||
serveAccount(metricsContext, loadBrandingMap(brandingPath), () => {})
|
||||
|
@ -16,48 +16,33 @@
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { configureAnalytics, SplitLogger } from '@hcengineering/analytics-service'
|
||||
import { startBackup } from '@hcengineering/backup-service'
|
||||
import { MeasureMetricsContext, metricsToString, newMetrics, type Tx } from '@hcengineering/core'
|
||||
import { type PipelineFactory } from '@hcengineering/server-core'
|
||||
import { MeasureMetricsContext, newMetrics, type Tx } from '@hcengineering/core'
|
||||
import { initStatisticsContext, type PipelineFactory } from '@hcengineering/server-core'
|
||||
import { createBackupPipeline, getConfig } from '@hcengineering/server-pipeline'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
const model = JSON.parse(readFileSync(process.env.MODEL_JSON ?? 'model.json').toString()) as Tx[]
|
||||
|
||||
const metricsContext = new MeasureMetricsContext(
|
||||
'backup',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('backup-service', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
const metricsContext = initStatisticsContext('backup', {
|
||||
factory: () =>
|
||||
new MeasureMetricsContext(
|
||||
'backup',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('backup', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const sentryDSN = process.env.SENTRY_DSN
|
||||
|
||||
configureAnalytics(sentryDSN, {})
|
||||
Analytics.setTag('application', 'backup-service')
|
||||
|
||||
let oldMetricsValue = ''
|
||||
|
||||
const intTimer = setInterval(() => {
|
||||
const val = metricsToString(metricsContext.metrics, 'Backup', 140)
|
||||
if (val !== oldMetricsValue) {
|
||||
oldMetricsValue = val
|
||||
void writeFile('metrics.txt', val).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const onClose = (): void => {
|
||||
clearInterval(intTimer)
|
||||
metricsContext.info('Closed')
|
||||
}
|
||||
|
||||
startBackup(
|
||||
metricsContext,
|
||||
(mongoUrl, storageAdapter) => {
|
||||
@ -78,6 +63,3 @@ startBackup(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
process.on('SIGINT', onClose)
|
||||
process.on('SIGTERM', onClose)
|
||||
|
@ -51,6 +51,7 @@
|
||||
"@hcengineering/collaborator": "^0.6.0",
|
||||
"@hcengineering/platform": "^0.6.11",
|
||||
"@hcengineering/analytics": "^0.6.0",
|
||||
"@hcengineering/analytics-service": "^0.6.0"
|
||||
"@hcengineering/analytics-service": "^0.6.0",
|
||||
"@hcengineering/server-core": "^0.6.1"
|
||||
}
|
||||
}
|
||||
|
@ -14,38 +14,27 @@
|
||||
//
|
||||
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { MeasureMetricsContext, metricsToString, newMetrics } from '@hcengineering/core'
|
||||
import { startCollaborator } from '@hcengineering/collaborator'
|
||||
import { configureAnalytics, SplitLogger } from '@hcengineering/analytics-service'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { startCollaborator } from '@hcengineering/collaborator'
|
||||
import { MeasureMetricsContext, newMetrics } from '@hcengineering/core'
|
||||
import { initStatisticsContext } from '@hcengineering/server-core'
|
||||
import { join } from 'path'
|
||||
|
||||
configureAnalytics(process.env.SENTRY_DSN, {})
|
||||
Analytics.setTag('application', 'collaborator')
|
||||
|
||||
const ctx = new MeasureMetricsContext(
|
||||
'collaborator',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('collaborator', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
|
||||
let oldMetricsValue = ''
|
||||
|
||||
const intTimer = setInterval(() => {
|
||||
const val = metricsToString(ctx.metrics, 'Collaborator', 140)
|
||||
if (val !== oldMetricsValue) {
|
||||
oldMetricsValue = val
|
||||
void writeFile('metrics.txt', val).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
void startCollaborator(ctx, () => {
|
||||
clearInterval(intTimer)
|
||||
const metricsContext = initStatisticsContext('collaborator', {
|
||||
factory: () =>
|
||||
new MeasureMetricsContext(
|
||||
'collaborator',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('collaborator', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
void startCollaborator(metricsContext, () => {})
|
||||
|
@ -7,20 +7,24 @@ import { MeasureMetricsContext, newMetrics } from '@hcengineering/core'
|
||||
import { startFront } from '@hcengineering/front/src/starter'
|
||||
import { configureAnalytics, SplitLogger } from '@hcengineering/analytics-service'
|
||||
import { join } from 'path'
|
||||
import { initStatisticsContext } from '@hcengineering/server-core'
|
||||
|
||||
configureAnalytics(process.env.SENTRY_DSN, {})
|
||||
Analytics.setTag('application', 'front')
|
||||
|
||||
const metricsContext = new MeasureMetricsContext(
|
||||
'front',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('front', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
const metricsContext = initStatisticsContext('front', {
|
||||
factory: () =>
|
||||
new MeasureMetricsContext(
|
||||
'front',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('front', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
startFront(metricsContext, {
|
||||
GITHUB_APP: process.env.GITHUB_APP ?? '',
|
||||
@ -40,5 +44,6 @@ startFront(metricsContext, {
|
||||
DESKTOP_UPDATES_CHANNEL: process.env.DESKTOP_UPDATES_CHANNEL,
|
||||
ANALYTICS_COLLECTOR_URL: process.env.ANALYTICS_COLLECTOR_URL,
|
||||
AI_URL: process.env.AI_URL,
|
||||
TELEGRAM_BOT_URL: process.env.TELEGRAM_BOT_URL
|
||||
TELEGRAM_BOT_URL: process.env.TELEGRAM_BOT_URL,
|
||||
STATS_URL: process.env.STATS_URL
|
||||
})
|
||||
|
@ -11,7 +11,16 @@ import notification from '@hcengineering/notification'
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import { serverConfigFromEnv } from '@hcengineering/server'
|
||||
import serverAiBot from '@hcengineering/server-ai-bot'
|
||||
import serverCore, { type StorageConfiguration, loadBrandingMap } from '@hcengineering/server-core'
|
||||
import serverCore, {
|
||||
type ConnectionSocket,
|
||||
type Session,
|
||||
type StorageConfiguration,
|
||||
type UserStatistics,
|
||||
type Workspace,
|
||||
type WorkspaceStatistics,
|
||||
initStatisticsContext,
|
||||
loadBrandingMap
|
||||
} from '@hcengineering/server-core'
|
||||
import serverNotification from '@hcengineering/server-notification'
|
||||
import { storageConfigFromEnv } from '@hcengineering/server-storage'
|
||||
import serverTelegram from '@hcengineering/server-telegram'
|
||||
@ -20,14 +29,19 @@ import { startHttpServer } from '@hcengineering/server-ws'
|
||||
import { join } from 'path'
|
||||
import { start } from '.'
|
||||
import { profileStart, profileStop } from './inspector'
|
||||
import { getMetricsContext } from './metrics'
|
||||
|
||||
configureAnalytics(process.env.SENTRY_DSN, {})
|
||||
Analytics.setTag('application', 'transactor')
|
||||
|
||||
let getUsers: () => WorkspaceStatistics[] = () => {
|
||||
return []
|
||||
}
|
||||
// Force create server metrics context with proper logging
|
||||
getMetricsContext(
|
||||
() =>
|
||||
const metricsContext = initStatisticsContext('transactor', {
|
||||
getUsers: (): WorkspaceStatistics[] => {
|
||||
return getUsers()
|
||||
},
|
||||
factory: () =>
|
||||
new MeasureMetricsContext(
|
||||
'server',
|
||||
{},
|
||||
@ -38,7 +52,7 @@ getMetricsContext(
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
setOperationLogProfiling(process.env.OPERATION_PROFILING === 'true')
|
||||
|
||||
@ -59,7 +73,7 @@ setMetadata(serverCore.metadata.ElasticIndexVersion, 'v1')
|
||||
setMetadata(serverTelegram.metadata.BotUrl, process.env.TELEGRAM_BOT_URL)
|
||||
setMetadata(serverAiBot.metadata.SupportWorkspaceId, process.env.SUPPORT_WORKSPACE)
|
||||
|
||||
const shutdown = start(config.dbUrl, {
|
||||
const { shutdown, sessionManager } = start(metricsContext, config.dbUrl, {
|
||||
fullTextUrl: config.elasticUrl,
|
||||
storageConfig,
|
||||
rekoniUrl: config.rekoniUrl,
|
||||
@ -77,6 +91,31 @@ const shutdown = start(config.dbUrl, {
|
||||
mongoUrl: config.mongoUrl
|
||||
})
|
||||
|
||||
const entryToUserStats = (session: Session, socket: ConnectionSocket): UserStatistics => {
|
||||
return {
|
||||
current: session.current,
|
||||
mins5: session.mins5,
|
||||
userId: session.getUser(),
|
||||
sessionId: socket.id,
|
||||
total: session.total,
|
||||
data: socket.data
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceToWorkspaceStats = (ws: Workspace): WorkspaceStatistics => {
|
||||
return {
|
||||
clientsTotal: new Set(Array.from(ws.sessions.values()).map((it) => it.session.getUser())).size,
|
||||
sessionsTotal: ws.sessions.size,
|
||||
workspaceName: ws.workspaceName,
|
||||
wsId: ws.workspaceId.name,
|
||||
sessions: Array.from(ws.sessions.values()).map((it) => entryToUserStats(it.session, it.socket))
|
||||
}
|
||||
}
|
||||
|
||||
getUsers = () => {
|
||||
return Array.from(sessionManager.workspaces.values()).map((it) => workspaceToWorkspaceStats(it))
|
||||
}
|
||||
|
||||
const close = (): void => {
|
||||
console.trace('Exiting from server')
|
||||
console.log('Shutdown request accepted')
|
||||
|
@ -1,57 +0,0 @@
|
||||
import { type MeasureContext, MeasureMetricsContext, metricsToString, newMetrics } from '@hcengineering/core'
|
||||
import { writeFile } from 'fs/promises'
|
||||
|
||||
const metricsFile = process.env.METRICS_FILE
|
||||
// const logsRoot = process.env.LOGS_ROOT
|
||||
const metricsConsole = (process.env.METRICS_CONSOLE ?? 'false') === 'true'
|
||||
|
||||
const METRICS_UPDATE_INTERVAL = !metricsConsole ? 1000 : 60000
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
let metricsContext: MeasureContext | undefined
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function getMetricsContext (factory?: () => MeasureMetricsContext): MeasureContext {
|
||||
if (metricsContext !== undefined) {
|
||||
return metricsContext
|
||||
}
|
||||
|
||||
const metrics = newMetrics()
|
||||
if (factory !== undefined) {
|
||||
metricsContext = factory()
|
||||
} else {
|
||||
metricsContext = new MeasureMetricsContext('System', {}, {}, metrics)
|
||||
}
|
||||
|
||||
if (metricsFile !== undefined || metricsConsole) {
|
||||
console.info('storing measurements into local file', metricsFile)
|
||||
let oldMetricsValue = ''
|
||||
|
||||
const intTimer = setInterval(() => {
|
||||
const val = metricsToString(metrics, 'System', 140)
|
||||
if (val !== oldMetricsValue) {
|
||||
oldMetricsValue = val
|
||||
if (metricsFile !== undefined) {
|
||||
writeFile(metricsFile, val).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
if (metricsConsole) {
|
||||
console.info('METRICS:', val)
|
||||
}
|
||||
}
|
||||
}, METRICS_UPDATE_INTERVAL)
|
||||
|
||||
const closeTimer = (): void => {
|
||||
clearInterval(intTimer)
|
||||
}
|
||||
process.on('SIGINT', closeTimer)
|
||||
process.on('SIGTERM', closeTimer)
|
||||
}
|
||||
|
||||
return metricsContext
|
||||
}
|
@ -14,12 +14,23 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type Branding, type BrandingMap, type Tx, type WorkspaceIdWithUrl } from '@hcengineering/core'
|
||||
import {
|
||||
type Branding,
|
||||
type BrandingMap,
|
||||
type MeasureContext,
|
||||
type Tx,
|
||||
type WorkspaceIdWithUrl
|
||||
} from '@hcengineering/core'
|
||||
import { buildStorageFromConfig } from '@hcengineering/server-storage'
|
||||
import { getMetricsContext } from './metrics'
|
||||
|
||||
import { ClientSession, startSessionManager } from '@hcengineering/server'
|
||||
import { type Pipeline, type ServerFactory, type Session, type StorageConfiguration } from '@hcengineering/server-core'
|
||||
import {
|
||||
type Pipeline,
|
||||
type ServerFactory,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
type StorageConfiguration
|
||||
} from '@hcengineering/server-core'
|
||||
import { type Token } from '@hcengineering/server-token'
|
||||
|
||||
import { serverAiBotId } from '@hcengineering/server-ai-bot'
|
||||
@ -35,6 +46,7 @@ registerStringLoaders()
|
||||
* @public
|
||||
*/
|
||||
export function start (
|
||||
metrics: MeasureContext,
|
||||
dbUrl: string,
|
||||
opt: {
|
||||
fullTextUrl: string
|
||||
@ -58,9 +70,7 @@ export function start (
|
||||
|
||||
mongoUrl?: string
|
||||
}
|
||||
): () => Promise<void> {
|
||||
const metrics = getMetricsContext()
|
||||
|
||||
): { shutdown: () => Promise<void>, sessionManager: SessionManager } {
|
||||
registerServerPlugins()
|
||||
|
||||
const externalStorage = buildStorageFromConfig(opt.storageConfig)
|
||||
@ -91,7 +101,7 @@ export function start (
|
||||
return new ClientSession(token, pipeline, workspaceId, branding, token.extra?.mode === 'backup')
|
||||
}
|
||||
|
||||
const onClose = startSessionManager(getMetricsContext(), {
|
||||
const { shutdown: onClose, sessionManager } = startSessionManager(metrics, {
|
||||
pipelineFactory,
|
||||
sessionFactory,
|
||||
port: opt.port,
|
||||
@ -102,8 +112,11 @@ export function start (
|
||||
externalStorage,
|
||||
profiling: opt.profiling
|
||||
})
|
||||
return async () => {
|
||||
await externalStorage.close()
|
||||
await onClose()
|
||||
return {
|
||||
shutdown: async () => {
|
||||
await externalStorage.close()
|
||||
await onClose()
|
||||
},
|
||||
sessionManager
|
||||
}
|
||||
}
|
||||
|
7
pods/stats/.eslintrc.js
Normal file
7
pods/stats/.eslintrc.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
}
|
4
pods/stats/.npmignore
Normal file
4
pods/stats/.npmignore
Normal file
@ -0,0 +1,4 @@
|
||||
*
|
||||
!/lib/**
|
||||
!CHANGELOG.md
|
||||
/lib/**/__tests__/
|
15
pods/stats/Dockerfile
Normal file
15
pods/stats/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
FROM node:20
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install libjemalloc2
|
||||
RUN apt-get clean
|
||||
|
||||
ENV LD_PRELOAD=libjemalloc.so.2
|
||||
ENV MALLOC_CONF=dirty_decay_ms:1000,narenas:2,background_thread:true
|
||||
|
||||
COPY bundle/bundle.js ./
|
||||
|
||||
EXPOSE 4900
|
||||
CMD [ "node", "bundle.js" ]
|
20
pods/stats/build.sh
Executable file
20
pods/stats/build.sh
Executable file
@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Copyright © 2020, 2021 Anticrm Platform Contributors.
|
||||
# Copyright © 2021 Hardcore Engineering Inc.
|
||||
#
|
||||
# Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License. You may
|
||||
# obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
#
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
rushx bundle
|
||||
rushx docker:build
|
||||
rushx docker:push
|
5
pods/stats/config/rig.json
Normal file
5
pods/stats/config/rig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||
"rigPackageName": "@hcengineering/platform-rig",
|
||||
"rigProfile": "node"
|
||||
}
|
7
pods/stats/jest.config.js
Normal file
7
pods/stats/jest.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
|
||||
roots: ["./src"],
|
||||
coverageReporters: ["text-summary", "html"]
|
||||
}
|
65
pods/stats/package.json
Normal file
65
pods/stats/package.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@hcengineering/pod-stats",
|
||||
"version": "0.6.0",
|
||||
"main": "lib/index.js",
|
||||
"svelte": "src/index.ts",
|
||||
"types": "types/index.d.ts",
|
||||
"author": "Anticrm Platform Contributors",
|
||||
"template": "@hcengineering/node-package",
|
||||
"license": "EPL-2.0",
|
||||
"scripts": {
|
||||
"start": "ts-node src/__start.ts",
|
||||
"build": "compile",
|
||||
"build:watch": "compile",
|
||||
"_phase:bundle": "rushx bundle",
|
||||
"_phase:docker-build": "rushx docker:build",
|
||||
"_phase:docker-staging": "rushx docker:staging",
|
||||
"bundle": "mkdir -p bundle && esbuild src/__start.ts --external:*.node --external:snappy --keep-names --bundle --define:process.env.MODEL_VERSION=$(node ../../common/scripts/show_version.js) --platform=node --outfile=bundle/bundle.js --log-level=error --sourcemap=external",
|
||||
"docker:build": "../../common/scripts/docker_build.sh hardcoreeng/stats",
|
||||
"docker:tbuild": "docker build -t hardcoreeng/stats . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/stats",
|
||||
"docker:abuild": "docker build -t hardcoreeng/stats . --platform=linux/arm64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/stats",
|
||||
"docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/stats staging",
|
||||
"docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/stats",
|
||||
"format": "format src",
|
||||
"test": "jest --passWithNoTests --silent --forceExit",
|
||||
"_phase:build": "compile transpile src",
|
||||
"_phase:test": "jest --passWithNoTests --silent --forceExit",
|
||||
"_phase:format": "format src",
|
||||
"_phase:validate": "compile validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "~7.0.3",
|
||||
"@hcengineering/platform-rig": "^0.6.0",
|
||||
"@types/node": "~20.11.16",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-n": "^15.4.0",
|
||||
"eslint": "^8.54.0",
|
||||
"esbuild": "^0.20.0",
|
||||
"@types/koa-bodyparser": "^4.3.12",
|
||||
"@types/koa-router": "^7.4.8",
|
||||
"@types/koa": "^2.15.0",
|
||||
"@types/koa__cors": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^6.11.0",
|
||||
"eslint-config-standard-with-typescript": "^40.0.0",
|
||||
"prettier": "^3.1.0",
|
||||
"ts-node": "^10.8.0",
|
||||
"typescript": "^5.3.3",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"@types/jest": "^29.5.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hcengineering/platform": "^0.6.11",
|
||||
"@hcengineering/core": "^0.6.32",
|
||||
"koa": "^2.15.3",
|
||||
"koa-router": "^12.0.1",
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"@koa/cors": "^5.0.0",
|
||||
"@hcengineering/server-token": "^0.6.11",
|
||||
"@hcengineering/server-core": "^0.6.1",
|
||||
"@hcengineering/analytics": "^0.6.0",
|
||||
"@hcengineering/analytics-service": "^0.6.0"
|
||||
}
|
||||
}
|
39
pods/stats/src/__start.ts
Normal file
39
pods/stats/src/__start.ts
Normal file
@ -0,0 +1,39 @@
|
||||
//
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { configureAnalytics, SplitLogger } from '@hcengineering/analytics-service'
|
||||
import { MeasureMetricsContext, metricsToString, newMetrics } from '@hcengineering/core'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { serveStats } from './stats'
|
||||
|
||||
configureAnalytics(process.env.SENTRY_DSN, {})
|
||||
Analytics.setTag('application', 'stats')
|
||||
|
||||
const metricsContext = new MeasureMetricsContext(
|
||||
'stats',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('stats', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
|
||||
let oldMetricsValue = ''
|
||||
|
||||
const intTimer = setInterval(() => {
|
||||
const val = metricsToString(metricsContext.metrics, 'Stats', 140)
|
||||
if (val !== oldMetricsValue) {
|
||||
oldMetricsValue = val
|
||||
void writeFile('metrics.txt', val).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
serveStats(metricsContext, () => {
|
||||
clearInterval(intTimer)
|
||||
})
|
239
pods/stats/src/stats.ts
Normal file
239
pods/stats/src/stats.ts
Normal file
@ -0,0 +1,239 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { metricsAggregate, type MeasureContext } from '@hcengineering/core'
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import {
|
||||
getCPUInfo,
|
||||
getMemoryInfo,
|
||||
type CPUStatistics,
|
||||
type MemoryStatistics,
|
||||
type ServiceStatistics,
|
||||
type WorkspaceStatistics
|
||||
} from '@hcengineering/server-core'
|
||||
import serverToken, { decodeToken } from '@hcengineering/server-token'
|
||||
import cors from '@koa/cors'
|
||||
import Koa from 'koa'
|
||||
import bodyParser from 'koa-bodyparser'
|
||||
import Router from 'koa-router'
|
||||
|
||||
const serviceTimeout = 30000
|
||||
|
||||
interface ServiceStatisticsEx extends ServiceStatistics {
|
||||
lastUpdate: number // Last updated
|
||||
}
|
||||
|
||||
interface OverviewStatistics {
|
||||
memory: MemoryStatistics
|
||||
cpu: CPUStatistics
|
||||
data: Record<string, Omit<ServiceStatistics, 'stats' | 'workspaces'>>
|
||||
usersTotal: number
|
||||
connectionsTotal: number
|
||||
|
||||
admin: boolean
|
||||
workspaces: WorkspaceStatistics[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function serveStats (ctx: MeasureContext, onClose?: () => void): void {
|
||||
const servicePort = parseInt(process.env.PORT ?? '4900')
|
||||
ctx.info('Starting stats service')
|
||||
|
||||
const serverSecret = process.env.SERVER_SECRET
|
||||
if (serverSecret === undefined) {
|
||||
ctx.info('Please provide server secret')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
setMetadata(serverToken.metadata.Secret, serverSecret)
|
||||
|
||||
const statistics = new Map<string, ServiceStatisticsEx>()
|
||||
const timeouts = new Map<string, number>()
|
||||
|
||||
const app = new Koa()
|
||||
const router = new Router()
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
credentials: true
|
||||
})
|
||||
)
|
||||
app.use(bodyParser())
|
||||
|
||||
router.get('/api/v1/overview', (req, res) => {
|
||||
try {
|
||||
const token = req.query.token as string
|
||||
const payload = decodeToken(token)
|
||||
const admin = payload.extra?.admin === 'true'
|
||||
if (!admin) {
|
||||
req.res.setHeader('Content-Type', 'application/json')
|
||||
const dta: OverviewStatistics = {
|
||||
memory: getMemoryInfo(),
|
||||
cpu: getCPUInfo(),
|
||||
data: {},
|
||||
usersTotal: 0,
|
||||
connectionsTotal: 0,
|
||||
admin: false,
|
||||
workspaces: []
|
||||
}
|
||||
req.body = dta
|
||||
return
|
||||
}
|
||||
|
||||
const toClean: string[] = []
|
||||
|
||||
let usersTotal: number = 0
|
||||
let connectionsTotal: number = 0
|
||||
|
||||
const allWorkspaces: WorkspaceStatistics[] = []
|
||||
|
||||
const json: Record<string, Omit<ServiceStatistics, 'stats' | 'workspaces'>> = {}
|
||||
for (const [k, v] of statistics.entries()) {
|
||||
if (Date.now() - v.lastUpdate > serviceTimeout) {
|
||||
timeouts.set(v.serviceName, (timeouts.get(v.serviceName) ?? 0) + 1)
|
||||
toClean.push(k)
|
||||
continue
|
||||
}
|
||||
const { stats: _, workspaces, ...data } = v
|
||||
|
||||
allWorkspaces.push(...(workspaces ?? []))
|
||||
if (workspaces !== undefined) {
|
||||
for (const ws of workspaces) {
|
||||
ws.service = k
|
||||
usersTotal += ws.clientsTotal
|
||||
connectionsTotal += ws.sessionsTotal
|
||||
}
|
||||
}
|
||||
json[k] = {
|
||||
...data
|
||||
}
|
||||
}
|
||||
for (const k of toClean) {
|
||||
statistics.delete(k)
|
||||
}
|
||||
|
||||
const dta: OverviewStatistics = {
|
||||
memory: getMemoryInfo(),
|
||||
cpu: getCPUInfo(),
|
||||
data: json,
|
||||
usersTotal,
|
||||
connectionsTotal,
|
||||
admin: true,
|
||||
workspaces: allWorkspaces
|
||||
}
|
||||
req.body = dta
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
console.error(err)
|
||||
req.res.writeHead(404, {})
|
||||
req.res.end()
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/api/v1/statistics', (req, res) => {
|
||||
try {
|
||||
const token = req.query.token as string
|
||||
const payload = decodeToken(token)
|
||||
const admin = payload.extra?.admin === 'true'
|
||||
ctx.info('get stats', { admin, service: req.query.name })
|
||||
if (admin) {
|
||||
const json = statistics.get((req.query.name as string) ?? '')
|
||||
if (json !== undefined) {
|
||||
req.res.setHeader('Content-Type', 'application/json')
|
||||
const result: ServiceStatistics = {
|
||||
...json,
|
||||
stats: json.stats !== undefined ? metricsAggregate(json.stats) : undefined
|
||||
}
|
||||
req.body = result
|
||||
return
|
||||
}
|
||||
}
|
||||
const json = {}
|
||||
req.res.setHeader('Content-Type', 'application/json')
|
||||
req.body = json
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
console.error(err)
|
||||
req.res.writeHead(404, {})
|
||||
req.res.end()
|
||||
}
|
||||
})
|
||||
router.put('/api/v1/statistics', (req, res) => {
|
||||
try {
|
||||
const token = req.query.token as string
|
||||
const payload = decodeToken(token)
|
||||
const service = payload.extra?.service === 'true'
|
||||
const serviceName = (req.query.name as string) ?? ''
|
||||
if (service) {
|
||||
ctx.info('put stats', { service: req.query.name })
|
||||
statistics.set(serviceName, {
|
||||
...(req.request.body as ServiceStatistics),
|
||||
lastUpdate: Date.now()
|
||||
})
|
||||
}
|
||||
req.res.writeHead(200)
|
||||
req.res.end()
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
console.error(err)
|
||||
req.res.writeHead(404, {})
|
||||
req.res.end()
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/api/v1/manage', async (req, res) => {
|
||||
try {
|
||||
const token = req.query.token as string
|
||||
const payload = decodeToken(token)
|
||||
if (payload.extra?.admin !== 'true') {
|
||||
req.res.writeHead(404, {})
|
||||
req.res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const operation = req.query.operation
|
||||
|
||||
switch (operation) {
|
||||
case 'wipe-statistics': {
|
||||
statistics.clear()
|
||||
req.res.writeHead(200)
|
||||
req.res.end()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
req.res.writeHead(404, {})
|
||||
req.res.end()
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
req.res.writeHead(404, {})
|
||||
req.res.end()
|
||||
}
|
||||
})
|
||||
|
||||
app.use(router.routes()).use(router.allowedMethods())
|
||||
|
||||
const server = app.listen(servicePort, () => {
|
||||
console.log(`server started on port ${servicePort}`)
|
||||
})
|
||||
|
||||
const close = (): void => {
|
||||
onClose?.()
|
||||
server.close()
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (e) => {
|
||||
ctx.error('uncaughtException', { error: e })
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
ctx.error('Unhandled Rejection at:', { reason, promise })
|
||||
})
|
||||
process.on('SIGINT', close)
|
||||
process.on('SIGTERM', close)
|
||||
process.on('exit', close)
|
||||
}
|
10
pods/stats/tsconfig.json
Normal file
10
pods/stats/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./node_modules/@hcengineering/platform-rig/profiles/node/tsconfig.json",
|
||||
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./lib",
|
||||
"declarationDir": "./types",
|
||||
"tsBuildInfoFile": ".build/build.tsbuildinfo"
|
||||
}
|
||||
}
|
@ -2,13 +2,12 @@
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { MeasureMetricsContext, metricsToString, newMetrics, type Tx } from '@hcengineering/core'
|
||||
import { loadBrandingMap } from '@hcengineering/server-core'
|
||||
import { configureAnalytics, SplitLogger } from '@hcengineering/analytics-service'
|
||||
import { MeasureMetricsContext, newMetrics, type Tx } from '@hcengineering/core'
|
||||
import builder, { getModelVersion, migrateOperations } from '@hcengineering/model-all'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { initStatisticsContext, loadBrandingMap } from '@hcengineering/server-core'
|
||||
import { serveWorkspaceAccount } from '@hcengineering/workspace-service'
|
||||
import { join } from 'path'
|
||||
|
||||
const enabled = (process.env.MODEL_ENABLED ?? '*').split(',').map((it) => it.trim())
|
||||
const disabled = (process.env.MODEL_DISABLED ?? '').split(',').map((it) => it.trim())
|
||||
@ -18,31 +17,28 @@ const txes = JSON.parse(JSON.stringify(builder(enabled, disabled).getTxes())) as
|
||||
configureAnalytics(process.env.SENTRY_DSN, {})
|
||||
Analytics.setTag('application', 'workspace')
|
||||
|
||||
const metricsContext = new MeasureMetricsContext(
|
||||
'workspace',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('workspace', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
|
||||
let oldMetricsValue = ''
|
||||
|
||||
const intTimer = setInterval(() => {
|
||||
const val = metricsToString(metricsContext.metrics, 'Workspace', 140)
|
||||
if (val !== oldMetricsValue) {
|
||||
oldMetricsValue = val
|
||||
void writeFile('metrics.txt', val).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
}, 30000)
|
||||
// Force create server metrics context with proper logging
|
||||
const metricsContext = initStatisticsContext('workspace', {
|
||||
factory: () =>
|
||||
new MeasureMetricsContext(
|
||||
'workspace',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('workspace', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const brandingPath = process.env.BRANDING_PATH
|
||||
|
||||
serveWorkspaceAccount(metricsContext, getModelVersion(), txes, migrateOperations, loadBrandingMap(brandingPath), () => {
|
||||
clearInterval(intTimer)
|
||||
})
|
||||
serveWorkspaceAccount(
|
||||
metricsContext,
|
||||
getModelVersion(),
|
||||
txes,
|
||||
migrateOperations,
|
||||
loadBrandingMap(brandingPath),
|
||||
() => {}
|
||||
)
|
||||
|
@ -825,6 +825,11 @@
|
||||
"projectFolder": "pods/front",
|
||||
"shouldPublish": false
|
||||
},
|
||||
{
|
||||
"packageName": "@hcengineering/pod-stats",
|
||||
"projectFolder": "pods/stats",
|
||||
"shouldPublish": false
|
||||
},
|
||||
{
|
||||
"packageName": "@hcengineering/pod-server",
|
||||
"projectFolder": "pods/server",
|
||||
|
@ -32,4 +32,5 @@ export * from './dbAdapterManager'
|
||||
export * from './domainHelper'
|
||||
export * from './nullAdapter'
|
||||
export * from './service'
|
||||
export * from './stats'
|
||||
export * from './triggers'
|
||||
|
155
server/core/src/stats.ts
Normal file
155
server/core/src/stats.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import type { MeasureContext, Metrics } from '@hcengineering/core'
|
||||
import { concatLink, MeasureMetricsContext, metricsToString, newMetrics, systemAccountEmail } from '@hcengineering/core'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import os from 'os'
|
||||
|
||||
export interface MemoryStatistics {
|
||||
memoryUsed: number
|
||||
memoryTotal: number
|
||||
memoryRSS: number
|
||||
freeMem: number
|
||||
totalMem: number
|
||||
}
|
||||
export interface CPUStatistics {
|
||||
usage: number
|
||||
cores: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface StatisticsElement {
|
||||
find: number
|
||||
tx: number
|
||||
}
|
||||
|
||||
export interface UserStatistics {
|
||||
userId: string
|
||||
sessionId: string
|
||||
data: any
|
||||
mins5: StatisticsElement
|
||||
total: StatisticsElement
|
||||
current: StatisticsElement
|
||||
}
|
||||
|
||||
export interface WorkspaceStatistics {
|
||||
sessions: UserStatistics[]
|
||||
workspaceName: string
|
||||
wsId: string
|
||||
sessionsTotal: number
|
||||
clientsTotal: number
|
||||
|
||||
service?: string
|
||||
}
|
||||
export interface ServiceStatistics {
|
||||
serviceName: string // A service category
|
||||
memory: MemoryStatistics
|
||||
cpu: CPUStatistics
|
||||
stats?: Metrics
|
||||
workspaces?: WorkspaceStatistics[]
|
||||
}
|
||||
|
||||
export function getMemoryInfo (): MemoryStatistics {
|
||||
const memU = process.memoryUsage()
|
||||
return {
|
||||
memoryUsed: Math.round((memU.heapUsed / 1024 / 1024) * 100) / 100,
|
||||
memoryRSS: Math.round((memU.rss / 1024 / 1024) * 100) / 100,
|
||||
memoryTotal: Math.round((memU.heapTotal / 1024 / 1024) * 100) / 100,
|
||||
freeMem: Math.round((os.freemem() / 1024 / 1024) * 100) / 100,
|
||||
totalMem: Math.round((os.totalmem() / 1024 / 1024) * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
export function getCPUInfo (): CPUStatistics {
|
||||
return {
|
||||
usage: Math.round(os.loadavg()[0] * 100) / 100,
|
||||
cores: os.cpus().length
|
||||
}
|
||||
}
|
||||
|
||||
const METRICS_UPDATE_INTERVAL = 5000
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function initStatisticsContext (
|
||||
serviceName: string,
|
||||
ops?: {
|
||||
logFile?: string
|
||||
logConsole?: boolean
|
||||
factory?: () => MeasureMetricsContext
|
||||
getUsers?: () => WorkspaceStatistics[]
|
||||
}
|
||||
): MeasureContext {
|
||||
let metricsContext: MeasureMetricsContext
|
||||
if (ops?.factory !== undefined) {
|
||||
metricsContext = ops.factory()
|
||||
} else {
|
||||
metricsContext = new MeasureMetricsContext(serviceName, {}, {}, newMetrics())
|
||||
}
|
||||
|
||||
const statsUrl = process.env.STATS_URL
|
||||
|
||||
const metricsFile = ops?.logFile
|
||||
|
||||
let errorToSend = 0
|
||||
|
||||
if (metricsFile !== undefined || ops?.logConsole === true || statsUrl !== undefined) {
|
||||
if (metricsFile !== undefined) {
|
||||
console.info('storing measurements into local file', metricsFile)
|
||||
}
|
||||
let oldMetricsValue = ''
|
||||
const token = generateToken(systemAccountEmail, { name: '' }, { service: 'true' })
|
||||
const serviceId = encodeURIComponent(os.hostname() + '-' + serviceName)
|
||||
|
||||
const intTimer = setInterval(() => {
|
||||
if (metricsFile !== undefined || ops?.logConsole === true) {
|
||||
const val = metricsToString(metricsContext.metrics, serviceName, 140)
|
||||
if (val !== oldMetricsValue) {
|
||||
oldMetricsValue = val
|
||||
if (metricsFile !== undefined) {
|
||||
writeFile(metricsFile, val).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
if (ops?.logConsole === true) {
|
||||
console.info('METRICS:', val)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (statsUrl !== undefined) {
|
||||
const data: ServiceStatistics = {
|
||||
serviceName,
|
||||
cpu: getCPUInfo(),
|
||||
memory: getMemoryInfo(),
|
||||
stats: metricsContext.metrics,
|
||||
workspaces: ops?.getUsers?.()
|
||||
}
|
||||
|
||||
void fetch(
|
||||
concatLink(statsUrl, '/api/v1/statistics') + `/?token=${encodeURIComponent(token)}&name=${serviceId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
}
|
||||
).catch((err) => {
|
||||
errorToSend++
|
||||
if (errorToSend % 20 === 0) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, METRICS_UPDATE_INTERVAL)
|
||||
|
||||
const closeTimer = (): void => {
|
||||
clearInterval(intTimer)
|
||||
}
|
||||
process.on('SIGINT', closeTimer)
|
||||
process.on('SIGTERM', closeTimer)
|
||||
}
|
||||
|
||||
return metricsContext
|
||||
}
|
@ -49,6 +49,7 @@ import type { Token } from '@hcengineering/server-token'
|
||||
import { type Readable } from 'stream'
|
||||
import type { DbAdapter, DomainHelper } from './adapter'
|
||||
import { type StorageAdapter } from './storage'
|
||||
import type { StatisticsElement } from './stats'
|
||||
|
||||
export interface ServerFindOptions<T extends Doc> extends FindOptions<T> {
|
||||
domain?: Domain // Allow to find for Doc's in specified domain only.
|
||||
@ -534,14 +535,6 @@ export interface SessionRequest {
|
||||
start: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface StatisticsElement {
|
||||
find: number
|
||||
tx: number
|
||||
}
|
||||
|
||||
export interface ClientSessionCtx {
|
||||
ctx: MeasureContext
|
||||
sendResponse: (msg: any) => Promise<void>
|
||||
|
@ -1060,7 +1060,7 @@ export function startSessionManager (
|
||||
stop: () => Promise<string | undefined>
|
||||
}
|
||||
} & Partial<Timeouts>
|
||||
): () => Promise<void> {
|
||||
): { shutdown: () => Promise<void>, sessionManager: SessionManager } {
|
||||
const sessions = createSessionManager(
|
||||
ctx,
|
||||
opt.sessionFactory,
|
||||
@ -1071,16 +1071,19 @@ export function startSessionManager (
|
||||
},
|
||||
opt.profiling
|
||||
)
|
||||
return opt.serverFactory(
|
||||
sessions,
|
||||
(rctx, service, ws, msg, workspace) => {
|
||||
sessions.handleRequest(rctx, service, ws, msg, workspace)
|
||||
},
|
||||
ctx,
|
||||
opt.pipelineFactory,
|
||||
opt.port,
|
||||
opt.enableCompression ?? false,
|
||||
opt.accountsUrl,
|
||||
opt.externalStorage
|
||||
)
|
||||
return {
|
||||
shutdown: opt.serverFactory(
|
||||
sessions,
|
||||
(rctx, service, ws, msg, workspace) => {
|
||||
sessions.handleRequest(rctx, service, ws, msg, workspace)
|
||||
},
|
||||
ctx,
|
||||
opt.pipelineFactory,
|
||||
opt.port,
|
||||
opt.enableCompression ?? false,
|
||||
opt.accountsUrl,
|
||||
opt.externalStorage
|
||||
),
|
||||
sessionManager: sessions
|
||||
}
|
||||
}
|
||||
|
@ -109,7 +109,7 @@ describe('server', () => {
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await cancelOp()
|
||||
await cancelOp.shutdown()
|
||||
})
|
||||
|
||||
it('should connect to server', (done) => {
|
||||
@ -278,7 +278,7 @@ describe('server', () => {
|
||||
console.error(err)
|
||||
} finally {
|
||||
console.log('calling shutdown')
|
||||
await cancelOp()
|
||||
await cancelOp.shutdown()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
@ -14,17 +14,17 @@
|
||||
//
|
||||
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import serverToken from '@hcengineering/server-token'
|
||||
import serverAiBot from '@hcengineering/server-ai-bot'
|
||||
import { MeasureMetricsContext } from '@hcengineering/core'
|
||||
import serverClient from '@hcengineering/server-client'
|
||||
import serverToken from '@hcengineering/server-token'
|
||||
|
||||
import config from './config'
|
||||
import { closeDB, DbStorage, getDB } from './storage'
|
||||
import { AIBotController } from './controller'
|
||||
import { initStatisticsContext } from '@hcengineering/server-core'
|
||||
import { createBotAccount } from './account'
|
||||
import config from './config'
|
||||
import { AIBotController } from './controller'
|
||||
import { registerLoaders } from './loaders'
|
||||
import { createServer, listen } from './server'
|
||||
import { closeDB, DbStorage, getDB } from './storage'
|
||||
|
||||
export const start = async (): Promise<void> => {
|
||||
setMetadata(serverToken.metadata.Secret, config.ServerSecret)
|
||||
@ -33,7 +33,7 @@ export const start = async (): Promise<void> => {
|
||||
setMetadata(serverClient.metadata.Endpoint, config.AccountsURL)
|
||||
|
||||
registerLoaders()
|
||||
const ctx = new MeasureMetricsContext('ai-bot-service', {})
|
||||
const ctx = initStatisticsContext('ai-bot-service', {})
|
||||
|
||||
ctx.info('AI Bot Service started', { firstName: config.FirstName, lastName: config.LastName })
|
||||
|
||||
|
@ -26,17 +26,21 @@ import { createServer, listen } from './server'
|
||||
import { Collector } from './collector'
|
||||
import { registerLoaders } from './loaders'
|
||||
import { closeDB, getDB } from './storage'
|
||||
import { initStatisticsContext } from '@hcengineering/server-core'
|
||||
|
||||
const ctx = new MeasureMetricsContext(
|
||||
'analytics-collector-service',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('analytics-collector-service', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
const ctx = initStatisticsContext('analytics-collector', {
|
||||
factory: () =>
|
||||
new MeasureMetricsContext(
|
||||
'analytics-collector-service',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('analytics-collector-service', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
configureAnalytics(config.SentryDSN, config)
|
||||
Analytics.setTag('application', 'analytics-collector-service')
|
||||
|
@ -4,41 +4,31 @@
|
||||
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service'
|
||||
import { MeasureMetricsContext, metricsToString, newMetrics } from '@hcengineering/core'
|
||||
import { loadBrandingMap } from '@hcengineering/server-core'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { MeasureMetricsContext, newMetrics } from '@hcengineering/core'
|
||||
import { initStatisticsContext, loadBrandingMap } from '@hcengineering/server-core'
|
||||
import { join } from 'path'
|
||||
import config from './config'
|
||||
import { start } from './server'
|
||||
|
||||
// Load and inc startID, to have easy logs.
|
||||
|
||||
const metricsContext = new MeasureMetricsContext(
|
||||
'github',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('github-service', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
const metricsContext = initStatisticsContext('github', {
|
||||
factory: () =>
|
||||
new MeasureMetricsContext(
|
||||
'github',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('github-service', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
configureAnalytics(config.SentryDSN, config)
|
||||
Analytics.setTag('application', 'github-service')
|
||||
|
||||
let oldMetricsValue = ''
|
||||
|
||||
const intTimer = setInterval(() => {
|
||||
const val = metricsToString(metricsContext.metrics, 'Github', 140)
|
||||
if (val !== oldMetricsValue) {
|
||||
oldMetricsValue = val
|
||||
void writeFile('metrics.txt', val).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
let doOnClose: () => Promise<void> = async () => {}
|
||||
|
||||
void start(metricsContext, loadBrandingMap(config.BrandingPath)).then((r) => {
|
||||
@ -46,7 +36,6 @@ void start(metricsContext, loadBrandingMap(config.BrandingPath)).then((r) => {
|
||||
})
|
||||
|
||||
const onClose = (): void => {
|
||||
clearInterval(intTimer)
|
||||
metricsContext.info('Closed')
|
||||
void doOnClose().then((r) => {
|
||||
process.exit(0)
|
||||
|
@ -14,10 +14,9 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { MeasureMetricsContext, newMetrics } from '@hcengineering/core'
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import serverClient from '@hcengineering/server-client'
|
||||
import { type StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { initStatisticsContext, type StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
|
||||
import serverToken, { decodeToken } from '@hcengineering/server-token'
|
||||
import { type IncomingHttpHeaders } from 'http'
|
||||
@ -37,7 +36,7 @@ const extractToken = (header: IncomingHttpHeaders): any => {
|
||||
}
|
||||
|
||||
export const main = async (): Promise<void> => {
|
||||
const ctx = new MeasureMetricsContext('gmail', {}, {}, newMetrics())
|
||||
const ctx = initStatisticsContext('gmail', {})
|
||||
|
||||
setMetadata(serverClient.metadata.Endpoint, config.AccountsURL)
|
||||
setMetadata(serverClient.metadata.UserAgent, config.ServiceID)
|
||||
|
@ -13,10 +13,10 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { MeasureMetricsContext, WorkspaceId, newMetrics, toWorkspaceString } from '@hcengineering/core'
|
||||
import { toWorkspaceString, WorkspaceId } from '@hcengineering/core'
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import serverClient from '@hcengineering/server-client'
|
||||
import { StorageConfig, StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { initStatisticsContext, StorageConfig, StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
|
||||
import serverToken, { decodeToken } from '@hcengineering/server-token'
|
||||
import cors from 'cors'
|
||||
@ -49,7 +49,9 @@ export const main = async (): Promise<void> => {
|
||||
setMetadata(serverToken.metadata.Secret, config.Secret)
|
||||
|
||||
const storageConfigs: StorageConfiguration = storageConfigFromEnv()
|
||||
const ctx = new MeasureMetricsContext('love', {}, {}, newMetrics())
|
||||
|
||||
const ctx = initStatisticsContext('love', {})
|
||||
|
||||
const storageConfig = storageConfigs.storages.findLast((p) => p.name === config.StorageProviderName)
|
||||
const storageAdapter = buildStorageFromConfig(storageConfigs)
|
||||
const app = express()
|
||||
|
@ -14,8 +14,8 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { MeasureMetricsContext, generateId } from '@hcengineering/core'
|
||||
import { StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { generateId } from '@hcengineering/core'
|
||||
import { StorageConfiguration, initStatisticsContext } from '@hcengineering/server-core'
|
||||
import { buildStorageFromConfig } from '@hcengineering/server-storage'
|
||||
import { Token, decodeToken } from '@hcengineering/server-token'
|
||||
import cors from 'cors'
|
||||
@ -112,7 +112,7 @@ const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, n
|
||||
|
||||
export function createServer (storageConfig: StorageConfiguration): { app: Express, close: () => void } {
|
||||
const storageAdapter = buildStorageFromConfig(storageConfig)
|
||||
const measureCtx = new MeasureMetricsContext('print', {})
|
||||
const measureCtx = initStatisticsContext('print', {})
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
|
@ -85,7 +85,8 @@
|
||||
"mime-types": "~2.1.34",
|
||||
"pdfjs-dist": "2.12.313",
|
||||
"sharp": "~0.32.0",
|
||||
"morgan": "^1.10.0"
|
||||
"morgan": "^1.10.0",
|
||||
"@hcengineering/server-core": "^0.6.1"
|
||||
},
|
||||
"description": "Document recognition service"
|
||||
}
|
||||
|
@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { MeasureMetricsContext, newMetrics } from '@hcengineering/core'
|
||||
import { initStatisticsContext } from '@hcengineering/server-core'
|
||||
import bodyParser from 'body-parser'
|
||||
import cors from 'cors'
|
||||
import express from 'express'
|
||||
@ -42,7 +42,7 @@ const extractToken = (header: IncomingHttpHeaders): any => {
|
||||
export const startServer = async (): Promise<void> => {
|
||||
const app = express()
|
||||
|
||||
const ctx = new MeasureMetricsContext('rekini', {}, {}, newMetrics())
|
||||
const ctx = initStatisticsContext('rekoni', {})
|
||||
|
||||
class MyStream {
|
||||
write (text: string): void {
|
||||
|
@ -14,8 +14,8 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { MeasureMetricsContext, generateId } from '@hcengineering/core'
|
||||
import { StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { generateId } from '@hcengineering/core'
|
||||
import { initStatisticsContext, StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { buildStorageFromConfig } from '@hcengineering/server-storage'
|
||||
import { Token } from '@hcengineering/server-token'
|
||||
import cors from 'cors'
|
||||
@ -60,7 +60,7 @@ const wrapRequest =
|
||||
|
||||
export function createServer (storageConfig: StorageConfiguration, brandings: BrandingMap): Express {
|
||||
const storageAdapter = buildStorageFromConfig(storageConfig)
|
||||
const measureCtx = new MeasureMetricsContext('sign', {})
|
||||
const measureCtx = initStatisticsContext('sign', {})
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
|
@ -13,37 +13,40 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service'
|
||||
import { MeasureMetricsContext, newMetrics } from '@hcengineering/core'
|
||||
import { setMetadata, translate } from '@hcengineering/platform'
|
||||
import serverToken from '@hcengineering/server-token'
|
||||
import serverClient from '@hcengineering/server-client'
|
||||
import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { join } from 'path'
|
||||
import type { StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { initStatisticsContext, type StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
|
||||
import serverToken from '@hcengineering/server-token'
|
||||
import telegram from '@hcengineering/telegram'
|
||||
import { join } from 'path'
|
||||
import { Telegraf } from 'telegraf'
|
||||
|
||||
import config from './config'
|
||||
import { Limiter } from './limiter'
|
||||
import { registerLoaders } from './loaders'
|
||||
import { createServer, listen } from './server'
|
||||
import { setUpBot } from './telegraf/bot'
|
||||
import { PlatformWorker } from './worker'
|
||||
import { registerLoaders } from './loaders'
|
||||
import { TgContext } from './telegraf/types'
|
||||
import { Command } from './telegraf/commands'
|
||||
import { Limiter } from './limiter'
|
||||
import { TgContext } from './telegraf/types'
|
||||
import { PlatformWorker } from './worker'
|
||||
|
||||
const ctx = new MeasureMetricsContext(
|
||||
'telegram-bot-service',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('telegram-bot-service', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
const ctx = initStatisticsContext('telegram-bot', {
|
||||
factory: () =>
|
||||
new MeasureMetricsContext(
|
||||
'telegram-bot-service',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('telegram-bot-service', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
configureAnalytics(config.SentryDSN, config)
|
||||
Analytics.setTag('application', 'telegram-bot-service')
|
||||
|
@ -3,10 +3,9 @@ import { PlatformWorker } from './platform'
|
||||
import { createServer, Handler, listen } from './server'
|
||||
import { telegram } from './telegram'
|
||||
|
||||
import { MeasureMetricsContext, newMetrics } from '@hcengineering/core'
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import serverClient from '@hcengineering/server-client'
|
||||
import { type StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { initStatisticsContext, type StorageConfiguration } from '@hcengineering/server-core'
|
||||
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
|
||||
import serverToken, { decodeToken, type Token } from '@hcengineering/server-token'
|
||||
import config from './config'
|
||||
@ -20,7 +19,7 @@ const extractToken = (header: IncomingHttpHeaders): Token | undefined => {
|
||||
}
|
||||
|
||||
export const main = async (): Promise<void> => {
|
||||
const ctx = new MeasureMetricsContext('telegram', {}, {}, newMetrics())
|
||||
const ctx = initStatisticsContext('telegram', {})
|
||||
|
||||
setMetadata(serverClient.metadata.Endpoint, config.AccountsURL)
|
||||
setMetadata(serverClient.metadata.UserAgent, config.ServiceID)
|
||||
|
Loading…
Reference in New Issue
Block a user