mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-13 02:59:05 +08:00
feat: implement metric details view in metrics explorer (#7238)
This commit is contained in:
parent
8fbf8155de
commit
1b758a088c
69
frontend/src/api/metricsExplorer/getMetricDetails.ts
Normal file
69
frontend/src/api/metricsExplorer/getMetricDetails.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
33
frontend/src/api/metricsExplorer/updateMetricMetadata.ts
Normal file
33
frontend/src/api/metricsExplorer/updateMetricMetadata.ts
Normal 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;
|
@ -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',
|
||||||
};
|
};
|
||||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
@ -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;
|
@ -0,0 +1,5 @@
|
|||||||
|
export const METRIC_METADATA_KEYS = {
|
||||||
|
description: 'Description',
|
||||||
|
unit: 'Unit',
|
||||||
|
metric_type: 'Metric Type',
|
||||||
|
};
|
@ -0,0 +1,3 @@
|
|||||||
|
import MetricDetails from './MetricDetails';
|
||||||
|
|
||||||
|
export default MetricDetails;
|
@ -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;
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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}%`}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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]: (
|
||||||
|
46
frontend/src/hooks/metricsExplorer/useGetMetricDetails.ts
Normal file
46
frontend/src/hooks/metricsExplorer/useGetMetricDetails.ts
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
@ -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),
|
||||||
|
});
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user