feat: implement metric details view in metrics explorer (#7238)

This commit is contained in:
Amlan Kumar Nandy 2025-03-11 12:02:08 +05:30 committed by GitHub
parent 8fbf8155de
commit 1b758a088c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1349 additions and 20 deletions

View File

@ -0,0 +1,69 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { MetricType } from './getMetricsList';
export interface MetricDetails {
name: string;
description: string;
type: string;
unit: string;
timeseries: number;
samples: number;
timeSeriesTotal: number;
timeSeriesActive: number;
lastReceived: string;
attributes: MetricDetailsAttribute[];
metadata: {
metric_type: MetricType;
description: string;
unit: string;
};
alerts: MetricDetailsAlert[] | null;
dashboards: MetricDetailsDashboard[] | null;
}
export interface MetricDetailsAttribute {
key: string;
value: string[];
valueCount: number;
}
export interface MetricDetailsAlert {
alert_name: string;
alert_id: string;
}
export interface MetricDetailsDashboard {
dashboard_name: string;
dashboard_id: string;
}
export interface MetricDetailsResponse {
status: string;
data: MetricDetails;
}
export const getMetricDetails = async (
metricName: string,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<MetricDetailsResponse> | ErrorResponse> => {
try {
const response = await axios.get(`/metrics/${metricName}/metadata`, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { MetricType } from './getMetricsList';
export interface UpdateMetricMetadataProps {
description: string;
unit: string;
type: MetricType;
}
export interface UpdateMetricMetadataResponse {
success: boolean;
message: string;
}
const updateMetricMetadata = async (
metricName: string,
props: UpdateMetricMetadataProps,
): Promise<SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse> => {
const response = await axios.put(`/metrics/${metricName}/metadata`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default updateMetricMetadata;

View File

@ -49,4 +49,5 @@ export const REACT_QUERY_KEY = {
GET_METRICS_TREE_MAP: 'GET_METRICS_TREE_MAP', GET_METRICS_TREE_MAP: 'GET_METRICS_TREE_MAP',
GET_METRICS_LIST_FILTER_KEYS: 'GET_METRICS_LIST_FILTER_KEYS', GET_METRICS_LIST_FILTER_KEYS: 'GET_METRICS_LIST_FILTER_KEYS',
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES', GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
}; };

View File

@ -0,0 +1,142 @@
import { Button, Collapse, Input, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { ResizeTable } from 'components/ResizeTable';
import { DataType } from 'container/LogDetailedView/TableView';
import { Search } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { AllAttributesProps } from './types';
function AllAttributes({
attributes,
metricName,
}: AllAttributesProps): JSX.Element {
const [searchString, setSearchString] = useState('');
const [activeKey, setActiveKey] = useState<string | string[]>(
'all-attributes',
);
const goToMetricsExploreWithAppliedAttribute = useCallback(
(attribute: string) => {
// TODO: Implement this when explore page is ready
console.log(metricName, attribute);
},
[metricName],
);
const filteredAttributes = useMemo(
() =>
attributes.filter((attribute) =>
attribute.key.toLowerCase().includes(searchString.toLowerCase()),
),
[attributes, searchString],
);
const tableData = useMemo(
() =>
filteredAttributes
? filteredAttributes.map((attribute) => ({
key: {
label: attribute.key,
contribution: attribute.valueCount,
},
value: attribute.value,
}))
: [],
[filteredAttributes],
);
const columns: ColumnsType<DataType> = useMemo(
() => [
{
title: 'Key',
dataIndex: 'key',
key: 'key',
width: 50,
align: 'left',
className: 'metric-metadata-key',
render: (field: { label: string; contribution: number }): JSX.Element => (
<div className="all-attributes-key">
<Typography.Text>{field.label}</Typography.Text>
<Typography.Text>{field.contribution}</Typography.Text>
</div>
),
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
width: 50,
align: 'left',
ellipsis: true,
className: 'metric-metadata-value',
render: (attributes: string[]): JSX.Element => (
<div className="all-attributes-value">
{attributes.map((attribute) => (
<Button
key={attribute}
type="text"
onClick={(): void => {
goToMetricsExploreWithAppliedAttribute(attribute);
}}
>
<Typography.Text>{attribute}</Typography.Text>
</Button>
))}
</div>
),
},
],
[goToMetricsExploreWithAppliedAttribute],
);
const items = useMemo(
() => [
{
label: (
<div className="metrics-accordion-header">
<Typography.Text>All Attributes</Typography.Text>
<Input
className="all-attributes-search-input"
placeholder="Search"
value={searchString}
size="small"
suffix={<Search size={12} />}
onChange={(e): void => {
setSearchString(e.target.value);
}}
onClick={(e): void => {
e.stopPropagation();
}}
/>
</div>
),
key: 'all-attributes',
children: (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={tableData}
pagination={false}
showHeader={false}
className="metrics-accordion-content all-attributes-content"
scroll={{ y: 600 }}
/>
),
},
],
[columns, tableData, searchString],
);
return (
<Collapse
bordered
className="metrics-accordion metrics-metadata-accordion"
activeKey={activeKey}
onChange={(keys): void => setActiveKey(keys)}
items={items}
/>
);
}
export default AllAttributes;

View File

@ -0,0 +1,124 @@
import { Color } from '@signozhq/design-tokens';
import { Dropdown, Typography } from 'antd';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { Bell, Grid } from 'lucide-react';
import { useMemo } from 'react';
import { generatePath } from 'react-router-dom';
import { DashboardsAndAlertsPopoverProps } from './types';
function DashboardsAndAlertsPopover({
alerts,
dashboards,
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
const { safeNavigate } = useSafeNavigate();
const params = useUrlQuery();
const alertsPopoverContent = useMemo(() => {
if (alerts && alerts.length > 0) {
return alerts.map((alert) => ({
key: alert.alert_id,
label: (
<Typography.Link
key={alert.alert_id}
onClick={(): void => {
params.set(QueryParams.ruleId, alert.alert_id);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}}
className="dashboards-popover-content-item"
>
{alert.alert_name || alert.alert_id}
</Typography.Link>
),
}));
}
return null;
}, [alerts, params]);
const uniqueDashboards = useMemo(
() =>
dashboards?.filter(
(item, index, self) =>
index === self.findIndex((t) => t.dashboard_id === item.dashboard_id),
),
[dashboards],
);
const dashboardsPopoverContent = useMemo(() => {
if (uniqueDashboards && uniqueDashboards.length > 0) {
return uniqueDashboards.map((dashboard) => ({
key: dashboard.dashboard_id,
label: (
<Typography.Link
key={dashboard.dashboard_id}
onClick={(): void => {
safeNavigate(
generatePath(ROUTES.DASHBOARD, {
dashboardId: dashboard.dashboard_id,
}),
);
}}
className="dashboards-popover-content-item"
>
{dashboard.dashboard_name || dashboard.dashboard_id}
</Typography.Link>
),
}));
}
return null;
}, [uniqueDashboards, safeNavigate]);
if (!dashboardsPopoverContent && !alertsPopoverContent) {
return null;
}
return (
<div className="dashboards-and-alerts-popover-container">
{dashboardsPopoverContent && (
<Dropdown
menu={{
items: dashboardsPopoverContent,
}}
placement="bottomLeft"
trigger={['click']}
>
<div
className="dashboards-and-alerts-popover dashboards-popover"
style={{ backgroundColor: `${Color.BG_SIENNA_500}33` }}
>
<Grid size={12} color={Color.BG_SIENNA_500} />
<Typography.Text>
{uniqueDashboards?.length} dashboard
{uniqueDashboards?.length === 1 ? '' : 's'}
</Typography.Text>
</div>
</Dropdown>
)}
{alertsPopoverContent && (
<Dropdown
menu={{
items: alertsPopoverContent,
}}
placement="bottomLeft"
trigger={['click']}
>
<div
className="dashboards-and-alerts-popover alerts-popover"
style={{ backgroundColor: `${Color.BG_SAKURA_500}33` }}
>
<Bell size={12} color={Color.BG_SAKURA_500} />
<Typography.Text>
{alerts?.length} alert {alerts?.length === 1 ? 'rule' : 'rules'}
</Typography.Text>
</div>
</Dropdown>
)}
</div>
);
}
export default DashboardsAndAlertsPopover;

View File

@ -0,0 +1,224 @@
import { Button, Collapse, Input, Select, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
import { ResizeTable } from 'components/ResizeTable';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
import { useUpdateMetricMetadata } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import { useNotifications } from 'hooks/useNotifications';
import { Edit2, Save } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { METRIC_TYPE_LABEL_MAP } from '../Summary/constants';
import { MetricTypeRenderer } from '../Summary/utils';
import { METRIC_METADATA_KEYS } from './constants';
import { MetadataProps } from './types';
function Metadata({
metricName,
metadata,
refetchMetricDetails,
}: MetadataProps): JSX.Element {
const [isEditing, setIsEditing] = useState(false);
const [
metricMetadata,
setMetricMetadata,
] = useState<UpdateMetricMetadataProps>({
type: metadata.metric_type,
description: metadata.description,
unit: metadata.unit,
});
const { notifications } = useNotifications();
const {
mutate: updateMetricMetadata,
isLoading: isUpdatingMetricsMetadata,
} = useUpdateMetricMetadata();
const [activeKey, setActiveKey] = useState<string | string[]>(
'metric-metadata',
);
const tableData = useMemo(
() =>
metadata
? Object.keys(metadata).map((key) => ({
key,
value: {
value: metadata[key as keyof typeof metadata],
key,
},
}))
: [],
[metadata],
);
const columns: ColumnsType<DataType> = useMemo(
() => [
{
title: 'Key',
dataIndex: 'key',
key: 'key',
width: 50,
align: 'left',
className: 'metric-metadata-key',
render: (field: string): JSX.Element => (
<FieldRenderer
field={METRIC_METADATA_KEYS[field as keyof typeof METRIC_METADATA_KEYS]}
/>
),
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
width: 50,
align: 'left',
ellipsis: true,
className: 'metric-metadata-value',
render: (field: { value: string; key: string }): JSX.Element => {
if (!isEditing) {
if (field.key === 'metric_type') {
return (
<div>
<MetricTypeRenderer type={field.value as MetricType} />
</div>
);
}
return <FieldRenderer field={field.value || '-'} />;
}
if (field.key === 'metric_type') {
return (
<Select
options={Object.entries(METRIC_TYPE_LABEL_MAP).map(([key, value]) => ({
value: key,
label: value,
}))}
value={metricMetadata.type}
onChange={(value): void => {
setMetricMetadata({
...metricMetadata,
type: value as MetricType,
});
}}
/>
);
}
return (
<Input
name={field.key}
value={metricMetadata[field.key as keyof UpdateMetricMetadataProps]}
onChange={(e): void => {
setMetricMetadata({ ...metricMetadata, [field.key]: e.target.value });
}}
/>
);
},
},
],
[isEditing, metricMetadata, setMetricMetadata],
);
const handleSave = useCallback(() => {
updateMetricMetadata(
{
metricName,
payload: metricMetadata,
},
{
onSuccess: (response): void => {
if (response?.payload?.success) {
notifications.success({
message: 'Metadata updated successfully',
});
refetchMetricDetails();
setIsEditing(false);
} else {
notifications.error({
message: 'Failed to update metadata',
});
}
},
onError: (): void =>
notifications.error({
message: 'Failed to update metadata',
}),
},
);
}, [
updateMetricMetadata,
metricName,
metricMetadata,
notifications,
refetchMetricDetails,
]);
const actionButton = useMemo(() => {
if (isEditing) {
return (
<Button
className="action-button"
type="text"
onClick={(e): void => {
e.stopPropagation();
handleSave();
}}
disabled={isUpdatingMetricsMetadata}
>
<Save size={14} />
<Typography.Text>Save</Typography.Text>
</Button>
);
}
return (
<Button
className="action-button"
type="text"
onClick={(e): void => {
e.stopPropagation();
setIsEditing(true);
}}
disabled={isUpdatingMetricsMetadata}
>
<Edit2 size={14} />
<Typography.Text>Edit</Typography.Text>
</Button>
);
}, [handleSave, isEditing, isUpdatingMetricsMetadata]);
const items = useMemo(
() => [
{
label: (
<div className="metrics-accordion-header metrics-metadata-header">
<Typography.Text>Metadata</Typography.Text>
{actionButton}
</div>
),
key: 'metric-metadata',
children: (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={tableData}
pagination={false}
showHeader={false}
className="metrics-accordion-content metrics-metadata-container"
/>
),
},
],
[actionButton, columns, tableData],
);
return (
<Collapse
bordered
className="metrics-accordion metrics-metadata-accordion"
activeKey={activeKey}
onChange={(keys): void => setActiveKey(keys)}
items={items}
/>
);
}
export default Metadata;

View File

@ -0,0 +1,344 @@
.metric-details-drawer {
.metric-details-header {
display: flex;
justify-content: space-between;
align-items: center;
.metric-details-title {
display: flex;
align-items: center;
gap: 8px;
.ant-typography {
font-family: 'Geist Mono';
}
}
.ant-btn {
display: flex;
align-items: center;
gap: 4px;
background-color: var(--bg-slate-300);
}
}
.metric-details-content {
display: flex;
flex-direction: column;
gap: 12px;
.metric-details-content-grid {
.labels-row,
.values-row {
display: grid;
grid-template-columns: 1fr 1fr 2fr 2fr;
gap: 30px;
align-items: center;
}
.labels-row {
margin-bottom: 8px;
.metric-details-grid-label {
font-size: 11px;
}
}
.values-row {
color: var(--bg-vanilla-100);
font-family: 'Geist Mono';
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.metric-details-grid-value {
font-size: 14px;
}
}
}
.dashboards-and-alerts-popover-container {
display: flex;
gap: 16px;
.dashboards-and-alerts-popover {
border-radius: 20px;
padding: 4px 8px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
&:hover {
opacity: 0.8;
}
}
.dashboards-popover {
border: 1px solid var(--bg-sienna-500);
.ant-typography {
color: var(--bg-sienna-500);
}
}
.alerts-popover {
border: 1px solid var(--bg-sakura-500);
.ant-typography {
color: var(--bg-sakura-500);
}
}
}
.metrics-accordion {
.metrics-accordion-header {
display: flex;
justify-content: space-between;
align-items: center;
height: 36px;
.ant-typography {
font-family: 'Geist Mono';
color: var(--bg-robin-400);
}
.action-button {
display: flex;
gap: 4px;
align-items: center;
align-self: flex-start;
.ant-typography {
font-family: 'Inter';
color: var(--bg-vanilla-400);
}
}
.all-attributes-search-input {
width: 300px;
border: 1px solid var(--bg-slate-300);
}
}
.all-attributes-content {
.metric-metadata-key {
.all-attributes-key {
display: flex;
justify-content: space-between;
.ant-typography:first-child {
font-family: 'Geist Mono';
color: var(--bg-robin-400);
}
.ant-typography:last-child {
font-family: 'Geist Mono';
color: var(--bg-vanilla-400);
background-color: rgba(171, 189, 255, 0.1);
height: 24px;
width: 24px;
border-radius: 50%;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.metric-metadata-value {
.all-attributes-value {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 120px;
overflow-y: scroll;
.ant-btn {
text-align: left;
width: fit-content;
background-color: var(--bg-slate-300);
.ant-typography {
color: var(--bg-vanilla-400);
font-family: 'Geist Mono';
}
&:hover {
background-color: var(--bg-slate-400);
}
}
}
}
}
.ant-collapse-content-box {
padding: 0;
}
.ant-collapse-header {
display: flex !important;
align-items: center !important;
}
.metric-type-renderer {
max-height: 12px;
}
.metric-metadata-key {
cursor: pointer;
padding-left: 10px;
vertical-align: middle;
text-align: center;
.field-renderer-container {
.label {
color: var(--bg-vanilla-400);
}
}
}
.metric-metadata-value {
background: rgba(22, 25, 34, 0.4);
overflow-x: scroll;
.field-renderer-container {
.label {
color: var(--bg-vanilla-100);
padding-right: 10px;
}
}
&::-webkit-scrollbar {
height: 2px;
}
}
.ant-table-content {
margin-bottom: 0 !important;
}
.ant-collapse {
border-width: 0.5px !important;
}
.ant-collapse-item {
border-width: 0.5px !important;
}
.ant-collapse-content {
border-top-width: 0.5px !important;
}
}
}
.ant-select {
.ant-select-selector {
width: 200px !important;
}
}
}
.top-attributes-content {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
font-family: 'Geist Mono' !important;
max-height: 300px;
overflow-y: scroll;
.top-attributes-item {
display: flex;
justify-content: space-between;
.top-attributes-item-progress {
display: flex;
gap: 12px;
height: 34px;
width: 100%;
color: var(--bg-vanilla-100);
padding: 0 12px;
align-items: center;
position: relative;
.top-attributes-item-key {
z-index: 2;
}
.top-attributes-item-count {
background-color: var(--bg-slate-300);
border-radius: 50px;
padding: 2px 6px;
color: var(--bg-vanilla-400);
z-index: 2;
}
.top-attributes-item-progress-bar {
background-color: var(--bg-slate-400);
height: 100%;
position: absolute;
top: 0;
left: 0;
}
}
}
}
.lightMode {
.metric-details-header {
.ant-btn {
background-color: var(--bg-white-400);
}
}
.metric-details-content {
.metrics-accordion {
.metrics-accordion-header {
.action-button {
.ant-typography {
color: var(--bg-slate-400);
}
}
}
.metrics-accordion-content {
.metric-metadata-key {
.all-attributes-key {
.ant-typography:last-child {
color: var(--bg-slate-400);
background-color: var(--bg-robin-300);
}
}
}
.metric-metadata-value {
background-color: var(--bg-vanilla-300);
.all-attributes-value {
.ant-btn {
background-color: var(--bg-vanilla-400);
.ant-typography {
color: var(--bg-slate-400);
}
&:hover {
background-color: var(--bg-vanilla-100);
}
}
}
.field-renderer-container {
.label {
color: var(--bg-slate-400);
}
}
}
}
}
}
.top-attributes-content {
.top-attributes-item-progress {
.top-attributes-item-progress-bar {
background-color: var(--bg-robin-400);
}
.top-attributes-item-count {
background-color: var(--bg-robin-300);
}
.top-attributes-item-key,
.top-attributes-item-count {
color: var(--bg-slate-400);
}
}
}
}

View File

@ -0,0 +1,143 @@
import './MetricDetails.styles.scss';
import '../Summary/Summary.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Skeleton, Tooltip, Typography } from 'antd';
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass, X } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import AllAttributes from './AllAttributes';
import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover';
import Metadata from './Metadata';
import TopAttributes from './TopAttributes';
import { MetricDetailsProps } from './types';
import {
formatNumberToCompactFormat,
formatTimestampToReadableDate,
} from './utils';
function MetricDetails({
onClose,
isOpen,
metricName,
}: MetricDetailsProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const {
data,
isLoading,
isFetching,
refetch: refetchMetricDetails,
} = useGetMetricDetails(metricName ?? '', {
enabled: !!metricName,
});
const metric = data?.payload?.data;
const lastReceived = useMemo(() => {
if (!metric) return null;
return formatTimestampToReadableDate(metric.lastReceived);
}, [metric]);
const isMetricDetailsLoading = isLoading || isFetching || !metric;
const timeSeries = useMemo(() => {
if (!metric) return null;
const timeSeriesActive = formatNumberToCompactFormat(metric.timeSeriesActive);
const timeSeriesTotal = formatNumberToCompactFormat(metric.timeSeriesTotal);
return `${timeSeriesActive}${timeSeriesTotal} active`;
}, [metric]);
const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
// TODO: Implement this when explore page is ready
console.log(metricName);
}, [metricName]);
const top5Attributes = useMemo(() => {
const totalSum =
metric?.attributes.reduce((acc, curr) => acc + curr.valueCount, 0) || 0;
if (!metric) return [];
return metric.attributes.slice(0, 5).map((attr) => ({
key: attr.key,
count: attr.valueCount,
percentage: totalSum === 0 ? 0 : (attr.valueCount / totalSum) * 100,
}));
}, [metric]);
return (
<Drawer
width="60%"
title={
<div className="metric-details-header">
<div className="metric-details-title">
<Divider type="vertical" />
<Typography.Text>{metric?.name}</Typography.Text>
</div>
<Button
onClick={goToMetricsExplorerwithSelectedMetric}
icon={<Compass size={16} />}
>
Open in Explorer
</Button>
</div>
}
placement="right"
onClose={onClose}
open={isOpen}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
className="metric-details-drawer"
destroyOnClose
closeIcon={<X size={16} />}
>
{isMetricDetailsLoading ? (
<Skeleton active />
) : (
<div className="metric-details-content">
<div className="metric-details-content-grid">
<div className="labels-row">
<Typography.Text type="secondary" className="metric-details-grid-label">
DATAPOINTS
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
TIME SERIES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
LAST RECEIVED
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text className="metric-details-grid-value">
<Tooltip title={metric?.samples}>
{metric?.samples.toLocaleString()}
</Tooltip>
</Typography.Text>
<Typography.Text className="metric-details-grid-value">
<Tooltip title={timeSeries}>{timeSeries}</Tooltip>
</Typography.Text>
<Typography.Text className="metric-details-grid-value">
<Tooltip title={lastReceived}>{lastReceived}</Tooltip>
</Typography.Text>
</div>
</div>
<DashboardsAndAlertsPopover
dashboards={metric.dashboards}
alerts={metric.alerts}
/>
<TopAttributes items={top5Attributes} title="Top 5 Attributes" />
<Metadata
metricName={metric?.name}
metadata={metric.metadata}
refetchMetricDetails={refetchMetricDetails}
/>
<AllAttributes metricName={metric?.name} attributes={metric.attributes} />
</div>
)}
</Drawer>
);
}
export default MetricDetails;

View File

@ -0,0 +1,67 @@
import { Button, Collapse, Typography } from 'antd';
import { useMemo, useState } from 'react';
import { TopAttributesProps } from './types';
function TopAttributes({
items,
title,
loadMore,
hideLoadMore,
}: TopAttributesProps): JSX.Element {
const [activeKey, setActiveKey] = useState<string | string[]>(
'top-attributes',
);
const collapseItems = useMemo(
() => [
{
label: (
<div className="metrics-accordion-header">
<Typography.Text>{title}</Typography.Text>
</div>
),
key: 'top-attributes',
children: (
<div className="top-attributes-content">
{items.map((item) => (
<div className="top-attributes-item" key={item.key}>
<div className="top-attributes-item-progress">
<div className="top-attributes-item-key">{item.key}</div>
<div className="top-attributes-item-count">{item.count}</div>
<div
className="top-attributes-item-progress-bar"
style={{ width: `${item.percentage}%` }}
/>
</div>
<div className="top-attributes-item-percentage">
{item.percentage.toFixed(2)}%
</div>
</div>
))}
{loadMore && !hideLoadMore && (
<div className="top-attributes-load-more">
<Button type="link" onClick={loadMore}>
Load more
</Button>
</div>
)}
</div>
),
},
],
[title, items, loadMore, hideLoadMore],
);
return (
<Collapse
bordered
className="metrics-accordion"
activeKey={activeKey}
onChange={(keys): void => setActiveKey(keys)}
items={collapseItems}
/>
);
}
export default TopAttributes;

View File

@ -0,0 +1,5 @@
export const METRIC_METADATA_KEYS = {
description: 'Description',
unit: 'Unit',
metric_type: 'Metric Type',
};

View File

@ -0,0 +1,3 @@
import MetricDetails from './MetricDetails';
export default MetricDetails;

View File

@ -0,0 +1,40 @@
import {
MetricDetails,
MetricDetailsAlert,
MetricDetailsAttribute,
MetricDetailsDashboard,
} from 'api/metricsExplorer/getMetricDetails';
export interface MetricDetailsProps {
onClose: () => void;
isOpen: boolean;
metricName: string | null;
isModalTimeSelection: boolean;
}
export interface DashboardsAndAlertsPopoverProps {
dashboards: MetricDetailsDashboard[] | null;
alerts: MetricDetailsAlert[] | null;
}
export interface MetadataProps {
metricName: string;
metadata: MetricDetails['metadata'];
refetchMetricDetails: () => void;
}
export interface AllAttributesProps {
attributes: MetricDetailsAttribute[];
metricName: string;
}
export interface TopAttributesProps {
items: Array<{
key: string;
count: number;
percentage: number;
}>;
title: string;
loadMore?: () => void;
hideLoadMore?: boolean;
}

View File

@ -0,0 +1,21 @@
export function formatTimestampToReadableDate(timestamp: string): string {
const date = new Date(timestamp);
// Extracting date components
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are 0-based
const year = String(date.getFullYear()).slice(-2); // Get last two digits of year
// Extracting time components
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${day}.${month}.${year}${hours}:${minutes}:${seconds}`;
}
export function formatNumberToCompactFormat(num: number): string {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(num);
}

View File

@ -20,6 +20,7 @@ function MetricsTable({
onPaginationChange, onPaginationChange,
setOrderBy, setOrderBy,
totalCount, totalCount,
openMetricDetails,
}: MetricsTableProps): JSX.Element { }: MetricsTableProps): JSX.Element {
const handleTableChange: TableProps<MetricsListItemRowData>['onChange'] = useCallback( const handleTableChange: TableProps<MetricsListItemRowData>['onChange'] = useCallback(
( (
@ -78,6 +79,10 @@ function MetricsTable({
onChange: onPaginationChange, onChange: onPaginationChange,
total: totalCount, total: totalCount,
}} }}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => openMetricDetails(record.key),
className: 'clickable-row',
})}
/> />
</div> </div>
); );

View File

@ -10,7 +10,7 @@ import {
TREEMAP_MARGINS, TREEMAP_MARGINS,
TREEMAP_SQUARE_PADDING, TREEMAP_SQUARE_PADDING,
} from './constants'; } from './constants';
import { TreemapProps, TreemapTile, TreemapViewType } from './types'; import { MetricsTreemapProps, TreemapTile, TreemapViewType } from './types';
import { import {
getTreemapTileStyle, getTreemapTileStyle,
getTreemapTileTextStyle, getTreemapTileTextStyle,
@ -21,7 +21,8 @@ function MetricsTreemap({
viewType, viewType,
data, data,
isLoading, isLoading,
}: TreemapProps): JSX.Element { openMetricDetails,
}: MetricsTreemapProps): JSX.Element {
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
const treemapWidth = useMemo( const treemapWidth = useMemo(
@ -93,6 +94,9 @@ function MetricsTreemap({
.map((node, i) => { .map((node, i) => {
const nodeWidth = node.x1 - node.x0 - TREEMAP_SQUARE_PADDING; const nodeWidth = node.x1 - node.x0 - TREEMAP_SQUARE_PADDING;
const nodeHeight = node.y1 - node.y0 - TREEMAP_SQUARE_PADDING; const nodeHeight = node.y1 - node.y0 - TREEMAP_SQUARE_PADDING;
if (nodeWidth < 0 || nodeHeight < 0) {
return null;
}
return ( return (
<Group <Group
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
@ -109,6 +113,7 @@ function MetricsTreemap({
width={nodeWidth} width={nodeWidth}
height={nodeHeight} height={nodeHeight}
style={getTreemapTileStyle(node.data)} style={getTreemapTileStyle(node.data)}
onClick={(): void => openMetricDetails(node.data.id)}
> >
<div style={getTreemapTileTextStyle()}> <div style={getTreemapTileTextStyle()}>
{`${node.data.displayValue}%`} {`${node.data.displayValue}%`}

View File

@ -159,20 +159,6 @@
gap: 16px; gap: 16px;
padding-top: 32px; padding-top: 32px;
} }
.metric-type-renderer {
border-radius: 50px;
height: 24px;
width: fit-content;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
text-transform: uppercase;
font-size: 12px;
padding: 5px 10px;
align-self: flex-end;
}
} }
.lightMode { .lightMode {
@ -221,3 +207,17 @@
} }
} }
} }
.metric-type-renderer {
border-radius: 50px;
max-height: 24px;
width: fit-content;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
text-transform: uppercase;
font-size: 12px;
padding: 5px 10px;
align-self: flex-end;
}

View File

@ -13,6 +13,7 @@ import { AppState } from 'store/reducers';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
import MetricDetails from '../MetricDetails';
import MetricsSearch from './MetricsSearch'; import MetricsSearch from './MetricsSearch';
import MetricsTable from './MetricsTable'; import MetricsTable from './MetricsTable';
import MetricsTreemap from './MetricsTreemap'; import MetricsTreemap from './MetricsTreemap';
@ -33,6 +34,10 @@ function Summary(): JSX.Element {
const [heatmapView, setHeatmapView] = useState<TreemapViewType>( const [heatmapView, setHeatmapView] = useState<TreemapViewType>(
TreemapViewType.CARDINALITY, TreemapViewType.CARDINALITY,
); );
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
const [selectedMetricName, setSelectedMetricName] = useState<string | null>(
null,
);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>( const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime, (state) => state.globalTime,
@ -133,6 +138,16 @@ function Summary(): JSX.Element {
[metricsData], [metricsData],
); );
const openMetricDetails = (metricName: string): void => {
setSelectedMetricName(metricName);
setIsMetricDetailsOpen(true);
};
const closeMetricDetails = (): void => {
setSelectedMetricName(null);
setIsMetricDetailsOpen(false);
};
return ( return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}> <Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="metrics-explorer-summary-tab"> <div className="metrics-explorer-summary-tab">
@ -146,6 +161,7 @@ function Summary(): JSX.Element {
data={treeMapData?.payload} data={treeMapData?.payload}
isLoading={isTreeMapLoading || isTreeMapFetching} isLoading={isTreeMapLoading || isTreeMapFetching}
viewType={heatmapView} viewType={heatmapView}
openMetricDetails={openMetricDetails}
/> />
<MetricsTable <MetricsTable
isLoading={isMetricsLoading || isMetricsFetching} isLoading={isMetricsLoading || isMetricsFetching}
@ -154,9 +170,18 @@ function Summary(): JSX.Element {
currentPage={currentPage} currentPage={currentPage}
onPaginationChange={onPaginationChange} onPaginationChange={onPaginationChange}
setOrderBy={setOrderBy} setOrderBy={setOrderBy}
totalCount={metricsData?.payload?.data.total || 0} totalCount={metricsData?.payload?.data?.total || 0}
openMetricDetails={openMetricDetails}
/> />
</div> </div>
{isMetricDetailsOpen && (
<MetricDetails
isOpen={isMetricDetailsOpen}
onClose={closeMetricDetails}
metricName={selectedMetricName}
isModalTimeSelection={false}
/>
)}
</Sentry.ErrorBoundary> </Sentry.ErrorBoundary>
); );
} }

View File

@ -13,6 +13,7 @@ export interface MetricsTableProps {
onPaginationChange: (page: number, pageSize: number) => void; onPaginationChange: (page: number, pageSize: number) => void;
setOrderBy: Dispatch<SetStateAction<OrderByPayload>>; setOrderBy: Dispatch<SetStateAction<OrderByPayload>>;
totalCount: number; totalCount: number;
openMetricDetails: (metricName: string) => void;
} }
export interface MetricsSearchProps { export interface MetricsSearchProps {
@ -22,10 +23,11 @@ export interface MetricsSearchProps {
setHeatmapView: (value: TreemapViewType) => void; setHeatmapView: (value: TreemapViewType) => void;
} }
export interface TreemapProps { export interface MetricsTreemapProps {
data: MetricsTreeMapResponse | null | undefined; data: MetricsTreeMapResponse | null | undefined;
isLoading: boolean; isLoading: boolean;
viewType: TreemapViewType; viewType: TreemapViewType;
openMetricDetails: (metricName: string) => void;
} }
export interface OrderByPayload { export interface OrderByPayload {

View File

@ -71,7 +71,11 @@ export const getMetricsListQuery = (): MetricsListPayload => ({
orderBy: { columnName: 'metric_name', order: 'asc' }, orderBy: { columnName: 'metric_name', order: 'asc' },
}); });
function MetricTypeRenderer({ type }: { type: MetricType }): JSX.Element { export function MetricTypeRenderer({
type,
}: {
type: MetricType;
}): JSX.Element {
const [icon, color] = useMemo(() => { const [icon, color] = useMemo(() => {
switch (type) { switch (type) {
case MetricType.SUM: case MetricType.SUM:
@ -157,7 +161,7 @@ export const formatDataForMetricsTable = (
), ),
[TreemapViewType.DATAPOINTS]: ( [TreemapViewType.DATAPOINTS]: (
<ValidateRowValueWrapper value={metric[TreemapViewType.DATAPOINTS]}> <ValidateRowValueWrapper value={metric[TreemapViewType.DATAPOINTS]}>
{metric[TreemapViewType.DATAPOINTS]} {metric[TreemapViewType.DATAPOINTS].toLocaleString()}
</ValidateRowValueWrapper> </ValidateRowValueWrapper>
), ),
[TreemapViewType.CARDINALITY]: ( [TreemapViewType.CARDINALITY]: (

View File

@ -0,0 +1,46 @@
import {
getMetricDetails,
MetricDetailsResponse,
} from 'api/metricsExplorer/getMetricDetails';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetMetricDetails = (
metricName: string,
options?: UseQueryOptions<
SuccessResponse<MetricDetailsResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<MetricDetailsResponse> | ErrorResponse,
Error
>;
export const useGetMetricDetails: UseGetMetricDetails = (
metricName,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_METRIC_DETAILS, metricName];
}, [options?.queryKey, metricName]);
return useQuery<SuccessResponse<MetricDetailsResponse> | ErrorResponse, Error>(
{
queryFn: ({ signal }) => getMetricDetails(metricName, signal, headers),
...options,
queryKey,
},
);
};

View File

@ -0,0 +1,26 @@
import updateMetricMetadata, {
UpdateMetricMetadataProps,
UpdateMetricMetadataResponse,
} from 'api/metricsExplorer/updateMetricMetadata';
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
interface UseUpdateMetricMetadataProps {
metricName: string;
payload: UpdateMetricMetadataProps;
}
export function useUpdateMetricMetadata(): UseMutationResult<
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
Error,
UseUpdateMetricMetadataProps
> {
return useMutation<
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
Error,
UseUpdateMetricMetadataProps
>({
mutationFn: ({ metricName, payload }) =>
updateMetricMetadata(metricName, payload),
});
}