mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
Metric Attribute Explorer (#1703)
<img width="1369" height="1157" alt="image" src="https://github.com/user-attachments/assets/592640b1-9e24-426d-886b-a3afb51410aa" /> Fixes HDX-2282
This commit is contained in:
parent
fa2b73cacc
commit
6241c38892
7 changed files with 845 additions and 18 deletions
5
.changeset/khaki-adults-study.md
Normal file
5
.changeset/khaki-adults-study.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": minor
|
||||
---
|
||||
|
||||
feat: Add metrics attribute explorer in chart builder
|
||||
|
|
@ -80,7 +80,11 @@ import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
|
|||
import { TimePicker } from '@/components/TimePicker';
|
||||
import { IS_LOCAL_MODE } from '@/config';
|
||||
import { GranularityPickerControlled } from '@/GranularityPicker';
|
||||
import { useFetchMetricResourceAttrs } from '@/hooks/useFetchMetricResourceAttrs';
|
||||
import { useFetchMetricMetadata } from '@/hooks/useFetchMetricMetadata';
|
||||
import {
|
||||
parseAttributeKeysFromSuggestions,
|
||||
useFetchMetricResourceAttrs,
|
||||
} from '@/hooks/useFetchMetricResourceAttrs';
|
||||
import SearchInputV2 from '@/SearchInputV2';
|
||||
import { getFirstTimestampValueExpression, useSource } from '@/source';
|
||||
import {
|
||||
|
|
@ -112,6 +116,7 @@ import {
|
|||
InputControlled,
|
||||
TextInputControlled,
|
||||
} from './InputControlled';
|
||||
import { MetricAttributeHelperPanel } from './MetricAttributeHelperPanel';
|
||||
import { MetricNameSelect } from './MetricNameSelect';
|
||||
import SaveToDashboardModal from './SaveToDashboardModal';
|
||||
import SourceSchemaPreview from './SourceSchemaPreview';
|
||||
|
|
@ -248,15 +253,54 @@ function ChartSeriesEditorComponent({
|
|||
: _tableName;
|
||||
|
||||
const metricName = useWatch({ control, name: `${namePrefix}metricName` });
|
||||
const { data: attributeKeys } = useFetchMetricResourceAttrs({
|
||||
const aggCondition = useWatch({
|
||||
control,
|
||||
name: `${namePrefix}aggCondition`,
|
||||
});
|
||||
const groupBy = useWatch({ control, name: 'groupBy' });
|
||||
|
||||
const { data: attributeSuggestions, isLoading: isLoadingAttributes } =
|
||||
useFetchMetricResourceAttrs({
|
||||
databaseName,
|
||||
metricType,
|
||||
metricName,
|
||||
tableSource,
|
||||
isSql: aggConditionLanguage === 'sql',
|
||||
});
|
||||
|
||||
const attributeKeys = useMemo(
|
||||
() => parseAttributeKeysFromSuggestions(attributeSuggestions ?? []),
|
||||
[attributeSuggestions],
|
||||
);
|
||||
|
||||
const { data: metricMetadata } = useFetchMetricMetadata({
|
||||
databaseName,
|
||||
tableName: tableName || '',
|
||||
metricType,
|
||||
metricName,
|
||||
tableSource,
|
||||
isSql: aggConditionLanguage === 'sql',
|
||||
});
|
||||
|
||||
const handleAddToWhere = useCallback(
|
||||
(clause: string) => {
|
||||
const currentValue = aggCondition || '';
|
||||
|
||||
const newValue = currentValue ? `${currentValue} AND ${clause}` : clause;
|
||||
setValue(`${namePrefix}aggCondition`, newValue);
|
||||
onSubmit();
|
||||
},
|
||||
[aggCondition, namePrefix, setValue, onSubmit],
|
||||
);
|
||||
|
||||
const handleAddToGroupBy = useCallback(
|
||||
(clause: string) => {
|
||||
const currentValue = groupBy || '';
|
||||
const newValue = currentValue ? `${currentValue}, ${clause}` : clause;
|
||||
setValue('groupBy', newValue);
|
||||
onSubmit();
|
||||
},
|
||||
[groupBy, setValue, onSubmit],
|
||||
);
|
||||
|
||||
const showWhere = aggFn !== 'none';
|
||||
|
||||
const tableConnection = useMemo(
|
||||
|
|
@ -414,7 +458,7 @@ function ChartSeriesEditorComponent({
|
|||
onLanguageChange={lang =>
|
||||
setValue(`${namePrefix}aggConditionLanguage`, lang)
|
||||
}
|
||||
additionalSuggestions={attributeKeys}
|
||||
additionalSuggestions={attributeSuggestions}
|
||||
language="sql"
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
|
@ -429,7 +473,7 @@ function ChartSeriesEditorComponent({
|
|||
language="lucene"
|
||||
placeholder="Search your events w/ Lucene ex. column:foo"
|
||||
onSubmit={onSubmit}
|
||||
additionalSuggestions={attributeKeys}
|
||||
additionalSuggestions={attributeSuggestions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -480,6 +524,20 @@ function ChartSeriesEditorComponent({
|
|||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
{tableSource?.kind === SourceKind.Metric && metricName && (
|
||||
<MetricAttributeHelperPanel
|
||||
databaseName={databaseName}
|
||||
metricType={metricType}
|
||||
metricName={metricName}
|
||||
tableSource={tableSource}
|
||||
attributeKeys={attributeKeys}
|
||||
isLoading={isLoadingAttributes}
|
||||
language={aggConditionLanguage === 'sql' ? 'sql' : 'lucene'}
|
||||
metricMetadata={metricMetadata}
|
||||
onAddToWhere={handleAddToWhere}
|
||||
onAddToGroupBy={handleAddToGroupBy}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
493
packages/app/src/components/MetricAttributeHelperPanel.tsx
Normal file
493
packages/app/src/components/MetricAttributeHelperPanel.tsx
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { TSource } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
Flex,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue, useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconFilter,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { useFetchMetricAttributeValues } from '@/hooks/useFetchMetricAttributeValues';
|
||||
import { MetricMetadata } from '@/hooks/useFetchMetricMetadata';
|
||||
import {
|
||||
AttributeCategory,
|
||||
AttributeKey,
|
||||
} from '@/hooks/useFetchMetricResourceAttrs';
|
||||
|
||||
interface MetricAttributeHelperPanelProps {
|
||||
databaseName: string;
|
||||
metricType: string;
|
||||
metricName: string;
|
||||
tableSource: TSource | undefined;
|
||||
attributeKeys: AttributeKey[];
|
||||
isLoading?: boolean;
|
||||
language: 'sql' | 'lucene';
|
||||
metricMetadata?: MetricMetadata | null;
|
||||
onAddToWhere: (clause: string) => void;
|
||||
onAddToGroupBy: (clause: string) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<AttributeCategory, string> = {
|
||||
ResourceAttributes: 'Resource',
|
||||
Attributes: 'Attributes',
|
||||
ScopeAttributes: 'Scope',
|
||||
};
|
||||
|
||||
// UCUM (Unified Code for Units of Measure) case-sensitive codes to human-readable names
|
||||
// Reference: https://ucum.org/ucum
|
||||
const UCUM_UNIT_NAMES: Record<string, string> = {
|
||||
// Time units
|
||||
s: 'Seconds',
|
||||
ms: 'Milliseconds',
|
||||
us: 'Microseconds',
|
||||
ns: 'Nanoseconds',
|
||||
min: 'Minutes',
|
||||
h: 'Hours',
|
||||
d: 'Days',
|
||||
wk: 'Weeks',
|
||||
mo: 'Months',
|
||||
a: 'Years',
|
||||
// Data units
|
||||
By: 'Bytes',
|
||||
KiBy: 'Kibibytes',
|
||||
MiBy: 'Mebibytes',
|
||||
GiBy: 'Gibibytes',
|
||||
TiBy: 'Tebibytes',
|
||||
kBy: 'Kilobytes',
|
||||
MBy: 'Megabytes',
|
||||
GBy: 'Gigabytes',
|
||||
TBy: 'Terabytes',
|
||||
bit: 'Bits',
|
||||
Kibit: 'Kibibits',
|
||||
Mibit: 'Mebibits',
|
||||
Gibit: 'Gibibits',
|
||||
// Frequency
|
||||
Hz: 'Hertz',
|
||||
kHz: 'Kilohertz',
|
||||
MHz: 'Megahertz',
|
||||
GHz: 'Gigahertz',
|
||||
// Temperature
|
||||
Cel: 'Celsius',
|
||||
K: 'Kelvin',
|
||||
'[degF]': 'Fahrenheit',
|
||||
// Percentage and dimensionless
|
||||
'%': 'Percent',
|
||||
'1': 'Count',
|
||||
'{request}': 'Requests',
|
||||
'{connection}': 'Connections',
|
||||
'{error}': 'Errors',
|
||||
'{packet}': 'Packets',
|
||||
'{thread}': 'Threads',
|
||||
'{process}': 'Processes',
|
||||
'{message}': 'Messages',
|
||||
'{operation}': 'Operations',
|
||||
'{call}': 'Calls',
|
||||
'{fault}': 'Faults',
|
||||
'{cpu}': 'CPUs',
|
||||
// Physical units
|
||||
m: 'Meters',
|
||||
km: 'Kilometers',
|
||||
g: 'Grams',
|
||||
kg: 'Kilograms',
|
||||
A: 'Amperes',
|
||||
V: 'Volts',
|
||||
W: 'Watts',
|
||||
kW: 'Kilowatts',
|
||||
J: 'Joules',
|
||||
kJ: 'Kilojoules',
|
||||
};
|
||||
|
||||
// Format UCUM unit code to human-readable name
|
||||
function formatUnitDisplay(unit: string): string {
|
||||
// Direct match
|
||||
if (UCUM_UNIT_NAMES[unit]) {
|
||||
return UCUM_UNIT_NAMES[unit];
|
||||
}
|
||||
|
||||
// Handle compound units like "By/s" -> "Bytes/Second"
|
||||
if (unit.includes('/')) {
|
||||
const [numerator, denominator] = unit.split('/');
|
||||
const numName = UCUM_UNIT_NAMES[numerator] || numerator;
|
||||
const denomName = UCUM_UNIT_NAMES[denominator] || denominator;
|
||||
// Singularize denominator (remove trailing 's' if present)
|
||||
const singularDenom = denomName.endsWith('s')
|
||||
? denomName.slice(0, -1)
|
||||
: denomName;
|
||||
return `${numName}/${singularDenom}`;
|
||||
}
|
||||
|
||||
// Return original if no mapping found
|
||||
return unit;
|
||||
}
|
||||
|
||||
function formatWhereClause(
|
||||
category: AttributeCategory,
|
||||
name: string,
|
||||
value: string,
|
||||
language: 'sql' | 'lucene',
|
||||
): string {
|
||||
if (language === 'sql') {
|
||||
return `${category}['${name}'] = '${value}'`;
|
||||
}
|
||||
return `${category}.${name}:"${value}"`;
|
||||
}
|
||||
|
||||
function formatGroupByClause(
|
||||
category: AttributeCategory,
|
||||
name: string,
|
||||
language: 'sql' | 'lucene',
|
||||
): string {
|
||||
if (language === 'sql') {
|
||||
return `${category}['${name}']`;
|
||||
}
|
||||
return `${category}.${name}`;
|
||||
}
|
||||
|
||||
interface AttributeValueListProps {
|
||||
databaseName: string;
|
||||
metricType: string;
|
||||
metricName: string;
|
||||
tableSource: TSource | undefined;
|
||||
attribute: AttributeKey;
|
||||
language: 'sql' | 'lucene';
|
||||
onAddToWhere: (clause: string) => void;
|
||||
onBack: () => void;
|
||||
onAddToGroupBy: (clause: string) => void;
|
||||
}
|
||||
|
||||
function AttributeValueList({
|
||||
databaseName,
|
||||
metricType,
|
||||
metricName,
|
||||
tableSource,
|
||||
attribute,
|
||||
language,
|
||||
onAddToWhere,
|
||||
onBack,
|
||||
onAddToGroupBy,
|
||||
}: AttributeValueListProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(searchTerm, 300);
|
||||
|
||||
const { data: values, isLoading } = useFetchMetricAttributeValues({
|
||||
databaseName,
|
||||
metricType,
|
||||
metricName,
|
||||
attributeName: attribute.name,
|
||||
attributeCategory: attribute.category,
|
||||
searchTerm: debouncedSearch,
|
||||
tableSource,
|
||||
});
|
||||
|
||||
const handleAddValueToWhere = useCallback(
|
||||
(value: string) => {
|
||||
const clause = formatWhereClause(
|
||||
attribute.category,
|
||||
attribute.name,
|
||||
value,
|
||||
language,
|
||||
);
|
||||
onAddToWhere(clause);
|
||||
},
|
||||
[attribute, language, onAddToWhere],
|
||||
);
|
||||
|
||||
const handleAddToGroupBy = useCallback(() => {
|
||||
// Group By is always SQL syntax, regardless of Where condition language
|
||||
const clause = formatGroupByClause(
|
||||
attribute.category,
|
||||
attribute.name,
|
||||
'sql',
|
||||
);
|
||||
onAddToGroupBy(clause);
|
||||
}, [attribute, onAddToGroupBy]);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<UnstyledButton onClick={onBack}>
|
||||
<Group gap={4}>
|
||||
<IconChevronLeft size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{attribute.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="default">
|
||||
{CATEGORY_LABELS[attribute.category]}
|
||||
</Badge>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={handleAddToGroupBy}
|
||||
>
|
||||
Group By
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Search values..."
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Flex justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Flex>
|
||||
) : values && values.length > 0 ? (
|
||||
<ScrollArea.Autosize mah={200}>
|
||||
<Stack gap={4}>
|
||||
{values.map(value => (
|
||||
<Group
|
||||
key={value}
|
||||
justify="space-between"
|
||||
py={4}
|
||||
px="xs"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
className="hover-highlight"
|
||||
>
|
||||
<Text size="xs" style={{ wordBreak: 'break-all' }}>
|
||||
{value}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact-xs"
|
||||
leftSection={<IconFilter size={12} />}
|
||||
onClick={() => handleAddValueToWhere(value)}
|
||||
>
|
||||
Where
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{searchTerm ? 'No matching values found' : 'No values found'}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttributeListProps {
|
||||
attributeKeys: AttributeKey[];
|
||||
onSelectAttribute: (attr: AttributeKey) => void;
|
||||
}
|
||||
|
||||
function AttributeList({
|
||||
attributeKeys,
|
||||
onSelectAttribute,
|
||||
}: AttributeListProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredAttributes = useMemo(() => {
|
||||
if (!searchTerm) return attributeKeys;
|
||||
const lower = searchTerm.toLowerCase();
|
||||
return attributeKeys.filter(attr =>
|
||||
attr.name.toLowerCase().includes(lower),
|
||||
);
|
||||
}, [attributeKeys, searchTerm]);
|
||||
|
||||
const groupedAttributes = useMemo(() => {
|
||||
const groups: Record<AttributeCategory, AttributeKey[]> = {
|
||||
ResourceAttributes: [],
|
||||
Attributes: [],
|
||||
ScopeAttributes: [],
|
||||
};
|
||||
for (const attr of filteredAttributes) {
|
||||
groups[attr.category].push(attr);
|
||||
}
|
||||
return groups;
|
||||
}, [filteredAttributes]);
|
||||
|
||||
const categories: AttributeCategory[] = [
|
||||
'ResourceAttributes',
|
||||
'Attributes',
|
||||
'ScopeAttributes',
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Search attributes..."
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<ScrollArea.Autosize mah={250}>
|
||||
<Stack gap="sm">
|
||||
{categories.map(category => {
|
||||
const attrs = groupedAttributes[category];
|
||||
if (attrs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box key={category}>
|
||||
<Group gap="xs" mb={4}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{CATEGORY_LABELS[category]}
|
||||
</Text>
|
||||
<Badge size="xs" variant="default">
|
||||
{attrs.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Flex gap={6} wrap="wrap">
|
||||
{attrs.map(attr => (
|
||||
<UnstyledButton
|
||||
key={`${attr.category}:${attr.name}`}
|
||||
onClick={() => onSelectAttribute(attr)}
|
||||
>
|
||||
<Paper
|
||||
py={4}
|
||||
px={8}
|
||||
withBorder
|
||||
style={{ cursor: 'pointer' }}
|
||||
className="hover-highlight"
|
||||
>
|
||||
<Group gap={4}>
|
||||
<Text size="xs">{attr.name}</Text>
|
||||
<IconChevronRight size={12} />
|
||||
</Group>
|
||||
</Paper>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricAttributeHelperPanel({
|
||||
databaseName,
|
||||
metricName,
|
||||
tableSource,
|
||||
metricType,
|
||||
attributeKeys,
|
||||
isLoading,
|
||||
language,
|
||||
metricMetadata,
|
||||
onAddToWhere,
|
||||
onAddToGroupBy,
|
||||
}: MetricAttributeHelperPanelProps) {
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
const [selectedAttribute, setSelectedAttribute] =
|
||||
useState<AttributeKey | null>(null);
|
||||
|
||||
const handleSelectAttribute = useCallback((attr: AttributeKey) => {
|
||||
setSelectedAttribute(attr);
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setSelectedAttribute(null);
|
||||
}, []);
|
||||
|
||||
if (!metricName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper withBorder p="xs" mt="xs">
|
||||
<UnstyledButton onClick={toggle} w="100%">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
{metricMetadata?.description ? (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{metricMetadata.description}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{metricName}
|
||||
</Text>
|
||||
)}
|
||||
{metricMetadata?.unit && (
|
||||
<Group gap={4} mt={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Unit:
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{formatUnitDisplay(metricMetadata.unit)}
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{attributeKeys.length > 0 && (
|
||||
<Badge size="xs" variant="light">
|
||||
{attributeKeys.length} attributes
|
||||
</Badge>
|
||||
)}
|
||||
<IconChevronDown
|
||||
size={16}
|
||||
style={{
|
||||
transform: opened ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 200ms',
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Collapse in={opened}>
|
||||
<Box pt="xs">
|
||||
{isLoading ? (
|
||||
<Flex justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Flex>
|
||||
) : attributeKeys.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
No attributes found for this metric
|
||||
</Text>
|
||||
) : selectedAttribute ? (
|
||||
<AttributeValueList
|
||||
databaseName={databaseName}
|
||||
metricType={metricType}
|
||||
metricName={metricName}
|
||||
tableSource={tableSource}
|
||||
attribute={selectedAttribute}
|
||||
language={language}
|
||||
onAddToWhere={onAddToWhere}
|
||||
onBack={handleBack}
|
||||
onAddToGroupBy={onAddToGroupBy}
|
||||
/>
|
||||
) : (
|
||||
<AttributeList
|
||||
attributeKeys={attributeKeys}
|
||||
onSelectAttribute={handleSelectAttribute}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,13 @@ jest.mock('@/hooks/useFetchMetricResourceAttrs', () => ({
|
|||
useFetchMetricResourceAttrs: jest.fn().mockReturnValue({
|
||||
data: [],
|
||||
}),
|
||||
parseAttributeKeysFromSuggestions: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useFetchMetricMetadata', () => ({
|
||||
useFetchMetricMetadata: jest.fn().mockReturnValue({
|
||||
data: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useMetadata', () => ({
|
||||
|
|
|
|||
112
packages/app/src/hooks/useFetchMetricAttributeValues.tsx
Normal file
112
packages/app/src/hooks/useFetchMetricAttributeValues.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import {
|
||||
chSql,
|
||||
ResponseJSON,
|
||||
tableExpr,
|
||||
} from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getClickhouseClient } from '@/clickhouse';
|
||||
import { getMetricTableName } from '@/utils';
|
||||
|
||||
import { AttributeCategory } from './useFetchMetricResourceAttrs';
|
||||
|
||||
const ATTRIBUTE_VALUES_LIMIT = 100;
|
||||
|
||||
interface MetricAttributeValuesProps {
|
||||
databaseName: string;
|
||||
metricName: string;
|
||||
attributeName: string;
|
||||
attributeCategory: AttributeCategory;
|
||||
searchTerm?: string;
|
||||
tableSource: TSource | undefined;
|
||||
metricType: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface AttributeValueResponse {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const useFetchMetricAttributeValues = ({
|
||||
databaseName,
|
||||
metricType,
|
||||
metricName,
|
||||
attributeName,
|
||||
attributeCategory,
|
||||
searchTerm,
|
||||
tableSource,
|
||||
enabled = true,
|
||||
}: MetricAttributeValuesProps) => {
|
||||
const tableName = tableSource
|
||||
? (getMetricTableName(tableSource, metricType) ?? '')
|
||||
: '';
|
||||
|
||||
const shouldFetch = Boolean(
|
||||
enabled &&
|
||||
databaseName &&
|
||||
tableName &&
|
||||
metricType &&
|
||||
metricName &&
|
||||
attributeName &&
|
||||
attributeCategory &&
|
||||
tableSource &&
|
||||
tableSource?.kind === SourceKind.Metric,
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'metric-attribute-values',
|
||||
metricName,
|
||||
metricType,
|
||||
attributeName,
|
||||
attributeCategory,
|
||||
searchTerm,
|
||||
tableSource,
|
||||
],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!shouldFetch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const clickhouseClient = getClickhouseClient();
|
||||
|
||||
// Build optional search filter
|
||||
const searchFilter = searchTerm
|
||||
? chSql` AND ${attributeCategory}[${{ String: attributeName }}] ILIKE ${{ String: `%${searchTerm}%` }}`
|
||||
: chSql``;
|
||||
|
||||
const sql = chSql`
|
||||
SELECT DISTINCT ${attributeCategory}[${{ String: attributeName }}] as value
|
||||
FROM ${tableExpr({ database: databaseName, table: tableName })}
|
||||
WHERE MetricName = ${{ String: metricName }}
|
||||
AND ${attributeCategory}[${{ String: attributeName }}] != ''
|
||||
${searchFilter}
|
||||
ORDER BY value
|
||||
LIMIT ${{ Int32: ATTRIBUTE_VALUES_LIMIT }}
|
||||
`;
|
||||
|
||||
const result = (await clickhouseClient
|
||||
.query<'JSON'>({
|
||||
query: sql.sql,
|
||||
query_params: sql.params,
|
||||
format: 'JSON',
|
||||
abort_signal: signal,
|
||||
connectionId: tableSource!.connection,
|
||||
clickhouse_settings: {
|
||||
max_execution_time: 60,
|
||||
timeout_overflow_mode: 'break',
|
||||
},
|
||||
})
|
||||
.then(res => res.json())) as ResponseJSON<AttributeValueResponse>;
|
||||
|
||||
if (result?.data) {
|
||||
return result.data.map(row => row.value).filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
enabled: shouldFetch,
|
||||
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
|
||||
});
|
||||
};
|
||||
87
packages/app/src/hooks/useFetchMetricMetadata.tsx
Normal file
87
packages/app/src/hooks/useFetchMetricMetadata.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
chSql,
|
||||
ResponseJSON,
|
||||
tableExpr,
|
||||
} from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getClickhouseClient } from '@/clickhouse';
|
||||
import { getMetricTableName } from '@/utils';
|
||||
|
||||
export interface MetricMetadata {
|
||||
unit: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface MetricMetadataProps {
|
||||
databaseName: string;
|
||||
metricType: string;
|
||||
metricName: string;
|
||||
tableSource: TSource | undefined;
|
||||
}
|
||||
|
||||
interface MetricMetadataResponse {
|
||||
MetricUnit: string;
|
||||
MetricDescription: string;
|
||||
}
|
||||
|
||||
export const useFetchMetricMetadata = ({
|
||||
databaseName,
|
||||
metricType,
|
||||
metricName,
|
||||
tableSource,
|
||||
}: MetricMetadataProps) => {
|
||||
const tableName = tableSource
|
||||
? (getMetricTableName(tableSource, metricType) ?? '')
|
||||
: '';
|
||||
|
||||
const shouldFetch = Boolean(
|
||||
databaseName &&
|
||||
metricType &&
|
||||
metricName &&
|
||||
tableSource &&
|
||||
tableName &&
|
||||
tableSource?.kind === SourceKind.Metric,
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['metric-metadata', databaseName, metricType, metricName],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!shouldFetch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clickhouseClient = getClickhouseClient();
|
||||
const sql = chSql`
|
||||
SELECT
|
||||
MetricUnit,
|
||||
MetricDescription
|
||||
FROM ${tableExpr({ database: databaseName, table: tableName })}
|
||||
WHERE MetricName = ${{ String: metricName }}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const result = (await clickhouseClient
|
||||
.query<'JSON'>({
|
||||
query: sql.sql,
|
||||
query_params: sql.params,
|
||||
format: 'JSON',
|
||||
abort_signal: signal,
|
||||
connectionId: tableSource!.connection,
|
||||
})
|
||||
.then(res => res.json())) as ResponseJSON<MetricMetadataResponse>;
|
||||
|
||||
if (result?.data?.[0]) {
|
||||
return {
|
||||
unit: result.data[0].MetricUnit || '',
|
||||
description: result.data[0].MetricDescription || '',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
enabled: shouldFetch,
|
||||
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
|
||||
});
|
||||
};
|
||||
|
|
@ -8,10 +8,79 @@ import { TSource } from '@hyperdx/common-utils/dist/types';
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getClickhouseClient } from '@/clickhouse';
|
||||
import { formatAttributeClause } from '@/utils';
|
||||
import { formatAttributeClause, getMetricTableName } from '@/utils';
|
||||
|
||||
const METRIC_FETCH_LIMIT = 10000;
|
||||
|
||||
export type AttributeCategory =
|
||||
| 'ResourceAttributes'
|
||||
| 'ScopeAttributes'
|
||||
| 'Attributes';
|
||||
|
||||
export interface AttributeKey {
|
||||
name: string;
|
||||
category: AttributeCategory;
|
||||
}
|
||||
|
||||
// Parse suggestion strings to extract unique attribute keys
|
||||
// SQL format: ResourceAttributes['key']='value'
|
||||
// Lucene format: ResourceAttributes.key:"value"
|
||||
export const parseAttributeKeysFromSuggestions = (
|
||||
suggestions: string[],
|
||||
): AttributeKey[] => {
|
||||
const categories: AttributeCategory[] = [
|
||||
'ResourceAttributes',
|
||||
'ScopeAttributes',
|
||||
'Attributes',
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
const attributeKeys: AttributeKey[] = [];
|
||||
|
||||
for (const suggestion of suggestions) {
|
||||
for (const category of categories) {
|
||||
if (!suggestion.startsWith(category)) continue;
|
||||
|
||||
let name: string | null = null;
|
||||
|
||||
// Try SQL format: Category['key']
|
||||
const sqlMatch = suggestion.match(
|
||||
new RegExp(`^${category}\\['([^']+)'\\]`),
|
||||
);
|
||||
if (sqlMatch) {
|
||||
name = sqlMatch[1];
|
||||
} else {
|
||||
// Try Lucene format: Category.key:
|
||||
const luceneMatch = suggestion.match(
|
||||
new RegExp(`^${category}\\.([^:]+):`),
|
||||
);
|
||||
if (luceneMatch) {
|
||||
name = luceneMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (name) {
|
||||
const uniqueKey = `${category}:${name}`;
|
||||
if (!seen.has(uniqueKey)) {
|
||||
seen.add(uniqueKey);
|
||||
attributeKeys.push({ name, category });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by category then name
|
||||
attributeKeys.sort((a, b) => {
|
||||
if (a.category !== b.category) {
|
||||
const order = ['ResourceAttributes', 'Attributes', 'ScopeAttributes'];
|
||||
return order.indexOf(a.category) - order.indexOf(b.category);
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return attributeKeys;
|
||||
};
|
||||
|
||||
const extractAttributeKeys = (
|
||||
attributesArr: MetricAttributesResponse[],
|
||||
isSql: boolean,
|
||||
|
|
@ -52,14 +121,13 @@ const extractAttributeKeys = (
|
|||
}
|
||||
return Array.from(resultSet);
|
||||
} catch (e) {
|
||||
console.error('Error parsing metric autocompleteattributes', e);
|
||||
console.error('Error parsing metric autocomplete attributes', e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
interface MetricResourceAttrsProps {
|
||||
databaseName: string;
|
||||
tableName: string;
|
||||
metricType: string;
|
||||
metricName: string;
|
||||
tableSource: TSource | undefined;
|
||||
|
|
@ -74,28 +142,25 @@ interface MetricAttributesResponse {
|
|||
|
||||
export const useFetchMetricResourceAttrs = ({
|
||||
databaseName,
|
||||
tableName,
|
||||
metricType,
|
||||
metricName,
|
||||
tableSource,
|
||||
isSql,
|
||||
}: MetricResourceAttrsProps) => {
|
||||
const tableName = tableSource
|
||||
? (getMetricTableName(tableSource, metricType) ?? '')
|
||||
: '';
|
||||
|
||||
const shouldFetch = Boolean(
|
||||
databaseName &&
|
||||
tableName &&
|
||||
metricType &&
|
||||
tableSource &&
|
||||
tableSource?.kind === SourceKind.Metric,
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'metric-attributes',
|
||||
databaseName,
|
||||
tableName,
|
||||
metricType,
|
||||
metricName,
|
||||
isSql,
|
||||
],
|
||||
queryKey: ['metric-attributes', metricType, metricName, isSql, tableSource],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!shouldFetch) {
|
||||
return [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue