(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
*
|