mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-12 13:48:35 +02:00
feat: custom metrics (#10022)
This commit is contained in:
parent
76b201e40e
commit
9fca29f254
@ -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<T> {
|
||||
private items: CyclicIterator<T>;
|
||||
private picked: Map<string, T> = new Map();
|
||||
constructor(items: T[]) {
|
||||
this.items = new CyclicIterator<T>(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<string, { count: number; timestamp: Date }[]>,
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div style={{ height: 400 }}>
|
||||
<Line
|
||||
options={options}
|
||||
data={data}
|
||||
aria-label='Explore Counters Metrics Chart'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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<string, string[]> | 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<string>) => {
|
||||
const selectedCounter = event.target.value as string;
|
||||
setCounter(selectedCounter);
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Grid container spacing={2}>
|
||||
<FormControl>
|
||||
<InputLabel id='counter-label' size='small'>
|
||||
Counter
|
||||
</InputLabel>
|
||||
<Select
|
||||
label='Counter'
|
||||
labelId='counter-label'
|
||||
id='counter-select'
|
||||
value={counter}
|
||||
onChange={counterChanged}
|
||||
variant='outlined'
|
||||
size='small'
|
||||
sx={{ width: 200, maxWidth: '100%' }}
|
||||
>
|
||||
{counterNames?.map((option) => (
|
||||
<MenuItem key={option} value={option}>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<SelectCounterLabel
|
||||
labels={labels}
|
||||
selectLabel={selectLabel}
|
||||
unselectLabel={unselectLabel}
|
||||
selectLabelValue={selectLabelValue}
|
||||
unselectLabelValue={unselectLabelValue}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
@ -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<string, string[]>,
|
||||
);
|
||||
};
|
||||
|
||||
export const ExploreCounters = () => {
|
||||
usePageTitle('Explore custom metrics');
|
||||
const data = useMetricCounters();
|
||||
const [counter, setCounter] = useState<string | undefined>(undefined);
|
||||
const [counterNames, setCounterNames] = useState<string[] | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [labels, setLabels] = useState<Record<string, string[]> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
|
||||
const [selectedLabelValues, setSelectedLabelValues] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
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 (
|
||||
<>
|
||||
<PageContent
|
||||
header={<PageHeader title={`Explore custom metrics`} />}
|
||||
>
|
||||
<ExploreCounterFilter
|
||||
selectLabel={selectLabel}
|
||||
unselectLabel={unselectLabel}
|
||||
selectLabelValue={selectLabelValue}
|
||||
unselectLabelValue={unselectLabelValue}
|
||||
counter={counter}
|
||||
setCounter={setCounter}
|
||||
counterNames={counterNames}
|
||||
labels={labels}
|
||||
/>
|
||||
{counter &&
|
||||
selectedLabels.length > 0 &&
|
||||
filteredCounters.length > 0 && (
|
||||
<ExploreCounterChart
|
||||
selectedLabels={selectedLabels}
|
||||
selectedLabelValues={selectedLabelValues}
|
||||
filteredCounters={filteredCounters}
|
||||
counter={counter}
|
||||
setCounter={setCounter}
|
||||
/>
|
||||
)}
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,107 @@
|
||||
import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
type SelectChangeEvent,
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
|
||||
const getLabelNames = (labels: Record<string, string[]>) => {
|
||||
return Object.keys(labels);
|
||||
};
|
||||
|
||||
const getLabelValues = (label: string, labels: Record<string, string[]>) => {
|
||||
return labels[label];
|
||||
};
|
||||
|
||||
export const SelectCounterLabel = ({
|
||||
labels,
|
||||
unselectLabel,
|
||||
selectLabel,
|
||||
unselectLabelValue,
|
||||
selectLabelValue,
|
||||
}: {
|
||||
labels: Record<string, string[]> | undefined;
|
||||
unselectLabel: (label: string) => void;
|
||||
selectLabel: (label: string) => void;
|
||||
unselectLabelValue: (labelValue: string) => void;
|
||||
selectLabelValue: (labelValue: string) => void;
|
||||
}) => {
|
||||
const [label, setLabel] = useState<string | undefined>(undefined);
|
||||
const [labelValue, setLabelValue] = useState<string | undefined>(undefined);
|
||||
const labelChanged = (event: SelectChangeEvent<string>) => {
|
||||
unselectLabel(label as string);
|
||||
selectLabel(event.target.value as string);
|
||||
const selectedLabel = event.target.value as string;
|
||||
setLabel(selectedLabel);
|
||||
};
|
||||
const labelValueChanged = (event: SelectChangeEvent<string>) => {
|
||||
unselectLabelValue(labelValue as string);
|
||||
const newValue = event.target.value as string;
|
||||
if (newValue === '') {
|
||||
setLabelValue(undefined);
|
||||
return;
|
||||
}
|
||||
selectLabelValue(newValue);
|
||||
setLabelValue(newValue);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<FormControl>
|
||||
<InputLabel id='labels-label' size='small'>
|
||||
Label
|
||||
</InputLabel>
|
||||
<Select
|
||||
label='Label'
|
||||
labelId='labels-label'
|
||||
id='label-select'
|
||||
value={label}
|
||||
onChange={labelChanged}
|
||||
variant='outlined'
|
||||
size='small'
|
||||
sx={{ width: 200, maxWidth: '100%' }}
|
||||
>
|
||||
{labels
|
||||
? getLabelNames(labels)?.map((option) => (
|
||||
<MenuItem key={option} value={option}>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))
|
||||
: null}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{label ? (
|
||||
<FormControl>
|
||||
<InputLabel id='label-value-label' size='small'>
|
||||
Label value
|
||||
</InputLabel>
|
||||
<Select
|
||||
label='Label value'
|
||||
labelId='label-value-label'
|
||||
id='label-value-select'
|
||||
value={labelValue}
|
||||
onChange={labelValueChanged}
|
||||
variant='outlined'
|
||||
size='small'
|
||||
sx={{ width: 200, maxWidth: '100%' }}
|
||||
>
|
||||
<MenuItem key='all' value=''>
|
||||
All
|
||||
</MenuItem>
|
||||
{labels
|
||||
? getLabelValues(label, labels)?.map((option) => (
|
||||
<MenuItem
|
||||
key={option}
|
||||
value={`${label}::${option}`}
|
||||
>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))
|
||||
: null}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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,
|
||||
};
|
||||
|
@ -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": {},
|
||||
|
@ -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',
|
||||
|
@ -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<string, string>;
|
||||
}
|
||||
|
||||
export interface IMetricsResponse {
|
||||
metrics: IMetricsCount[];
|
||||
count: number;
|
||||
metricNames: string[];
|
||||
}
|
||||
|
||||
export const useMetricCounters = () => {
|
||||
const { data, error, mutate } = useConditionalSWR<IMetricsResponse>(
|
||||
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());
|
||||
};
|
@ -91,6 +91,7 @@ export type UiFlags = {
|
||||
registerFrontendClient?: boolean;
|
||||
featureLinks?: boolean;
|
||||
projectLinkTemplates?: boolean;
|
||||
customMetrics?: boolean;
|
||||
};
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
Loading…
Reference in New Issue
Block a user