diff --git a/packages/hawtio/src/core/config-manager.ts b/packages/hawtio/src/core/config-manager.ts index 29e4458b..de7fd4d9 100644 --- a/packages/hawtio/src/core/config-manager.ts +++ b/packages/hawtio/src/core/config-manager.ts @@ -210,7 +210,7 @@ class ConfigManager { async filterEnabledPlugins(plugins: Plugin[]): Promise { const enabledPlugins: Plugin[] = [] for (const plugin of plugins) { - if (await this.isRouteEnabled(plugin.path)) { + if ((plugin.path == null && (await plugin.isActive())) || (await this.isRouteEnabled(plugin.path!))) { enabledPlugins.push(plugin) } else { log.debug(`Plugin "${plugin.id}" disabled by hawtconfig.json`) diff --git a/packages/hawtio/src/core/core.ts b/packages/hawtio/src/core/core.ts index aaedf1fc..9b8ef528 100644 --- a/packages/hawtio/src/core/core.ts +++ b/packages/hawtio/src/core/core.ts @@ -42,9 +42,20 @@ export function isUniversalHeaderItem(item: HeaderItem): item is UniversalHeader * Internal representation of a Hawtio plugin. */ export interface Plugin { + /** + * Mandatory, unique plugin identifier + */ id: string - title: string - path: string + + /** + * Title to be displayed in left PageSidebar + */ + title?: string + + /** + * Path for plugin's main component. Optional if the plugin only contributes header elements for example + */ + path?: string /** * The order to be shown in the Hawtio sidebar. @@ -62,8 +73,11 @@ export interface Plugin { */ isLogin?: boolean + /** + * Plugins main component to be displayed + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - component: React.ComponentType + component?: React.ComponentType headerItems?: HeaderItem[] diff --git a/packages/hawtio/src/plugins/connect/ConnectionStatus.tsx b/packages/hawtio/src/plugins/connect/ConnectionStatus.tsx new file mode 100644 index 00000000..658c54a4 --- /dev/null +++ b/packages/hawtio/src/plugins/connect/ConnectionStatus.tsx @@ -0,0 +1,33 @@ +import React, { useEffect, useState } from 'react' +import { connectService } from '@hawtiosrc/plugins/shared/connect-service' +import { PluggedIcon, UnpluggedIcon } from '@patternfly/react-icons' + +/** + * Component to be displayed in HawtioHeaderToolbar for remote connection tabs + * @constructor + */ +export const ConnectionStatus: React.FunctionComponent = () => { + const [reachable, setReachable] = useState(false) + + const connectionId = connectService.getCurrentConnectionId() + const connectionName = connectService.getCurrentConnectionName() + + useEffect(() => { + const check = async () => { + const connection = await connectService.getCurrentConnection() + if (connection) { + connectService.checkReachable(connection).then(result => setReachable(result)) + } + } + check() // initial fire + const timer = setInterval(check, 20000) + return () => clearInterval(timer) + }, [connectionId]) + + return ( + <> + {reachable ? : } + {connectionName ? connectionName : ''} + + ) +} diff --git a/packages/hawtio/src/plugins/connect/connections.ts b/packages/hawtio/src/plugins/connect/connections.ts index 6bf13e6f..1e59996c 100644 --- a/packages/hawtio/src/plugins/connect/connections.ts +++ b/packages/hawtio/src/plugins/connect/connections.ts @@ -1,4 +1,5 @@ import { Connection, Connections } from '@hawtiosrc/plugins/shared' +import { connectService } from '@hawtiosrc/plugins/shared/connect-service' export const ADD = 'ADD' export const UPDATE = 'UPDATE' @@ -8,45 +9,53 @@ export const RESET = 'RESET' export type ConnectionsAction = | { type: typeof ADD; connection: Connection } - | { type: typeof UPDATE; name: string; connection: Connection } - | { type: typeof DELETE; name: string } + | { type: typeof UPDATE; id: string; connection: Connection } + | { type: typeof DELETE; id: string } | { type: typeof IMPORT; connections: Connection[] } | { type: typeof RESET } function addConnection(state: Connections, connection: Connection): Connections { - if (state[connection.name]) { - // TODO: error handling - return state + // generate ID + if (!connection.id) { + connectService.generateId(connection, state) } - return { ...state, [connection.name]: connection } -} -function updateConnection(state: Connections, name: string, connection: Connection): Connections { - if (name === connection.name) { - // normal update - if (!state[connection.name]) { - // TODO: error handling - return state - } - return { ...state, [connection.name]: connection } - } + return { ...state, [connection.id]: connection } +} - // name change - if (state[connection.name]) { - // TODO: error handling - return state - } - return Object.fromEntries( - Object.entries(state).map(([k, v]) => (k === name ? [connection.name, connection] : [k, v])), - ) +function updateConnection(state: Connections, id: string, connection: Connection): Connections { + // name change is handled correctly, because we use id + return { ...state, [id]: connection } } -function deleteConnection(state: Connections, name: string): Connections { +function deleteConnection(state: Connections, id: string): Connections { const newState = { ...state } - delete newState[name] + delete newState[id] return newState } +function importConnections(state: Connections, imported: Connection[]): Connections { + return imported.reduce((newState, conn) => { + // if there's a connection with given ID, change it, otherwise, add new one + if (!conn.id) { + // importing old format without ID + connectService.generateId(conn, state) + } + let exists = false + for (const c in state) { + if (c === conn.id) { + exists = true + break + } + } + if (exists) { + return updateConnection(state, conn.id, conn) + } else { + return addConnection(newState, conn) + } + }, state) +} + export function reducer(state: Connections, action: ConnectionsAction): Connections { switch (action.type) { case ADD: { @@ -54,16 +63,16 @@ export function reducer(state: Connections, action: ConnectionsAction): Connecti return addConnection(state, connection) } case UPDATE: { - const { name, connection } = action - return updateConnection(state, name, connection) + const { id, connection } = action + return updateConnection(state, id, connection) } case DELETE: { - const { name } = action - return deleteConnection(state, name) + const { id } = action + return deleteConnection(state, id) } case IMPORT: { const { connections } = action - return connections.reduce((newState, conn) => addConnection(newState, conn), state) + return importConnections(state, connections) } case RESET: return {} diff --git a/packages/hawtio/src/plugins/connect/discover/Discover.tsx b/packages/hawtio/src/plugins/connect/discover/Discover.tsx index c266df3c..0b8f74fe 100644 --- a/packages/hawtio/src/plugins/connect/discover/Discover.tsx +++ b/packages/hawtio/src/plugins/connect/discover/Discover.tsx @@ -143,8 +143,8 @@ export const Discover: React.FunctionComponent = () => { log.debug('Discover - connect to:', conn) // Save the connection before connecting - if (connections[conn.name]) { - dispatch({ type: UPDATE, name: conn.name, connection: conn }) + if (connections[conn.id]) { + dispatch({ type: UPDATE, id: conn.id, connection: conn }) } else { dispatch({ type: ADD, connection: conn }) } diff --git a/packages/hawtio/src/plugins/connect/discover/discover-service.ts b/packages/hawtio/src/plugins/connect/discover/discover-service.ts index 76889488..44c06888 100644 --- a/packages/hawtio/src/plugins/connect/discover/discover-service.ts +++ b/packages/hawtio/src/plugins/connect/discover/discover-service.ts @@ -3,7 +3,7 @@ import { isBlank } from '@hawtiosrc/util/strings' import { log } from '../globals' /** - * @see https://jolokia.org/reference/html/mbeans.html#mbean-discovery + * @see https://jolokia.org/reference/html/manual/jolokia_mbeans.html#mbean-discovery */ export type Agent = { // Properties from Jolokia API @@ -89,7 +89,11 @@ class DiscoverService { } agentToConnection(agent: Agent): Connection { - const conn = { ...INITIAL_CONNECTION, name: agent.agent_description ?? `discover-${agent.agent_id}` } + const conn = { + ...INITIAL_CONNECTION, + id: agent.agent_id ?? `discover-${agent.agent_id}`, + name: agent.agent_description ?? `discover-${agent.agent_id}`, + } if (!agent.url) { log.warn('No URL available to connect to agent:', agent) return conn diff --git a/packages/hawtio/src/plugins/connect/globals.ts b/packages/hawtio/src/plugins/connect/globals.ts index aab15e55..694a1ebd 100644 --- a/packages/hawtio/src/plugins/connect/globals.ts +++ b/packages/hawtio/src/plugins/connect/globals.ts @@ -1,6 +1,7 @@ import { Logger } from '@hawtiosrc/core/logging' export const pluginId = 'connect' +export const statusPluginId = 'connectStatus' export const pluginTitle = 'Connect' export const pluginPath = '/connect' export const pluginName = 'hawtio-connect' diff --git a/packages/hawtio/src/plugins/connect/index.ts b/packages/hawtio/src/plugins/connect/index.ts index 14407d45..a84d880a 100644 --- a/packages/hawtio/src/plugins/connect/index.ts +++ b/packages/hawtio/src/plugins/connect/index.ts @@ -1,14 +1,20 @@ -import { hawtio, HawtioPlugin } from '@hawtiosrc/core' +import { hawtio, HawtioPlugin, UniversalHeaderItem } from '@hawtiosrc/core' import { helpRegistry } from '@hawtiosrc/help/registry' import { preferencesRegistry } from '@hawtiosrc/preferences/registry' import { Connect } from './Connect' import { ConnectPreferences } from './ConnectPreferences' -import { pluginId, pluginPath, pluginTitle } from './globals' +import { pluginId, pluginPath, pluginTitle, statusPluginId } from './globals' import help from './help.md' -import { isActive, registerUserHooks } from './init' +import { isActive, isConnectionStatusActive, registerUserHooks } from './init' +import { ConnectionStatus } from '@hawtiosrc/plugins/connect/ConnectionStatus' const order = 11 +const connectStatusItem: UniversalHeaderItem = { + component: ConnectionStatus, + universal: true, +} + export const connect: HawtioPlugin = () => { registerUserHooks() hawtio.addPlugin({ @@ -19,6 +25,11 @@ export const connect: HawtioPlugin = () => { component: Connect, isActive, }) + hawtio.addPlugin({ + id: statusPluginId, + headerItems: [connectStatusItem], + isActive: isConnectionStatusActive, + }) helpRegistry.add(pluginId, pluginTitle, help, order) preferencesRegistry.add(pluginId, pluginTitle, ConnectPreferences, order) } diff --git a/packages/hawtio/src/plugins/connect/init.test.ts b/packages/hawtio/src/plugins/connect/init.test.ts index ac9bea6f..d6e507f4 100644 --- a/packages/hawtio/src/plugins/connect/init.test.ts +++ b/packages/hawtio/src/plugins/connect/init.test.ts @@ -18,14 +18,14 @@ describe('isActive', () => { test('/proxy/enabled returns not false & connection name is not set', async () => { fetchMock.mockResponse('true') - connectService.getCurrentConnectionName = jest.fn(() => null) + connectService.getCurrentConnectionId = jest.fn(() => null) await expect(isActive()).resolves.toEqual(true) }) test('/proxy/enabled returns not false & connection name is set', async () => { fetchMock.mockResponse('') - connectService.getCurrentConnectionName = jest.fn(() => 'test-connection') + connectService.getCurrentConnectionId = jest.fn(() => 'test-connection') await expect(isActive()).resolves.toEqual(false) }) diff --git a/packages/hawtio/src/plugins/connect/init.ts b/packages/hawtio/src/plugins/connect/init.ts index e1e8ec54..a6acca92 100644 --- a/packages/hawtio/src/plugins/connect/init.ts +++ b/packages/hawtio/src/plugins/connect/init.ts @@ -10,7 +10,18 @@ export async function isActive(): Promise { // The connect login path is exceptionally allowlisted to provide login form for // remote Jolokia endpoints requiring authentication. - return connectService.getCurrentConnectionName() === null || isConnectLogin() + return connectService.getCurrentConnectionId() === null || isConnectLogin() +} + +export async function isConnectionStatusActive(): Promise { + const proxyEnabled = await isProxyEnabled() + if (!proxyEnabled) { + return false + } + + // for "main" hawtio page, where this plugin is fully active, we don't have to show the connection status + // but for actually connected tab, we want the status in the header + return connectService.getCurrentConnectionId() !== null } async function isProxyEnabled(): Promise { diff --git a/packages/hawtio/src/plugins/connect/remote/ConnectionModal.tsx b/packages/hawtio/src/plugins/connect/remote/ConnectionModal.tsx index 81a19514..2817a776 100644 --- a/packages/hawtio/src/plugins/connect/remote/ConnectionModal.tsx +++ b/packages/hawtio/src/plugins/connect/remote/ConnectionModal.tsx @@ -59,7 +59,7 @@ export const ConnectionModal: React.FunctionComponent<{ const validate = () => { const result = { ...emptyResult } - const { name, host, port } = connection + const { id: cid, name, host, port } = connection let valid = true // Name @@ -69,12 +69,17 @@ export const ConnectionModal: React.FunctionComponent<{ validated: 'error', } valid = false - } else if (name !== input.name && connections[name]) { - result.name = { - text: `Connection name '${connection.name.trim()}' is already in use`, - validated: 'error', + } else if (name !== input.name) { + for (const id in connections) { + if (id !== cid && connections[id]?.name === name) { + result.name = { + text: `Connection name '${connection.name.trim()}' is already in use`, + validated: 'error', + } + valid = false + break + } } - valid = false } // Host @@ -86,6 +91,7 @@ export const ConnectionModal: React.FunctionComponent<{ valid = false } else if (host.indexOf(':') !== -1) { result.host = { + // TODO: IPv6 text: "Invalid character ':'", validated: 'error', } @@ -115,12 +121,15 @@ export const ConnectionModal: React.FunctionComponent<{ switch (mode) { case 'add': dispatch({ type: ADD, connection }) + setConnection(input) break case 'edit': - dispatch({ type: UPDATE, name: input.name, connection }) + dispatch({ type: UPDATE, id: input.id, connection }) + setConnection(connection) break } - clear() + setValidations(emptyResult) + onClose() } const clear = () => { diff --git a/packages/hawtio/src/plugins/connect/remote/ImportModal.tsx b/packages/hawtio/src/plugins/connect/remote/ImportModal.tsx index 86b29dbf..886c9ff0 100644 --- a/packages/hawtio/src/plugins/connect/remote/ImportModal.tsx +++ b/packages/hawtio/src/plugins/connect/remote/ImportModal.tsx @@ -43,10 +43,24 @@ export const ImportModal: React.FunctionComponent<{ } const importConnections = () => { - const connections = JSON.parse(fileContent) - dispatch({ type: 'IMPORT', connections }) - clearAndClose() - eventService.notify({ type: 'success', message: 'Connections imported successfully' }) + try { + const connections = JSON.parse(fileContent) + if (Array.isArray(connections)) { + dispatch({ type: 'IMPORT', connections }) + clearAndClose() + eventService.notify({ type: 'success', message: 'Connections imported successfully' }) + } else { + clearAndClose() + eventService.notify({ type: 'danger', message: 'Unexpected connections data format' }) + } + } catch (e) { + clearAndClose() + let msg = 'Invalid connections data format' + if (e instanceof Error) { + msg = (e as Error).message + } + eventService.notify({ type: 'danger', message: msg }) + } } return ( diff --git a/packages/hawtio/src/plugins/connect/remote/Remote.tsx b/packages/hawtio/src/plugins/connect/remote/Remote.tsx index 318589bb..63932490 100644 --- a/packages/hawtio/src/plugins/connect/remote/Remote.tsx +++ b/packages/hawtio/src/plugins/connect/remote/Remote.tsx @@ -34,8 +34,8 @@ export const Remote: React.FunctionComponent = () => { - {Object.entries(connections).map(([name, connection]) => ( - + {Object.entries(connections).map(([id, connection]) => ( + ))} @@ -94,9 +94,9 @@ const RemoteToolbar: React.FunctionComponent = () => { } const ConnectionItem: React.FunctionComponent<{ - name: string + id: string connection: Connection -}> = ({ name, connection }) => { +}> = ({ id, connection }) => { const { dispatch } = useContext(ConnectContext) const [reachable, setReachable] = useState(false) const [isDropdownOpen, setIsDropdownOpen] = useState(false) @@ -134,7 +134,7 @@ const ConnectionItem: React.FunctionComponent<{ } const deleteConnection = () => { - dispatch({ type: DELETE, name }) + dispatch({ type: DELETE, id }) handleConfirmDeleteToggle() } @@ -154,33 +154,33 @@ const ConnectionItem: React.FunctionComponent<{ , ]} > - You are about to delete the {name} connection. + You are about to delete the {connection.name} connection. ) return ( - + + {reachable ? : } , - - {name} + + {connection.name} , - + {connectService.connectionToUrl(connection)} , ]} />