mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-17 13:46:47 +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 { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon';
|
||||||
import PlaygroundIcon from '@mui/icons-material/AutoFixNormal';
|
import PlaygroundIcon from '@mui/icons-material/AutoFixNormal';
|
||||||
import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
|
import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
|
||||||
|
import RocketLaunchIcon from '@mui/icons-material/RocketLaunchOutlined';
|
||||||
|
|
||||||
// TODO: move to routes
|
// TODO: move to routes
|
||||||
const icons: Record<
|
const icons: Record<
|
||||||
@ -82,6 +83,7 @@ const icons: Record<
|
|||||||
'/personal': PersonalDashboardIcon,
|
'/personal': PersonalDashboardIcon,
|
||||||
'/projects': ProjectIcon,
|
'/projects': ProjectIcon,
|
||||||
'/playground': PlaygroundIcon,
|
'/playground': PlaygroundIcon,
|
||||||
|
'/custom-metrics': RocketLaunchIcon,
|
||||||
GitHub: GitHubIcon,
|
GitHub: GitHubIcon,
|
||||||
Documentation: LibraryBooksIcon,
|
Documentation: LibraryBooksIcon,
|
||||||
};
|
};
|
||||||
|
@ -152,6 +152,16 @@ exports[`returns all baseRoutes 1`] = `
|
|||||||
"title": "Applications",
|
"title": "Applications",
|
||||||
"type": "protected",
|
"type": "protected",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component": [Function],
|
||||||
|
"flag": "customMetrics",
|
||||||
|
"menu": {
|
||||||
|
"main": true,
|
||||||
|
},
|
||||||
|
"path": "/custom-metrics",
|
||||||
|
"title": "Custom metrics",
|
||||||
|
"type": "protected",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component": [Function],
|
"component": [Function],
|
||||||
"menu": {},
|
"menu": {},
|
||||||
|
@ -50,6 +50,7 @@ import { PersonalDashboard } from '../personalDashboard/PersonalDashboard.jsx';
|
|||||||
import { ReleaseManagement } from 'component/releases/ReleaseManagement/ReleaseManagement';
|
import { ReleaseManagement } from 'component/releases/ReleaseManagement/ReleaseManagement';
|
||||||
import { CreateReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/CreateReleasePlanTemplate';
|
import { CreateReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/CreateReleasePlanTemplate';
|
||||||
import { EditReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/EditReleasePlanTemplate';
|
import { EditReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/EditReleasePlanTemplate';
|
||||||
|
import { ExploreCounters } from 'component/counters/ExploreCounters/ExploreCounters.js';
|
||||||
|
|
||||||
export const routes: IRoute[] = [
|
export const routes: IRoute[] = [
|
||||||
// Splash
|
// Splash
|
||||||
@ -175,6 +176,16 @@ export const routes: IRoute[] = [
|
|||||||
menu: { main: true },
|
menu: { main: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Counters
|
||||||
|
{
|
||||||
|
path: '/custom-metrics',
|
||||||
|
title: 'Custom metrics',
|
||||||
|
component: ExploreCounters,
|
||||||
|
type: 'protected',
|
||||||
|
menu: { main: true },
|
||||||
|
flag: 'customMetrics',
|
||||||
|
},
|
||||||
|
|
||||||
// Context
|
// Context
|
||||||
{
|
{
|
||||||
path: '/context/create',
|
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;
|
registerFrontendClient?: boolean;
|
||||||
featureLinks?: boolean;
|
featureLinks?: boolean;
|
||||||
projectLinkTemplates?: boolean;
|
projectLinkTemplates?: boolean;
|
||||||
|
customMetrics?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
Loading…
Reference in New Issue
Block a user