From 0fb87dc30ed8adc0c4b707198935636da3b8b286 Mon Sep 17 00:00:00 2001 From: Tadayoshi Sato Date: Sun, 12 Nov 2023 16:30:25 +0900 Subject: [PATCH] feat(connect): add Discover tab - by Jolokia Discovery MBean --- .../src/plugins/connect/discover/Discover.tsx | 203 +++++++++++++++++- .../connect/discover/discover-service.ts | 90 ++++++++ .../src/plugins/connect/img/java-logo.svg | 1 + .../src/plugins/connect/img/jetty-logo.svg | 146 +++++++++++++ .../src/plugins/connect/img/tomcat-logo.svg | 1 + .../src/plugins/connect/remote/Remote.tsx | 10 +- packages/hawtio/src/plugins/logs/log-entry.ts | 16 +- .../shared/__mocks__/connect-service.ts | 5 + .../src/plugins/shared/connect-service.ts | 14 +- packages/hawtio/src/util/dates.test.ts | 18 ++ packages/hawtio/src/util/dates.ts | 15 ++ 11 files changed, 493 insertions(+), 26 deletions(-) create mode 100644 packages/hawtio/src/plugins/connect/discover/discover-service.ts create mode 100644 packages/hawtio/src/plugins/connect/img/java-logo.svg create mode 100644 packages/hawtio/src/plugins/connect/img/jetty-logo.svg create mode 100644 packages/hawtio/src/plugins/connect/img/tomcat-logo.svg create mode 100644 packages/hawtio/src/util/dates.test.ts create mode 100644 packages/hawtio/src/util/dates.ts diff --git a/packages/hawtio/src/plugins/connect/discover/Discover.tsx b/packages/hawtio/src/plugins/connect/discover/Discover.tsx index 587c054e..72102435 100644 --- a/packages/hawtio/src/plugins/connect/discover/Discover.tsx +++ b/packages/hawtio/src/plugins/connect/discover/Discover.tsx @@ -1,5 +1,204 @@ -import React from 'react' +import { HawtioEmptyCard, HawtioLoadingCard, connectService } from '@hawtiosrc/plugins/shared' +import { formatTimestamp } from '@hawtiosrc/util/dates' +import { + Button, + Card, + CardActions, + CardBody, + CardHeader, + CardTitle, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Gallery, + SearchInput, + Text, + Toolbar, + ToolbarContent, + ToolbarItem, +} from '@patternfly/react-core' +import React, { useContext, useEffect, useState } from 'react' +import { ADD, UPDATE } from '../connections' +import { ConnectContext } from '../context' +import { log } from '../globals' +import javaLogo from '../img/java-logo.svg' +import jettyLogo from '../img/jetty-logo.svg' +import tomcatLogo from '../img/tomcat-logo.svg' +import { Agent, discoverService } from './discover-service' export const Discover: React.FunctionComponent = () => { - return null + const [discoverable, setDiscoverable] = useState(false) + const [discovering, setDiscovering] = useState(true) + const [agents, setAgents] = useState([]) + + // Filter + const [filter, setFilter] = useState('') + const [filteredAgents, setFilteredAgents] = useState([]) + + useEffect(() => { + if (!discovering) { + return + } + + const isDiscoverable = async () => { + const discoverable = await discoverService.isDiscoverable() + setDiscoverable(discoverable) + setDiscovering(false) + } + isDiscoverable() + + setDiscovering(true) + const discoverAgents = async () => { + const agents = await discoverService.discoverAgents() + log.debug('Discover - agents:', agents) + setAgents(agents) + setFilteredAgents(agents) + setDiscovering(false) + } + discoverAgents() + }, [discovering]) + + if (discovering) { + return + } + + if (!discoverable) { + return + } + + const applyFilter = () => { + const filtered = agents.filter(agent => + Object.values(agent).some(value => typeof value === 'string' && value.includes(filter)), + ) + log.debug('Discover - apply filter:', filter, filtered) + setFilteredAgents(filtered) + } + + const clearFilter = () => { + setFilter('') + setFilteredAgents(agents) + } + + const refresh = () => { + setDiscovering(true) + } + + const toolbar = ( + + + + setFilter(value)} + onSearch={applyFilter} + onClear={clearFilter} + /> + + + + + + + + ) + + return ( + + {toolbar} + + {filteredAgents.map((agent, index) => ( + + ))} + + + ) +} + +const PRODUCT_LOGO: Record = { + jetty: jettyLogo, + tomcat: tomcatLogo, + generic: javaLogo, +} + +export const AgentCard: React.FunctionComponent<{ agent: Agent }> = ({ agent }) => { + const { connections, dispatch } = useContext(ConnectContext) + + const connect = () => { + const conn = discoverService.toConnection(agent) + log.debug('Discover - connect to:', conn) + + // Save the connection before connecting + if (connections[conn.name]) { + dispatch({ type: UPDATE, name: conn.name, connection: conn }) + } else { + dispatch({ type: ADD, connection: conn }) + } + + connectService.connect(conn) + } + + const productLogo = (agent: Agent) => { + return PRODUCT_LOGO[agent.server_product?.toLowerCase() ?? 'generic'] ?? PRODUCT_LOGO.generic + } + + return ( + + + {agent.server_product} + {discoverService.hasName(agent) && ( + + {agent.server_vendor} {agent.server_product} {agent.server_version} + + )} + {agent.command && ( + + {agent.command} + + )} + + + + + + + + Agent ID + {agent.agent_id} + + + Agent Version + {agent.agent_version} + + + Agent Description + {agent.agent_description} + + {agent.startTime && ( + + JVM Started + {formatTimestamp(new Date(agent.startTime))} + + )} + {agent.url && ( + + Agent URL + + + {agent.url} + + + + )} + + + + ) } diff --git a/packages/hawtio/src/plugins/connect/discover/discover-service.ts b/packages/hawtio/src/plugins/connect/discover/discover-service.ts new file mode 100644 index 00000000..b690c78b --- /dev/null +++ b/packages/hawtio/src/plugins/connect/discover/discover-service.ts @@ -0,0 +1,90 @@ +import { Connection, INITIAL_CONNECTION, connectService, jolokiaService, workspace } from '@hawtiosrc/plugins/shared' +import { isBlank } from '@hawtiosrc/util/strings' +import { log } from '../globals' + +/** + * @see https://jolokia.org/reference/html/mbeans.html#mbean-discovery + */ +export type Agent = { + // Properties from Jolokia API + agent_id?: string + agent_description?: string + agent_version?: string + url?: string + secured?: boolean + server_vendor?: string + server_product?: string + server_version?: string + + // Properties that Hawtio attaches + startTime?: number + command?: string +} + +class DiscoverService { + async isDiscoverable(): Promise { + return (await this.hasLocalMBean()) || (await this.hasDiscoveryMBean()) + } + + private hasLocalMBean(): Promise { + return workspace.treeContainsDomainAndProperties('hawtio', { type: 'JVMList' }) + } + + private hasDiscoveryMBean(): Promise { + return workspace.treeContainsDomainAndProperties('jolokia', { type: 'Discovery' }) + } + + async discoverAgents(): Promise { + // Jolokia 1.x: 'jolokia:type=Discovery' + // Jolokia 2.x: 'jolokia:type=Discovery,agent=...' + const discoveryMBean = (await workspace.findMBeans('jolokia', { type: 'Discovery' }))[0] + if (discoveryMBean && discoveryMBean.objectName) { + // Use 10 sec timeout + const agents = (await jolokiaService.execute(discoveryMBean.objectName, 'lookupAgentsWithTimeout(int)', [ + 10 * 1000, + ])) as Agent[] + await this.fetchMoreJvmDetails(agents) + return agents + } + + return [] + } + + private async fetchMoreJvmDetails(agents: Agent[]) { + for (const agent of agents) { + if (!agent.url || agent.secured) { + continue + } + // One-off Jolokia instance to connect to the agent + const jolokia = connectService.createJolokia(this.toConnection(agent)) + agent.startTime = jolokia.getAttribute('java.lang:type=Runtime', 'StartTime') as number + if (!this.hasName(agent)) { + // Only look for command if agent vm is not known + agent.command = jolokia.getAttribute('java.lang:type=Runtime', 'SystemProperties', 'sun.java.command') as string + } + } + } + + toConnection(agent: Agent): Connection { + const conn = { ...INITIAL_CONNECTION, name: agent.agent_description ?? `discover-${agent.agent_id}` } + if (!agent.url) { + log.warn('No URL available to connect to agent:', agent) + return conn + } + + const url = new URL(agent.url) + conn.scheme = url.protocol.substring(0, url.protocol.length - 1) // strip last ':' + conn.host = url.hostname + conn.port = parseInt(url.port) + conn.path = url.pathname + + log.debug('Discover - connection from agent:', conn) + return conn + } + + hasName(agent: Agent): boolean { + return [agent.server_vendor, agent.server_product, agent.server_version].every(s => !isBlank(s)) + } +} + +export const discoverService = new DiscoverService() diff --git a/packages/hawtio/src/plugins/connect/img/java-logo.svg b/packages/hawtio/src/plugins/connect/img/java-logo.svg new file mode 100644 index 00000000..bd9542c5 --- /dev/null +++ b/packages/hawtio/src/plugins/connect/img/java-logo.svg @@ -0,0 +1 @@ + diff --git a/packages/hawtio/src/plugins/connect/img/jetty-logo.svg b/packages/hawtio/src/plugins/connect/img/jetty-logo.svg new file mode 100644 index 00000000..3e7e3fd2 --- /dev/null +++ b/packages/hawtio/src/plugins/connect/img/jetty-logo.svg @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/hawtio/src/plugins/connect/img/tomcat-logo.svg b/packages/hawtio/src/plugins/connect/img/tomcat-logo.svg new file mode 100644 index 00000000..487a0d70 --- /dev/null +++ b/packages/hawtio/src/plugins/connect/img/tomcat-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/hawtio/src/plugins/connect/remote/Remote.tsx b/packages/hawtio/src/plugins/connect/remote/Remote.tsx index 17b52696..318589bb 100644 --- a/packages/hawtio/src/plugins/connect/remote/Remote.tsx +++ b/packages/hawtio/src/plugins/connect/remote/Remote.tsx @@ -1,4 +1,4 @@ -import { Connection, connectService } from '@hawtiosrc/plugins/shared/connect-service' +import { Connection, connectService, INITIAL_CONNECTION } from '@hawtiosrc/plugins/shared/connect-service' import { Button, ButtonVariant, @@ -60,13 +60,7 @@ const RemoteToolbar: React.FunctionComponent = () => { connectService.export(connections) } - const initialConnection: Connection = { - name: '', - scheme: 'http', - host: '', - port: 8080, - path: '/hawtio/jolokia', - } + const initialConnection = { ...INITIAL_CONNECTION } return ( diff --git a/packages/hawtio/src/plugins/logs/log-entry.ts b/packages/hawtio/src/plugins/logs/log-entry.ts index d4ca5154..84a31fd8 100644 --- a/packages/hawtio/src/plugins/logs/log-entry.ts +++ b/packages/hawtio/src/plugins/logs/log-entry.ts @@ -1,3 +1,4 @@ +import { formatTimestamp } from '@hawtiosrc/util/dates' import { isEmpty } from '@hawtiosrc/util/objects' export type LogEvent = { @@ -58,22 +59,9 @@ export class LogEntry { getTimestamp(): string { const { seq, timestamp } = this.event - const padZero = (n: number, len = 2) => String(n).padStart(len, '0') - // If there is a seq in the log event, then it's the timestamp with milliseconds. const date = seq ? new Date(seq) : new Date(timestamp) - const year = date.getFullYear() - const month = padZero(date.getMonth() + 1) - const day = padZero(date.getDate()) - const hours = padZero(date.getHours()) - const minutes = padZero(date.getMinutes()) - const seconds = padZero(date.getSeconds()) - if (seq) { - const millis = padZero(date.getMilliseconds(), 3) - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${millis}` - } else { - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` - } + return formatTimestamp(date, !isNaN(seq)) } match(filter: LogFilter): boolean { diff --git a/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts b/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts index 18c825fb..32eb0250 100644 --- a/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts +++ b/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts @@ -1,3 +1,4 @@ +import Jolokia from 'jolokia.js' import { Connection, ConnectionCredentials, @@ -60,6 +61,10 @@ class MockConnectService implements IConnectService { // no-op } + createJolokia(connection: Connection, checkCredentials?: boolean): Jolokia { + return new Jolokia('/jolokia') + } + getJolokiaUrl(connection: Connection): string { return '' } diff --git a/packages/hawtio/src/plugins/shared/connect-service.ts b/packages/hawtio/src/plugins/shared/connect-service.ts index df35e8fe..1e4fa2e0 100644 --- a/packages/hawtio/src/plugins/shared/connect-service.ts +++ b/packages/hawtio/src/plugins/shared/connect-service.ts @@ -15,13 +15,22 @@ export type Connection = { port: number path: string - useProxy?: boolean jolokiaUrl?: string username?: string password?: string + + // TODO: check if it is used token?: string } +export const INITIAL_CONNECTION: Connection = { + name: '', + scheme: 'http', + host: 'localhost', + port: 8080, + path: '/hawtio/jolokia', +} as const + export type ConnectionTestResult = { ok: boolean message: string @@ -54,6 +63,7 @@ export interface IConnectService { connect(connection: Connection): void login(username: string, password: string): Promise redirect(): void + createJolokia(connection: Connection, checkCredentials?: boolean): Jolokia getJolokiaUrl(connection: Connection): string getJolokiaUrlFromName(name: string): string | null getLoginPath(): string @@ -241,7 +251,7 @@ class ConnectService implements IConnectService { /** * Create a Jolokia instance with the given connection. */ - private createJolokia(connection: Connection, checkCredentials = false): Jolokia { + createJolokia(connection: Connection, checkCredentials = false): Jolokia { if (checkCredentials) { return new Jolokia({ url: this.getJolokiaUrl(connection), diff --git a/packages/hawtio/src/util/dates.test.ts b/packages/hawtio/src/util/dates.test.ts new file mode 100644 index 00000000..1d9bb9fe --- /dev/null +++ b/packages/hawtio/src/util/dates.test.ts @@ -0,0 +1,18 @@ +import timezoneMock from 'timezone-mock' +import { formatTimestamp } from './dates' + +describe('dates', () => { + beforeAll(() => { + timezoneMock.register('UTC') + }) + + afterAll(() => { + timezoneMock.unregister() + }) + + test('formatTimestamp', () => { + expect(formatTimestamp(new Date(1693631904253))).toEqual('2023-09-02 05:18:24') + expect(formatTimestamp(new Date(1693631904253), true)).toEqual('2023-09-02 05:18:24.253') + expect(formatTimestamp(new Date('2023-09-02T14:18:24+09:00'))).toEqual('2023-09-02 05:18:24') + }) +}) diff --git a/packages/hawtio/src/util/dates.ts b/packages/hawtio/src/util/dates.ts new file mode 100644 index 00000000..76a08de1 --- /dev/null +++ b/packages/hawtio/src/util/dates.ts @@ -0,0 +1,15 @@ +export function formatTimestamp(date: Date, millis = false): string { + const padZero = (n: number, len = 2) => String(n).padStart(len, '0') + + const year = date.getFullYear() + const month = padZero(date.getMonth() + 1) + const day = padZero(date.getDate()) + const hours = padZero(date.getHours()) + const minutes = padZero(date.getMinutes()) + const seconds = padZero(date.getSeconds()) + if (!millis) { + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` + } + const milliseconds = padZero(date.getMilliseconds(), 3) + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}` +}