Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/prominent-view-trace-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@hyperdx/app': patch
---

Make the log side panel "View Trace" action more noticeable: it now uses an
outlined (secondary) button with the trace source icon, larger compact size, and
is right-aligned so it stands out from the dimmed metadata row instead of
blending in as subtle inline text. The first time a log with a correlated trace
is opened, a one-time popover points users to the button; it is dismissed by an
explicit acknowledgement ("Got it" or clicking View Trace) and then never shows
again (persisted per browser). It deliberately does not intercept Escape, which
keeps its normal side-panel behavior.
55 changes: 55 additions & 0 deletions .claude/skills/data-source-icons/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
name: data-source-icons
description: Use the correct Tabler icon for each HyperDX data source kind (Log, Trace, Session, Metric, PromQL). Use whenever adding or changing an icon that represents a source kind or a signal type — source selectors, side panels, cross-source actions like "View Trace", tabs, badges, breadcrumbs, or anywhere a log/trace/session/metric is depicted.
---

# Data source icons

There is **one canonical icon per source kind**. The source of truth is
`SOURCE_KIND_ICONS` in
`packages/app/src/components/sourceSelectUtils.tsx`. Never invent a different
icon for a source kind (e.g. don't use `IconTimeline` for a trace) — always
match the table below so icons stay consistent across the app.

## Canonical mapping

| Source kind (`SourceKind`) | Tabler icon | Meaning |
| -------------------------- | ---------------- | ----------------------------- |
| `Log` | `IconLogs` | Logs |
| `Trace` | `IconConnection` | Traces / spans |
| `Session` | `IconDeviceLaptop` | Session replay / client sessions |
| `Metric` | `IconChartLine` | Metrics |
| `Promql` | `IconChartLine` | PromQL metrics (same as Metric) |

All imported from `@tabler/icons-react`.

## Rules

1. **Prefer the shared map.** When you need a source-kind icon in a context that
already has (or can accept) a `SourceKind`, use `SOURCE_KIND_ICONS[kind]`
from `sourceSelectUtils.tsx` rather than hardcoding an icon component.
2. **If you must hardcode** (e.g. a fixed "View Trace" action that always points
at a trace), import the exact icon from the table above — for a trace that is
`IconConnection`, not `IconTimeline`/`IconRoute`/etc.
3. **Sizing**: the shared map uses `size={16}`. Match the surrounding UI; small
inline buttons/badges commonly use `14`.
4. **Adding a new source kind**: update `SOURCE_KIND_ICONS` first, then update
this table so the two never drift.

## Examples

Cross-source "View Trace" action (fixed target → hardcode the trace icon):

```tsx
import { IconConnection } from '@tabler/icons-react';

<Button leftSection={<IconConnection size={14} />}>View Trace</Button>;
```

Dynamic, kind-driven icon (prefer the shared map):

```tsx
import { SOURCE_KIND_ICONS } from '@/components/sourceSelectUtils';

<span>{SOURCE_KIND_ICONS[source.kind]}</span>;
```
4 changes: 3 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
},
"packages/common-utils": {
"entry": ["src/**/*.ts", "!src/__tests__/**", "!src/**/*.test.*"],
"project": ["src/**/*.ts"]
"project": ["src/**/*.ts"],
"ignoreDependencies": ["@stryker-mutator/core"],
"ignoreBinaries": ["stryker"]
},
"packages/hdx-eval": {
"project": ["src/**/*.ts"],
Expand Down
14 changes: 5 additions & 9 deletions packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
import LogLevel from './LogLevel';
import SidePanelBreadcrumbs, { BreadcrumbItem } from './SidePanelBreadcrumbs';
import { SpanLinkData } from './SpanLinksSubpanel';
import { ViewTraceCalloutButton } from './ViewTraceCalloutButton';

import styles from '@/../styles/LogSidePanel.module.scss';

Expand Down Expand Up @@ -865,11 +866,9 @@ export const DBRowSidePanelInner = ({
</>
)}
{showLogTraceActions && (
<Button
data-testid="side-panel-view-trace"
variant="subtle"
size="compact-xs"
onClick={() => {
<ViewTraceCalloutButton
disabled={!traceSourceData || !traceSpanRowId}
onView={() => {
if (traceSourceData && traceSpanRowId) {
handleSourceStackPush({
sourceId: traceSourceData.id,
Expand All @@ -880,10 +879,7 @@ export const DBRowSidePanelInner = ({
});
}
}}
disabled={!traceSourceData || !traceSpanRowId}
>
View Trace →
</Button>
/>
)}
</Group>
<DBRowSidePanelHeader
Expand Down
97 changes: 97 additions & 0 deletions packages/app/src/components/ViewTraceCalloutButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useCallback } from 'react';
import { Button, Popover, Stack, Text } from '@mantine/core';
import { IconArrowRight, IconConnection } from '@tabler/icons-react';

import { useLocalStorage } from '@/utils';

import { VIEW_TRACE_CALLOUT_DISMISSED_KEY } from './viewTraceCallout';

type ViewTraceCalloutButtonProps = {
/** True while the correlated trace hasn't resolved; the button is disabled. */
disabled: boolean;
/** Navigate to the correlated trace. Only reachable when not disabled. */
onView: () => void;
};

/**
* The prominent "View trace" action shown in a log side panel, wrapped in a
* one-time nudge popover.
*
* The callout is dismissed only by an explicit acknowledgement — clicking
* "Got it" or the button itself — which persists under its own localStorage key
* (not user settings). It deliberately does not close on stray clicks, drawer
* resizes, or Escape: Escape stays owned by the panel-level hotkey so the
* callout never fights it for the keypress. Because nothing transient is
* stored, the nudge simply reappears the next time the panel opens on an
* eligible log until it is acknowledged.
*/
export function ViewTraceCalloutButton({
disabled,
onView,
}: ViewTraceCalloutButtonProps) {
const [dismissed, setDismissed] = useLocalStorage(
VIEW_TRACE_CALLOUT_DISMISSED_KEY,
false,
);
const dismiss = useCallback(() => setDismissed(true), [setDismissed]);

return (
<Popover
width={260}
position="bottom-end"
withArrow
shadow="md"
trapFocus={false}
// Intentional: an outside click neither dismisses the callout nor is it
// treated as an accidental dismissal. This is a one-time, one-line hint
// that only ever covers a small strip below the button; it is dismissed
// deliberately via "Got it" or by clicking View Trace, and does not
// reappear once acknowledged. We accept that the covered controls are
// briefly non-interactive so a stray click can't burn the message before
// it is read. (Reviewed: PR #2815 — kept by design.)
closeOnClickOutside={false}
closeOnEscape={false}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
opened={!disabled && !dismissed}
>
<Popover.Target>
<Button
data-testid="side-panel-view-trace"
variant="secondary"
size="compact-sm"
ml="auto"
leftSection={<IconConnection size={14} />}
rightSection={<IconArrowRight size={14} />}
onClick={() => {
dismiss();
onView();
}}
disabled={disabled}
>
View Trace
</Button>
</Popover.Target>
<Popover.Dropdown
data-testid="view-trace-callout"
role="status"
aria-live="polite"
>
<Stack gap="xs">
<Text size="sm" fw={600}>
Jump to this log&apos;s full trace
</Text>
<Text size="xs" c="dimmed">
Open the correlated trace in one click.
</Text>
<Button
variant="primary"
size="compact-xs"
ml="auto"
onClick={dismiss}
>
Got it
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { fireEvent, screen } from '@testing-library/react';

import { VIEW_TRACE_CALLOUT_DISMISSED_KEY } from '@/components/viewTraceCallout';
import { ViewTraceCalloutButton } from '@/components/ViewTraceCalloutButton';

describe('ViewTraceCalloutButton', () => {
beforeEach(() => {
window.localStorage.clear();
});

it('shows the one-time callout when enabled and not yet dismissed', () => {
renderWithMantine(
<ViewTraceCalloutButton disabled={false} onView={jest.fn()} />,
);

expect(screen.getByTestId('side-panel-view-trace')).toBeEnabled();
expect(screen.getByTestId('view-trace-callout')).toBeInTheDocument();
expect(screen.getByText('Got it')).toBeInTheDocument();
});

it('does not open the callout while the trace is unresolved (disabled)', () => {
renderWithMantine(<ViewTraceCalloutButton disabled onView={jest.fn()} />);

expect(screen.getByTestId('side-panel-view-trace')).toBeDisabled();
expect(screen.queryByTestId('view-trace-callout')).not.toBeInTheDocument();
});

it('does not show the callout once it has been dismissed previously', () => {
window.localStorage.setItem(
VIEW_TRACE_CALLOUT_DISMISSED_KEY,
JSON.stringify(true),
);

renderWithMantine(
<ViewTraceCalloutButton disabled={false} onView={jest.fn()} />,
);

// Button still available, but the nudge no longer appears.
expect(screen.getByTestId('side-panel-view-trace')).toBeInTheDocument();
expect(screen.queryByTestId('view-trace-callout')).not.toBeInTheDocument();
});

it('persists dismissal and navigates when the View Trace button is clicked', () => {
const onView = jest.fn();
renderWithMantine(
<ViewTraceCalloutButton disabled={false} onView={onView} />,
);

fireEvent.click(screen.getByTestId('side-panel-view-trace'));

expect(onView).toHaveBeenCalledTimes(1);
expect(window.localStorage.getItem(VIEW_TRACE_CALLOUT_DISMISSED_KEY)).toBe(
JSON.stringify(true),
);
});

it('persists dismissal without navigating when "Got it" is clicked', () => {
const onView = jest.fn();
renderWithMantine(
<ViewTraceCalloutButton disabled={false} onView={onView} />,
);

fireEvent.click(screen.getByText('Got it'));

expect(onView).not.toHaveBeenCalled();
expect(window.localStorage.getItem(VIEW_TRACE_CALLOUT_DISMISSED_KEY)).toBe(
JSON.stringify(true),
);
});
});
9 changes: 9 additions & 0 deletions packages/app/src/components/viewTraceCallout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* localStorage key for the one-time "View trace" button callout dismissal.
*
* Kept in this dependency-free module so it can be shared by the React
* component and the Playwright fixture (which seeds it) without either copy
* drifting from the other.
*/
export const VIEW_TRACE_CALLOUT_DISMISSED_KEY =
'hdx-view-trace-callout-dismissed';
17 changes: 15 additions & 2 deletions packages/app/tests/e2e/utils/base-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import fs from 'fs';
import path from 'path';
import { expect, test as base } from '@playwright/test';

import { VIEW_TRACE_CALLOUT_DISMISSED_KEY } from '../../../src/components/viewTraceCallout';

// Single source of truth: e2e-fixtures.json (connections/sources). API gets them via run-api-with-fixtures.js.
const E2E_FIXTURES_PATH = path.join(__dirname, '../fixtures/e2e-fixtures.json');
function loadE2EFixtures(): { connections: unknown[]; sources: unknown[] } {
Expand All @@ -28,7 +30,7 @@ export const test = base.extend({
// e2e-fixtures.json so local mode uses the same data as full-stack.
await page.addInitScript(
(arg: unknown[]) => {
const [connections, sources] = arg;
const [connections, sources, calloutDismissedKey] = arg;
window.localStorage.setItem('TanstackQueryDevtools.open', 'false');
window.sessionStorage.setItem(
'connections',
Expand All @@ -38,8 +40,19 @@ export const test = base.extend({
'hdx-local-source',
JSON.stringify(sources),
);
// Suppress the one-time "View trace" callout so its auto-opening
// popover never interferes with side-panel interactions. useLocalStorage
// JSON-encodes values, so store the boolean as JSON.
window.localStorage.setItem(
String(calloutDismissedKey),
JSON.stringify(true),
);
},
[e2eFixtures.connections, e2eFixtures.sources],
[
e2eFixtures.connections,
e2eFixtures.sources,
VIEW_TRACE_CALLOUT_DISMISSED_KEY,
],
);
await fn(page);
},
Expand Down
Loading