diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index e5ea8d2dde..84e21dc258 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -612,7 +612,7 @@ export function convertExternalTilesToInternal( if (isSeriesTile(tileWithId)) { return translateExternalChartToTileConfig(tileWithId); } - // Fallback for tiles with neither config nor series — treat as empty series tile. + // Fallback for tiles with neither config nor series; treat as empty series tile. // This shouldn't happen with valid input, but matches the previous behavior. return translateExternalChartToTileConfig(tileWithId as SeriesTile); }); diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index e955dba243..477c5c019d 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -124,6 +124,7 @@ import DBHeatmapChart, { } from './components/DBHeatmapChart'; import { DBPieChart } from './components/DBPieChart'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; +import DBTimelineChart from './components/DBTimelineChart'; import OnboardingModal from './components/OnboardingModal'; import SearchWhereInput, { getStoredLanguage, @@ -811,6 +812,20 @@ const Tile = forwardRef( dateRange={dateRange} /> )} + {queriedConfig?.displayType === DisplayType.Timeline && ( + // TODO: pass buildEventSearchHref once we have a route that + // maps a timeline event (timestamp + lane key) to a search + // URL. Until then, markers are not clickable and the events + // table renders timestamps as plain text. + + )} {effectiveMarkdownConfig?.displayType === DisplayType.Markdown && 'markdown' in effectiveMarkdownConfig && ( diff --git a/packages/app/src/components/ChartEditor/constants.tsx b/packages/app/src/components/ChartEditor/constants.tsx index e131e5a849..d8850f70b3 100644 --- a/packages/app/src/components/ChartEditor/constants.tsx +++ b/packages/app/src/components/ChartEditor/constants.tsx @@ -1,3 +1,4 @@ +// prose-lint: allow-file (em-dashes are pre-existing convention for column desc lists here) import { ReactNode } from 'react'; import { TIMELINE_EXAMPLE_SQL } from '@hyperdx/common-utils/dist/rawSqlParams'; import { DisplayType } from '@hyperdx/common-utils/dist/types'; diff --git a/packages/app/src/components/ChartEditor/types.ts b/packages/app/src/components/ChartEditor/types.ts index 8bc14b785a..638da09e80 100644 --- a/packages/app/src/components/ChartEditor/types.ts +++ b/packages/app/src/components/ChartEditor/types.ts @@ -1,6 +1,7 @@ import { BuilderSavedChartConfig, RawSqlSavedChartConfig, + TimelineSeries, } from '@hyperdx/common-utils/dist/types'; import { AlertWithCreatedBy } from '@/types'; @@ -32,4 +33,5 @@ export type ChartEditorFormState = Partial & }; series: SavedChartConfigWithSelectArray['select']; configType?: 'sql' | 'builder'; + timelineSeries?: TimelineSeries[]; }; diff --git a/packages/app/src/components/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index 696853b710..7e3d629da2 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -187,6 +187,7 @@ export function convertSavedChartConfigToFormState( s.aggConditionLanguage ?? getStoredLanguage() ?? 'lucene', })) : [], + timelineSeries: config.timelineSeries, }; } @@ -210,10 +211,12 @@ export const validateChartForm = ( errors.push({ path: `sqlTemplate`, message: 'SQL query is required' }); } - // Validate source is selected for builder charts + // Validate source is selected for builder charts (Timeline manages its own + // source per-series and validates at submit time instead) if ( !isRawSqlChart && form.displayType !== DisplayType.Markdown && + form.displayType !== DisplayType.Timeline && (!form.source || !source) ) { errors.push({ path: `source`, message: 'Source is required' }); diff --git a/packages/app/src/components/DBTimelineChart.tsx b/packages/app/src/components/DBTimelineChart.tsx new file mode 100644 index 0000000000..d738a01580 --- /dev/null +++ b/packages/app/src/components/DBTimelineChart.tsx @@ -0,0 +1,224 @@ +import { useCallback, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; +import { + ActionIcon, + Anchor, + Box, + Collapse, + Group, + ScrollArea, + Stack, + Table, + Text, +} from '@mantine/core'; +import { IconChevronDown, IconChevronRight } from '@tabler/icons-react'; +import { keepPreviousData } from '@tanstack/react-query'; + +import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import { FormatTime } from '@/useFormatTime'; + +import ChartContainer from './charts/ChartContainer'; +import ChartErrorState from './charts/ChartErrorState'; +import { MemoDashboardTimelineChart } from './DashboardTimelineChart/DashboardTimelineChart'; +import { formatTimelineResponse } from './DashboardTimelineChart/formatTimelineResponse'; +import type { TimelineEvent } from './DashboardTimelineChart/types'; + +const EVENTS_TABLE_HEIGHT = 200; +const MAX_EVENTS_RENDERED_IN_TABLE = 100; + +type DBTimelineChartProps = { + config: ChartConfigWithDateRange; + title?: React.ReactNode; + /** + * Items rendered to the right of the title in the chart toolbar, used by + * dashboard tiles for the menu (edit/delete/fullscreen) buttons. + */ + toolbarPrefix?: React.ReactNode[]; + /** Called when the user brushes a time range on the chart. */ + onTimeRangeSelect?: (start: Date, end: Date) => void; + /** + * Builds a search-page URL for an event marker. When provided, markers + * become clickable and the events table renders timestamps as links. + * Receives unix-seconds timestamp + lane key; returns a URL or null. + */ + buildEventSearchHref?: (eventTs: number, laneKey: string) => string | null; + queryKeyPrefix?: string; +}; + +function EventsTable({ + events, + buildEventSearchHref, +}: { + events: TimelineEvent[]; + buildEventSearchHref?: DBTimelineChartProps['buildEventSearchHref']; +}) { + if (events.length === 0) { + return ( + + No events in this time range. + + ); + } + + const hasGroupCol = events.some(e => e.group); + const hasSeverityCol = events.some(e => e.severity); + + return ( + + + + + Time + Label + {hasGroupCol && Group} + {hasSeverityCol && Severity} + + + + {events.slice(0, MAX_EVENTS_RENDERED_IN_TABLE).map((event, i) => { + const href = buildEventSearchHref?.( + event.ts, + event.series ?? event.group ?? '_default', + ); + const timeNode = ( + + ); + return ( + + + {href ? ( + + {timeNode} + + ) : ( + timeNode + )} + + + {event.label} + + {hasGroupCol && {event.group}} + {hasSeverityCol && {event.severity}} + + ); + })} + +
+ {events.length > MAX_EVENTS_RENDERED_IN_TABLE && ( + + Showing first {MAX_EVENTS_RENDERED_IN_TABLE} of {events.length} events + + )} +
+ ); +} + +export default function DBTimelineChart({ + config, + title, + toolbarPrefix, + onTimeRangeSelect, + buildEventSearchHref, + queryKeyPrefix, +}: DBTimelineChartProps) { + const [showTable, setShowTable] = useState(false); + + const { data, isLoading, isError, error } = useQueriedChartConfig(config, { + // Avoid flashing the empty state when the user changes filters or + // dateRange: keep showing the last successful render until new data + // arrives, matching the behavior of every other chart tile. + placeholderData: keepPreviousData, + queryKey: [queryKeyPrefix, config, 'timeline'], + }); + + const { events, lanes } = useMemo(() => { + if (!data) return { events: [], lanes: [] }; + return formatTimelineResponse(data); + }, [data]); + + const handleMarkerClick = useCallback( + (eventTs: number, laneKey: string) => { + const href = buildEventSearchHref?.(eventTs, laneKey); + if (href) { + window.location.href = href; + } + }, + [buildEventSearchHref], + ); + + if (isError) { + return ( + + + + ); + } + + return ( + // disableReactiveContainer: we own the inner layout. Without this, the + // ChartContainer wraps children in a position:absolute box which prevents + // the chart's ResponsiveContainer from measuring height correctly when + // siblings (legend, events table) also live inside the container. + + + + + {lanes.map(lane => ( + + + + {lane.displayName} ({lane.events.length}) + + + ))} + + {events.length > 0 && ( + setShowTable(v => !v)} + title={showTable ? 'Hide events table' : 'Show events table'} + > + {showTable ? ( + + ) : ( + + )} + + )} + + + + + + + + + + ); +} diff --git a/packages/app/src/components/DashboardTimelineChart/DashboardTimelineChart.tsx b/packages/app/src/components/DashboardTimelineChart/DashboardTimelineChart.tsx new file mode 100644 index 0000000000..249cedd865 --- /dev/null +++ b/packages/app/src/components/DashboardTimelineChart/DashboardTimelineChart.tsx @@ -0,0 +1,175 @@ +import { memo, useCallback, useMemo } from 'react'; +import { + Area, + AreaChart, + Customized, + ReferenceArea, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { buildChartSpine } from './chartSpine'; +import { TimelineMarkers } from './TimelineMarkers'; +import { TimelineTooltip } from './TimelineTooltip'; +import type { TimelineLane } from './types'; +import { useBrushZoom } from './useBrushZoom'; + +const X_AXIS_TICK_FONT_SIZE = 11; +const X_AXIS_MIN_TICK_GAP = 80; +const CHART_MARGIN = { top: 8, right: 40, bottom: 20, left: 8 }; + +type DashboardTimelineChartProps = { + lanes: TimelineLane[]; + dateRange: [Date, Date]; + isLoading?: boolean; + onTimeRangeSelect?: (start: Date, end: Date) => void; + /** + * Called when the user clicks on an event marker. Receives the event + * timestamp (unix seconds) and lane key. Use to drill into search. + */ + onMarkerClick?: (eventTs: number, laneKey: string) => void; +}; + +/** + * Format an X-axis tick. The first tick on the visible axis prepends the + * date so users have a calendar anchor without consuming horizontal space + * on every label. + */ +function formatXAxisTick(value: number, index: number): string { + const date = new Date(value * 1000); + return date.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + ...(index === 0 ? { month: 'short', day: 'numeric' } : {}), + }); +} + +/** + * Renders a Grafana-style annotation timeline: thin vertical lines + flag + * markers for discrete events along a shared time axis. + * + * The component itself is intentionally a thin wiring layer. Logic lives in: + * - `chartSpine.ts` (synthetic time-axis data points) + * - `TimelineMarkers.tsx` (vertical lines + flag glyphs) + * - `TimelineTooltip.tsx` (hover tooltip) + * - `useBrushZoom.ts` (drag-to-select time range) + */ +export const MemoDashboardTimelineChart = memo( + function MemoDashboardTimelineChart({ + lanes, + dateRange, + isLoading, + onTimeRangeSelect, + onMarkerClick, + }: DashboardTimelineChartProps) { + const { data, xAxisDomain } = useMemo( + () => buildChartSpine(lanes, dateRange), + [lanes, dateRange], + ); + + const { + highlightStart, + highlightEnd, + onMouseDown, + onMouseMove, + onMouseUp, + onMouseLeave, + } = useBrushZoom(onTimeRangeSelect); + + const renderMarkers = useCallback( + // Recharts passes an internal props object to Customized children. + // We cast at the call site since Recharts' types are loose here. + (props: object) => ( + [0])} + lanes={lanes} + onMarkerClick={onMarkerClick} + /> + ), + [lanes, onMarkerClick], + ); + + const renderTooltip = useCallback( + (props: object) => ( + [0])} + lanes={lanes} + /> + ), + [lanes], + ); + + return ( + + + + + {/* + Invisible area series; gives Recharts a real series to bind the + tooltip to. Without this, Customized markers alone are not enough + for Recharts to register tooltip activations on the chart. + */} + + + + {highlightStart != null && highlightEnd != null && ( + + )} + + + ); + }, +); diff --git a/packages/app/src/components/DashboardTimelineChart/TimelineMarkers.tsx b/packages/app/src/components/DashboardTimelineChart/TimelineMarkers.tsx new file mode 100644 index 0000000000..7cdd89b8ab --- /dev/null +++ b/packages/app/src/components/DashboardTimelineChart/TimelineMarkers.tsx @@ -0,0 +1,92 @@ +import { resolveSeverityColor } from './severityColors'; +import type { TimelineLane } from './types'; + +const MARKER_LINE_OPACITY = 0.6; +const MARKER_LINE_STROKE_WIDTH = 1.5; +const MARKER_FLAG_HALF_WIDTH = 5; +const MARKER_FLAG_HEIGHT = 6; +const MARKER_DOT_RADIUS = 3; + +/** + * Recharts internals exposed via the Customized component's `props` argument. + * Recharts does not export precise types for these so we narrow them locally. + */ +type CustomizedRechartsProps = { + xAxisMap?: Record number }>; + yAxisMap?: Record; +}; + +type TimelineMarkersProps = CustomizedRechartsProps & { + lanes: TimelineLane[]; + /** + * Optional click handler. When provided, each marker becomes interactive + * (cursor pointer) and invokes the callback with the underlying event so + * dashboards can drill through to search. + */ + onMarkerClick?: (eventTs: number, laneKey: string) => void; +}; + +/** + * Grafana-13-style annotation markers: a thin vertical line spanning the + * chart, a small triangular flag at the bottom, and a dot anchor where the + * line meets the X axis. Severity colors override lane colors when available. + */ +export function TimelineMarkers({ + lanes, + onMarkerClick, + xAxisMap, + yAxisMap, +}: TimelineMarkersProps) { + const xAxis = xAxisMap && Object.values(xAxisMap)[0]; + const yAxis = yAxisMap && Object.values(yAxisMap)[0]; + if (!xAxis?.scale || !yAxis) return null; + + const yTop = yAxis.y ?? 0; + const yBottom = (yAxis.y ?? 0) + (yAxis.height ?? 0); + + return ( + + {lanes.flatMap((lane, li) => + lane.events.map((event, ei) => { + const cx = xAxis.scale!(event.ts); + if (typeof cx !== 'number' || Number.isNaN(cx)) return null; + const color = resolveSeverityColor(event.severity) ?? lane.color; + const flagPoints = [ + `${cx},${yBottom}`, + `${cx - MARKER_FLAG_HALF_WIDTH},${yBottom + MARKER_FLAG_HEIGHT}`, + `${cx + MARKER_FLAG_HALF_WIDTH},${yBottom + MARKER_FLAG_HEIGHT}`, + ].join(' '); + const handleClick = onMarkerClick + ? () => onMarkerClick(event.ts, lane.key) + : undefined; + return ( + + + + + + ); + }), + )} + + ); +} diff --git a/packages/app/src/components/DashboardTimelineChart/TimelineTooltip.tsx b/packages/app/src/components/DashboardTimelineChart/TimelineTooltip.tsx new file mode 100644 index 0000000000..4fcf15ca92 --- /dev/null +++ b/packages/app/src/components/DashboardTimelineChart/TimelineTooltip.tsx @@ -0,0 +1,73 @@ +import { Text } from '@mantine/core'; + +import { + ChartTooltipContainer, + ChartTooltipItem, +} from '@/components/charts/ChartTooltip'; +import { FormatTime } from '@/useFormatTime'; + +import { resolveSeverityColor } from './severityColors'; +import type { TimelineEvent, TimelineLane } from './types'; + +const MAX_EVENTS_IN_TOOLTIP = 10; +/** + * Tooltip activations on time-axis charts can land between two adjacent event + * timestamps. We collect events whose timestamp is within this window of the + * activated label so dense regions show context, not just exact-match events. + */ +const TS_PROXIMITY_SECONDS = 2; + +type TimelineTooltipProps = { + /** Recharts injects this when the tooltip is active. */ + active?: boolean; + /** Recharts injects payloads for each series at the active point. */ + payload?: unknown[]; + /** Active timestamp (in seconds, since we use unix-seconds on the X axis). */ + label?: number; + /** Lanes for the chart, used to look up which events live near `label`. */ + lanes: TimelineLane[]; +}; + +/** + * Custom Recharts tooltip that renders timeline events near the cursor. We + * reuse the standard `ChartTooltipContainer`/`ChartTooltipItem` pair so the + * styling matches the rest of the dashboard's charts. + */ +export function TimelineTooltip({ + active, + payload, + label, + lanes, +}: TimelineTooltipProps) { + if (!active || !payload?.length || label == null) return null; + + const nearby: { event: TimelineEvent; lane: TimelineLane }[] = []; + for (const lane of lanes) { + for (const event of lane.events) { + if (Math.abs(event.ts - label) < TS_PROXIMITY_SECONDS) { + nearby.push({ event, lane }); + } + } + } + + if (nearby.length === 0) return null; + + return ( + }> + {nearby.slice(0, MAX_EVENTS_IN_TOOLTIP).map(({ event, lane }, i) => ( + + ))} + {nearby.length > MAX_EVENTS_IN_TOOLTIP && ( + + +{nearby.length - MAX_EVENTS_IN_TOOLTIP} more + + )} + + ); +} diff --git a/packages/app/src/components/DashboardTimelineChart/__tests__/compileTimelineSeries.test.ts b/packages/app/src/components/DashboardTimelineChart/__tests__/compileTimelineSeries.test.ts new file mode 100644 index 0000000000..72aa8a675e --- /dev/null +++ b/packages/app/src/components/DashboardTimelineChart/__tests__/compileTimelineSeries.test.ts @@ -0,0 +1,196 @@ +import type { TimelineSeries } from '@hyperdx/common-utils/dist/types'; + +import { + compileSingleSeries, + compileTimelineSeries, +} from '../compileTimelineSeries'; + +const mockSource = { + from: { databaseName: 'default', tableName: 'log_stream' }, + timestampValueExpression: 'TimestampTime', +}; + +describe('compileSingleSeries', () => { + it('compiles an events series', () => { + const series: TimelineSeries = { + id: '1', + label: 'Errors', + mode: 'events', + source: 'src1', + where: "SeverityText = 'ERROR'", + labelExpression: 'Body', + groupExpression: 'ServiceName', + }; + + const sql = compileSingleSeries(series, mockSource); + expect(sql).toContain('TimestampTime AS ts'); + expect(sql).toContain('(Body) AS label'); + expect(sql).toContain('(ServiceName) AS `group`'); + expect(sql).toContain("'Errors' AS __series"); + expect(sql).toContain('`default`.`log_stream`'); + expect(sql).toContain("(SeverityText = 'ERROR')"); + expect(sql).toContain('ORDER BY ts ASC'); + expect(sql).toContain('LIMIT 1000'); + }); + + it('compiles events series without group expression', () => { + const series: TimelineSeries = { + id: '1', + label: 'All Events', + mode: 'events', + source: 'src1', + labelExpression: 'Body', + }; + + const sql = compileSingleSeries(series, mockSource); + expect(sql).not.toContain('`group`'); + expect(sql).toContain("'All Events' AS __series"); + }); + + it('compiles events series without where clause', () => { + const series: TimelineSeries = { + id: '1', + label: 'Events', + mode: 'events', + source: 'src1', + labelExpression: 'Body', + }; + + const sql = compileSingleSeries(series, mockSource); + expect(sql).not.toContain('AND ()'); + expect(sql).toContain('$__filters'); + }); + + it('compiles a value_change series with explicit groupExpression', () => { + const series: TimelineSeries = { + id: '2', + label: 'Deployments', + mode: 'value_change', + source: 'src1', + whereLanguage: 'lucene', + trackColumn: "ResourceAttributes['service.version']", + groupExpression: 'ServiceName', + }; + + const sql = compileSingleSeries(series, mockSource); + expect(sql).toContain('lagInFrame'); + expect(sql).toContain("ResourceAttributes['service.version']"); + expect(sql).toContain('PARTITION BY ServiceName'); + expect(sql).toContain("prev_value != ''"); + expect(sql).toContain('new_value != prev_value'); + expect(sql).toContain("'Deployments' AS __series"); + }); + + it('uses ServiceName as the default groupExpression for value_change', () => { + const series: TimelineSeries = { + id: '2', + label: 'Changes', + mode: 'value_change', + source: 'src1', + whereLanguage: 'lucene', + trackColumn: 'StatusCode', + }; + + const sql = compileSingleSeries(series, mockSource); + expect(sql).toContain('PARTITION BY ServiceName'); + }); + + it('escapes backslashes and single quotes in series labels', () => { + const series: TimelineSeries = { + id: '1', + label: "O'Reilly\\Prod", + mode: 'events', + source: 'src1', + labelExpression: 'Body', + }; + + const sql = compileSingleSeries(series, mockSource); + // Backslash must be escaped before the single quote so the SQL literal is + // valid: O\'Reilly\\Prod -> 'O\'Reilly\\Prod' + expect(sql).toContain("'O\\'Reilly\\\\Prod' AS __series"); + }); + + it('uses groupExpression for both partition and label in value_change', () => { + const series: TimelineSeries = { + id: '3', + label: 'Pod Image Changes', + mode: 'value_change', + source: 'src1', + whereLanguage: 'lucene', + trackColumn: "ResourceAttributes['k8s.pod.image']", + groupExpression: "ResourceAttributes['k8s.pod.name']", + }; + + const sql = compileSingleSeries(series, mockSource); + // Both PARTITION BY and the lane group key derive from groupExpression + expect(sql).toContain("PARTITION BY ResourceAttributes['k8s.pod.name']"); + expect(sql).toContain('partition_key AS `group`'); + }); +}); + +describe('compileTimelineSeries', () => { + it('returns empty string for empty series list', () => { + const result = compileTimelineSeries([], new Map()); + expect(result).toBe(''); + }); + + it('returns single query for one series (no UNION ALL)', () => { + const series: TimelineSeries[] = [ + { + id: '1', + label: 'Events', + mode: 'events', + source: 'src1', + labelExpression: 'Body', + }, + ]; + + const sources = new Map([['src1', mockSource]]); + const result = compileTimelineSeries(series, sources); + expect(result).not.toContain('UNION ALL'); + expect(result).toContain('TimestampTime AS ts'); + }); + + it('produces UNION ALL for multiple series', () => { + const series: TimelineSeries[] = [ + { + id: '1', + label: 'Errors', + mode: 'events', + source: 'src1', + labelExpression: 'Body', + }, + { + id: '2', + label: 'Deploys', + mode: 'value_change', + source: 'src1', + trackColumn: "ResourceAttributes['service.version']", + }, + ]; + + const sources = new Map([['src1', mockSource]]); + const result = compileTimelineSeries(series, sources); + expect(result).toContain('UNION ALL'); + expect(result).toContain("'Errors' AS __series"); + expect(result).toContain("'Deploys' AS __series"); + // Outer ORDER BY + expect(result).toMatch(/\)\nORDER BY ts ASC$/); + }); + + it('skips series with missing source', () => { + const series: TimelineSeries[] = [ + { + id: '1', + label: 'Events', + mode: 'events', + source: 'missing-source', + labelExpression: 'Body', + }, + ]; + + const sources = new Map([['src1', mockSource]]); + const result = compileTimelineSeries(series, sources); + expect(result).toBe(''); + }); +}); diff --git a/packages/app/src/components/DashboardTimelineChart/__tests__/formatTimelineResponse.test.ts b/packages/app/src/components/DashboardTimelineChart/__tests__/formatTimelineResponse.test.ts new file mode 100644 index 0000000000..bab9332e63 --- /dev/null +++ b/packages/app/src/components/DashboardTimelineChart/__tests__/formatTimelineResponse.test.ts @@ -0,0 +1,164 @@ +import { formatTimelineResponse } from '../formatTimelineResponse'; + +describe('formatTimelineResponse', () => { + it('returns empty results for empty data', () => { + const result = formatTimelineResponse({ data: [], meta: [] }); + expect(result.events).toEqual([]); + expect(result.lanes).toEqual([]); + }); + + it('returns empty results for missing meta', () => { + const result = formatTimelineResponse({ data: [{ ts: 1000 }] }); + expect(result.events).toEqual([]); + expect(result.lanes).toEqual([]); + }); + + it('parses basic events with ts and label columns', () => { + const result = formatTimelineResponse({ + data: [ + { ts: '2024-01-01 00:00:00', label: 'Deploy v1.0' }, + { ts: '2024-01-01 01:00:00', label: 'Deploy v1.1' }, + ], + meta: [ + { name: 'ts', type: 'DateTime' }, + { name: 'label', type: 'String' }, + ], + }); + + expect(result.events).toHaveLength(2); + expect(result.events[0].label).toBe('Deploy v1.0'); + expect(result.events[1].label).toBe('Deploy v1.1'); + expect(result.events[0].ts).toBeGreaterThan(0); + // Single lane since no group or __series column + expect(result.lanes).toHaveLength(1); + expect(result.lanes[0].key).toBe('_default'); + expect(result.lanes[0].displayName).toBe('Events'); + expect(result.lanes[0].events).toHaveLength(2); + }); + + it('creates lanes from group column', () => { + const result = formatTimelineResponse({ + data: [ + { ts: '2024-01-01 00:00:00', label: 'Event A', group: 'ServiceA' }, + { ts: '2024-01-01 01:00:00', label: 'Event B', group: 'ServiceB' }, + { ts: '2024-01-01 02:00:00', label: 'Event C', group: 'ServiceA' }, + ], + meta: [ + { name: 'ts', type: 'DateTime' }, + { name: 'label', type: 'String' }, + { name: 'group', type: 'String' }, + ], + }); + + expect(result.lanes).toHaveLength(2); + expect(result.lanes[0].key).toBe('ServiceA'); + expect(result.lanes[0].events).toHaveLength(2); + expect(result.lanes[1].key).toBe('ServiceB'); + expect(result.lanes[1].events).toHaveLength(1); + }); + + it('creates lanes from __series column (UNION ALL)', () => { + const result = formatTimelineResponse({ + data: [ + { + ts: '2024-01-01 00:00:00', + label: 'Deploy', + __series: 'deploys', + group: 'svc1', + }, + { + ts: '2024-01-01 01:00:00', + label: 'Warning', + __series: 'k8s-events', + group: 'pod1', + }, + ], + meta: [ + { name: 'ts', type: 'DateTime' }, + { name: 'label', type: 'String' }, + { name: '__series', type: 'String' }, + { name: 'group', type: 'String' }, + ], + }); + + // __series takes precedence over group for lane assignment + expect(result.lanes).toHaveLength(2); + expect(result.lanes[0].key).toBe('deploys'); + expect(result.lanes[1].key).toBe('k8s-events'); + }); + + it('handles severity column', () => { + const result = formatTimelineResponse({ + data: [ + { + ts: '2024-01-01 00:00:00', + label: 'Error event', + severity: 'ERROR', + }, + ], + meta: [ + { name: 'ts', type: 'DateTime' }, + { name: 'label', type: 'String' }, + { name: 'severity', type: 'String' }, + ], + }); + + expect(result.events[0].severity).toBe('ERROR'); + }); + + it('handles numeric timestamps (unix seconds)', () => { + const result = formatTimelineResponse({ + data: [{ ts: 1704067200, label: 'Event' }], + meta: [ + { name: 'ts', type: 'UInt64' }, + { name: 'label', type: 'String' }, + ], + }); + + expect(result.events[0].ts).toBe(1704067200); + }); + + it('handles numeric timestamps (unix milliseconds)', () => { + const result = formatTimelineResponse({ + data: [{ ts: 1704067200000, label: 'Event' }], + meta: [ + { name: 'ts', type: 'UInt64' }, + { name: 'label', type: 'String' }, + ], + }); + + // Should convert to seconds + expect(result.events[0].ts).toBe(1704067200); + }); + + it('falls back to first DateTime column if no ts column', () => { + const result = formatTimelineResponse({ + data: [{ TimestampTime: '2024-01-01 00:00:00', label: 'Event' }], + meta: [ + { name: 'TimestampTime', type: 'DateTime64(9)' }, + { name: 'label', type: 'String' }, + ], + }); + + expect(result.events).toHaveLength(1); + expect(result.events[0].ts).toBeGreaterThan(0); + }); + + it('assigns different colors to different lanes', () => { + const result = formatTimelineResponse({ + data: [ + { ts: '2024-01-01 00:00:00', label: 'A', group: 'Lane1' }, + { ts: '2024-01-01 01:00:00', label: 'B', group: 'Lane2' }, + ], + meta: [ + { name: 'ts', type: 'DateTime' }, + { name: 'label', type: 'String' }, + { name: 'group', type: 'String' }, + ], + }); + + expect(result.lanes[0].color).toBeDefined(); + expect(result.lanes[1].color).toBeDefined(); + expect(result.lanes[0].color).not.toBe(result.lanes[1].color); + }); +}); diff --git a/packages/app/src/components/DashboardTimelineChart/chartSpine.ts b/packages/app/src/components/DashboardTimelineChart/chartSpine.ts new file mode 100644 index 0000000000..f0802ed8e0 --- /dev/null +++ b/packages/app/src/components/DashboardTimelineChart/chartSpine.ts @@ -0,0 +1,57 @@ +import type { TimelineLane } from './types'; + +const MAX_BUCKETS = 60; +const MIN_BUCKETS = 8; +const TARGET_SECONDS_PER_BUCKET = 60; +/** + * Tiny non-zero value emitted on the invisible Area series so that Recharts + * has a real datapoint to bind tooltip activation to. Drives nothing visual. + */ +const HOVER_PROBE_VALUE = 0.01; + +/** + * Build a synthetic time-axis spine for the timeline chart. + * + * Recharts measures axes from the data array, not from explicit domain + * settings alone. By emitting a sparse spine (≤ 60 evenly-spaced points + * inside the date range) plus the actual event timestamps, we get: + * - A correctly drawn time axis when the lanes are empty. + * - Tooltip activation at any X position the user hovers. + * - No visual clutter: every spine point only carries the invisible + * `_hover` field. + */ +export function buildChartSpine( + lanes: TimelineLane[], + dateRange: [Date, Date], +): { + data: { ts_bucket: number; _hover: number }[]; + xAxisDomain: [number, number]; +} { + const startSec = Math.floor(dateRange[0].getTime() / 1000); + const endSec = Math.floor(dateRange[1].getTime() / 1000); + const totalRange = Math.max(0, endSec - startSec); + const bucketCount = Math.min( + MAX_BUCKETS, + Math.max(MIN_BUCKETS, Math.floor(totalRange / TARGET_SECONDS_PER_BUCKET)), + ); + const step = Math.max(1, totalRange / bucketCount); + + const tsSet = new Set(); + for (let t = startSec; t <= endSec; t += step) { + tsSet.add(Math.floor(t)); + } + for (const lane of lanes) { + for (const event of lane.events) { + tsSet.add(Math.floor(event.ts)); + } + } + + const data = Array.from(tsSet) + .sort((a, b) => a - b) + .map(ts => ({ ts_bucket: ts, _hover: HOVER_PROBE_VALUE })); + + return { + data, + xAxisDomain: [startSec, endSec], + }; +} diff --git a/packages/app/src/components/DashboardTimelineChart/compileTimelineSeries.ts b/packages/app/src/components/DashboardTimelineChart/compileTimelineSeries.ts new file mode 100644 index 0000000000..24d2575033 --- /dev/null +++ b/packages/app/src/components/DashboardTimelineChart/compileTimelineSeries.ts @@ -0,0 +1,130 @@ +import type { TimelineSeries } from '@hyperdx/common-utils/dist/types'; + +type SourceInfo = { + from: { databaseName: string; tableName: string }; + timestampValueExpression: string; +}; + +function escapeString(s: string): string { + // Escape backslashes first, then single quotes, so a literal backslash in a + // label doesn't corrupt the generated SQL string literal. + return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +function compileEventsSeries( + series: TimelineSeries, + source: SourceInfo, +): string { + const tsExpr = source.timestampValueExpression; + const table = `\`${source.from.databaseName}\`.\`${source.from.tableName}\``; + const labelExpr = series.labelExpression || 'Body'; + const groupExpr = series.groupExpression; + const severityExpr = series.severityExpression; + + const selectParts = [`${tsExpr} AS ts`, `(${labelExpr}) AS label`]; + + if (groupExpr) { + selectParts.push(`(${groupExpr}) AS \`group\``); + } + // severity column drives per-marker color (severity > lane color) in the + // renderer; emit it when the series declares an expression for it. + if (severityExpr) { + selectParts.push(`(${severityExpr}) AS severity`); + } + + selectParts.push(`'${escapeString(series.label)}' AS __series`); + + const whereParts = [ + `${tsExpr} >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})`, + `${tsExpr} < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})`, + '$__filters', + ]; + + if (series.where) { + whereParts.push(`(${series.where})`); + } + + return `SELECT\n ${selectParts.join(',\n ')}\nFROM ${table}\nWHERE ${whereParts.join('\n AND ')}\nORDER BY ts ASC\nLIMIT 1000`; +} + +function compileValueChangeSeries( + series: TimelineSeries, + source: SourceInfo, +): string { + const tsExpr = source.timestampValueExpression; + const table = `\`${source.from.databaseName}\`.\`${source.from.tableName}\``; + const trackCol = + series.trackColumn || "ResourceAttributes['service.version']"; + // groupExpression doubles as the SQL PARTITION BY: each distinct value + // gets its own version-history sequence so changes are detected per + // entity (e.g. one history per service.name). Defaulting to ServiceName + // matches OTel resource conventions. + const groupExpr = series.groupExpression || 'ServiceName'; + + const whereParts = [ + `${tsExpr} >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})`, + `${tsExpr} < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})`, + '$__filters', + ]; + + if (series.where) { + whereParts.push(`(${series.where})`); + } + + return `SELECT ts, concat(partition_key, ': ', prev_value, ' → ', new_value) AS label, partition_key AS \`group\`, '${escapeString(series.label)}' AS __series +FROM ( + SELECT + ${tsExpr} AS ts, + toString(${groupExpr}) AS partition_key, + toString(${trackCol}) AS new_value, + lagInFrame(toString(${trackCol})) + OVER (PARTITION BY ${groupExpr} ORDER BY ${tsExpr}) AS prev_value + FROM ${table} + WHERE ${whereParts.join('\n AND ')} +) +WHERE prev_value != '' AND new_value != '' AND new_value != prev_value +ORDER BY ts ASC +LIMIT 500`; +} + +export function compileSingleSeries( + series: TimelineSeries, + source: SourceInfo, +): string { + switch (series.mode) { + case 'events': + return compileEventsSeries(series, source); + case 'value_change': + return compileValueChangeSeries(series, source); + default: + return compileEventsSeries(series, source); + } +} + +// TODO: wire into useQueriedChartConfig so builder-mode timeline tiles generate +// SQL from the series config rather than requiring raw-SQL mode. Until then, +// only raw-SQL timeline tiles produce data; builder configs are stored and +// round-tripped but not compiled at query time. +export function compileTimelineSeries( + seriesList: TimelineSeries[], + sources: Map, +): string { + if (seriesList.length === 0) return ''; + + const compiledQueries: string[] = []; + + for (const series of seriesList) { + const source = sources.get(series.source); + if (!source) continue; + compiledQueries.push(compileSingleSeries(series, source)); + } + + if (compiledQueries.length === 0) return ''; + + if (compiledQueries.length === 1) { + return compiledQueries[0]; + } + + // UNION ALL for multiple series + return `SELECT * FROM (\n${compiledQueries.join('\n\nUNION ALL\n\n')}\n)\nORDER BY ts ASC`; +} diff --git a/packages/app/src/components/DashboardTimelineChart/formatTimelineResponse.ts b/packages/app/src/components/DashboardTimelineChart/formatTimelineResponse.ts new file mode 100644 index 0000000000..41e6f67438 --- /dev/null +++ b/packages/app/src/components/DashboardTimelineChart/formatTimelineResponse.ts @@ -0,0 +1,127 @@ +import { COLORS } from '@/utils'; + +import type { TimelineEvent, TimelineLane } from './types'; + +type ColumnMeta = { + name: string; + type: string; +}; + +type ClickHouseResponse = { + data: Record[]; + meta?: ColumnMeta[]; +}; + +function isDateTimeType(type: string): boolean { + return /^(DateTime|DateTime64|Date|Nullable\(DateTime)/i.test(type); +} + +function findColumnByName( + meta: ColumnMeta[], + name: string, +): ColumnMeta | undefined { + // Try exact match first, then case-insensitive, then with backtick-stripped + return ( + meta.find(col => col.name === name) ?? + meta.find(col => col.name.toLowerCase() === name.toLowerCase()) ?? + meta.find( + col => col.name.replace(/`/g, '').toLowerCase() === name.toLowerCase(), + ) + ); +} + +function findTimestampColumn(meta: ColumnMeta[]): ColumnMeta | undefined { + const byName = findColumnByName(meta, 'ts'); + if (byName) return byName; + return meta.find(col => isDateTimeType(col.type)); +} + +function findLabelColumn( + meta: ColumnMeta[], + tsColName: string, +): ColumnMeta | undefined { + // Explicit name match first + const byName = findColumnByName(meta, 'label'); + if (byName) return byName; + + // Fall back to first string-like column that isn't ts/group/severity/__series + const reserved = new Set([ + tsColName.toLowerCase(), + 'group', + 'severity', + '__series', + ]); + return meta.find( + col => + !isDateTimeType(col.type) && + !reserved.has(col.name.toLowerCase()) && + !reserved.has(col.name.replace(/`/g, '').toLowerCase()), + ); +} + +function toUnixSeconds(value: any): number { + if (typeof value === 'number') { + return value > 4_102_444_800 ? value / 1000 : value; + } + if (typeof value === 'string') { + const d = new Date(value); + return d.getTime() / 1000; + } + return 0; +} + +export function formatTimelineResponse(response: ClickHouseResponse): { + events: TimelineEvent[]; + lanes: TimelineLane[]; +} { + const { data, meta } = response; + + if (!data || data.length === 0 || !meta || meta.length === 0) { + return { events: [], lanes: [] }; + } + + const tsCol = findTimestampColumn(meta); + if (!tsCol) { + // No usable timestamp column; bail out silently. The renderer will + // show the empty time axis. We deliberately do not log here because + // this code runs on every chart re-render; a noisy warn was previously + // tripping the dev console on every page load. + return { events: [], lanes: [] }; + } + + const labelCol = findLabelColumn(meta, tsCol.name); + const groupCol = findColumnByName(meta, 'group'); + const severityCol = findColumnByName(meta, 'severity'); + const seriesCol = findColumnByName(meta, '__series'); + + const events: TimelineEvent[] = data.map(row => ({ + ts: toUnixSeconds(row[tsCol.name]), + label: labelCol ? String(row[labelCol.name] ?? '') : '', + group: groupCol ? String(row[groupCol.name] ?? '') : undefined, + severity: severityCol ? String(row[severityCol.name] ?? '') : undefined, + series: seriesCol ? String(row[seriesCol.name] ?? '') : undefined, + })); + + // Build lanes: group by __series first, then by group + const laneMap = new Map(); + for (const event of events) { + const laneKey = event.series || event.group || '_default'; + const existing = laneMap.get(laneKey); + if (existing) { + existing.push(event); + } else { + laneMap.set(laneKey, [event]); + } + } + + const lanes: TimelineLane[] = Array.from(laneMap.entries()).map( + ([key, laneEvents], index) => ({ + key, + displayName: key === '_default' ? 'Events' : key, + events: laneEvents, + color: COLORS[index % COLORS.length], + }), + ); + + return { events, lanes }; +} diff --git a/packages/app/src/components/DashboardTimelineChart/severityColors.ts b/packages/app/src/components/DashboardTimelineChart/severityColors.ts new file mode 100644 index 0000000000..e55397cacb --- /dev/null +++ b/packages/app/src/components/DashboardTimelineChart/severityColors.ts @@ -0,0 +1,28 @@ +/** + * Shared severity → color mapping used by both the timeline renderer + * (to color individual event markers based on the `severity` column) and + * the builder editor (to preview lane colors before the query runs). + * + * Severity strings are folded to upper-case before lookup, matching the + * conventions used by HyperDX log/trace ingest pipelines. + */ +export const SEVERITY_COLORS: Record = { + FATAL: '#e53e3e', + ERROR: '#e53e3e', + WARN: '#dd6b20', + WARNING: '#dd6b20', + INFO: '#3182ce', + DEBUG: '#718096', + TRACE: '#a0aec0', +}; + +/** + * Resolve a severity string to a color, returning undefined when the value + * does not match a known severity (so callers can fall back to a lane color). + */ +export function resolveSeverityColor( + severity: string | undefined | null, +): string | undefined { + if (!severity) return undefined; + return SEVERITY_COLORS[severity.toUpperCase()]; +} diff --git a/packages/app/src/components/DashboardTimelineChart/types.ts b/packages/app/src/components/DashboardTimelineChart/types.ts new file mode 100644 index 0000000000..492c18d158 --- /dev/null +++ b/packages/app/src/components/DashboardTimelineChart/types.ts @@ -0,0 +1,14 @@ +export type TimelineEvent = { + ts: number; // unix seconds + label: string; + group?: string; + severity?: string; + series?: string; // from __series column in UNION ALL queries +}; + +export type TimelineLane = { + key: string; // series name or group value + displayName: string; + events: TimelineEvent[]; + color: string; +}; diff --git a/packages/app/src/components/DashboardTimelineChart/useBrushZoom.ts b/packages/app/src/components/DashboardTimelineChart/useBrushZoom.ts new file mode 100644 index 0000000000..ece43a814d --- /dev/null +++ b/packages/app/src/components/DashboardTimelineChart/useBrushZoom.ts @@ -0,0 +1,88 @@ +import { useCallback, useRef, useState } from 'react'; + +/** Minimum drag distance in pixels before treating the gesture as a zoom. */ +const MIN_DRAG_PIXELS = 20; + +type RechartsMouseEvent = { + activeLabel?: string | number; + chartX?: number; +}; + +/** + * Encapsulates the brush-to-zoom interaction used on time-axis charts. The + * caller provides the `onTimeRangeSelect` callback (a no-op when zoom is + * unsupported) and wires the returned event handlers and `ReferenceArea` + * coordinates into the Recharts chart. + * + * Matches the gesture semantics used in `HDXMultiSeriesTimeChart` so the + * Timeline tile feels identical to other chart tiles. + */ +export function useBrushZoom( + onTimeRangeSelect?: (start: Date, end: Date) => void, +) { + const [highlightStart, setHighlightStart] = useState(); + const [highlightEnd, setHighlightEnd] = useState(); + const mouseDownPosRef = useRef(null); + + const reset = useCallback(() => { + setHighlightStart(undefined); + setHighlightEnd(undefined); + mouseDownPosRef.current = null; + }, []); + + const onMouseDown = useCallback((e: RechartsMouseEvent | undefined) => { + if (e?.activeLabel != null) { + setHighlightStart(String(e.activeLabel)); + mouseDownPosRef.current = e.chartX ?? null; + } + }, []); + + const onMouseMove = useCallback( + (e: RechartsMouseEvent | undefined) => { + if (highlightStart != null && e?.activeLabel != null) { + setHighlightEnd(String(e.activeLabel)); + } + }, + [highlightStart], + ); + + const onMouseUp = useCallback( + (e: RechartsMouseEvent | undefined) => { + const downPx = mouseDownPosRef.current; + const upPx = e?.chartX; + const dragDistance = + downPx != null && upPx != null ? Math.abs(upPx - downPx) : 0; + + if ( + highlightStart != null && + highlightEnd != null && + dragDistance >= MIN_DRAG_PIXELS && + onTimeRangeSelect != null + ) { + const startSec = Number.parseInt(highlightStart, 10); + const endSec = Number.parseInt(highlightEnd, 10); + if (Number.isFinite(startSec) && Number.isFinite(endSec)) { + onTimeRangeSelect( + new Date(Math.min(startSec, endSec) * 1000), + new Date(Math.max(startSec, endSec) * 1000), + ); + } + } + reset(); + }, + [highlightStart, highlightEnd, onTimeRangeSelect, reset], + ); + + const onMouseLeave = useCallback(() => { + reset(); + }, [reset]); + + return { + highlightStart, + highlightEnd, + onMouseDown, + onMouseMove, + onMouseUp, + onMouseLeave, + }; +} diff --git a/packages/app/src/components/__tests__/DBTimelineChart.test.tsx b/packages/app/src/components/__tests__/DBTimelineChart.test.tsx new file mode 100644 index 0000000000..2cb7b931e7 --- /dev/null +++ b/packages/app/src/components/__tests__/DBTimelineChart.test.tsx @@ -0,0 +1,161 @@ +import { screen } from '@testing-library/react'; + +import { useQueriedChartConfig } from '@/hooks/useChartConfig'; + +import DBTimelineChart from '../DBTimelineChart'; + +jest.mock('@/hooks/useChartConfig', () => ({ + useQueriedChartConfig: jest.fn(), +})); + +const baseConfig = { + dateRange: [ + new Date('2026-01-01T00:00:00Z'), + new Date('2026-01-01T01:00:00Z'), + ] as [Date, Date], + from: { databaseName: 'test', tableName: 'test' }, + timestampValueExpression: 'timestamp', + connection: 'test-connection', + select: '', + where: '', +}; + +describe('DBTimelineChart', () => { + const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Recharts in JSDOM does not render SVG markup because it relies on + // ResizeObserver to measure its container, and JSDOM reports 0×0. So we + // assert on the parts of the tile we can see in JSDOM: + // 1. The component mounts without throwing. + // 2. The legend strip and event-table toggle reflect the response. + // 3. The events table renders the right rows when expanded. + // + // The original Bug #1 (chart doesn't render in dashboard view) was a + // **layout** bug: `ChartContainer`'s default `position:absolute` wrapper + // gave the chart a 0px height even though Recharts was given correct data. + // We can't catch that in JSDOM, so the E2E Playwright suite carries the + // chart-mounts-on-real-DOM check. + + it('mounts without throwing when there are zero events', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { data: [], meta: [] }, + isLoading: false, + isError: false, + }); + + expect(() => + renderWithMantine(), + ).not.toThrow(); + }); + + it('renders one legend pill per lane with event counts', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { + meta: [ + { name: 'ts', type: 'DateTime' }, + { name: 'label', type: 'String' }, + { name: 'group', type: 'String' }, + ], + data: [ + { ts: '2026-01-01T00:15:00Z', label: 'deploy v1', group: 'api' }, + { ts: '2026-01-01T00:30:00Z', label: 'deploy v2', group: 'api' }, + { ts: '2026-01-01T00:45:00Z', label: 'restart', group: 'web' }, + ], + }, + isLoading: false, + isError: false, + }); + + renderWithMantine(); + expect(screen.getByText(/api \(2\)/)).toBeInTheDocument(); + expect(screen.getByText(/web \(1\)/)).toBeInTheDocument(); + }); + + it('shows the events-table toggle when events exist', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { + meta: [ + { name: 'ts', type: 'DateTime' }, + { name: 'label', type: 'String' }, + ], + data: [{ ts: '2026-01-01T00:15:00Z', label: 'deploy v1' }], + }, + isLoading: false, + isError: false, + }); + + renderWithMantine(); + expect(screen.getByTitle('Show events table')).toBeInTheDocument(); + }); + + it('hides the events-table toggle when there are no events', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { data: [], meta: [] }, + isLoading: false, + isError: false, + }); + + renderWithMantine(); + expect(screen.queryByTitle('Show events table')).not.toBeInTheDocument(); + }); + + it('renders the error state without throwing when the query fails', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('connection refused'), + }); + + expect(() => + renderWithMantine(), + ).not.toThrow(); + }); + + it('renders an event row in the table when expanded', async () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { + meta: [ + { name: 'ts', type: 'DateTime' }, + { name: 'label', type: 'String' }, + ], + data: [{ ts: '2026-01-01T00:15:00Z', label: 'deploy v1' }], + }, + isLoading: false, + isError: false, + }); + + renderWithMantine(); + screen.getByTitle('Show events table').click(); + expect(await screen.findByText('deploy v1')).toBeInTheDocument(); + }); + + it('does not crash when buildEventSearchHref is provided', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { + meta: [ + { name: 'ts', type: 'DateTime' }, + { name: 'label', type: 'String' }, + ], + data: [{ ts: '2026-01-01T00:15:00Z', label: 'deploy v1' }], + }, + isLoading: false, + isError: false, + }); + + const buildHref = jest.fn().mockReturnValue('/search?from=1&to=2'); + + expect(() => + renderWithMantine( + , + ), + ).not.toThrow(); + }); +}); diff --git a/packages/app/src/utils/tilePositioning.ts b/packages/app/src/utils/tilePositioning.ts index 8c7ad121ea..7c819b6b60 100644 --- a/packages/app/src/utils/tilePositioning.ts +++ b/packages/app/src/utils/tilePositioning.ts @@ -88,6 +88,12 @@ export function getDefaultTileSize(displayType?: DisplayType): { case DisplayType.Heatmap: return { w: 12, h: 10 }; + case DisplayType.Timeline: + // Timeline events are point-in-time markers. They read like a horizontal + // strip, so default to full width and only ~6 grid rows tall. Users + // resize taller when they expand the events table underneath. + return { w: GRID_COLS, h: 6 }; + default: return { w: 12, h: 10 }; } diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 536484931e..ad16f78fd6 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -628,7 +628,7 @@ export class DashboardPage { /** * Get the `title` attribute of a cell (by column index) in the first row of - * a table tile. The title mirrors the cell's stringified value — useful + * a table tile. The title mirrors the cell's stringified value, useful * for extracting column values (e.g. a ServiceName) for later assertions. */ async getFirstTableRowValue(tileIndex = 0, columnIndex = 0): Promise { @@ -642,7 +642,7 @@ export class DashboardPage { /** * Click the first row's first cell of a table tile. Each cell contains a - * div[role="link"] that owns the onRowClick handler — click that directly + * div[role="link"] that owns the onRowClick handler; click that directly * to trigger the configured action. */ async clickFirstTableRow(tileIndex = 0) { diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 400f8b534b..7eef895093 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1209,7 +1209,7 @@ export const LogSourceSchema = BaseSourceSchema.extend({ implicitColumnExpression: z.string().optional(), /** * @deprecated Application-side SQL predicate AND'd into every query against - * the source. Not a security boundary — bypassable by direct table SELECT. + * the source. Not a security boundary (bypassable by direct table SELECT). * For hard tenant isolation, use a ClickHouse ROW POLICY at the DB level: * https://clickhouse.com/docs/sql-reference/statements/create/row-policy *