diff --git a/app/public/hawtconfig.json b/app/public/hawtconfig.json index 043d4cb1..cbc520c5 100644 --- a/app/public/hawtconfig.json +++ b/app/public/hawtconfig.json @@ -20,14 +20,8 @@ "description": "A Hawtio reimplementation based on TypeScript + React.", "imgSrc": "hawtio-logo.svg", "productInfo": [ - { - "name": "ABC", - "value": "1.2.3" - }, - { - "name": "XYZ", - "value": "7.8.9" - } + { "name": "ABC", "value": "1.2.3" }, + { "name": "XYZ", "value": "7.8.9" } ], "copyright": "© Hawtio project" }, diff --git a/packages/hawtio/src/core/config-manager.ts b/packages/hawtio/src/core/config-manager.ts index 8fbe6dca..05269ac2 100644 --- a/packages/hawtio/src/core/config-manager.ts +++ b/packages/hawtio/src/core/config-manager.ts @@ -7,15 +7,48 @@ const log = Logger.get('hawtio-core-config') export const DEFAULT_APP_NAME = 'Hawtio Management Console' export const DEFAULT_LOGIN_TITLE = 'Log in to your account' +/** + * The single user-customisable entrypoint for the Hawtio console configurations. + */ export type Hawtconfig = { - branding?: Branding - login?: Login - about?: About + /** + * Configuration for branding & styles. + */ + branding?: BrandingConfig + + /** + * Configuration for the built-in login page. + */ + login?: LoginConfig + + /** + * Configuration for the About modal. + */ + about?: AboutConfig + + /** + * The user can explicitly disable plugins by specifying the plugin route paths. + * + * This option can be used if some of the built-in plugins are not desirable + * for the custom installation of Hawtio console. + */ disabledRoutes?: DisabledRoutes - online?: Online + + /** + * Configuration for JMX plugin. + */ + jmx?: JmxConfig + + /** + * Configuration for Hawtio Online. + */ + online?: OnlineConfig } -export type Branding = { +/** + * Branding configuration type. + */ +export type BrandingConfig = { appName?: string showAppName?: boolean appLogoUrl?: string @@ -23,7 +56,10 @@ export type Branding = { favicon?: string } -export type Login = { +/** + * Login configuration type. + */ +export type LoginConfig = { title?: string description?: string links?: LoginLink[] @@ -34,7 +70,10 @@ export type LoginLink = { text: string } -export type About = { +/** + * About configuration type. + */ +export type AboutConfig = { title?: string description?: string imgSrc?: string @@ -49,7 +88,33 @@ export type AboutProductInfo = { export type DisabledRoutes = string[] -export type Online = { +/** + * JMX configuration type. + */ +export type JmxConfig = { + /** + * This option can either disable workspace completely by setting `false`, or + * specify an array of MBean paths in the form of + * `/=,=,...` + * to fine-tune which MBeans to load into workspace. + * + * Note that disabling workspace should also deactivate all the plugins that + * depend on MBeans provided by workspace. + * + * @see https://github.com/hawtio/hawtio-next/issues/421 + */ + workspace?: boolean | string[] +} + +/** + * Hawtio Online configuration type. + */ +export type OnlineConfig = { + /** + * Selector for OpenShift projects or Kubernetes namespaces. + * + * @see https://github.com/hawtio/hawtio-online/issues/64 + */ projectSelector?: string } diff --git a/packages/hawtio/src/plugins/shared/__mocks__/jolokia-service.ts b/packages/hawtio/src/plugins/shared/__mocks__/jolokia-service.ts index d06e3467..d4437c61 100644 --- a/packages/hawtio/src/plugins/shared/__mocks__/jolokia-service.ts +++ b/packages/hawtio/src/plugins/shared/__mocks__/jolokia-service.ts @@ -1,6 +1,7 @@ import Jolokia, { ListRequestOptions, Request, Response } from 'jolokia.js' import { AttributeValues, IJolokiaService, JolokiaListMethod, JolokiaStoredOptions } from '../jolokia-service' import jmxCamelResponse from './jmx-camel-tree.json' +import { OptimisedJmxDomains } from '../tree' class MockJolokiaService implements IJolokiaService { constructor() { @@ -28,12 +29,12 @@ class MockJolokiaService implements IJolokiaService { return '' } - async list(options?: ListRequestOptions): Promise { - return jmxCamelResponse + async list(options?: ListRequestOptions): Promise { + return jmxCamelResponse.domains as unknown as OptimisedJmxDomains } - async sublist(path: string, options?: ListRequestOptions): Promise { - return jmxCamelResponse + async sublist(paths: string | string[], options?: ListRequestOptions): Promise { + return jmxCamelResponse.domains as unknown as OptimisedJmxDomains } async readAttributes(mbean: string): Promise { diff --git a/packages/hawtio/src/plugins/shared/jolokia-service.ts b/packages/hawtio/src/plugins/shared/jolokia-service.ts index dea18f37..f9d480c5 100644 --- a/packages/hawtio/src/plugins/shared/jolokia-service.ts +++ b/packages/hawtio/src/plugins/shared/jolokia-service.ts @@ -10,7 +10,7 @@ import { onSuccessAndError, onVersionSuccessAndError, } from '@hawtiosrc/util/jolokia' -import { isObject } from '@hawtiosrc/util/objects' +import { isObject, isString } from '@hawtiosrc/util/objects' import { parseBoolean } from '@hawtiosrc/util/strings' import Jolokia, { AttributeRequestOptions, @@ -29,9 +29,10 @@ import Jolokia, { } from 'jolokia.js' import 'jolokia.js/simple' import $ from 'jquery' -import { func, is, object } from 'superstruct' +import { define, func, is, object, optional, record, string, type } from 'superstruct' import { PARAM_KEY_CONNECTION, PARAM_KEY_REDIRECT, connectService } from '../shared/connect-service' import { log } from './globals' +import { OptimisedJmxDomain, OptimisedJmxDomains, OptimisedMBeanInfo } from './tree' export const DEFAULT_MAX_DEPTH = 7 export const DEFAULT_MAX_COLLECTION_SIZE = 50000 @@ -66,6 +67,14 @@ const OPTIMISED_JOLOKIA_LIST_MBEAN = 'hawtio:type=security,name=RBACRegistry' const OPTIMISED_JOLOKIA_LIST_MAX_DEPTH = 9 +export type OptimisedListResponse = { + cache: OptimisedMBeanInfoCache + domains: CacheableOptimisedJmxDomains +} +export type OptimisedMBeanInfoCache = Record +export type CacheableOptimisedJmxDomains = Record +export type CacheableOptimisedJmxDomain = Record + export type JolokiaConfig = { method: JolokiaListMethod mbean: string @@ -80,16 +89,20 @@ export const STORAGE_KEY_JOLOKIA_OPTIONS = 'connect.jolokia.options' export const STORAGE_KEY_UPDATE_RATE = 'connect.jolokia.updateRate' export const STORAGE_KEY_AUTO_REFRESH = 'connect.jolokia.autoRefresh' +type JQueryBeforeSend = (this: unknown, jqXHR: JQueryXHR, settings: unknown) => false | void +type JQueryAjaxError = (xhr: JQueryXHR, text: string, error: string) => void type AjaxErrorResolver = () => void +export type AttributeValues = Record + export interface IJolokiaService { reset(): void getJolokiaUrl(): Promise getJolokia(): Promise getListMethod(): Promise getFullJolokiaUrl(): Promise - list(options?: ListRequestOptions): Promise - sublist(path: string, options?: ListRequestOptions): Promise + list(options?: ListRequestOptions): Promise + sublist(paths: string | string[], options?: ListRequestOptions): Promise readAttributes(mbean: string): Promise readAttribute(mbean: string, attribute: string): Promise execute(mbean: string, operation: string, args?: unknown[]): Promise @@ -415,62 +428,238 @@ class JolokiaService implements IJolokiaService { return this.config.method } - list(options?: ListRequestOptions): Promise { - return this.doList(null, options) + list(options?: ListRequestOptions): Promise { + return this.doList([], options) } - sublist(path: string, options?: ListRequestOptions): Promise { - return this.doList(path, options) + sublist(paths: string | string[], options?: ListRequestOptions): Promise { + return this.doList(Array.isArray(paths) ? paths : [paths], options) } - private async doList(path: string | null, options: ListRequestOptions = {}): Promise { + private async doList(paths: string[], options: ListRequestOptions = {}): Promise { + // Granularity of the return value is MBean info and cannot be smaller + paths.forEach(path => { + if (path.split('/').length > 2) { + throw new Error('Path cannot specify children of MBean (attr, op, etc.): ' + path) + } + }) + const jolokia = await this.getJolokia() + if (jolokia.CLIENT_VERSION === 'DUMMY') { + // For dummy Jolokia client, it's too difficult to properly resolve the promise + // of complex bulk list request, so shortcut here. + return {} + } + const { method, mbean } = this.config const { success, error: errorFn, ajaxError } = options return new Promise((resolve, reject) => { - const listOptions = onListSuccessAndError( - value => { - success?.(value) - resolve(value) - }, - error => { - errorFn?.(error) - reject(error) - }, - options, - ) // Override ajaxError to make sure it terminates in case of ajax error - listOptions.ajaxError = (xhr, text, error) => { + options.ajaxError = (xhr, text, error) => { ajaxError?.(xhr, text, error) reject(error) } switch (method) { - case JolokiaListMethod.OPTIMISED: - log.debug('Invoke Jolokia list MBean in optimised mode') + case JolokiaListMethod.OPTIMISED: { + log.debug('Invoke Jolokia list MBean in optimised mode:', paths) + const execOptions = onExecuteSuccessAndError( + value => { + // For empty or single list, the first path should be enough + const path = paths?.[0]?.split('/') + const domains = this.unwindListResponse(value, path) + success?.(domains) + resolve(domains) + }, + error => { + errorFn?.(error) + reject(error) + }, + options as BaseRequestOptions, + ) // Overwrite max depth as listing MBeans requires some constant depth to work // TODO: Is this needed? - listOptions.maxDepth = OPTIMISED_JOLOKIA_LIST_MAX_DEPTH - // This is execute operation but ListRequestOptions is compatible with - // ExecuteRequestOptions for list(), so this usage is intentional. - if (path === null) { - jolokia.execute(mbean, 'list()', listOptions) + execOptions.maxDepth = OPTIMISED_JOLOKIA_LIST_MAX_DEPTH + if (paths.length === 0) { + jolokia.execute(mbean, 'list()', execOptions) + } else if (paths.length === 1) { + jolokia.execute(mbean, 'list(java.lang.String)', paths[0], execOptions) } else { - jolokia.execute(mbean, 'list(java.lang.String)', path, listOptions) + // Bulk request and merge the result + const requests: Request[] = paths.map(path => ({ + type: 'exec', + mbean, + operation: 'list(java.lang.String)', + arguments: [path], + config: execOptions, + })) + this.bulkList(jolokia, requests, execOptions) } break + } case JolokiaListMethod.DEFAULT: case JolokiaListMethod.UNDETERMINED: - default: - log.debug('Invoke Jolokia list MBean in default mode') - if (path === null) { + default: { + log.debug('Invoke Jolokia list MBean in default mode:', paths) + const listOptions = onListSuccessAndError( + value => { + // For empty or single list, the first path should be enough + const path = paths?.[0]?.split('/') + const domains = this.unwindListResponse(value, path) + success?.(domains) + resolve(domains) + }, + error => { + errorFn?.(error) + reject(error) + }, + options, + ) + if (paths.length === 0) { jolokia.list(listOptions) + } else if (paths.length === 1) { + jolokia.list(paths[0] ?? '', listOptions) + } else { + // Bulk request and merge the result + const requests: Request[] = paths.map(path => ({ type: 'list', path, config: listOptions })) + this.bulkList(jolokia, requests, listOptions) + } + } + } + }) + } + + /** + * Detects whether the given response comes from optimised or default list and + * restores its shape to the standard list response of type {@link OptimisedJmxDomains}. + * + * @param response response value from Jolokia LIST + * @param path optional path information to restore the response to {@link OptimisedJmxDomains} + */ + private unwindListResponse(response: unknown, path?: string[]): OptimisedJmxDomains { + const isOptimisedListResponse = (value: unknown): value is OptimisedListResponse => + is(value, object({ cache: object(), domains: object() })) + const isMBeanInfo = (value: unknown): value is OptimisedMBeanInfo => + is( + value, + type({ + desc: string(), + class: optional(string()), + attr: optional(record(string(), object())), + op: optional(record(string(), object())), + notif: optional(record(string(), object())), + }), + ) + const isJmxDomain = (value: unknown): value is OptimisedJmxDomain => + is(value, record(string(), define('MBeanInfo', isMBeanInfo))) + const isJmxDomains = (value: unknown): value is OptimisedJmxDomains => + is(value, record(string(), define('JmxDomain', isJmxDomain))) + + if (isOptimisedListResponse(response)) { + // Post process cached MBean info + const { cache, domains } = response + Object.entries(domains).forEach(([_, domain]) => { + Object.entries(domain).forEach(([mbeanName, mbeanOrCache]) => { + if (isString(mbeanOrCache)) { + domain[mbeanName] = cache[mbeanOrCache] as OptimisedMBeanInfo + } + }) + }) + return domains as OptimisedJmxDomains + } + + if (isJmxDomains(response)) { + return response + } + + if (isJmxDomain(response)) { + const domain = path?.[0] + if (!domain) { + throw new Error('Domain must be provided: ' + path) + } + return { [domain]: response } + } + + if (isMBeanInfo(response)) { + const domain = path?.[0] + const mbean = path?.[1] + if (!domain || !mbean) { + throw new Error('Domain/property list must be provided: ' + path) + } + return { [domain]: { [mbean]: response } } + } + + throw new Error('Unexpected Jolokia list response: ' + JSON.stringify(response)) + } + + private bulkList(jolokia: Jolokia, requests: Request[], listOptions: ListRequestOptions) { + const bulkResponse: Response[] = [] + const mergeResponses = () => { + const domains = bulkResponse + .filter(response => { + if (response.status === 200) { + return true } else { - jolokia.list(path, listOptions) + const error = response as ErrorResponse + log.warn('Bulk list response error:', error.error) + return false + } + }) + .map(response => { + switch (response.request.type) { + case 'list': { + const path = response.request.path?.split('/') + return this.unwindListResponse(response.value, path) + } + case 'exec': { + const path = response.request.arguments as string[] + return this.unwindListResponse(response.value, path) + } + default: + return this.unwindListResponse(response.value) + } + }) + .reduce((merged, response) => this.mergeDomains(response, merged), {}) + listOptions.success?.(domains) + } + jolokia.request( + requests, + onBulkSuccessAndError( + response => { + bulkResponse.push(response) + // Resolve only when all the responses from the bulk request are collected + if (bulkResponse.length === requests.length) { + mergeResponses() + } + }, + error => { + log.error('Error during bulk list:', error) + bulkResponse.push(error) + // Resolve only when all the responses from the bulk request are collected + if (bulkResponse.length === requests.length) { + mergeResponses() } + }, + // Reuse the list options other than success and error functions + listOptions as BaseRequestOptions, + ), + ) + } + + private mergeDomains(source: OptimisedJmxDomains, target: OptimisedJmxDomains): OptimisedJmxDomains { + Object.entries(source).forEach(([domainName, domain]) => { + const targetDomain = target[domainName] + if (targetDomain) { + Object.entries(domain).forEach(([mbeanName, mbean]) => { + // Latter always overrides former + targetDomain[mbeanName] = mbean + }) + } else { + target[domainName] = domain } }) + return target } async readAttributes(mbean: string): Promise { @@ -568,7 +757,11 @@ class JolokiaService implements IJolokiaService { }, error => { log.error('Error during bulkRequest:', error) - resolve(bulkResponse) + bulkResponse.push(error) + // Resolve only when all the responses from the bulk request are collected + if (bulkResponse.length === requests.length) { + resolve(bulkResponse) + } }, ), ) @@ -616,11 +809,6 @@ class JolokiaService implements IJolokiaService { } } -type JQueryBeforeSend = (this: unknown, jqXHR: JQueryXHR, settings: unknown) => false | void -type JQueryAjaxError = (xhr: JQueryXHR, text: string, error: string) => void - -export type AttributeValues = Record - /* eslint-disable @typescript-eslint/no-unused-vars */ /** * Dummy Jolokia implementation that does nothing. @@ -660,7 +848,7 @@ class DummyJolokia implements Jolokia { } execute(mbean: string, operation: string, ...args: unknown[]) { - args?.forEach(arg => is(arg, object({ success: func() })) && arg.success?.(null)) + args?.forEach(arg => is(arg, type({ success: func() })) && arg.success(null)) return null } search(mbeanPattern: string, opts?: SearchRequestOptions) { diff --git a/packages/hawtio/src/plugins/shared/workspace.ts b/packages/hawtio/src/plugins/shared/workspace.ts index fdce0552..8c71e1f4 100644 --- a/packages/hawtio/src/plugins/shared/workspace.ts +++ b/packages/hawtio/src/plugins/shared/workspace.ts @@ -1,19 +1,15 @@ import { userService } from '@hawtiosrc/auth' -import { eventService, Logger } from '@hawtiosrc/core' +import { configManager, eventService, JmxConfig, Logger } from '@hawtiosrc/core' import { jolokiaService } from '@hawtiosrc/plugins/shared/jolokia-service' -import { isString } from '@hawtiosrc/util/objects' import { ErrorResponse, ListRequestOptions, Response } from 'jolokia.js' -import { is, object } from 'superstruct' import { pluginName } from './globals' -import { MBeanNode, MBeanTree, OptimisedJmxDomain, OptimisedJmxDomains, OptimisedMBeanInfo } from './tree' +import { MBeanNode, MBeanTree } from './tree' const log = Logger.get(`${pluginName}-workspace`) const HAWTIO_REGISTRY_MBEAN = 'hawtio:type=Registry' const HAWTIO_TREE_WATCHER_MBEAN = 'hawtio:type=TreeWatcher' -export type MBeanCache = { [propertyList: string]: string } - export interface IWorkspace { refreshTree(): Promise getTree(): Promise @@ -50,7 +46,13 @@ class Workspace implements IWorkspace { throw new Error('User needs to have logged in to use workspace') } - log.debug('Load JMX MBean tree') + const config = await this.getConfig() + if (config.workspace === false || (typeof config.workspace !== 'boolean' && config.workspace?.length === 0)) { + return MBeanTree.createEmpty(pluginName) + } + const mbeanPaths = config.workspace && typeof config.workspace !== 'boolean' ? config.workspace : [] + + log.debug('Load JMX MBean tree:', mbeanPaths) const options: ListRequestOptions = { ignoreErrors: true, error: (response: ErrorResponse) => { @@ -61,9 +63,9 @@ class Workspace implements IWorkspace { }, } try { - const value = await jolokiaService.list(options) - - const domains = this.unwindResponseWithRBACCache(value) + const domains = await (mbeanPaths.length > 0 + ? jolokiaService.sublist(mbeanPaths, options) + : jolokiaService.list(options)) log.debug('JMX tree loaded:', domains) const tree = await MBeanTree.createFromDomains(pluginName, domains) @@ -78,27 +80,9 @@ class Workspace implements IWorkspace { } } - /** - * Processes response from Jolokia LIST - if it contains "domains" and "cache" - * properties. - * - * @param value response value from Jolokia - */ - private unwindResponseWithRBACCache(value: unknown): OptimisedJmxDomains { - if (is(value, object({ domains: object(), cache: object() }))) { - // post process cached RBAC info - for (const domainName in value.domains) { - const domain = value.domains[domainName] as OptimisedJmxDomain | MBeanCache - for (const mbeanName in domain) { - const mbeanOrCache = domain[mbeanName] - if (isString(mbeanOrCache)) { - domain[mbeanName] = value.cache[mbeanOrCache] as OptimisedMBeanInfo - } - } - } - return value.domains as OptimisedJmxDomains - } - return value as OptimisedJmxDomains + private async getConfig(): Promise { + const { jmx } = await configManager.getHawtconfig() + return jmx ?? {} } /** diff --git a/packages/hawtio/src/ui/about/context.ts b/packages/hawtio/src/ui/about/context.ts index 3b907c01..e5540a41 100644 --- a/packages/hawtio/src/ui/about/context.ts +++ b/packages/hawtio/src/ui/about/context.ts @@ -1,11 +1,11 @@ -import { About, configManager } from '@hawtiosrc/core' +import { AboutConfig, configManager } from '@hawtiosrc/core' import { useEffect, useState } from 'react' /** * Custom React hook for using Hawtio About. */ export function useAbout() { - const [about, setAbout] = useState({}) + const [about, setAbout] = useState({}) const [aboutLoaded, setAboutLoaded] = useState(false) useEffect(() => {