diff --git a/frontend/src/component/counters/ExploreCounters/ExploreCounterChart.tsx b/frontend/src/component/counters/ExploreCounters/ExploreCounterChart.tsx new file mode 100644 index 0000000000..24299c8603 --- /dev/null +++ b/frontend/src/component/counters/ExploreCounters/ExploreCounterChart.tsx @@ -0,0 +1,227 @@ +import { Line } from 'react-chartjs-2'; +import type { IMetricsCount } from 'hooks/api/getters/useMetricCounters/useMetricCounters'; +import type {} from 'chart.js'; +import type { Theme } from '@mui/material/styles/createTheme'; +import { + useLocationSettings, + type ILocationSettings, +} from 'hooks/useLocationSettings'; +import { formatDateHM } from 'utils/formatDate'; +import { useMemo } from 'react'; +import { useTheme } from '@mui/material'; +import { CyclicIterator } from 'utils/cyclicIterator'; +import { + CategoryScale, + Chart as ChartJS, + type ChartDataset, + type ChartOptions, + Legend, + LinearScale, + LineElement, + PointElement, + TimeScale, + Title, + Tooltip, +} from 'chart.js'; + +type ChartDatasetType = ChartDataset<'line', IPoint[]>; + +export interface IPoint { + x: Date; + y: number; +} + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + TimeScale, + Legend, + Tooltip, + Title, +); + +export const createChartOptions = ( + theme: Theme, + metrics: IMetricsCount[], + hoursBack: number, + locationSettings: ILocationSettings, +): ChartOptions<'line'> => { + return { + locale: locationSettings.locale, + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false, + }, + color: theme.palette.text.secondary, + scales: { + y: { + type: 'linear', + title: { + display: true, + text: 'Count', + color: theme.palette.text.secondary, + }, + suggestedMin: 0, + ticks: { precision: 0, color: theme.palette.text.secondary }, + grid: { + color: theme.palette.divider, + borderColor: theme.palette.divider, + }, + }, + x: { + type: 'time', + time: { unit: 'minute' }, + grid: { display: false }, + ticks: { + callback: (_, i, data) => + formatDateHM(data[i].value, locationSettings.locale), + color: theme.palette.text.secondary, + }, + }, + }, + }; +}; + +export function buildMetricKey({ name, labels }: IMetricsCount): string { + if (!labels || Object.keys(labels).length === 0) { + return encodeURIComponent(name); + } + + const labelParts = Object.entries(labels) + .filter(([, v]) => v != null && v !== '') // robustness: ignore empties + .sort(([a], [b]) => a.localeCompare(b)) // robustness: deterministic order + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`); + + return [encodeURIComponent(name), ...labelParts].join('_'); +} + +const createChartPoint = (value: number, timestamp: Date): IPoint => { + return { + x: timestamp, + y: value, + }; +}; + +class ItemPicker { + private items: CyclicIterator; + private picked: Map = new Map(); + constructor(items: T[]) { + this.items = new CyclicIterator(items); + } + + public pick(key: string): T { + if (!this.picked.has(key)) { + this.picked.set(key, this.items.next()); + } + return this.picked.get(key)!; + } +} + +const toValues = ( + metrics: IMetricsCount[], + selectedLabels: string[], + selectedValues: string[], +) => { + return metrics.reduce( + (acc, metric) => { + for (const [key, value] of Object.entries(metric.labels)) { + const labelKey = buildMetricKey(metric); + if (!acc[labelKey]) { + acc[labelKey] = []; + } + acc[labelKey].push({ + count: metric.value, + timestamp: metric.timestamp, + }); + } + return acc; + }, + {} as Record, + ); +}; + +export const createChartData = ( + theme: Theme, + metrics: IMetricsCount[], + selectedLabels: string[], + selectedLabelValues: string[], + locationSettings: ILocationSettings, +): ChartDatasetType[] => { + const colorPicker = new ItemPicker([ + theme.palette.success, + theme.palette.error, + theme.palette.primary, + theme.palette.warning, + theme.palette.info, + theme.palette.secondary, + ]); + const labelValues = toValues(metrics, selectedLabels, selectedLabelValues); + const datasets = Object.entries(labelValues).map(([key, values]) => { + const color = colorPicker.pick(key); + return { + label: key, + data: values.map((value) => + createChartPoint(value.count, value.timestamp), + ), + borderColor: color.main, + backgroundColor: color.main, + fill: false, + tension: 0.1, + pointRadius: 0, + pointHoverRadius: 5, + }; + }); + return datasets; +}; + +export const ExploreCounterChart = ({ + selectedLabels, + selectedLabelValues, + filteredCounters, + counter, + setCounter, +}: { + selectedLabels: string[]; + selectedLabelValues: string[]; + filteredCounters: IMetricsCount[]; + counter: string | undefined; + setCounter: (counter: string) => void; +}) => { + const theme = useTheme(); + const { locationSettings } = useLocationSettings(); + + const options = useMemo(() => { + return createChartOptions( + theme, + filteredCounters, + 10, + locationSettings, + ); + }, [theme, filteredCounters, locationSettings]); + + const data = useMemo(() => { + return { + datasets: createChartData( + theme, + filteredCounters, + selectedLabels, + selectedLabelValues, + locationSettings, + ), + }; + }, [theme, filteredCounters, locationSettings]); + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/component/counters/ExploreCounters/ExploreCounterFilter.tsx b/frontend/src/component/counters/ExploreCounters/ExploreCounterFilter.tsx new file mode 100644 index 0000000000..4ad58c344f --- /dev/null +++ b/frontend/src/component/counters/ExploreCounters/ExploreCounterFilter.tsx @@ -0,0 +1,73 @@ +import { + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + type SelectChangeEvent, +} from '@mui/material'; +import { SelectCounterLabel } from './SelectCounterLabel.js'; + +interface IExploreCounterFilter { + counter: string | undefined; + setCounter: (counter: string) => void; + counterNames: string[] | undefined; + labels: Record | undefined; + selectLabel: (label: string) => void; + unselectLabel: (label: string) => void; + selectLabelValue: (value: string) => void; + unselectLabelValue: (value: string) => void; +} + +export const ExploreCounterFilter = ({ + counterNames, + labels, + counter, + setCounter, + selectLabel, + unselectLabel, + selectLabelValue, + unselectLabelValue, +}: IExploreCounterFilter) => { + const counterChanged = (event: SelectChangeEvent) => { + const selectedCounter = event.target.value as string; + setCounter(selectedCounter); + }; + + return ( + + + + + + Counter + + + + + + + + ); +}; diff --git a/frontend/src/component/counters/ExploreCounters/ExploreCounters.tsx b/frontend/src/component/counters/ExploreCounters/ExploreCounters.tsx new file mode 100644 index 0000000000..ccc69e3bf9 --- /dev/null +++ b/frontend/src/component/counters/ExploreCounters/ExploreCounters.tsx @@ -0,0 +1,137 @@ +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { + type IMetricsCount, + useMetricCounters, +} from 'hooks/api/getters/useMetricCounters/useMetricCounters'; +import { usePageTitle } from 'hooks/usePageTitle'; +import { useEffect, useMemo, useState } from 'react'; +import { ExploreCounterFilter } from './ExploreCounterFilter.js'; +import { ExploreCounterChart } from './ExploreCounterChart.js'; + +const mapCounterNames = (metrics: IMetricsCount[]) => { + return metrics.reduce((acc, metric) => { + if (!acc.includes(metric.name)) { + acc.push(metric.name); + } + return acc; + }, [] as string[]); +}; + +const mapLabels = (metrics: IMetricsCount[]) => { + return metrics.reduce( + (acc, metric) => { + for (const [key, value] of Object.entries(metric.labels)) { + if (!acc[key]) { + acc[key] = []; + } + if (!acc[key].includes(value)) { + acc[key].push(value); + } + } + return acc; + }, + {} as Record, + ); +}; + +export const ExploreCounters = () => { + usePageTitle('Explore custom metrics'); + const data = useMetricCounters(); + const [counter, setCounter] = useState(undefined); + const [counterNames, setCounterNames] = useState( + undefined, + ); + const [labels, setLabels] = useState | undefined>( + undefined, + ); + const [selectedLabels, setSelectedLabels] = useState([]); + const [selectedLabelValues, setSelectedLabelValues] = useState( + [], + ); + const filteredCounters = useMemo(() => { + return data?.counters?.metrics?.filter((metric) => { + if (counter && metric.name !== counter) { + return false; + } + if (selectedLabels.length > 0) { + const labels = Object.keys(metric.labels); + return selectedLabels.every( + (label) => + labels.includes(label) && + (selectedLabelValues.length === 0 || + selectedLabelValues.includes( + `${label}::${metric.labels[label]}`, + )), + ); + } + return false; + }); + }, [data, counter, selectedLabels, selectedLabelValues]); + + useEffect(() => { + setCounterNames(mapCounterNames(data.counters.metrics)); + const labelMetrics = data.counters.metrics.filter((metric) => { + return counter && metric.name === counter; + }); + const counterLabels = mapLabels(labelMetrics); + setLabels(counterLabels); + }, [data.counters, filteredCounters, counter]); + + const selectLabel = (label: string) => { + setSelectedLabels((prev) => { + if (prev.includes(label)) { + return prev.filter((l) => l !== label); + } + return [...prev, label]; + }); + }; + const unselectLabel = (label: string) => { + setSelectedLabels((prev) => { + return prev.filter((l) => l !== label); + }); + }; + const selectLabelValue = (label: string) => { + setSelectedLabelValues((prev) => { + if (prev.includes(label)) { + return prev.filter((l) => l !== label); + } + return [...prev, label]; + }); + }; + const unselectLabelValue = (label: string) => { + setSelectedLabelValues((prev) => { + return prev.filter((l) => l !== label); + }); + }; + + return ( + <> + } + > + + {counter && + selectedLabels.length > 0 && + filteredCounters.length > 0 && ( + + )} + + + ); +}; diff --git a/frontend/src/component/counters/ExploreCounters/SelectCounterLabel.tsx b/frontend/src/component/counters/ExploreCounters/SelectCounterLabel.tsx new file mode 100644 index 0000000000..7c822883f8 --- /dev/null +++ b/frontend/src/component/counters/ExploreCounters/SelectCounterLabel.tsx @@ -0,0 +1,107 @@ +import { + FormControl, + InputLabel, + MenuItem, + Select, + type SelectChangeEvent, +} from '@mui/material'; +import { useState } from 'react'; + +const getLabelNames = (labels: Record) => { + return Object.keys(labels); +}; + +const getLabelValues = (label: string, labels: Record) => { + return labels[label]; +}; + +export const SelectCounterLabel = ({ + labels, + unselectLabel, + selectLabel, + unselectLabelValue, + selectLabelValue, +}: { + labels: Record | undefined; + unselectLabel: (label: string) => void; + selectLabel: (label: string) => void; + unselectLabelValue: (labelValue: string) => void; + selectLabelValue: (labelValue: string) => void; +}) => { + const [label, setLabel] = useState(undefined); + const [labelValue, setLabelValue] = useState(undefined); + const labelChanged = (event: SelectChangeEvent) => { + unselectLabel(label as string); + selectLabel(event.target.value as string); + const selectedLabel = event.target.value as string; + setLabel(selectedLabel); + }; + const labelValueChanged = (event: SelectChangeEvent) => { + unselectLabelValue(labelValue as string); + const newValue = event.target.value as string; + if (newValue === '') { + setLabelValue(undefined); + return; + } + selectLabelValue(newValue); + setLabelValue(newValue); + }; + return ( + <> + + + Label + + + + {label ? ( + + + Label value + + + + ) : null} + + ); +}; diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx index 7aaed7ddb2..27a59e9371 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx @@ -34,6 +34,7 @@ import PersonalDashboardIcon from '@mui/icons-material/DashboardOutlined'; import { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon'; import PlaygroundIcon from '@mui/icons-material/AutoFixNormal'; import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined'; +import RocketLaunchIcon from '@mui/icons-material/RocketLaunchOutlined'; // TODO: move to routes const icons: Record< @@ -82,6 +83,7 @@ const icons: Record< '/personal': PersonalDashboardIcon, '/projects': ProjectIcon, '/playground': PlaygroundIcon, + '/custom-metrics': RocketLaunchIcon, GitHub: GitHubIcon, Documentation: LibraryBooksIcon, }; diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index 144832f1b5..eeccfd38c5 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -152,6 +152,16 @@ exports[`returns all baseRoutes 1`] = ` "title": "Applications", "type": "protected", }, + { + "component": [Function], + "flag": "customMetrics", + "menu": { + "main": true, + }, + "path": "/custom-metrics", + "title": "Custom metrics", + "type": "protected", + }, { "component": [Function], "menu": {}, diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index fe7c11c995..a1a8f9b51e 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -50,6 +50,7 @@ import { PersonalDashboard } from '../personalDashboard/PersonalDashboard.jsx'; import { ReleaseManagement } from 'component/releases/ReleaseManagement/ReleaseManagement'; import { CreateReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/CreateReleasePlanTemplate'; import { EditReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/EditReleasePlanTemplate'; +import { ExploreCounters } from 'component/counters/ExploreCounters/ExploreCounters.js'; export const routes: IRoute[] = [ // Splash @@ -175,6 +176,16 @@ export const routes: IRoute[] = [ menu: { main: true }, }, + // Counters + { + path: '/custom-metrics', + title: 'Custom metrics', + component: ExploreCounters, + type: 'protected', + menu: { main: true }, + flag: 'customMetrics', + }, + // Context { path: '/context/create', diff --git a/frontend/src/hooks/api/getters/useMetricCounters/useMetricCounters.ts b/frontend/src/hooks/api/getters/useMetricCounters/useMetricCounters.ts new file mode 100644 index 0000000000..e572928d2f --- /dev/null +++ b/frontend/src/hooks/api/getters/useMetricCounters/useMetricCounters.ts @@ -0,0 +1,42 @@ +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR.js'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler.js'; +import { useMemo } from 'react'; + +export interface IMetricsCount { + name: string; + value: number; + timestamp: Date; + labels: Record; +} + +export interface IMetricsResponse { + metrics: IMetricsCount[]; + count: number; + metricNames: string[]; +} + +export const useMetricCounters = () => { + const { data, error, mutate } = useConditionalSWR( + true, + { metrics: [], count: 0, metricNames: [] }, + formatApiPath('api/admin/custom-metrics'), + fetcher, + ); + + return useMemo( + () => ({ + counters: data ?? { metrics: [], count: 0, metricNames: [] }, + loading: !error && !data, + refetch: () => mutate(), + error, + }), + [data, error, mutate], + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Metric counters')) + .then((res) => res.json()); +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index b5b5aef6fd..9ddd7a8c19 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -91,6 +91,7 @@ export type UiFlags = { registerFrontendClient?: boolean; featureLinks?: boolean; projectLinkTemplates?: boolean; + customMetrics?: boolean; }; export interface IVersionInfo {