From 1b758a088c2f3ea224bacb2dcee5376d25bda0db Mon Sep 17 00:00:00 2001 From: Amlan Kumar Nandy <45410599+amlannandy@users.noreply.github.com> Date: Tue, 11 Mar 2025 12:02:08 +0530 Subject: [PATCH] feat: implement metric details view in metrics explorer (#7238) --- .../api/metricsExplorer/getMetricDetails.ts | 69 ++++ .../metricsExplorer/updateMetricMetadata.ts | 33 ++ frontend/src/constants/reactQueryKeys.ts | 1 + .../MetricDetails/AllAttributes.tsx | 142 ++++++++ .../DashboardsAndAlertsPopover.tsx | 124 +++++++ .../MetricDetails/Metadata.tsx | 224 ++++++++++++ .../MetricDetails/MetricDetails.styles.scss | 344 ++++++++++++++++++ .../MetricDetails/MetricDetails.tsx | 143 ++++++++ .../MetricDetails/TopAttributes.tsx | 67 ++++ .../MetricDetails/constants.ts | 5 + .../MetricsExplorer/MetricDetails/index.ts | 3 + .../MetricsExplorer/MetricDetails/types.ts | 40 ++ .../MetricsExplorer/MetricDetails/utils.tsx | 21 ++ .../MetricsExplorer/Summary/MetricsTable.tsx | 5 + .../Summary/MetricsTreemap.tsx | 9 +- .../Summary/Summary.styles.scss | 28 +- .../MetricsExplorer/Summary/Summary.tsx | 27 +- .../MetricsExplorer/Summary/types.ts | 4 +- .../MetricsExplorer/Summary/utils.tsx | 8 +- .../metricsExplorer/useGetMetricDetails.ts | 46 +++ .../useUpdateMetricMetadata.ts | 26 ++ 21 files changed, 1349 insertions(+), 20 deletions(-) create mode 100644 frontend/src/api/metricsExplorer/getMetricDetails.ts create mode 100644 frontend/src/api/metricsExplorer/updateMetricMetadata.ts create mode 100644 frontend/src/container/MetricsExplorer/MetricDetails/AllAttributes.tsx create mode 100644 frontend/src/container/MetricsExplorer/MetricDetails/DashboardsAndAlertsPopover.tsx create mode 100644 frontend/src/container/MetricsExplorer/MetricDetails/Metadata.tsx create mode 100644 frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.styles.scss create mode 100644 frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx create mode 100644 frontend/src/container/MetricsExplorer/MetricDetails/TopAttributes.tsx create mode 100644 frontend/src/container/MetricsExplorer/MetricDetails/constants.ts create mode 100644 frontend/src/container/MetricsExplorer/MetricDetails/index.ts create mode 100644 frontend/src/container/MetricsExplorer/MetricDetails/types.ts create mode 100644 frontend/src/container/MetricsExplorer/MetricDetails/utils.tsx create mode 100644 frontend/src/hooks/metricsExplorer/useGetMetricDetails.ts create mode 100644 frontend/src/hooks/metricsExplorer/useUpdateMetricMetadata.ts diff --git a/frontend/src/api/metricsExplorer/getMetricDetails.ts b/frontend/src/api/metricsExplorer/getMetricDetails.ts new file mode 100644 index 0000000000..3f206167aa --- /dev/null +++ b/frontend/src/api/metricsExplorer/getMetricDetails.ts @@ -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, +): Promise | 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); + } +}; diff --git a/frontend/src/api/metricsExplorer/updateMetricMetadata.ts b/frontend/src/api/metricsExplorer/updateMetricMetadata.ts new file mode 100644 index 0000000000..712bffd43d --- /dev/null +++ b/frontend/src/api/metricsExplorer/updateMetricMetadata.ts @@ -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 | 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; diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index 261539e631..85ad7edec4 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -49,4 +49,5 @@ export const REACT_QUERY_KEY = { GET_METRICS_TREE_MAP: 'GET_METRICS_TREE_MAP', GET_METRICS_LIST_FILTER_KEYS: 'GET_METRICS_LIST_FILTER_KEYS', GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES', + GET_METRIC_DETAILS: 'GET_METRIC_DETAILS', }; diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/AllAttributes.tsx b/frontend/src/container/MetricsExplorer/MetricDetails/AllAttributes.tsx new file mode 100644 index 0000000000..f9ff84aa31 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/MetricDetails/AllAttributes.tsx @@ -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( + '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 = useMemo( + () => [ + { + title: 'Key', + dataIndex: 'key', + key: 'key', + width: 50, + align: 'left', + className: 'metric-metadata-key', + render: (field: { label: string; contribution: number }): JSX.Element => ( +
+ {field.label} + {field.contribution} +
+ ), + }, + { + title: 'Value', + dataIndex: 'value', + key: 'value', + width: 50, + align: 'left', + ellipsis: true, + className: 'metric-metadata-value', + render: (attributes: string[]): JSX.Element => ( +
+ {attributes.map((attribute) => ( + + ))} +
+ ), + }, + ], + [goToMetricsExploreWithAppliedAttribute], + ); + + const items = useMemo( + () => [ + { + label: ( +
+ All Attributes + } + onChange={(e): void => { + setSearchString(e.target.value); + }} + onClick={(e): void => { + e.stopPropagation(); + }} + /> +
+ ), + key: 'all-attributes', + children: ( + + ), + }, + ], + [columns, tableData, searchString], + ); + + return ( + setActiveKey(keys)} + items={items} + /> + ); +} + +export default AllAttributes; diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/DashboardsAndAlertsPopover.tsx b/frontend/src/container/MetricsExplorer/MetricDetails/DashboardsAndAlertsPopover.tsx new file mode 100644 index 0000000000..67bf9d9d1b --- /dev/null +++ b/frontend/src/container/MetricsExplorer/MetricDetails/DashboardsAndAlertsPopover.tsx @@ -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: ( + { + 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} + + ), + })); + } + 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: ( + { + safeNavigate( + generatePath(ROUTES.DASHBOARD, { + dashboardId: dashboard.dashboard_id, + }), + ); + }} + className="dashboards-popover-content-item" + > + {dashboard.dashboard_name || dashboard.dashboard_id} + + ), + })); + } + return null; + }, [uniqueDashboards, safeNavigate]); + + if (!dashboardsPopoverContent && !alertsPopoverContent) { + return null; + } + + return ( +
+ {dashboardsPopoverContent && ( + +
+ + + {uniqueDashboards?.length} dashboard + {uniqueDashboards?.length === 1 ? '' : 's'} + +
+
+ )} + {alertsPopoverContent && ( + +
+ + + {alerts?.length} alert {alerts?.length === 1 ? 'rule' : 'rules'} + +
+
+ )} +
+ ); +} + +export default DashboardsAndAlertsPopover; diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/Metadata.tsx b/frontend/src/container/MetricsExplorer/MetricDetails/Metadata.tsx new file mode 100644 index 0000000000..d7dcea7a7f --- /dev/null +++ b/frontend/src/container/MetricsExplorer/MetricDetails/Metadata.tsx @@ -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({ + type: metadata.metric_type, + description: metadata.description, + unit: metadata.unit, + }); + const { notifications } = useNotifications(); + const { + mutate: updateMetricMetadata, + isLoading: isUpdatingMetricsMetadata, + } = useUpdateMetricMetadata(); + const [activeKey, setActiveKey] = useState( + '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 = useMemo( + () => [ + { + title: 'Key', + dataIndex: 'key', + key: 'key', + width: 50, + align: 'left', + className: 'metric-metadata-key', + render: (field: string): JSX.Element => ( + + ), + }, + { + 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 ( +
+ +
+ ); + } + return ; + } + if (field.key === 'metric_type') { + return ( + { + 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 ( + + ); + } + return ( + + ); + }, [handleSave, isEditing, isUpdatingMetricsMetadata]); + + const items = useMemo( + () => [ + { + label: ( +
+ Metadata + {actionButton} +
+ ), + key: 'metric-metadata', + children: ( + + ), + }, + ], + [actionButton, columns, tableData], + ); + + return ( + setActiveKey(keys)} + items={items} + /> + ); +} + +export default Metadata; diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.styles.scss b/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.styles.scss new file mode 100644 index 0000000000..342926742d --- /dev/null +++ b/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.styles.scss @@ -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); + } + } + } +} diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx b/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx new file mode 100644 index 0000000000..d873261fa1 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx @@ -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 ( + +
+ + {metric?.name} +
+ + + } + 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={} + > + {isMetricDetailsLoading ? ( + + ) : ( +
+
+
+ + DATAPOINTS + + + TIME SERIES + + + LAST RECEIVED + +
+
+ + + {metric?.samples.toLocaleString()} + + + + {timeSeries} + + + {lastReceived} + +
+
+ + + + +
+ )} +
+ ); +} + +export default MetricDetails; diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/TopAttributes.tsx b/frontend/src/container/MetricsExplorer/MetricDetails/TopAttributes.tsx new file mode 100644 index 0000000000..f5b11fb424 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/MetricDetails/TopAttributes.tsx @@ -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( + 'top-attributes', + ); + + const collapseItems = useMemo( + () => [ + { + label: ( +
+ {title} +
+ ), + key: 'top-attributes', + children: ( +
+ {items.map((item) => ( +
+
+
{item.key}
+
{item.count}
+
+
+
+ {item.percentage.toFixed(2)}% +
+
+ ))} + {loadMore && !hideLoadMore && ( +
+ +
+ )} +
+ ), + }, + ], + [title, items, loadMore, hideLoadMore], + ); + + return ( + setActiveKey(keys)} + items={collapseItems} + /> + ); +} + +export default TopAttributes; diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/constants.ts b/frontend/src/container/MetricsExplorer/MetricDetails/constants.ts new file mode 100644 index 0000000000..d00ce5ddb0 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/MetricDetails/constants.ts @@ -0,0 +1,5 @@ +export const METRIC_METADATA_KEYS = { + description: 'Description', + unit: 'Unit', + metric_type: 'Metric Type', +}; diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/index.ts b/frontend/src/container/MetricsExplorer/MetricDetails/index.ts new file mode 100644 index 0000000000..ab7d143591 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/MetricDetails/index.ts @@ -0,0 +1,3 @@ +import MetricDetails from './MetricDetails'; + +export default MetricDetails; diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/types.ts b/frontend/src/container/MetricsExplorer/MetricDetails/types.ts new file mode 100644 index 0000000000..3a5f54772a --- /dev/null +++ b/frontend/src/container/MetricsExplorer/MetricDetails/types.ts @@ -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; +} diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/utils.tsx b/frontend/src/container/MetricsExplorer/MetricDetails/utils.tsx new file mode 100644 index 0000000000..bb91f8bd80 --- /dev/null +++ b/frontend/src/container/MetricsExplorer/MetricDetails/utils.tsx @@ -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); +} diff --git a/frontend/src/container/MetricsExplorer/Summary/MetricsTable.tsx b/frontend/src/container/MetricsExplorer/Summary/MetricsTable.tsx index 22e15588c5..a6921521e8 100644 --- a/frontend/src/container/MetricsExplorer/Summary/MetricsTable.tsx +++ b/frontend/src/container/MetricsExplorer/Summary/MetricsTable.tsx @@ -20,6 +20,7 @@ function MetricsTable({ onPaginationChange, setOrderBy, totalCount, + openMetricDetails, }: MetricsTableProps): JSX.Element { const handleTableChange: TableProps['onChange'] = useCallback( ( @@ -78,6 +79,10 @@ function MetricsTable({ onChange: onPaginationChange, total: totalCount, }} + onRow={(record): { onClick: () => void; className: string } => ({ + onClick: (): void => openMetricDetails(record.key), + className: 'clickable-row', + })} />
); diff --git a/frontend/src/container/MetricsExplorer/Summary/MetricsTreemap.tsx b/frontend/src/container/MetricsExplorer/Summary/MetricsTreemap.tsx index 8d214b2d5a..5d7199f321 100644 --- a/frontend/src/container/MetricsExplorer/Summary/MetricsTreemap.tsx +++ b/frontend/src/container/MetricsExplorer/Summary/MetricsTreemap.tsx @@ -10,7 +10,7 @@ import { TREEMAP_MARGINS, TREEMAP_SQUARE_PADDING, } from './constants'; -import { TreemapProps, TreemapTile, TreemapViewType } from './types'; +import { MetricsTreemapProps, TreemapTile, TreemapViewType } from './types'; import { getTreemapTileStyle, getTreemapTileTextStyle, @@ -21,7 +21,8 @@ function MetricsTreemap({ viewType, data, isLoading, -}: TreemapProps): JSX.Element { + openMetricDetails, +}: MetricsTreemapProps): JSX.Element { const { width: windowWidth } = useWindowSize(); const treemapWidth = useMemo( @@ -93,6 +94,9 @@ function MetricsTreemap({ .map((node, i) => { const nodeWidth = node.x1 - node.x0 - TREEMAP_SQUARE_PADDING; const nodeHeight = node.y1 - node.y0 - TREEMAP_SQUARE_PADDING; + if (nodeWidth < 0 || nodeHeight < 0) { + return null; + } return ( openMetricDetails(node.data.id)} >
{`${node.data.displayValue}%`} diff --git a/frontend/src/container/MetricsExplorer/Summary/Summary.styles.scss b/frontend/src/container/MetricsExplorer/Summary/Summary.styles.scss index 74bda73125..2efdc7402d 100644 --- a/frontend/src/container/MetricsExplorer/Summary/Summary.styles.scss +++ b/frontend/src/container/MetricsExplorer/Summary/Summary.styles.scss @@ -159,20 +159,6 @@ gap: 16px; 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 { @@ -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; +} diff --git a/frontend/src/container/MetricsExplorer/Summary/Summary.tsx b/frontend/src/container/MetricsExplorer/Summary/Summary.tsx index 1c9bdd0063..a44e43d5d9 100644 --- a/frontend/src/container/MetricsExplorer/Summary/Summary.tsx +++ b/frontend/src/container/MetricsExplorer/Summary/Summary.tsx @@ -13,6 +13,7 @@ import { AppState } from 'store/reducers'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { GlobalReducer } from 'types/reducer/globalTime'; +import MetricDetails from '../MetricDetails'; import MetricsSearch from './MetricsSearch'; import MetricsTable from './MetricsTable'; import MetricsTreemap from './MetricsTreemap'; @@ -33,6 +34,10 @@ function Summary(): JSX.Element { const [heatmapView, setHeatmapView] = useState( TreemapViewType.CARDINALITY, ); + const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false); + const [selectedMetricName, setSelectedMetricName] = useState( + null, + ); const { maxTime, minTime } = useSelector( (state) => state.globalTime, @@ -133,6 +138,16 @@ function Summary(): JSX.Element { [metricsData], ); + const openMetricDetails = (metricName: string): void => { + setSelectedMetricName(metricName); + setIsMetricDetailsOpen(true); + }; + + const closeMetricDetails = (): void => { + setSelectedMetricName(null); + setIsMetricDetailsOpen(false); + }; + return ( }>
@@ -146,6 +161,7 @@ function Summary(): JSX.Element { data={treeMapData?.payload} isLoading={isTreeMapLoading || isTreeMapFetching} viewType={heatmapView} + openMetricDetails={openMetricDetails} />
+ {isMetricDetailsOpen && ( + + )}
); } diff --git a/frontend/src/container/MetricsExplorer/Summary/types.ts b/frontend/src/container/MetricsExplorer/Summary/types.ts index 5b2e10f232..d5b2b48c91 100644 --- a/frontend/src/container/MetricsExplorer/Summary/types.ts +++ b/frontend/src/container/MetricsExplorer/Summary/types.ts @@ -13,6 +13,7 @@ export interface MetricsTableProps { onPaginationChange: (page: number, pageSize: number) => void; setOrderBy: Dispatch>; totalCount: number; + openMetricDetails: (metricName: string) => void; } export interface MetricsSearchProps { @@ -22,10 +23,11 @@ export interface MetricsSearchProps { setHeatmapView: (value: TreemapViewType) => void; } -export interface TreemapProps { +export interface MetricsTreemapProps { data: MetricsTreeMapResponse | null | undefined; isLoading: boolean; viewType: TreemapViewType; + openMetricDetails: (metricName: string) => void; } export interface OrderByPayload { diff --git a/frontend/src/container/MetricsExplorer/Summary/utils.tsx b/frontend/src/container/MetricsExplorer/Summary/utils.tsx index 407531e873..b15afb8642 100644 --- a/frontend/src/container/MetricsExplorer/Summary/utils.tsx +++ b/frontend/src/container/MetricsExplorer/Summary/utils.tsx @@ -71,7 +71,11 @@ export const getMetricsListQuery = (): MetricsListPayload => ({ 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(() => { switch (type) { case MetricType.SUM: @@ -157,7 +161,7 @@ export const formatDataForMetricsTable = ( ), [TreemapViewType.DATAPOINTS]: ( - {metric[TreemapViewType.DATAPOINTS]} + {metric[TreemapViewType.DATAPOINTS].toLocaleString()} ), [TreemapViewType.CARDINALITY]: ( diff --git a/frontend/src/hooks/metricsExplorer/useGetMetricDetails.ts b/frontend/src/hooks/metricsExplorer/useGetMetricDetails.ts new file mode 100644 index 0000000000..45ffc141d8 --- /dev/null +++ b/frontend/src/hooks/metricsExplorer/useGetMetricDetails.ts @@ -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 | ErrorResponse, + Error + >, + headers?: Record, +) => UseQueryResult< + SuccessResponse | 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 | ErrorResponse, Error>( + { + queryFn: ({ signal }) => getMetricDetails(metricName, signal, headers), + ...options, + queryKey, + }, + ); +}; diff --git a/frontend/src/hooks/metricsExplorer/useUpdateMetricMetadata.ts b/frontend/src/hooks/metricsExplorer/useUpdateMetricMetadata.ts new file mode 100644 index 0000000000..b6796ef872 --- /dev/null +++ b/frontend/src/hooks/metricsExplorer/useUpdateMetricMetadata.ts @@ -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 | ErrorResponse, + Error, + UseUpdateMetricMetadataProps +> { + return useMutation< + SuccessResponse | ErrorResponse, + Error, + UseUpdateMetricMetadataProps + >({ + mutationFn: ({ metricName, payload }) => + updateMetricMetadata(metricName, payload), + }); +}