diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f86296874..4ba4f4f7a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 64.0.0-SNAPSHOT - unreleased +### 🎁 New Features +* Provides admin support for Cluster-aware version of Hoist. + +### 💥 Breaking Changes +* Requires update to `hoist-core >= 20.0.0`. + ## 63.0.1 - 2024-04-05 ### 🐞 Bug Fixes @@ -9,6 +15,7 @@ * New filterable fields exposed in the Admin Console for `ActivityTracking` and `ClientErrors` modules. * `url`, `appEnvironment`, `appVersion` in `ActivityTracking` * `impersonating` in `ClientErrors` + * ## 63.0.0 - 2024-04-04 diff --git a/admin/AppModel.ts b/admin/AppModel.ts index d3a52c7357..84006c4a20 100644 --- a/admin/AppModel.ts +++ b/admin/AppModel.ts @@ -12,7 +12,7 @@ import {Route} from 'router5'; import {activityTab} from './tabs/activity/ActivityTab'; import {generalTab} from './tabs/general/GeneralTab'; import {monitorTab} from './tabs/monitor/MonitorTab'; -import {serverTab} from './tabs/server/ServerTab'; +import {clusterTab} from '@xh/hoist/admin/tabs/cluster/ClusterTab'; import {userDataTab} from './tabs/userData/UserDataTab'; export class AppModel extends HoistAppModel { @@ -63,11 +63,26 @@ export class AppModel extends HoistAppModel { children: [ {name: 'about', path: '/about'}, {name: 'config', path: '/config'}, + {name: 'logLevels', path: '/logLevels'}, {name: 'users', path: '/users'}, {name: 'roles', path: '/roles'}, {name: 'alertBanner', path: '/alertBanner'} ] }, + { + name: 'cluster', + path: '/cluster', + children: [ + {name: 'logs', path: '/logs'}, + {name: 'memory', path: '/memory'}, + {name: 'jdbcPool', path: '/jdbcPool'}, + {name: 'environment', path: '/environment'}, + {name: 'services', path: '/services'}, + {name: 'objects', path: '/objects'}, + {name: 'hibernate', path: '/hibernate'}, + {name: 'webSockets', path: '/webSockets'} + ] + }, { name: 'activity', path: '/activity', @@ -77,20 +92,6 @@ export class AppModel extends HoistAppModel { {name: 'feedback', path: '/feedback'} ] }, - { - name: 'server', - path: '/server', - children: [ - {name: 'logViewer', path: '/logViewer'}, - {name: 'logLevels', path: '/logLevels'}, - {name: 'memory', path: '/memory'}, - {name: 'connectionPool', path: '/connectionPool'}, - {name: 'environment', path: '/environment'}, - {name: 'services', path: '/services'}, - {name: 'ehCache', path: '/ehCache'}, - {name: 'webSockets', path: '/webSockets'} - ] - }, { name: 'monitor', path: '/monitor', @@ -118,16 +119,16 @@ export class AppModel extends HoistAppModel { icon: Icon.info(), content: generalTab }, + { + id: 'cluster', + icon: Icon.server(), + content: clusterTab + }, { id: 'activity', icon: Icon.analytics(), content: activityTab }, - { - id: 'server', - icon: Icon.server(), - content: serverTab - }, { id: 'monitor', icon: Icon.shieldCheck(), diff --git a/admin/columns/Core.ts b/admin/columns/Core.ts index 51eed210cc..6a1dc2d680 100644 --- a/admin/columns/Core.ts +++ b/admin/columns/Core.ts @@ -4,7 +4,8 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {ColumnSpec} from '@xh/hoist/cmp/grid'; +import {ColumnSpec, dateTimeSec} from '@xh/hoist/cmp/grid'; +import {dateTimeRenderer} from '@xh/hoist/format'; export const name: ColumnSpec = { field: {name: 'name', type: 'string'}, @@ -39,3 +40,9 @@ export const note: ColumnSpec = { flex: true, tooltip: true }; + +export const timestampNoYear: ColumnSpec = { + field: {name: 'timestamp', type: 'date'}, + ...dateTimeSec, + renderer: dateTimeRenderer({fmt: 'MMM DD HH:mm:ss'}) +}; diff --git a/admin/tabs/cluster/BaseInstanceModel.ts b/admin/tabs/cluster/BaseInstanceModel.ts new file mode 100644 index 0000000000..d2db66c82b --- /dev/null +++ b/admin/tabs/cluster/BaseInstanceModel.ts @@ -0,0 +1,56 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ +import {ClusterTabModel} from '@xh/hoist/admin/tabs/cluster/ClusterTabModel'; +import {HoistModel, LoadSpec, lookup, PlainObject, XH} from '@xh/hoist/core'; +import {fmtDateTimeSec, fmtJson} from '@xh/hoist/format'; +import {DAYS} from '@xh/hoist/utils/datetime'; +import {cloneDeep, forOwn, isArray, isNumber, isPlainObject} from 'lodash'; + +export class BaseInstanceModel extends HoistModel { + @lookup(() => ClusterTabModel) parent: ClusterTabModel; + + get instanceName(): string { + return this.parent.instanceName; + } + + fmtStats(stats: PlainObject): string { + stats = cloneDeep(stats); + this.processTimestamps(stats); + return fmtJson(JSON.stringify(stats)); + } + + handleLoadException(e: unknown, loadSpec: LoadSpec) { + const instanceNotFound = this.isInstanceNotFound(e); + XH.handleException(e, { + showAlert: !loadSpec.isAutoRefresh && !instanceNotFound, + logOnServer: !instanceNotFound + }); + } + + isInstanceNotFound(e: unknown): boolean { + return e['name'] == 'InstanceNotFoundException'; + } + + //------------------- + // Implementation + //------------------- + private processTimestamps(stats: PlainObject) { + forOwn(stats, (v, k) => { + // Convert numbers that look like recent timestamps to date values. + if ( + (k.endsWith('Time') || k.endsWith('Date') || k == 'timestamp') && + isNumber(v) && + v > Date.now() - 365 * DAYS + ) { + stats[k] = v ? fmtDateTimeSec(v, {fmt: 'MMM DD HH:mm:ss'}) : null; + } + if (isPlainObject(v) || isArray(v)) { + this.processTimestamps(v); + } + }); + } +} diff --git a/admin/tabs/cluster/ClusterTab.ts b/admin/tabs/cluster/ClusterTab.ts new file mode 100644 index 0000000000..620983fd0a --- /dev/null +++ b/admin/tabs/cluster/ClusterTab.ts @@ -0,0 +1,47 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ +import {grid} from '@xh/hoist/cmp/grid'; +import {tabContainer} from '@xh/hoist/cmp/tab'; +import {creates, hoistCmp} from '@xh/hoist/core'; +import {mask} from '@xh/hoist/desktop/cmp/mask'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {tabSwitcher} from '@xh/hoist/desktop/cmp/tab'; +import {box, hspacer, placeholder, vframe} from '@xh/hoist/cmp/layout'; +import {ClusterTabModel} from './ClusterTabModel'; +import {Icon} from '@xh/hoist/icon'; + +export const clusterTab = hoistCmp.factory({ + model: creates(ClusterTabModel), + render({model}) { + const {instance} = model; + return vframe( + panel({ + modelConfig: { + side: 'top', + defaultSize: 105, + minSize: 75, + collapsible: false, + persistWith: model.persistWith + }, + item: grid() + }), + instance?.isReady + ? panel({ + compactHeader: true, + tbar: [ + box({width: 150, item: model.formatInstance(instance)}), + hspacer(25), + tabSwitcher() + ], + flex: 1, + item: tabContainer() + }) + : placeholder(Icon.server(), 'Select a running instance above.'), + mask({bind: model.loadModel}) + ); + } +}); diff --git a/admin/tabs/cluster/ClusterTabModel.ts b/admin/tabs/cluster/ClusterTabModel.ts new file mode 100644 index 0000000000..f0e263f789 --- /dev/null +++ b/admin/tabs/cluster/ClusterTabModel.ts @@ -0,0 +1,203 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ +import {AppModel} from '@xh/hoist/admin/AppModel'; +import {timestampNoYear} from '@xh/hoist/admin/columns'; +import {connPoolMonitorPanel} from '@xh/hoist/admin/tabs/cluster/connpool/ConnPoolMonitorPanel'; +import {serverEnvPanel} from '@xh/hoist/admin/tabs/cluster/environment/ServerEnvPanel'; +import {hzObjectPanel} from '@xh/hoist/admin/tabs/cluster/hzobject/HzObjectPanel'; +import {logViewer} from '@xh/hoist/admin/tabs/cluster/logs/LogViewer'; +import {usedHeapMb, usedPctMax} from '@xh/hoist/admin/tabs/cluster/memory/MemoryMonitorModel'; +import {memoryMonitorPanel} from '@xh/hoist/admin/tabs/cluster/memory/MemoryMonitorPanel'; +import {servicePanel} from '@xh/hoist/admin/tabs/cluster/services/ServicePanel'; +import {webSocketPanel} from '@xh/hoist/admin/tabs/cluster/websocket/WebSocketPanel'; +import {badge} from '@xh/hoist/cmp/badge'; +import {GridModel, numberCol} from '@xh/hoist/cmp/grid'; +import {hbox} from '@xh/hoist/cmp/layout'; +import {getRelativeTimestamp} from '@xh/hoist/cmp/relativetimestamp'; +import {TabContainerModel} from '@xh/hoist/cmp/tab'; +import {HoistModel, LoadSpec, managed, PlainObject, XH} from '@xh/hoist/core'; +import {RecordActionSpec} from '@xh/hoist/data'; +import {Icon} from '@xh/hoist/icon'; +import {ReactNode} from 'react'; + +export class ClusterTabModel extends HoistModel { + override persistWith = {localStorageKey: 'xhAdminClusterTabState'}; + + shutdownAction: RecordActionSpec = { + icon: Icon.skull(), + text: 'Shutdown Instance', + intent: 'danger', + actionFn: ({record}) => this.shutdownInstanceAsync(record.data), + displayFn: () => ({hidden: AppModel.readonly}), + recordsRequired: 1 + }; + + @managed gridModel: GridModel = this.createGridModel(); + @managed tabModel: TabContainerModel = this.createTabModel(); + + get instance(): PlainObject { + return this.gridModel.selectedRecord?.data; + } + + get instanceName(): string { + return this.instance?.name; + } + + get isMultiInstance(): boolean { + return this.gridModel.store.allCount > 1; + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {gridModel} = this; + try { + let data = await XH.fetchJson({url: 'clusterAdmin/allInstances', loadSpec}); + data = data.map(row => ({ + ...row, + usedHeapMb: row.memory?.usedHeapMb, + usedPctMax: row.memory?.usedPctMax + })); + + gridModel.loadData(data); + await gridModel.preSelectFirstAsync(); + } catch (e) { + XH.handleException(e); + } + } + + constructor() { + super(); + this.addReaction({ + track: () => this.instance, + run: () => { + if (this.instance) this.tabModel.refreshContextModel.refreshAsync(); + } + }); + } + + private createGridModel() { + return new GridModel({ + store: { + idSpec: 'name', + fields: [ + {name: 'name', type: 'string'}, + {name: 'isMaster', type: 'bool'}, + {name: 'isLocal', type: 'bool'}, + {name: 'isReady', type: 'bool'}, + {name: 'wsConnections', type: 'int'}, + {name: 'startupTime', type: 'date'}, + {name: 'address', type: 'string'} + ] + }, + columns: [ + { + field: 'isReady', + headerName: '', + align: 'center', + width: 40, + renderer: v => + v + ? Icon.circle({prefix: 'fas', className: 'xh-green'}) + : Icon.circle({prefix: 'fal', className: 'xh-white'}), + tooltip: v => (v ? 'Ready' : 'Not Ready') + }, + { + field: 'name', + rendererIsComplex: true, + renderer: (v, {record}) => { + return this.formatInstance(record.data); + } + }, + { + field: 'address' + }, + { + ...usedHeapMb, + headerName: 'Heap (MB)' + }, + { + ...usedPctMax, + headerName: 'Heap (% Max)' + }, + { + field: 'wsConnections', + headerName: 'WS Connections', + ...numberCol + }, + { + ...timestampNoYear, + field: 'startupTime' + }, + { + colId: 'uptime', + field: 'startupTime', + renderer: v => getRelativeTimestamp(v, {pastSuffix: ''}), + rendererIsComplex: true + } + ], + contextMenu: [this.shutdownAction, '-', ...GridModel.defaultContextMenu] + }); + } + + createTabModel() { + return new TabContainerModel({ + route: 'default.cluster', + switcher: false, + tabs: [ + {id: 'logs', icon: Icon.fileText(), content: logViewer}, + {id: 'memory', icon: Icon.memory(), content: memoryMonitorPanel}, + { + id: 'jdbcPool', + title: 'JDBC Pool', + icon: Icon.database(), + content: connPoolMonitorPanel + }, + {id: 'environment', icon: Icon.globe(), content: serverEnvPanel}, + {id: 'services', icon: Icon.gears(), content: servicePanel}, + { + id: 'objects', + title: 'Distributed Objects', + icon: Icon.grip(), + content: hzObjectPanel + }, + {id: 'webSockets', title: 'WebSockets', icon: Icon.bolt(), content: webSocketPanel} + ] + }); + } + + formatInstance(instance: PlainObject): ReactNode { + const content = [instance.name]; + if (instance.isMaster) content.push(badge({item: 'master', intent: 'primary'})); + if (instance.isLocal) content.push(badge('local')); + return hbox(content); + } + + async shutdownInstanceAsync(instance: PlainObject) { + if ( + !(await XH.confirm({ + message: `Are you SURE you want to shutdown instance ${instance.name}?`, + title: 'Please confirm...', + confirmProps: { + icon: Icon.skull(), + text: 'Shutdown Now', + intent: 'danger' + }, + cancelProps: { + autoFocus: true + } + })) + ) + return; + + await XH.fetchJson({ + url: 'clusterAdmin/shutdownInstance', + params: {instance: instance.name} + }) + .finally(() => this.loadAsync()) + .linkTo({observer: this.loadModel, message: 'Attempting instance shutdown'}) + .catchDefault(); + } +} diff --git a/admin/tabs/server/connectionpool/ConnPoolMonitorModel.ts b/admin/tabs/cluster/connpool/ConnPoolMonitorModel.ts similarity index 84% rename from admin/tabs/server/connectionpool/ConnPoolMonitorModel.ts rename to admin/tabs/cluster/connpool/ConnPoolMonitorModel.ts index c3d63df4f2..f2894b53b7 100644 --- a/admin/tabs/server/connectionpool/ConnPoolMonitorModel.ts +++ b/admin/tabs/cluster/connpool/ConnPoolMonitorModel.ts @@ -5,15 +5,16 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils'; +import {timestampNoYear} from '@xh/hoist/admin/columns'; +import {BaseInstanceModel} from '@xh/hoist/admin/tabs/cluster/BaseInstanceModel'; import {ChartModel} from '@xh/hoist/cmp/chart'; import {GridModel} from '@xh/hoist/cmp/grid'; -import {HoistModel, LoadSpec, managed, PlainObject, XH} from '@xh/hoist/core'; +import {LoadSpec, managed, PlainObject, XH} from '@xh/hoist/core'; import {fmtTime} from '@xh/hoist/format'; import {bindable} from '@xh/hoist/mobx'; import {forOwn, sortBy} from 'lodash'; -import * as MCol from '../../monitor/MonitorColumns'; -export class ConnPoolMonitorModel extends HoistModel { +export class ConnPoolMonitorModel extends BaseInstanceModel { @bindable enabled: boolean = true; @bindable.ref poolConfiguration: PlainObject = {}; @@ -32,7 +33,7 @@ export class ConnPoolMonitorModel extends HoistModel { headerMenuDisplay: 'hover', colDefaults: {filterable: true, align: 'right'}, columns: [ - MCol.timestamp, + {...timestampNoYear}, {field: 'size'}, {field: 'active'}, {field: 'idle'}, @@ -85,7 +86,8 @@ export class ConnPoolMonitorModel extends HoistModel { try { const resp = await XH.fetchJson({ - url: 'connectionPoolMonitorAdmin', + url: 'connectionPoolMonitorAdmin/snapshots', + params: {instance: this.instanceName}, loadSpec }); @@ -129,19 +131,16 @@ export class ConnPoolMonitorModel extends HoistModel { } ]); } catch (e) { - XH.handleException(e, {showAlert: false}); - if (!loadSpec.isAutoRefresh) { - this.clear(); - throw e; - } + this.handleLoadException(e, loadSpec); } } async takeSnapshotAsync() { try { - await XH.fetchJson({url: 'connectionPoolMonitorAdmin/takeSnapshot'}).linkTo( - this.loadModel - ); + await XH.fetchJson({ + url: 'connectionPoolMonitorAdmin/takeSnapshot', + params: {instance: this.instanceName} + }).linkTo(this.loadModel); await this.refreshAsync(); XH.successToast('Updated snapshot loaded.'); } catch (e) { @@ -151,9 +150,10 @@ export class ConnPoolMonitorModel extends HoistModel { async resetStatsAsync() { try { - await XH.fetchJson({url: 'connectionPoolMonitorAdmin/resetStats'}).linkTo( - this.loadModel - ); + await XH.fetchJson({ + url: 'connectionPoolMonitorAdmin/resetStats', + params: {instance: this.instanceName} + }).linkTo(this.loadModel); await this.refreshAsync(); XH.successToast('Connection pool stats reset.'); } catch (e) { diff --git a/admin/tabs/server/connectionpool/ConnPoolMonitorPanel.ts b/admin/tabs/cluster/connpool/ConnPoolMonitorPanel.ts similarity index 78% rename from admin/tabs/server/connectionpool/ConnPoolMonitorPanel.ts rename to admin/tabs/cluster/connpool/ConnPoolMonitorPanel.ts index 33425be2f3..ce7248fb19 100644 --- a/admin/tabs/server/connectionpool/ConnPoolMonitorPanel.ts +++ b/admin/tabs/cluster/connpool/ConnPoolMonitorPanel.ts @@ -5,10 +5,10 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {AppModel} from '@xh/hoist/admin/AppModel'; -import {ConnPoolMonitorModel} from '@xh/hoist/admin/tabs/server/connectionpool/ConnPoolMonitorModel'; +import {ConnPoolMonitorModel} from '@xh/hoist/admin/tabs/cluster/connpool/ConnPoolMonitorModel'; import {chart} from '@xh/hoist/cmp/chart'; import {grid, gridCountLabel} from '@xh/hoist/cmp/grid'; -import {filler, hframe, vframe} from '@xh/hoist/cmp/layout'; +import {filler, hframe, span, vframe} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp} from '@xh/hoist/core'; import {button, exportButton} from '@xh/hoist/desktop/cmp/button'; import {errorMessage} from '@xh/hoist/desktop/cmp/error'; @@ -26,33 +26,28 @@ export const connPoolMonitorPanel = hoistCmp.factory({ }); } - if (model.lastLoadException) { - return errorMessage({ - title: 'Error loading connection pool snapshots.', - error: model.lastLoadException, - actionFn: () => model.refreshAsync() - }); - } - const {readonly} = AppModel; return panel({ tbar: [ + span({ + item: 'JDBC Connection Pool', + className: 'xh-bold' + }), + filler(), + gridCountLabel({unit: 'snapshot'}), + '-', button({ text: 'Take Snapshot', icon: Icon.camera(), omit: readonly, onClick: () => model.takeSnapshotAsync() }), - '-', button({ text: 'Reset Stats', icon: Icon.reset(), - intent: 'danger', omit: readonly, onClick: () => model.resetStatsAsync() }), - filler(), - gridCountLabel({unit: 'snapshot'}), '-', exportButton() ], @@ -77,20 +72,22 @@ export const connPoolMonitorPanel = hoistCmp.factory({ const poolConfigPanel = hoistCmp.factory({ render({model}) { return panel({ - title: 'Connection Pool Configuration', - icon: Icon.gears(), + title: 'Pool Configuration', + icon: Icon.info(), compactHeader: true, modelConfig: { - defaultSize: 500, - defaultCollapsed: true, - side: 'right' + side: 'right', + defaultSize: 450, + defaultCollapsed: true }, item: jsonInput({ - value: JSON.stringify(model.poolConfiguration, null, 2), readonly: true, height: '100%', width: '100%', - enableSearch: true + enableSearch: true, + showFullscreenButton: false, + editorProps: {lineNumbers: false}, + value: JSON.stringify(model.poolConfiguration, null, 2) }) }); } diff --git a/admin/tabs/server/environment/ServerEnvModel.ts b/admin/tabs/cluster/environment/ServerEnvModel.ts similarity index 64% rename from admin/tabs/server/environment/ServerEnvModel.ts rename to admin/tabs/cluster/environment/ServerEnvModel.ts index ce9b1d4e81..0bf9779e8b 100644 --- a/admin/tabs/server/environment/ServerEnvModel.ts +++ b/admin/tabs/cluster/environment/ServerEnvModel.ts @@ -4,16 +4,17 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ +import {BaseInstanceModel} from '@xh/hoist/admin/tabs/cluster/BaseInstanceModel'; import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils'; import {GridModel} from '@xh/hoist/cmp/grid'; -import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {LoadSpec, managed, XH} from '@xh/hoist/core'; import {forOwn} from 'lodash'; /** * Model/tab to list server-side environment variables and JVM system properties, as loaded from * a dedicated admin-only endpoint. */ -export class ServerEnvModel extends HoistModel { +export class ServerEnvModel extends BaseInstanceModel { @managed gridModel: GridModel; constructor() { @@ -46,17 +47,24 @@ export class ServerEnvModel extends HoistModel { } override async doLoadAsync(loadSpec: LoadSpec) { - const resp = await XH.fetchJson({url: 'envAdmin'}), - data = []; + try { + const resp = await XH.fetchJson({ + url: 'envAdmin', + params: {instance: this.instanceName} + }), + data = []; - forOwn(resp.environment, (value, name) => { - data.push({type: 'Environment Variables', value, name}); - }); + forOwn(resp.environment, (value, name) => { + data.push({type: 'Environment Variables', value, name}); + }); - forOwn(resp.properties, (value, name) => { - data.push({type: 'System Properties', value, name}); - }); + forOwn(resp.properties, (value, name) => { + data.push({type: 'System Properties', value, name}); + }); - this.gridModel.loadData(data); + this.gridModel.loadData(data); + } catch (e) { + this.handleLoadException(e, loadSpec); + } } } diff --git a/admin/tabs/server/environment/ServerEnvPanel.ts b/admin/tabs/cluster/environment/ServerEnvPanel.ts similarity index 83% rename from admin/tabs/server/environment/ServerEnvPanel.ts rename to admin/tabs/cluster/environment/ServerEnvPanel.ts index c709049527..cbcb10132a 100644 --- a/admin/tabs/server/environment/ServerEnvPanel.ts +++ b/admin/tabs/cluster/environment/ServerEnvPanel.ts @@ -4,7 +4,7 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {ServerEnvModel} from '@xh/hoist/admin/tabs/server/environment/ServerEnvModel'; +import {ServerEnvModel} from '@xh/hoist/admin/tabs/cluster/environment/ServerEnvModel'; import {grid, gridCountLabel} from '@xh/hoist/cmp/grid'; import {filler, span} from '@xh/hoist/cmp/layout'; import {storeFilterField} from '@xh/hoist/cmp/store'; @@ -12,7 +12,6 @@ import {creates, hoistCmp} from '@xh/hoist/core'; import {exportButton} from '@xh/hoist/desktop/cmp/button'; import {errorMessage} from '@xh/hoist/desktop/cmp/error'; import {panel} from '@xh/hoist/desktop/cmp/panel'; -import {Icon} from '@xh/hoist/icon'; export const serverEnvPanel = hoistCmp.factory({ model: creates(ServerEnvModel), @@ -22,9 +21,8 @@ export const serverEnvPanel = hoistCmp.factory({ return panel({ tbar: [ - Icon.info(), span({ - item: 'Server-side environment variables and JVM system properties', + item: 'Environment variables + JVM system properties', className: 'xh-bold' }), filler(), diff --git a/admin/tabs/cluster/hzobject/HzObjectModel.ts b/admin/tabs/cluster/hzobject/HzObjectModel.ts new file mode 100644 index 0000000000..05ec6c3185 --- /dev/null +++ b/admin/tabs/cluster/hzobject/HzObjectModel.ts @@ -0,0 +1,127 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ +import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils'; +import {AppModel} from '@xh/hoist/admin/AppModel'; +import {timestampNoYear} from '@xh/hoist/admin/columns'; +import {BaseInstanceModel} from '@xh/hoist/admin/tabs/cluster/BaseInstanceModel'; +import {GridModel} from '@xh/hoist/cmp/grid'; +import * as Col from '@xh/hoist/cmp/grid/columns'; +import {LoadSpec, managed, XH} from '@xh/hoist/core'; +import {RecordActionSpec} from '@xh/hoist/data'; +import {Icon} from '@xh/hoist/icon'; +import {isEmpty} from 'lodash'; + +export class HzObjectModel extends BaseInstanceModel { + clearAction: RecordActionSpec = { + icon: Icon.reset(), + text: 'Clear Objects', + intent: 'danger', + actionFn: () => this.clearAsync(), + displayFn: ({selectedRecords}) => ({ + hidden: AppModel.readonly, + disabled: + isEmpty(selectedRecords) || selectedRecords.every(r => r.data.objectType == 'Topic') + }), + recordsRequired: true + }; + + @managed + gridModel = new GridModel({ + selModel: 'multiple', + enableExport: true, + exportOptions: {filename: exportFilenameWithDate('distributed-objects'), columns: 'ALL'}, + sortBy: 'name', + groupBy: 'type', + store: { + fields: [ + {name: 'name', type: 'string'}, + {name: 'type', type: 'string', displayName: 'Type'}, + {name: 'size', type: 'int'}, + {name: 'lastUpdateTime', type: 'date'}, + {name: 'lastAccessTime', type: 'date'} + ], + idSpec: 'name' + }, + columns: [ + {field: 'type', hidden: true}, + {field: 'name', flex: 1}, + {field: 'size', displayName: 'Entry Count', ...Col.number, width: 130}, + { + ...timestampNoYear, + field: 'lastUpdateTime', + displayName: 'Last Update' + }, + { + ...timestampNoYear, + field: 'lastAccessTime', + displayName: 'Last Access' + } + ], + contextMenu: [this.clearAction, '-', ...GridModel.defaultContextMenu] + }); + + async clearAsync() { + const {gridModel} = this; + if ( + gridModel.selectedRecords.some( + it => it.data.objectType != 'Cache' && !it.data.name.startsWith('cache') + ) && + !(await XH.confirm({ + title: 'Warning', + message: + 'Your selection contains objects that may not be caches.' + + 'This may impact application behavior. Continue?' + })) + ) { + return; + } + + try { + await XH.fetchJson({ + url: 'hzObjectAdmin/clearObjects', + params: { + instance: this.instanceName, + names: this.gridModel.selectedIds + } + }).linkTo(this.loadModel); + + await this.refreshAsync(); + XH.successToast('Objects cleared.'); + } catch (e) { + XH.handleException(e); + } + } + + async clearHibernateCachesAsync() { + try { + await XH.fetchJson({ + url: 'hzObjectAdmin/clearHibernateCaches', + params: {instance: this.instanceName} + }).linkTo(this.loadModel); + + await this.refreshAsync(); + XH.successToast('Hibernate Caches Cleared.'); + } catch (e) { + XH.handleException(e); + } + } + + override async doLoadAsync(loadSpec: LoadSpec) { + try { + const response = await XH.fetchJson({ + url: 'hzObjectAdmin/listObjects', + params: { + instance: this.instanceName + } + }); + + return this.gridModel.loadData(response); + } catch (e) { + this.handleLoadException(e, loadSpec); + } + } +} diff --git a/admin/tabs/cluster/hzobject/HzObjectPanel.ts b/admin/tabs/cluster/hzobject/HzObjectPanel.ts new file mode 100644 index 0000000000..2db55106da --- /dev/null +++ b/admin/tabs/cluster/hzobject/HzObjectPanel.ts @@ -0,0 +1,71 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ +import {grid, gridCountLabel} from '@xh/hoist/cmp/grid'; +import {filler, hframe, placeholder, span} from '@xh/hoist/cmp/layout'; +import {storeFilterField} from '@xh/hoist/cmp/store'; +import {creates, hoistCmp, uses} from '@xh/hoist/core'; +import {button, exportButton} from '@xh/hoist/desktop/cmp/button'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {Icon} from '@xh/hoist/icon'; +import {HzObjectModel} from './HzObjectModel'; +import {jsonInput} from '@xh/hoist/desktop/cmp/input'; + +export const hzObjectPanel = hoistCmp.factory({ + model: creates(HzObjectModel), + + render({model}) { + return panel({ + mask: 'onLoad', + tbar: [ + span({ + item: 'Hazelcast Distributed Objects', + className: 'xh-bold' + }), + filler(), + gridCountLabel({unit: 'objects'}), + '-', + button({ + icon: Icon.reset(), + text: 'Clear All Hibernate Caches', + tooltip: 'Clear the Hibernate caches using the native Hibernate API', + onClick: () => model.clearHibernateCachesAsync() + }), + storeFilterField({matchMode: 'any'}), + exportButton() + ], + item: hframe(grid(), detailsPanel()) + }); + } +}); + +const detailsPanel = hoistCmp.factory({ + model: uses(HzObjectModel), + render({model}) { + const data = model.gridModel.selectedRecord?.raw; + return panel({ + title: data ? `Stats: ${data.name}` : 'Stats', + icon: Icon.info(), + compactHeader: true, + modelConfig: { + side: 'right', + defaultSize: 450 + }, + item: data + ? panel({ + item: jsonInput({ + readonly: true, + width: '100%', + height: '100%', + showFullscreenButton: false, + editorProps: {lineNumbers: false}, + value: model.fmtStats(data) + }) + }) + : placeholder(Icon.grip(), 'Select an object.') + }); + } +}); diff --git a/admin/tabs/server/logViewer/LogDisplay.ts b/admin/tabs/cluster/logs/LogDisplay.ts similarity index 88% rename from admin/tabs/server/logViewer/LogDisplay.ts rename to admin/tabs/cluster/logs/LogDisplay.ts index 481aa33929..45d302262a 100644 --- a/admin/tabs/server/logViewer/LogDisplay.ts +++ b/admin/tabs/cluster/logs/LogDisplay.ts @@ -8,7 +8,7 @@ import {clock} from '@xh/hoist/cmp/clock'; import {grid} from '@xh/hoist/cmp/grid'; import {code, div, fragment, hspacer, label, filler} from '@xh/hoist/cmp/layout'; import {hoistCmp, uses, XH} from '@xh/hoist/core'; -import {button} from '@xh/hoist/desktop/cmp/button'; +import {button, modalToggleButton} from '@xh/hoist/desktop/cmp/button'; import {gridFindField} from '@xh/hoist/desktop/cmp/grid'; import {numberInput, switchInput, textInput} from '@xh/hoist/desktop/cmp/input'; import {panel} from '@xh/hoist/desktop/cmp/panel'; @@ -30,7 +30,8 @@ export const logDisplay = hoistCmp.factory({ tbar: tbar(), item: grid(), loadingIndicator: 'onLoad', - bbar: bbar() + bbar: bbar(), + model: model.panelModel }); } }); @@ -96,7 +97,9 @@ const tbar = hoistCmp.factory(({model}) => { model.scrollToTail(); }, omit: !model.tail || model.tailActive - }) + }), + '-', + modalToggleButton() ); }); @@ -107,16 +110,18 @@ const bbar = hoistCmp.factory({ {logRootPath} = model; return toolbar( - div('Server time: '), - clock({ - timezone: zone, - format: 'HH:mm', - suffix: fmtTimeZone(zone, offset) - }), + Icon.clock(), + code( + clock({ + timezone: zone, + format: 'HH:mm', + suffix: fmtTimeZone(zone, offset) + }) + ), filler(), div({ omit: !logRootPath, - items: ['Log Location: ', code(logRootPath)] + items: [Icon.folder(), ' ', code(logRootPath)] }) ); } diff --git a/admin/tabs/server/logViewer/LogDisplayModel.ts b/admin/tabs/cluster/logs/LogDisplayModel.ts similarity index 94% rename from admin/tabs/server/logViewer/LogDisplayModel.ts rename to admin/tabs/cluster/logs/LogDisplayModel.ts index 2c3ad5e052..6917af0a77 100644 --- a/admin/tabs/server/logViewer/LogDisplayModel.ts +++ b/admin/tabs/cluster/logs/LogDisplayModel.ts @@ -6,6 +6,7 @@ */ import {GridModel} from '@xh/hoist/cmp/grid'; import {HoistModel, managed, persist, XH} from '@xh/hoist/core'; +import {PanelModel} from '@xh/hoist/desktop/cmp/panel'; import {Icon} from '@xh/hoist/icon'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import {Timer} from '@xh/hoist/utils/async'; @@ -22,6 +23,13 @@ export class LogDisplayModel extends HoistModel { parent: LogViewerModel; + @managed + panelModel = new PanelModel({ + collapsible: false, + resizable: false, + modalSupport: {width: '100vw', height: '100vh'} + }); + // Form State/Display options @bindable @persist @@ -97,7 +105,8 @@ export class LogDisplayModel extends HoistModel { startLine: this.startLine, maxLines: this.maxLines, pattern: this.regexOption ? this.pattern : escapeRegExp(this.pattern), - caseSensitive: this.caseSensitive + caseSensitive: this.caseSensitive, + instance: parent.instanceName }, loadSpec }); diff --git a/admin/tabs/server/logViewer/LogViewer.scss b/admin/tabs/cluster/logs/LogViewer.scss similarity index 100% rename from admin/tabs/server/logViewer/LogViewer.scss rename to admin/tabs/cluster/logs/LogViewer.scss diff --git a/admin/tabs/server/logViewer/LogViewer.ts b/admin/tabs/cluster/logs/LogViewer.ts similarity index 70% rename from admin/tabs/server/logViewer/LogViewer.ts rename to admin/tabs/cluster/logs/LogViewer.ts index c8e6a168a3..3c108cacb2 100644 --- a/admin/tabs/server/logViewer/LogViewer.ts +++ b/admin/tabs/cluster/logs/LogViewer.ts @@ -9,8 +9,8 @@ import {hframe} from '@xh/hoist/cmp/layout'; import {storeFilterField} from '@xh/hoist/cmp/store'; import {creates, hoistCmp} from '@xh/hoist/core'; import {errorMessage} from '@xh/hoist/desktop/cmp/error'; +import {select} from '@xh/hoist/desktop/cmp/input'; import {panel} from '@xh/hoist/desktop/cmp/panel'; -import {recordActionBar} from '@xh/hoist/desktop/cmp/record'; import {Icon} from '@xh/hoist/icon'; import {logDisplay} from './LogDisplay'; import './LogViewer.scss'; @@ -24,15 +24,13 @@ export const logViewer = hoistCmp.factory({ return errorMessage({error: 'Log viewer disabled via xhEnableLogViewer config.'}); } - const {filesGridModel} = model; return hframe({ className: 'xh-log-viewer', ref: model.viewRef, items: [ panel({ - title: 'Available Server Logs', - icon: Icon.fileText(), - compactHeader: true, + collapsedTitle: 'Log Files', + collapsedIcon: Icon.fileText(), modelConfig: { side: 'left', defaultSize: 380 @@ -40,12 +38,15 @@ export const logViewer = hoistCmp.factory({ item: grid(), bbar: [ storeFilterField({flex: 1}), - recordActionBar({ - selModel: filesGridModel.selModel, - gridModel: filesGridModel, - actions: [ - {...model.downloadFileAction, text: null}, - {...model.deleteFileAction, text: null} + select({ + bind: 'instanceOnly', + width: 90, + enableFilter: false, + hideDropdownIndicator: true, + hideSelectedOptionCheck: true, + options: [ + {label: model.instanceName, value: true}, + {label: 'ALL', value: false} ] }) ] diff --git a/admin/tabs/server/logViewer/LogViewerModel.ts b/admin/tabs/cluster/logs/LogViewerModel.ts similarity index 82% rename from admin/tabs/server/logViewer/LogViewerModel.ts rename to admin/tabs/cluster/logs/LogViewerModel.ts index 85f8afcf03..0c7479f138 100644 --- a/admin/tabs/server/logViewer/LogViewerModel.ts +++ b/admin/tabs/cluster/logs/LogViewerModel.ts @@ -6,12 +6,13 @@ */ import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils'; import {AppModel} from '@xh/hoist/admin/AppModel'; +import {BaseInstanceModel} from '@xh/hoist/admin/tabs/cluster/BaseInstanceModel'; import {GridModel} from '@xh/hoist/cmp/grid'; -import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {LoadSpec, managed, XH} from '@xh/hoist/core'; import {RecordActionSpec} from '@xh/hoist/data'; import {compactDateRenderer, fmtNumber} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; -import {makeObservable, observable} from '@xh/hoist/mobx'; +import {bindable, makeObservable, observable} from '@xh/hoist/mobx'; import download from 'downloadjs'; import {createRef} from 'react'; import {LogDisplayModel} from './LogDisplayModel'; @@ -19,7 +20,7 @@ import {LogDisplayModel} from './LogDisplayModel'; /** * @internal */ -export class LogViewerModel extends HoistModel { +export class LogViewerModel extends BaseInstanceModel { @observable file: string = null; viewRef = createRef(); @@ -30,6 +31,9 @@ export class LogViewerModel extends HoistModel { @managed filesGridModel: GridModel; + @bindable + instanceOnly: boolean = true; + get enabled(): boolean { return XH.getConf('xhEnableLogViewer', true); } @@ -66,10 +70,15 @@ export class LogViewerModel extends HoistModel { this.file = rec?.data?.filename; } }); + + this.addReaction({ + track: () => this.instanceOnly, + run: () => this.loadAsync() + }); } override async doLoadAsync(loadSpec: LoadSpec) { - const {enabled, filesGridModel} = this; + const {enabled, filesGridModel, instanceOnly, instanceName} = this; if (!enabled) return; const store = filesGridModel.store, @@ -78,22 +87,27 @@ export class LogViewerModel extends HoistModel { try { const data = await XH.fetchJson({ url: 'logViewerAdmin/listFiles', + params: {instance: instanceName}, loadSpec }); + const files = instanceOnly + ? data.files.filter(f => f.filename.includes(instanceName)) + : data.files; + this.logDisplayModel.logRootPath = data.logRootPath; - store.loadData(data.files); + store.loadData(files); if (selModel.isEmpty) { const latestAppLog = store.records.find( - rec => rec.data.filename === `${XH.appCode}.log` + rec => rec.data.filename === `${XH.appCode}-${instanceName}.log` ); if (latestAppLog) { selModel.select(latestAppLog); } } } catch (e) { - XH.handleException(e, {title: 'Error loading list of available log files'}); + this.handleLoadException(e, loadSpec); } } @@ -112,7 +126,10 @@ export class LogViewerModel extends HoistModel { const filenames = recs.map(r => r.data.filename); await XH.fetch({ url: 'logViewerAdmin/deleteFiles', - params: {filenames} + params: { + filenames, + instance: this.instanceName + } }); await this.refreshAsync(); } catch (e) { @@ -128,7 +145,10 @@ export class LogViewerModel extends HoistModel { const {filename} = selectedRecord.data, response = await XH.fetch({ url: 'logViewerAdmin/download', - params: {filename} + params: { + filename, + instance: this.instanceName + } }); const blob = await response.blob(); diff --git a/admin/tabs/server/memory/MemoryMonitorModel.ts b/admin/tabs/cluster/memory/MemoryMonitorModel.ts similarity index 68% rename from admin/tabs/server/memory/MemoryMonitorModel.ts rename to admin/tabs/cluster/memory/MemoryMonitorModel.ts index 29c3d1fe81..79b618d56f 100644 --- a/admin/tabs/server/memory/MemoryMonitorModel.ts +++ b/admin/tabs/cluster/memory/MemoryMonitorModel.ts @@ -5,16 +5,17 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils'; +import {timestampNoYear} from '@xh/hoist/admin/columns'; +import {BaseInstanceModel} from '@xh/hoist/admin/tabs/cluster/BaseInstanceModel'; import {ChartModel} from '@xh/hoist/cmp/chart'; -import {GridModel} from '@xh/hoist/cmp/grid'; -import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {ColumnSpec, GridModel} from '@xh/hoist/cmp/grid'; +import {LoadSpec, managed, XH} from '@xh/hoist/core'; import {lengthIs, required} from '@xh/hoist/data'; -import {fmtTime} from '@xh/hoist/format'; +import {fmtTime, numberRenderer} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {forOwn, sortBy} from 'lodash'; -import * as MCol from '../../monitor/MonitorColumns'; -export class MemoryMonitorModel extends HoistModel { +export class MemoryMonitorModel extends BaseInstanceModel { @managed gridModel: GridModel; @managed chartModel: ChartModel; @@ -38,22 +39,16 @@ export class MemoryMonitorModel extends HoistModel { colDefaults: {filterable: true}, headerMenuDisplay: 'hover', columns: [ - MCol.timestamp, + {...timestampNoYear}, { groupId: 'heap', headerAlign: 'center', - children: [ - MCol.totalHeapMb, - MCol.maxHeapMb, - MCol.freeHeapMb, - MCol.usedHeapMb, - MCol.usedPctMax - ] + children: [totalHeapMb, maxHeapMb, freeHeapMb, usedHeapMb, usedPctMax] }, { groupId: 'GC', headerAlign: 'center', - children: [MCol.collectionCount, MCol.avgCollectionTime, MCol.pctCollectionTime] + children: [collectionCount, avgCollectionTime, pctCollectionTime] } ] }); @@ -104,6 +99,7 @@ export class MemoryMonitorModel extends HoistModel { try { const snapsByTimestamp = await XH.fetchJson({ url: 'memoryMonitorAdmin/snapshots', + params: {instance: this.instanceName}, loadSpec }); @@ -161,13 +157,16 @@ export class MemoryMonitorModel extends HoistModel { } ]); } catch (e) { - XH.handleException(e); + this.handleLoadException(e, loadSpec); } } async takeSnapshotAsync() { try { - await XH.fetchJson({url: 'memoryMonitorAdmin/takeSnapshot'}).linkTo(this.loadModel); + await XH.fetchJson({ + url: 'memoryMonitorAdmin/takeSnapshot', + params: {instance: this.instanceName} + }).linkTo(this.loadModel); await this.loadAsync(); XH.successToast('Updated snapshot loaded'); } catch (e) { @@ -177,7 +176,10 @@ export class MemoryMonitorModel extends HoistModel { async requestGcAsync() { try { - await XH.fetchJson({url: 'memoryMonitorAdmin/requestGc'}).linkTo(this.loadModel); + await XH.fetchJson({ + url: 'memoryMonitorAdmin/requestGc', + params: {instance: this.instanceName} + }).linkTo(this.loadModel); await this.loadAsync(); XH.successToast('GC run complete'); } catch (e) { @@ -200,7 +202,10 @@ export class MemoryMonitorModel extends HoistModel { if (!filename) return; await XH.fetchJson({ url: 'memoryMonitorAdmin/dumpHeap', - params: {filename} + params: { + instance: this.instanceName, + filename + } }).linkTo(this.loadModel); await this.loadAsync(); XH.successToast('Heap dumped successfully to ' + filename); @@ -213,3 +218,79 @@ export class MemoryMonitorModel extends HoistModel { return XH.getConf('xhMemoryMonitoringConfig', {heapDumpDir: null, enabled: true}); } } + +const mbCol = {width: 150, renderer: numberRenderer({precision: 2, withCommas: true})}, + pctCol = {width: 150, renderer: numberRenderer({precision: 2, withCommas: true, label: '%'})}, + msCol = {width: 150, renderer: numberRenderer({precision: 0, withCommas: false})}; + +export const totalHeapMb: ColumnSpec = { + field: { + name: 'totalHeapMb', + type: 'number', + displayName: 'Total (mb)' + }, + ...mbCol +}; + +export const maxHeapMb: ColumnSpec = { + field: { + name: 'maxHeapMb', + type: 'number', + displayName: 'Max (mb)' + }, + ...mbCol +}; + +export const freeHeapMb: ColumnSpec = { + field: { + name: 'freeHeapMb', + type: 'number', + displayName: 'Free (mb)' + }, + ...mbCol +}; + +export const usedHeapMb: ColumnSpec = { + field: { + name: 'usedHeapMb', + type: 'number', + displayName: 'Used (mb)' + }, + ...mbCol +}; + +export const usedPctMax: ColumnSpec = { + field: { + name: 'usedPctMax', + type: 'number', + displayName: 'Used (pct Max)' + }, + ...pctCol +}; + +export const avgCollectionTime: ColumnSpec = { + field: { + name: 'avgCollectionTime', + type: 'number', + displayName: 'Avg (ms)' + }, + ...msCol +}; + +export const collectionCount: ColumnSpec = { + field: { + name: 'collectionCount', + type: 'number', + displayName: '# GCs' + }, + ...msCol +}; + +export const pctCollectionTime: ColumnSpec = { + field: { + name: 'pctCollectionTime', + type: 'number', + displayName: '% Time in GC' + }, + ...pctCol +}; diff --git a/admin/tabs/server/memory/MemoryMonitorPanel.ts b/admin/tabs/cluster/memory/MemoryMonitorPanel.ts similarity index 90% rename from admin/tabs/server/memory/MemoryMonitorPanel.ts rename to admin/tabs/cluster/memory/MemoryMonitorPanel.ts index 4ae6b25839..cbe62a7f45 100644 --- a/admin/tabs/server/memory/MemoryMonitorPanel.ts +++ b/admin/tabs/cluster/memory/MemoryMonitorPanel.ts @@ -4,10 +4,10 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {MemoryMonitorModel} from '@xh/hoist/admin/tabs/server/memory/MemoryMonitorModel'; +import {MemoryMonitorModel} from '@xh/hoist/admin/tabs/cluster/memory/MemoryMonitorModel'; import {chart} from '@xh/hoist/cmp/chart'; import {grid, gridCountLabel} from '@xh/hoist/cmp/grid'; -import {filler} from '@xh/hoist/cmp/layout'; +import {filler, span} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp} from '@xh/hoist/core'; import {button, exportButton} from '@xh/hoist/desktop/cmp/button'; import {errorMessage} from '@xh/hoist/desktop/cmp/error'; @@ -30,24 +30,28 @@ export const memoryMonitorPanel = hoistCmp.factory({ dumpDisabled = isNil(model.heapDumpDir); return panel({ tbar: [ + span({ + item: 'Memory Usage', + className: 'xh-bold' + }), + filler(), + gridCountLabel({unit: 'snapshot'}), + '-', button({ text: 'Take Snapshot', icon: Icon.camera(), omit: readonly, onClick: () => model.takeSnapshotAsync() }), - '-', button({ text: 'Request GC', icon: Icon.trash(), - intent: 'danger', omit: readonly, onClick: () => model.requestGcAsync() }), button({ text: 'Dump Heap', icon: Icon.fileArchive(), - intent: 'danger', omit: readonly, disabled: dumpDisabled, tooltip: dumpDisabled @@ -55,8 +59,6 @@ export const memoryMonitorPanel = hoistCmp.factory({ : null, onClick: () => model.dumpHeapAsync() }), - filler(), - gridCountLabel({unit: 'snapshot'}), '-', exportButton() ], diff --git a/admin/tabs/cluster/services/DetailsModel.ts b/admin/tabs/cluster/services/DetailsModel.ts new file mode 100644 index 0000000000..3ab34c1b37 --- /dev/null +++ b/admin/tabs/cluster/services/DetailsModel.ts @@ -0,0 +1,61 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ +import {HoistModel, LoadSpec, lookup, PlainObject, XH} from '@xh/hoist/core'; +import {StoreRecord} from '@xh/hoist/data'; +import {bindable} from '@xh/hoist/mobx'; +import {ServiceModel} from './ServiceModel'; + +export class DetailsModel extends HoistModel { + @lookup(ServiceModel) + parent: ServiceModel; + + @bindable.ref + svcName: StoreRecord; + + @bindable.ref + stats: PlainObject; + + get selectedRecord() { + return this.parent.gridModel.selectedRecord; + } + + override onLinked() { + this.addReaction({ + track: () => this.selectedRecord, + run: () => this.loadAsync(), + debounce: 500 + }); + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {selectedRecord, parent} = this, + selected = selectedRecord?.data; + + // Fetch needed, clear existing data if known obsolete + if (selected?.displayName != this.svcName) this.stats = null; + this.svcName = selected?.displayName; + + if (!selected) return; + + const resp = await XH.fetchJson({ + url: 'serviceManagerAdmin/getStats', + params: {instance: parent.instanceName, name: selected.name}, + autoAbortKey: 'serviceDetails', + loadSpec + }); + if (loadSpec.isStale) return; + this.preprocessRawData(resp); + this.stats = resp; + } + + private preprocessRawData(resp) { + // Format distributed objects for readability + resp.distributedObjects?.forEach(obj => { + obj.name = obj.name.substring(obj.name.indexOf('_') + 1); + }); + } +} diff --git a/admin/tabs/cluster/services/DetailsPanel.ts b/admin/tabs/cluster/services/DetailsPanel.ts new file mode 100644 index 0000000000..a218bb3219 --- /dev/null +++ b/admin/tabs/cluster/services/DetailsPanel.ts @@ -0,0 +1,59 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ +import {DetailsModel} from '@xh/hoist/admin/tabs/cluster/services/DetailsModel'; +import {placeholder} from '@xh/hoist/cmp/layout'; +import {creates, hoistCmp, XH} from '@xh/hoist/core'; +import {errorMessage} from '@xh/hoist/desktop/cmp/error'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {jsonInput} from '@xh/hoist/desktop/cmp/input'; +import {Icon} from '@xh/hoist/icon'; + +export const detailsPanel = hoistCmp.factory({ + model: creates(DetailsModel), + + render({model}) { + const {svcName} = model; + return panel({ + title: svcName ? `Stats: ${svcName}` : 'Stats', + mask: 'onLoad', + icon: Icon.info(), + compactHeader: true, + modelConfig: { + side: 'right', + defaultSize: 450 + }, + item: svcName ? stats() : placeholder(Icon.gears(), 'Select a service.') + }); + } +}); + +const stats = hoistCmp.factory({ + render({model}) { + const {stats, lastLoadException, loadModel} = model; + + if (!loadModel.isPending && lastLoadException) { + return errorMessage({ + error: lastLoadException, + detailsFn: e => XH.exceptionHandler.showExceptionDetails(e) + }); + } + + if (stats == null) return null; + + return panel( + jsonInput({ + readonly: true, + width: '100%', + height: '100%', + enableSearch: true, + showFullscreenButton: false, + editorProps: {lineNumbers: false}, + value: model.parent.fmtStats(stats) + }) + ); + } +}); diff --git a/admin/tabs/cluster/services/ServiceModel.ts b/admin/tabs/cluster/services/ServiceModel.ts new file mode 100644 index 0000000000..11719b03f2 --- /dev/null +++ b/admin/tabs/cluster/services/ServiceModel.ts @@ -0,0 +1,110 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ +import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils'; +import {AppModel} from '@xh/hoist/admin/AppModel'; +import {timestampNoYear} from '@xh/hoist/admin/columns'; +import {BaseInstanceModel} from '@xh/hoist/admin/tabs/cluster/BaseInstanceModel'; +import {GridModel} from '@xh/hoist/cmp/grid'; +import {LoadSpec, managed, XH} from '@xh/hoist/core'; +import {RecordActionSpec} from '@xh/hoist/data'; +import {Icon} from '@xh/hoist/icon'; +import {isEmpty, lowerFirst} from 'lodash'; + +export class ServiceModel extends BaseInstanceModel { + clearCachesAction: RecordActionSpec = { + icon: Icon.reset(), + text: 'Clear Caches', + intent: 'danger', + actionFn: () => this.clearCachesAsync(false), + displayFn: () => ({ + hidden: AppModel.readonly, + text: `Clear Caches (@ ${this.instanceName})` + }), + recordsRequired: true + }; + + clearClusterCachesAction: RecordActionSpec = { + icon: Icon.reset(), + text: 'Clear Caches (entire cluster)', + intent: 'danger', + actionFn: () => this.clearCachesAsync(true), + displayFn: () => ({ + hidden: AppModel.readonly || !this.parent.isMultiInstance + }), + recordsRequired: true + }; + + @managed + gridModel: GridModel = new GridModel({ + selModel: 'multiple', + enableExport: true, + exportOptions: {filename: exportFilenameWithDate('services')}, + store: { + idSpec: 'name', + processRawData: this.processRawData, + fields: [ + {name: 'provider', type: 'string'}, + {name: 'name', type: 'string'}, + {name: 'displayName', type: 'string'}, + {name: 'initializedDate', type: 'date', displayName: 'Initialized'}, + {name: 'lastCachesCleared', type: 'date', displayName: 'Last Cleared'} + ] + }, + sortBy: 'displayName', + groupBy: 'provider', + columns: [ + {field: 'provider', hidden: true}, + {field: 'displayName', flex: 1}, + {...timestampNoYear, field: 'lastCachesCleared'}, + {...timestampNoYear, field: 'initializedDate'} + ], + contextMenu: [ + this.clearCachesAction, + this.clearClusterCachesAction, + '-', + ...GridModel.defaultContextMenu + ] + }); + + async clearCachesAsync(entireCluster: boolean) { + const {selectedRecords} = this.gridModel; + if (isEmpty(selectedRecords)) return; + + try { + await XH.fetchJson({ + url: 'serviceManagerAdmin/clearCaches', + params: { + instance: entireCluster ? null : this.instanceName, + names: selectedRecords.map(it => it.data.name) + } + }).linkTo(this.loadModel); + await this.refreshAsync(); + XH.successToast('Service caches cleared.'); + } catch (e) { + XH.handleException(e); + } + } + + override async doLoadAsync(loadSpec: LoadSpec) { + try { + const data = await XH.fetchJson({ + url: 'serviceManagerAdmin/listServices', + params: {instance: this.instanceName}, + loadSpec + }); + return this.gridModel.loadData(data); + } catch (e) { + this.handleLoadException(e, loadSpec); + } + } + + private processRawData(r) { + const provider = r.name && r.name.startsWith('hoistCore') ? 'Hoist' : 'App'; + const displayName = lowerFirst(r.name.replace('hoistCore', '')); + return {provider, displayName, ...r}; + } +} diff --git a/admin/tabs/cluster/services/ServicePanel.ts b/admin/tabs/cluster/services/ServicePanel.ts new file mode 100644 index 0000000000..e40601eb15 --- /dev/null +++ b/admin/tabs/cluster/services/ServicePanel.ts @@ -0,0 +1,46 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2024 Extremely Heavy Industries Inc. + */ +import {detailsPanel} from '@xh/hoist/admin/tabs/cluster/services/DetailsPanel'; +import {grid, gridCountLabel} from '@xh/hoist/cmp/grid'; +import {filler, hframe, span} from '@xh/hoist/cmp/layout'; +import {storeFilterField} from '@xh/hoist/cmp/store'; +import {creates, hoistCmp} from '@xh/hoist/core'; +import {exportButton} from '@xh/hoist/desktop/cmp/button'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {ServiceModel} from './ServiceModel'; + +export const servicePanel = hoistCmp.factory({ + model: creates(ServiceModel), + + render() { + return panel({ + mask: 'onLoad', + tbar: [ + span({ + item: 'Hoist + Application Services', + className: 'xh-bold' + }), + filler(), + gridCountLabel({unit: 'service'}), + '-', + storeFilterField({matchMode: 'any'}), + exportButton() + ], + item: hframe( + grid({ + flex: 1, + agOptions: { + groupRowRendererParams: { + innerRenderer: params => params.value + ' Services' + } + } + }), + detailsPanel() + ) + }); + } +}); diff --git a/admin/tabs/server/websocket/WebSocketColumns.ts b/admin/tabs/cluster/websocket/WebSocketColumns.ts similarity index 100% rename from admin/tabs/server/websocket/WebSocketColumns.ts rename to admin/tabs/cluster/websocket/WebSocketColumns.ts diff --git a/admin/tabs/server/websocket/WebSocketModel.ts b/admin/tabs/cluster/websocket/WebSocketModel.ts similarity index 73% rename from admin/tabs/server/websocket/WebSocketModel.ts rename to admin/tabs/cluster/websocket/WebSocketModel.ts index 3a2942f4ce..fbc56c84d2 100644 --- a/admin/tabs/server/websocket/WebSocketModel.ts +++ b/admin/tabs/cluster/websocket/WebSocketModel.ts @@ -6,9 +6,10 @@ */ import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils'; import * as Col from '@xh/hoist/admin/columns'; +import {BaseInstanceModel} from '@xh/hoist/admin/tabs/cluster/BaseInstanceModel'; import {GridModel} from '@xh/hoist/cmp/grid'; import {div, p} from '@xh/hoist/cmp/layout'; -import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {LoadSpec, managed, XH} from '@xh/hoist/core'; import {textInput} from '@xh/hoist/desktop/cmp/input'; import {Icon} from '@xh/hoist/icon'; import {makeObservable, observable, runInAction} from '@xh/hoist/mobx'; @@ -18,8 +19,10 @@ import {isDisplayed} from '@xh/hoist/utils/js'; import {isEmpty} from 'lodash'; import {createRef} from 'react'; import * as WSCol from './WebSocketColumns'; +import {RecordActionSpec} from '@xh/hoist/data'; +import {AppModel} from '@xh/hoist/admin/AppModel'; -export class WebSocketModel extends HoistModel { +export class WebSocketModel extends BaseInstanceModel { viewRef = createRef(); @observable @@ -31,6 +34,15 @@ export class WebSocketModel extends HoistModel { @managed private _timer: Timer; + forceSuspendAction: RecordActionSpec = { + text: 'Force suspend', + icon: Icon.stopCircle(), + intent: 'danger', + actionFn: () => this.forceSuspendAsync(), + displayFn: () => ({hidden: AppModel.readonly}), + recordsRequired: true + }; + constructor() { super(); makeObservable(this); @@ -40,6 +52,7 @@ export class WebSocketModel extends HoistModel { enableExport: true, exportOptions: {filename: exportFilenameWithDate('ws-connections')}, selModel: 'multiple', + contextMenu: [this.forceSuspendAction, '-', ...GridModel.defaultContextMenu], store: { idSpec: 'key', processRawData: row => { @@ -84,27 +97,40 @@ export class WebSocketModel extends HoistModel { } override async doLoadAsync(loadSpec: LoadSpec) { - const data = await XH.fetchJson({url: 'webSocketAdmin/allChannels'}); - this.gridModel.loadData(data); - runInAction(() => { - this.lastRefresh = Date.now(); - }); + try { + const data = await XH.fetchJson({ + url: 'webSocketAdmin/allChannels', + params: {instance: this.instanceName}, + loadSpec + }); + this.gridModel.loadData(data); + runInAction(() => { + this.lastRefresh = Date.now(); + }); + } catch (e) { + this.handleLoadException(e, loadSpec); + } } - async forceSuspendOnSelectedAsync() { + async forceSuspendAsync() { const {selectedRecords} = this.gridModel; if (isEmpty(selectedRecords)) return; const message = await XH.prompt({ title: 'Force suspend', icon: Icon.stopCircle(), - confirmProps: {text: 'Force Suspend', icon: Icon.stopCircle(), intent: 'danger'}, + confirmProps: { + text: 'Force Suspend', + icon: Icon.stopCircle(), + intent: 'danger', + outlined: true + }, cancelProps: {autoFocus: true}, message: div( p( `This action will force ${selectedRecords.length} connected client(s) into suspended mode, halting all background refreshes and other activity, masking the UI, and requiring users to reload the app to continue.` ), - p('If desired, you can enter a message below to display within the suspended app.') + p('Enter an optional message below to display within the suspended app.') ), input: { item: textInput({placeholder: 'User-facing message (optional)'}), @@ -119,6 +145,7 @@ export class WebSocketModel extends HoistModel { params: { channelKey: rec.data.key, topic: XH.webSocketService.FORCE_APP_SUSPEND_TOPIC, + instance: this.instanceName, message } }) diff --git a/admin/tabs/server/websocket/WebSocketPanel.ts b/admin/tabs/cluster/websocket/WebSocketPanel.ts similarity index 71% rename from admin/tabs/server/websocket/WebSocketPanel.ts rename to admin/tabs/cluster/websocket/WebSocketPanel.ts index d77fdd9142..15c3452736 100644 --- a/admin/tabs/server/websocket/WebSocketPanel.ts +++ b/admin/tabs/cluster/websocket/WebSocketPanel.ts @@ -4,18 +4,16 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {WebSocketModel} from '@xh/hoist/admin/tabs/server/websocket/WebSocketModel'; +import {WebSocketModel} from '@xh/hoist/admin/tabs/cluster/websocket/WebSocketModel'; import {grid, gridCountLabel} from '@xh/hoist/cmp/grid'; -import {filler, box, fragment, p} from '@xh/hoist/cmp/layout'; +import {filler, box, fragment, p, span} from '@xh/hoist/cmp/layout'; import {relativeTimestamp} from '@xh/hoist/cmp/relativetimestamp'; import {storeFilterField} from '@xh/hoist/cmp/store'; import {XH, creates, hoistCmp} from '@xh/hoist/core'; -import {button, exportButton} from '@xh/hoist/desktop/cmp/button'; +import {exportButton} from '@xh/hoist/desktop/cmp/button'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {toolbarSep} from '@xh/hoist/desktop/cmp/toolbar'; -import {Icon} from '@xh/hoist/icon'; import {errorMessage} from '@xh/hoist/desktop/cmp/error'; -import {AppModel} from '@xh/hoist/admin/AppModel'; export const webSocketPanel = hoistCmp.factory({ model: creates(WebSocketModel), @@ -25,16 +23,12 @@ export const webSocketPanel = hoistCmp.factory({ return panel({ tbar: [ - button({ - text: 'Force suspend', - icon: Icon.stopCircle(), - intent: 'danger', - disabled: !model.gridModel.hasSelection, - omit: AppModel.readonly, - onClick: () => model.forceSuspendOnSelectedAsync() + span({ + item: 'WebSocket Connections', + className: 'xh-bold' }), filler(), - relativeTimestamp({bind: 'lastRefresh'}), + relativeTimestamp({bind: 'lastRefresh', options: {prefix: 'Refreshed'}}), toolbarSep(), gridCountLabel({unit: 'client'}), toolbarSep(), diff --git a/admin/tabs/general/GeneralTab.ts b/admin/tabs/general/GeneralTab.ts index 78d24833ca..cd779fa869 100644 --- a/admin/tabs/general/GeneralTab.ts +++ b/admin/tabs/general/GeneralTab.ts @@ -5,6 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {configPanel} from '@xh/hoist/admin/tabs/general/config/ConfigPanel'; +import {logLevelPanel} from '@xh/hoist/admin/tabs/general/logLevel/LogLevelPanel'; import {rolePanel} from '@xh/hoist/admin/tabs/general/roles/RolePanel'; import {tabContainer} from '@xh/hoist/cmp/tab'; import {hoistCmp, XH} from '@xh/hoist/core'; @@ -21,6 +22,7 @@ export const generalTab = hoistCmp.factory(() => tabs: [ {id: 'about', icon: Icon.info(), content: aboutPanel}, {id: 'config', icon: Icon.settings(), content: configPanel}, + {id: 'logLevels', icon: Icon.settings(), content: logLevelPanel}, {id: 'users', icon: Icon.users(), content: userPanel, omit: hideUsersTab()}, {id: 'roles', icon: Icon.idBadge(), content: rolePanel}, {id: 'alertBanner', icon: Icon.bullhorn(), content: alertBannerPanel} diff --git a/admin/tabs/general/about/AboutPanel.ts b/admin/tabs/general/about/AboutPanel.ts index 77293029ac..06f5a701c6 100644 --- a/admin/tabs/general/about/AboutPanel.ts +++ b/admin/tabs/general/about/AboutPanel.ts @@ -5,7 +5,6 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {div, h2, hbox, span, table, tbody, td, th, tr, a, p} from '@xh/hoist/cmp/layout'; -import {relativeTimestamp} from '@xh/hoist/cmp/relativetimestamp'; import {hoistCmp, XH} from '@xh/hoist/core'; import {fmtDateTime} from '@xh/hoist/format'; import {Icon, xhLogo} from '@xh/hoist/icon'; @@ -21,7 +20,6 @@ export const aboutPanel = hoistCmp.factory(() => function renderTables() { const get = str => XH.environmentService.get(str), - startupTime = get('startupTime'), row = (label, data) => { data = data || span({item: 'Not available', className: 'xh-text-color-muted'}); return tr(th(label), td(data)); @@ -40,6 +38,7 @@ function renderTables() { item: tbody( row('App Name / Code', `${get('appName')} / ${get('appCode')}`), row('Environment', get('appEnvironment')), + row('Instance Name', XH.environmentService.serverInstance), row('Database', get('databaseConnectionString')), row( 'DB User / Create Mode', @@ -53,13 +52,7 @@ function renderTables() { row( 'Client Time Zone', fmtTimeZone(get('clientTimeZone'), get('clientTimeZoneOffset')) - ), - startupTime - ? row( - 'Server Uptime', - relativeTimestamp({timestamp: startupTime, options: {pastSuffix: ''}}) - ) - : null + ) ) }), h2(Icon.books(), 'Application and Library Versions'), diff --git a/admin/tabs/server/logLevel/LogLevelColumns.ts b/admin/tabs/general/logLevel/LogLevelColumns.ts similarity index 100% rename from admin/tabs/server/logLevel/LogLevelColumns.ts rename to admin/tabs/general/logLevel/LogLevelColumns.ts diff --git a/admin/tabs/server/logLevel/LogLevelPanel.ts b/admin/tabs/general/logLevel/LogLevelPanel.ts similarity index 100% rename from admin/tabs/server/logLevel/LogLevelPanel.ts rename to admin/tabs/general/logLevel/LogLevelPanel.ts diff --git a/admin/tabs/monitor/MonitorColumns.ts b/admin/tabs/monitor/MonitorColumns.ts index 58308789a4..59e897c4c4 100644 --- a/admin/tabs/monitor/MonitorColumns.ts +++ b/admin/tabs/monitor/MonitorColumns.ts @@ -4,14 +4,9 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {numberRenderer} from '@xh/hoist/format'; import * as Col from '@xh/hoist/cmp/grid/columns'; import {ColumnSpec} from '@xh/hoist/cmp/grid/columns'; -const mbCol = {width: 150, renderer: numberRenderer({precision: 2, withCommas: true})}, - pctCol = {width: 150, renderer: numberRenderer({precision: 2, withCommas: true, label: '%'})}, - msCol = {width: 150, renderer: numberRenderer({precision: 0, withCommas: false})}; - export const metricUnit: ColumnSpec = { field: {name: 'metricUnit', type: 'string'}, headerName: 'Units', @@ -43,80 +38,3 @@ export const code: ColumnSpec = { field: {name: 'code', type: 'string'}, width: 150 }; - -export const timestamp: ColumnSpec = { - field: {name: 'timestamp', type: 'date'}, - ...Col.dateTime -}; - -export const totalHeapMb: ColumnSpec = { - field: { - name: 'totalHeapMb', - type: 'number', - displayName: 'Total (mb)' - }, - ...mbCol -}; - -export const maxHeapMb: ColumnSpec = { - field: { - name: 'maxHeapMb', - type: 'number', - displayName: 'Max (mb)' - }, - ...mbCol -}; - -export const freeHeapMb: ColumnSpec = { - field: { - name: 'freeHeapMb', - type: 'number', - displayName: 'Free (mb)' - }, - ...mbCol -}; - -export const usedHeapMb: ColumnSpec = { - field: { - name: 'usedHeapMb', - type: 'number', - displayName: 'Used (mb)' - }, - ...mbCol -}; - -export const usedPctMax: ColumnSpec = { - field: { - name: 'usedPctMax', - type: 'number', - displayName: 'Used (pct Max)' - }, - ...pctCol -}; - -export const avgCollectionTime: ColumnSpec = { - field: { - name: 'avgCollectionTime', - type: 'number', - displayName: 'Avg (ms)' - }, - ...msCol -}; - -export const collectionCount: ColumnSpec = { - field: { - name: 'collectionCount', - type: 'number', - displayName: '# GCs' - }, - ...msCol -}; - -export const pctCollectionTime: ColumnSpec = { - field: { - name: 'pctCollectionTime', - type: 'number', - displayName: '% Time in GC' - }, - ...pctCol -}; diff --git a/admin/tabs/monitor/MonitorResultsModel.ts b/admin/tabs/monitor/MonitorResultsModel.ts index 70f9f3dd9e..f394934d22 100644 --- a/admin/tabs/monitor/MonitorResultsModel.ts +++ b/admin/tabs/monitor/MonitorResultsModel.ts @@ -63,7 +63,7 @@ export class MonitorResultsModel extends HoistModel { override async doLoadAsync(loadSpec: LoadSpec) { if (!XH.pageIsVisible) return; - return XH.fetchJson({url: 'monitorAdmin/results', loadSpec}) + return XH.fetchJson({url: 'monitorResultsAdmin/results', loadSpec}) .then(rows => { this.completeLoad(rows); }) @@ -75,7 +75,7 @@ export class MonitorResultsModel extends HoistModel { async forceRunAllMonitorsAsync() { try { - await XH.fetchJson({url: 'monitorAdmin/forceRunAllMonitors'}); + await XH.fetchJson({url: 'monitorResultsAdmin/forceRunAllMonitors'}); XH.toast('Request received - results will be generated shortly.'); } catch (e) { XH.handleException(e); diff --git a/admin/tabs/server/ServerTab.ts b/admin/tabs/server/ServerTab.ts index 9415e6e99a..e69de29bb2 100644 --- a/admin/tabs/server/ServerTab.ts +++ b/admin/tabs/server/ServerTab.ts @@ -1,36 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2024 Extremely Heavy Industries Inc. - */ -import {connPoolMonitorPanel} from '@xh/hoist/admin/tabs/server/connectionpool/ConnPoolMonitorPanel'; -import {serverEnvPanel} from '@xh/hoist/admin/tabs/server/environment/ServerEnvPanel'; -import {tabContainer} from '@xh/hoist/cmp/tab'; -import {hoistCmp} from '@xh/hoist/core'; -import {Icon} from '@xh/hoist/icon'; -import {ehCachePanel} from './ehcache/EhCachePanel'; -import {logLevelPanel} from './logLevel/LogLevelPanel'; -import {logViewer} from './logViewer/LogViewer'; -import {memoryMonitorPanel} from './memory/MemoryMonitorPanel'; -import {servicePanel} from './services/ServicePanel'; -import {webSocketPanel} from './websocket/WebSocketPanel'; - -export const serverTab = hoistCmp.factory(() => - tabContainer({ - modelConfig: { - route: 'default.server', - switcher: {orientation: 'left', testId: 'server-tab-switcher'}, - tabs: [ - {id: 'logViewer', icon: Icon.fileText(), content: logViewer}, - {id: 'logLevels', icon: Icon.settings(), content: logLevelPanel}, - {id: 'memory', icon: Icon.server(), content: memoryMonitorPanel}, - {id: 'connectionPool', icon: Icon.database(), content: connPoolMonitorPanel}, - {id: 'environment', icon: Icon.globe(), content: serverEnvPanel}, - {id: 'services', icon: Icon.gears(), content: servicePanel}, - {id: 'ehCache', icon: Icon.memory(), title: 'Caches', content: ehCachePanel}, - {id: 'webSockets', title: 'WebSockets', icon: Icon.bolt(), content: webSocketPanel} - ] - } - }) -); diff --git a/admin/tabs/server/ehcache/EhCacheModel.ts b/admin/tabs/server/ehcache/EhCacheModel.ts index ab16230993..e69de29bb2 100644 --- a/admin/tabs/server/ehcache/EhCacheModel.ts +++ b/admin/tabs/server/ehcache/EhCacheModel.ts @@ -1,61 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2024 Extremely Heavy Industries Inc. - */ -import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils'; -import {GridModel} from '@xh/hoist/cmp/grid'; -import * as Col from '@xh/hoist/cmp/grid/columns'; -import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core'; -import {UrlStore} from '@xh/hoist/data'; -import {trimEnd} from 'lodash'; - -export class EhCacheModel extends HoistModel { - override persistWith = {localStorageKey: 'xhAdminEhCacheState'}; - - @managed - gridModel = new GridModel({ - persistWith: this.persistWith, - colChooserModel: true, - enableExport: true, - exportOptions: {filename: exportFilenameWithDate('eh-caches')}, - store: new UrlStore({ - url: 'ehCacheAdmin/listCaches', - fields: [ - {name: 'name', type: 'string'}, - {name: 'heapSize', type: 'int'}, - {name: 'entries', type: 'int'}, - {name: 'status', type: 'string'} - ], - idSpec: 'name', - processRawData: row => { - return { - ...row, - heapSize: parseFloat(trimEnd(row.heapSize, 'MB')) - }; - } - }), - sortBy: 'name', - columns: [ - {field: 'name', width: 360}, - {field: 'heapSize', ...Col.number, headerName: 'Heap Size (MB)', width: 130}, - {field: 'entries', ...Col.number, width: 120}, - {field: 'status', width: 120} - ] - }); - - async clearAllAsync() { - try { - await XH.fetchJson({url: 'ehCacheAdmin/clearAllCaches'}); - await this.refreshAsync(); - XH.successToast('Hibernate caches cleared.'); - } catch (e) { - XH.handleException(e); - } - } - - override async doLoadAsync(loadSpec: LoadSpec) { - return this.gridModel.loadAsync(loadSpec).catchDefault(); - } -} diff --git a/admin/tabs/server/ehcache/EhCachePanel.ts b/admin/tabs/server/ehcache/EhCachePanel.ts index a230e8a8b4..e69de29bb2 100644 --- a/admin/tabs/server/ehcache/EhCachePanel.ts +++ b/admin/tabs/server/ehcache/EhCachePanel.ts @@ -1,49 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2024 Extremely Heavy Industries Inc. - */ -import {grid, gridCountLabel} from '@xh/hoist/cmp/grid'; -import {filler, span} from '@xh/hoist/cmp/layout'; -import {storeFilterField} from '@xh/hoist/cmp/store'; -import {creates, hoistCmp} from '@xh/hoist/core'; -import {button, exportButton} from '@xh/hoist/desktop/cmp/button'; -import {panel} from '@xh/hoist/desktop/cmp/panel'; -import {Icon} from '@xh/hoist/icon'; -import {toolbarSeparator} from '@xh/hoist/desktop/cmp/toolbar'; -import {AppModel} from '@xh/hoist/admin/AppModel'; -import {EhCacheModel} from './EhCacheModel'; - -export const ehCachePanel = hoistCmp.factory({ - model: creates(EhCacheModel), - - render({model}) { - const {readonly} = AppModel; - - return panel({ - mask: 'onLoad', - tbar: [ - Icon.info(), - span({ - item: 'Hibernate (Ehcache) caches for server-side domain objects', - className: 'xh-bold' - }), - filler(), - button({ - icon: Icon.reset(), - text: 'Clear All', - intent: 'danger', - onClick: () => model.clearAllAsync(), - omit: readonly - }), - toolbarSeparator({omit: readonly}), - gridCountLabel({unit: 'cache'}), - '-', - storeFilterField({matchMode: 'any'}), - exportButton() - ], - item: grid() - }); - } -}); diff --git a/admin/tabs/server/services/ServiceModel.ts b/admin/tabs/server/services/ServiceModel.ts index c1bbf6433a..e69de29bb2 100644 --- a/admin/tabs/server/services/ServiceModel.ts +++ b/admin/tabs/server/services/ServiceModel.ts @@ -1,63 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2024 Extremely Heavy Industries Inc. - */ -import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils'; -import {GridModel} from '@xh/hoist/cmp/grid'; -import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core'; -import {UrlStore} from '@xh/hoist/data'; -import {isEmpty, lowerFirst} from 'lodash'; - -export class ServiceModel extends HoistModel { - @managed - gridModel: GridModel = new GridModel({ - enableExport: true, - exportOptions: {filename: exportFilenameWithDate('services')}, - hideHeaders: true, - store: new UrlStore({ - url: 'serviceAdmin/listServices', - idSpec: data => `${data.provider}-${data.name}`, - processRawData: this.processRawData, - fields: [ - {name: 'provider', type: 'string'}, - {name: 'name', type: 'string'}, - {name: 'displayName', type: 'string'} - ] - }), - selModel: 'multiple', - sortBy: 'displayName', - groupBy: 'provider', - columns: [ - {field: 'provider', hidden: true}, - {field: 'displayName', minWidth: 300, flex: true} - ] - }); - - async clearCachesAsync() { - const {selectedRecords} = this.gridModel; - if (isEmpty(selectedRecords)) return; - - try { - await XH.fetchJson({ - url: 'serviceAdmin/clearCaches', - params: {names: selectedRecords.map(it => it.data.name)} - }); - await this.refreshAsync(); - XH.successToast('Service caches cleared.'); - } catch (e) { - XH.handleException(e); - } - } - - override async doLoadAsync(loadSpec: LoadSpec) { - return this.gridModel.loadAsync(loadSpec).catchDefault(); - } - - private processRawData(r) { - const provider = r.name && r.name.startsWith('hoistCore') ? 'Hoist' : 'App'; - const displayName = lowerFirst(r.name.replace('hoistCore', '')); - return {provider, displayName, ...r}; - } -} diff --git a/admin/tabs/server/services/ServicePanel.ts b/admin/tabs/server/services/ServicePanel.ts deleted file mode 100644 index d09b66adb2..0000000000 --- a/admin/tabs/server/services/ServicePanel.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2024 Extremely Heavy Industries Inc. - */ -import {grid, gridCountLabel} from '@xh/hoist/cmp/grid'; -import {filler, span} from '@xh/hoist/cmp/layout'; -import {storeFilterField} from '@xh/hoist/cmp/store'; -import {creates, hoistCmp} from '@xh/hoist/core'; -import {button, exportButton} from '@xh/hoist/desktop/cmp/button'; -import {panel} from '@xh/hoist/desktop/cmp/panel'; -import {Icon} from '@xh/hoist/icon'; -import {toolbarSeparator} from '@xh/hoist/desktop/cmp/toolbar'; -import {ServiceModel} from './ServiceModel'; -import {AppModel} from '@xh/hoist/admin/AppModel'; - -export const servicePanel = hoistCmp.factory({ - model: creates(ServiceModel), - - render({model}) { - const {readonly} = AppModel; - - return panel({ - mask: 'onLoad', - tbar: [ - Icon.info(), - span({ - item: 'Service classes for server-side Hoist and application-level business logic', - className: 'xh-bold' - }), - filler(), - button({ - icon: Icon.reset(), - text: 'Clear Selected', - intent: 'danger', - onClick: () => model.clearCachesAsync(), - omit: readonly, - disabled: model.gridModel.selModel.isEmpty - }), - toolbarSeparator({omit: readonly}), - gridCountLabel({unit: 'service'}), - '-', - storeFilterField({matchMode: 'any'}), - exportButton() - ], - item: grid({ - agOptions: { - groupRowRendererParams: { - innerRenderer: params => params.value + ' Services' - } - } - }) - }); - } -}); diff --git a/core/HoistAppModel.ts b/core/HoistAppModel.ts index 1ddcf2fe5a..7c7f0ecaee 100644 --- a/core/HoistAppModel.ts +++ b/core/HoistAppModel.ts @@ -87,6 +87,7 @@ export class HoistAppModel extends HoistModel { {label: 'App', value: `${svc.get('appName')} (${svc.get('appCode')})`}, {label: 'Current User', value: XH.identityService.username}, {label: 'Environment', value: svc.get('appEnvironment')}, + {label: 'Instance', value: svc.serverInstance}, {label: 'Server', value: `${svc.get('appVersion')} (build ${svc.get('appBuild')})`}, { label: 'Client', diff --git a/desktop/appcontainer/VersionBar.ts b/desktop/appcontainer/VersionBar.ts index 2133b4f554..7abf2b9eaf 100644 --- a/desktop/appcontainer/VersionBar.ts +++ b/desktop/appcontainer/VersionBar.ts @@ -16,9 +16,11 @@ export const versionBar = hoistCmp.factory({ if (!isShowing()) return null; const inspectorSvc = XH.inspectorService, - env = XH.getEnv('appEnvironment'), - version = XH.getEnv('clientVersion'), - build = XH.getEnv('clientBuild'), + envSvc = XH.environmentService, + env = envSvc.get('appEnvironment'), + version = envSvc.get('clientVersion'), + build = envSvc.get('clientBuild'), + instance = envSvc.serverInstance, isAdminApp = window.location.pathname?.startsWith('/admin/'), versionAndBuild = !build || build === 'UNKNOWN' ? version : `${version} (build ${build})`; @@ -29,7 +31,7 @@ export const versionBar = hoistCmp.factory({ flex: 'none', className: `xh-version-bar xh-version-bar--${env.toLowerCase()}`, items: [ - [XH.appName, env, versionAndBuild].join(' • '), + [XH.appName, env, versionAndBuild, instance].join(' • '), Icon.info({ omit: !XH.appContainerModel.hasAboutDialog(), onClick: () => XH.showAboutDialog() diff --git a/mobile/appcontainer/VersionBar.ts b/mobile/appcontainer/VersionBar.ts index ce96e4d1d4..0c229be731 100644 --- a/mobile/appcontainer/VersionBar.ts +++ b/mobile/appcontainer/VersionBar.ts @@ -17,8 +17,10 @@ export const versionBar = hoistCmp.factory({ render() { if (!isShowing()) return null; - const env = XH.getEnv('appEnvironment'), - version = XH.getEnv('clientVersion'); + const svc = XH.environmentService, + env = svc.get('appEnvironment'), + version = svc.get('clientVersion'), + instance = svc.serverInstance; return box({ justifyContent: 'center', @@ -26,7 +28,7 @@ export const versionBar = hoistCmp.factory({ flex: 'none', className: `xh-version-bar xh-version-bar--${env.toLowerCase()}`, items: [ - [XH.appName, env, version].join(' • '), + [env, version, instance].join(' • '), button({ icon: Icon.info(), minimal: true, diff --git a/svc/EnvironmentService.ts b/svc/EnvironmentService.ts index ae1c301059..f1116dafba 100644 --- a/svc/EnvironmentService.ts +++ b/svc/EnvironmentService.ts @@ -21,17 +21,25 @@ export class EnvironmentService extends HoistService { /** * Version of this application currently running on the Hoist UI server. - * Unlike all other EnvironmentService state, this is refreshed by default on a configured - * interval and is observable to allow apps to take actions (e.g. reload immediately) when - * they detect an update on the server. + * Unlike most other EnvironmentService state, this is refreshed on a timer and observable. */ @observable serverVersion: string; - /** Build of this application currently running on the Hoist UI server. */ + /** + * Build of this application currently running on the Hoist UI server. + * Unlike most other EnvironmentService state, this is refreshed on a timer and observable. + */ @observable serverBuild: string; + /** + * Instance of Hoist UI server currently delivering content to this client. + * Unlike most other EnvironmentService state, this is refreshed on a timer and observable. + */ + @observable + serverInstance: string; + private _data = {}; override async initAsync() { @@ -58,9 +66,12 @@ export class EnvironmentService extends HoistService { serverEnv ); + // This bit is considered transient. Maintain in 'serverInstance' mutable property only + delete this._data['instanceName']; + deepFreeze(this._data); - this.setServerVersion(serverEnv.appVersion, serverEnv.appBuild); + this.setServerInfo(serverEnv.instanceName, serverEnv.appVersion, serverEnv.appBuild); this.addReaction({ when: () => XH.appIsRunning, @@ -97,11 +108,7 @@ export class EnvironmentService extends HoistService { } private startVersionChecking() { - // Todo: `xhAppVersionCheckSecs` checked for backwards compatibility with hoist-core v16.3.0 - // and earlier - remove in future. - const interval = - XH.getConf('xhAppVersionCheck', {})?.interval ?? - XH.getConf('xhAppVersionCheckSecs', null); + const interval = XH.getConf('xhAppVersionCheck', {})?.interval ?? -1; Timer.create({ runFn: this.checkServerVersionAsync, interval: interval * SECONDS @@ -110,7 +117,7 @@ export class EnvironmentService extends HoistService { private checkServerVersionAsync = async () => { const data = await XH.fetchJson({url: 'xh/version'}), - {appVersion, appBuild, mode} = data; + {instanceName, appVersion, appBuild, mode} = data; // Compare latest version/build info from server against the same info (also supplied by // server) when the app initialized. A change indicates an update to the app and will @@ -139,11 +146,12 @@ export class EnvironmentService extends HoistService { ); } - this.setServerVersion(appVersion, appBuild); + this.setServerInfo(instanceName, appVersion, appBuild); }; @action - private setServerVersion(serverVersion, serverBuild) { + private setServerInfo(serverInstance, serverVersion, serverBuild) { + this.serverInstance = serverInstance; this.serverVersion = serverVersion; this.serverBuild = serverBuild; }