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

fix(17700): Improve the UX of the metrics panel in the workspace #204

Merged
merged 34 commits into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
566d4ed
feat(17700): add metric name parser
vanch3d Nov 17, 2023
571569f
refactor(17700): change the theme
vanch3d Nov 17, 2023
934b6cc
refactor(17700): refactor sample component
vanch3d Nov 17, 2023
deab041
refactor(17700): refactor metrics selector
vanch3d Nov 17, 2023
531edc4
refactor(17700): add an optional configuration panel for editing metrics
vanch3d Nov 17, 2023
4b6b32f
refactor(17700): refactor metrics component
vanch3d Nov 17, 2023
151431f
refactor(17700): fix type and a little bit of cleaning
vanch3d Nov 17, 2023
5a4c227
refactor(17700): fix type
vanch3d Nov 17, 2023
861591f
refactor(17700): fix type
vanch3d Nov 18, 2023
f6cc0c3
refactor(17700): fix reset
vanch3d Nov 18, 2023
86a4567
refactor(17700): moved metrics into its own module
vanch3d Nov 18, 2023
444a2bc
fix(17700): add type
vanch3d Nov 18, 2023
9c0eb17
test(17700): add tests
vanch3d Nov 18, 2023
78f9073
refactor(17700): add "copy-to-clipboard" support
vanch3d Nov 20, 2023
9c28d54
refactor(17700): add "copy-to-clipboard" support
vanch3d Nov 20, 2023
ae66deb
refactor(17700): update translations
vanch3d Nov 20, 2023
ce9915d
fix(17700): fix typos
vanch3d Nov 20, 2023
576a3e9
fix(17700): fix parsing of metric name
vanch3d Nov 20, 2023
2c18ff2
fix(17700): fix default metric for bridge
vanch3d Nov 20, 2023
981bdbf
fix(17700): fix translations
vanch3d Nov 20, 2023
0362635
feat(17700): add support for clipboard copy
vanch3d Nov 20, 2023
b73e6d0
fix(17700): fix metric name display
vanch3d Nov 20, 2023
7994070
test(17700): add test for metric name parsing
vanch3d Nov 20, 2023
2902031
test(17700): fix mocks
vanch3d Nov 20, 2023
bd29a58
test(17700): add test for the sample and the selector components
vanch3d Nov 21, 2023
ace2447
test(17700): fix ids and add tests for the selector
vanch3d Nov 21, 2023
f2ef25e
refactor(17700): split component
vanch3d Nov 21, 2023
c8655cc
fix(17700): fix number display
vanch3d Nov 21, 2023
323bfc9
test(17700): add tests for the stat renderer
vanch3d Nov 21, 2023
e2dc42f
test(17700): add tests for the stat renderer
vanch3d Nov 21, 2023
ea9e080
test(17700): refactor tests for sample container
vanch3d Nov 21, 2023
7d9f2ad
test(17700): fix test
vanch3d Nov 21, 2023
29b968a
test(17700): fix translation
vanch3d Nov 21, 2023
ac64cf6
fix(17700): only the metric name is copied
vanch3d Nov 22, 2023
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
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Metric } from '@/api/__generated__'
import { DataPoint, Metric } from '@/api/__generated__'
import { mockBridgeId } from '@/api/hooks/useGetBridges/__handlers__'
import { MOCK_ADAPTER_ID } from '@/__test-utils__/mocks.ts'

export const mockMetrics: Array<Metric> = [
export const MOCK_METRICS: Array<Metric> = [
{ name: `com.hivemq.edge.bridge.${mockBridgeId}.forward.publish.count` },
{ name: `com.hivemq.edge.bridge.${mockBridgeId}.forward.publish.excluded.count` },
{ name: `com.hivemq.edge.bridge.${mockBridgeId}.forward.publish.failed.count` },
Expand Down Expand Up @@ -44,3 +44,18 @@ export const mockMetrics: Array<Metric> = [
{ name: `com.hivemq.edge.subscriptions.overall.current` },
{ name: `com.hivemq.messages.governance.count` },
]

// main metrics
export const MOCK_METRIC_BRIDGE = MOCK_METRICS[0].name as string
export const MOCK_METRIC_ADAPTER = MOCK_METRICS[28].name as string

// not in use at the moment
export const MOCK_METRIC_MESSAGE = MOCK_METRICS[10].name as string
export const MOCK_METRIC_NETWORKING = MOCK_METRICS[20].name as string
export const MOCK_METRIC_MQTT = MOCK_METRICS[19].name as string
export const MOCK_METRIC_PERSISTENCE = MOCK_METRICS[24].name as string

export const MOCK_METRIC_SAMPLE: DataPoint = {
sampleTime: '2023-11-18T00:00:00Z',
value: 50000,
}
30 changes: 25 additions & 5 deletions hivemq-edge/src/frontend/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -656,17 +656,37 @@
}
},
"metrics": {
"protocolAdapters": {
"command": {
"showSelector": {
"ariaLabel": "Show/hide the metrics selector"
},
"remove": {
"ariaLabel": "Remove from panel"
},
"copy": {
"ariaLabel": "Copy the metric to the clipboard",
"prompt": "The metric information has been copied to the clipboard"
}
},
"protocol-adapters": {
"connection.success.count": "Connection success (count)",
"connection.failed.count": "Connection failed (count)",
"read.publish.failed.count": "Publish failed (count)",
"read.publish.success.count": "Publish success (count)"
},
"bridge": {
"publish.loop-hops-exceeded.count": "",
"publish.count": "",
"publish.failed.count": "",
"publish.excluded.count": ""
"forward.publish.loop-hops-exceeded.count": "[Forward] Loop hops exceeded (count)",
"forward.publish.count": "[Forward] Publish success (count)",
"forward.publish.failed.count": "[Forward] Publish failed (count)",
"forward.publish.excluded.count": "[Forward] Publish excluded (count)",
"local.publish.loop-hops-exceeded.count": "[Local] Loop hops exceeded (count)",
"local.publish.count": "[Local] Publish success (count)",
"local.publish.failed.count": "[Local] Publish failed (count)",
"local.publish.excluded.count": "[Local] Publish excluded (count)",
"local.publish.received.count": "[Local] Publish received (count)",
"local.publish.no-subscriber-present.count": "[Local] No subscriber (count)",
"remote.publish.received.count": "[Remote] Publish received (count)",
"remote.publish.loop-hops-exceeded.count": "[Remote] Loop hops exceeded (count)"
},
"messages.retained.current": "Message retained"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('NodePropertyDrawer', () => {
cy.get('@onClose').should('have.been.calledOnce')

// check that the metrics is there
cy.get('label').should('contain.text', 'Select a metric to display')
cy.getByTestId('metrics-toggle').should('be.visible')

// check that the event log is there
cy.get('p').should('contain.text', 'The 5 most recent events for adapter idAdapter')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { MdOutlineEventNote } from 'react-icons/md'
import { Adapter, Bridge } from '@/api/__generated__'
import { DeviceTypes } from '@/api/types/api-devices.ts'
import ConnectionController from '@/components/ConnectionController/ConnectionController.tsx'
import Metrics from '@/modules/Welcome/components/Metrics.tsx'
import Metrics from '@/modules/Metrics/Metrics.tsx'
import EventLogTable from '@/modules/EventLog/components/table/EventLogTable.tsx'
import { NodeTypes } from '@/modules/EdgeVisualisation/types.ts'

Expand All @@ -44,15 +44,19 @@ const NodePropertyDrawer: FC<NodePropertyDrawerProps> = ({ isOpen, selectedNode,
return (
<Drawer isOpen={isOpen} placement="right" size={'md'} onClose={onClose}>
<DrawerOverlay />
<DrawerContent aria-label={t('workspace.observability.header') as string}>
<DrawerContent aria-label={t('workspace.property.header', { context: selectedNode.type }) as string}>
<DrawerCloseButton />
<DrawerHeader>
<Text> {t('workspace.property.header', { context: selectedNode.type })}</Text>
</DrawerHeader>
<DrawerBody>
<VStack gap={4} alignItems={'stretch'}>
<NodeNameCard selectedNode={selectedNode} />
<Metrics initMetrics={getDefaultMetricsFor(selectedNode)} />
<Metrics
type={selectedNode.type as NodeTypes}
id={selectedNode.data.id}
initMetrics={getDefaultMetricsFor(selectedNode)}
/>
<Card size={'sm'}>
<CardHeader>
<Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export const getDefaultMetricsFor = (node: Node): string[] => {
if (NodeTypes.BRIDGE_NODE === node.type) {
const data = node.data as Bridge
const suffix = 'com.hivemq.edge.bridge'
const prefix = 'publish.count'
const prefix = 'forward.publish.count'
return [`${suffix}.${data.id}.${prefix}`]
}
return [] as string[]
Expand Down
78 changes: 78 additions & 0 deletions hivemq-edge/src/frontend/src/modules/Metrics/Metrics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { FC, useState } from 'react'
import { Card, CardBody, CardHeader, Flex, IconButton, SimpleGrid, useDisclosure, useToast } from '@chakra-ui/react'
import { TbLayoutNavbarExpand, TbLayoutNavbarCollapse } from 'react-icons/tb'
import { useTranslation } from 'react-i18next'

import { NodeTypes } from '@/modules/EdgeVisualisation/types.ts'

import config from '@/config'

import MetricNameSelector from './components/MetricNameSelector.tsx'
import Sample from './components/Sample.tsx'

interface MetricsProps {
type: NodeTypes
id: string
initMetrics?: string[]
}

const Metrics: FC<MetricsProps> = ({ id, initMetrics }) => {
const [metrics, setMetrics] = useState<string[]>(initMetrics || [])
const showSelector = config.features.METRICS_SELECT_PANEL
const { isOpen, onOpen, onClose } = useDisclosure()
const toast = useToast()
const { t } = useTranslation()

const handleCopyMetric = (metricName: string, timestamp: string) => {
const id = `${metricName}-${timestamp}`
navigator.clipboard.writeText(metricName).then(() => {
if (!toast.isActive(id))
toast({ id, duration: 3000, variant: 'subtle', description: t('metrics.command.copy.prompt') })
})
}

return (
<Card size={'sm'}>
{showSelector && (
<CardHeader>
<Flex justifyContent={'flex-end'}>
<IconButton
data-testid="metrics-toggle"
variant={'ghost'}
size={'sm'}
aria-label={t('metrics.command.showSelector.ariaLabel')}
fontSize={'20px'}
icon={!isOpen ? <TbLayoutNavbarExpand /> : <TbLayoutNavbarCollapse />}
onClick={() => (isOpen ? onClose() : onOpen())}
/>
</Flex>
{isOpen && (
<MetricNameSelector
filter={id}
selectedMetrics={metrics}
onSubmit={(value) => {
const { selectedTopic } = value
setMetrics((old) => [...old, selectedTopic.value])
}}
/>
)}
</CardHeader>
)}

<CardBody>
<SimpleGrid spacing={4} templateColumns="repeat(auto-fill, minmax(200px, 1fr))">
{metrics.map((e) => (
<Sample
key={e}
metricName={e}
onClose={() => setMetrics((old) => old.filter((x) => x !== e))}
onClipboardCopy={handleCopyMetric}
/>
))}
</SimpleGrid>
</CardBody>
</Card>
)
}

export default Metrics
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/// <reference types="cypress" />

import { MetricList } from '@/api/__generated__'
import { MOCK_METRICS } from '@/api/hooks/useGetMetrics/__handlers__'
import { mockBridgeId } from '@/api/hooks/useGetBridges/__handlers__'

import MetricNameSelector from './MetricNameSelector.tsx'

describe('MetricNameSelector', () => {
beforeEach(() => {
cy.viewport(800, 800)
cy.intercept('/api/v1/metrics', { items: MOCK_METRICS } as MetricList).as('getMetrics')
})

it('should render the selector', () => {
const onSubmit = cy.stub().as('onSubmit')
cy.mountWithProviders(
<MetricNameSelector
filter={mockBridgeId}
onSubmit={onSubmit}
selectedMetrics={[MOCK_METRICS[0].name as string, MOCK_METRICS[0].name as string]}
/>
)
cy.get('div#react-select-2-placeholder').should('contain.text', 'Select...')
cy.get("button[type='submit']").should('be.disabled')
cy.get('input#metrics-select').click()

cy.get('div#react-select-2-listbox').find("[role='option']").should('have.length', 10)
cy.get('div#react-select-2-listbox')
.find("[role='option']")
.eq(3)
.should('have.text', '[Forward] Publish success (count)')
.should('have.attr', 'aria-disabled', 'true')

cy.get('div#metrics-container').should('contain.text', '[Local] Publish failed (count)')
cy.get('div#react-select-2-listbox').find("[role='option']").eq(5).click()
cy.get("button[type='submit']").should('not.be.disabled')
cy.get("button[type='submit']").click()
cy.get('@onSubmit').should('have.been.calledWithMatch', {
selectedTopic: {
label: '[Local] Publish failed (count)',
value: 'com.hivemq.edge.bridge.bridge-id-01.local.publish.failed.count',
isDisabled: false,
},
})
})

it('should be accessible', () => {
cy.injectAxe()
cy.mountWithProviders(
<MetricNameSelector
filter={mockBridgeId}
onSubmit={cy.stub()}
selectedMetrics={[MOCK_METRICS[0].name as string, MOCK_METRICS[0].name as string]}
/>
)
cy.checkAccessibility()
cy.percySnapshot('Component: MetricNameSelector')
})
})
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import { FC, useEffect, useMemo } from 'react'
import { Select } from 'chakra-react-select'
import { Box, Button, Flex, FormControl, FormLabel } from '@chakra-ui/react'
import { Controller, SubmitHandler, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Box, Button, Flex, FormControl, FormLabel } from '@chakra-ui/react'
import { BiAddToQueue } from 'react-icons/bi'

import { useGetMetrics } from '@/api/hooks/useGetMetrics/useGetMetrics.tsx'
import { BiAddToQueue } from 'react-icons/bi'
import { extractMetricInfo } from '../utils/metrics-name.utils.ts'

interface MetricNameOption {
label: string
value: string
isDisabled?: boolean
}

interface MetricNameSelectorForm {
myTopic: string
selectedTopic: MetricNameOption
}

interface MetricNameSelectorProps {
onSubmit: SubmitHandler<MetricNameSelectorForm>
selectedMetrics: string[]
filter: string
}

const MetricNameSelector: FC<MetricNameSelectorProps> = ({ onSubmit }) => {
const MetricNameSelector: FC<MetricNameSelectorProps> = ({ onSubmit, filter, selectedMetrics }) => {
const { t } = useTranslation()
const { data } = useGetMetrics()
const {
Expand All @@ -26,15 +34,26 @@ const MetricNameSelector: FC<MetricNameSelectorProps> = ({ onSubmit }) => {
formState: { isValid, isSubmitted },
} = useForm<MetricNameSelectorForm>()

const sortedItems: string[] = useMemo(() => {
if (!data?.items) return []
return (
data.items.sort((a, b) => (a.name as string).localeCompare(b.name as string)).map((e) => e.name as string) || ''
)
}, [data])
const sortedItems: MetricNameOption[] = useMemo(() => {
if (!data || !data.items) return []

return data.items
.filter((e) => e.name && e.name.includes(filter))
.map((e) => {
const { device, suffix } = extractMetricInfo(e.name as string)
return {
label: t(`metrics.${device}.${suffix}`),
value: e.name as string,
isDisabled: selectedMetrics?.includes(e.name as string),
}
})
.sort((a, b) => a.label.localeCompare(b.label))
}, [data, filter, selectedMetrics, t])

useEffect(() => {
if (isSubmitted) reset()
if (isSubmitted) {
reset()
}
}, [isSubmitted, reset])

return (
Expand All @@ -44,11 +63,11 @@ const MetricNameSelector: FC<MetricNameSelectorProps> = ({ onSubmit }) => {
style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}
>
<FormControl>
<FormLabel htmlFor={'tlsConfiguration.protocols'}>{t('welcome.metrics.select')}</FormLabel>
<Flex>
<FormLabel htmlFor={'metrics-select'}>{t('welcome.metrics.select')}</FormLabel>
<Flex gap={2}>
<Box flex={1}>
<Controller
name={'myTopic'}
name={'selectedTopic'}
control={control}
rules={{
required: true,
Expand All @@ -58,10 +77,11 @@ const MetricNameSelector: FC<MetricNameSelectorProps> = ({ onSubmit }) => {
return (
<Select
{...rest}
value={{ label: value, value: value }}
inputId={'tlsConfiguration.protocols'}
onChange={(values) => onChange(values?.value)}
options={sortedItems.map((e) => ({ label: e, value: e }))}
id={'metrics-container'}
inputId={'metrics-select'}
value={value || null}
onChange={(values) => onChange(values)}
options={sortedItems}
isClearable={true}
isMulti={false}
isSearchable={true}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/// <reference types="cypress" />

import { MOCK_METRIC_SAMPLE, MOCK_METRICS } from '@/api/hooks/useGetMetrics/__handlers__'
import Sample from './Sample.tsx'

describe('Sample', () => {
beforeEach(() => {
cy.viewport(800, 800)
cy.intercept(`/api/v1/metrics/**`, MOCK_METRIC_SAMPLE).as('getSample')
})

it('should render the bridge component', () => {
const onClose = cy.stub().as('onClose')
const onClipboardCopy = cy.stub().as('onClipboardCopy')
cy.mountWithProviders(
<Sample metricName={MOCK_METRICS[0].name} onClose={onClose} onClipboardCopy={onClipboardCopy} />
)

cy.get('dd').should('contain.text', '50,000')

cy.getByTestId('metrics-remove').click()
cy.get('@onClose').should('have.been.calledOnce')

cy.getByTestId('metrics-copy').click()
cy.get('@onClipboardCopy').should(
'have.been.calledWith',
'com.hivemq.edge.bridge.bridge-id-01.forward.publish.count',
'2023-11-18T00:00:00Z',
50000
)
})
})
Loading
Loading