Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(connect): Improve remote connections handling (closes #906) #932

Merged
merged 1 commit into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/hawtio/src/core/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ class ConfigManager {
async filterEnabledPlugins(plugins: Plugin[]): Promise<Plugin[]> {
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`)
Expand Down
20 changes: 17 additions & 3 deletions packages/hawtio/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<any>
component?: React.ComponentType<any>

headerItems?: HeaderItem[]

Expand Down
33 changes: 33 additions & 0 deletions packages/hawtio/src/plugins/connect/ConnectionStatus.tsx
Original file line number Diff line number Diff line change
@@ -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 ? <PluggedIcon color='green' /> : <UnpluggedIcon color='red' />}
{connectionName ? connectionName : ''}
</>
)
}
71 changes: 40 additions & 31 deletions packages/hawtio/src/plugins/connect/connections.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -8,62 +9,70 @@ 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: {
const { connection } = action
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 {}
Expand Down
4 changes: 2 additions & 2 deletions packages/hawtio/src/plugins/connect/discover/Discover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/hawtio/src/plugins/connect/globals.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
17 changes: 14 additions & 3 deletions packages/hawtio/src/plugins/connect/index.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions packages/hawtio/src/plugins/connect/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
13 changes: 12 additions & 1 deletion packages/hawtio/src/plugins/connect/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@ export async function isActive(): Promise<boolean> {

// 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<boolean> {
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<boolean> {
Expand Down
25 changes: 17 additions & 8 deletions packages/hawtio/src/plugins/connect/remote/ConnectionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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',
}
Expand Down Expand Up @@ -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 = () => {
Expand Down
22 changes: 18 additions & 4 deletions packages/hawtio/src/plugins/connect/remote/ImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading
Loading