mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
[HDX-3978] Add 'o' keybinding to open trace/span in browser from TUI (#2105)
## Summary Adds an `o` keybinding to the TUI that opens the currently expanded row in the HyperDX web app browser, deep-linking directly to the same view with the side panel open. **Linear ticket:** https://linear.app/hyperdx/issue/HDX-3978 ## What changed When a user expands a row in the TUI (via `l`/Enter) and presses `o`, the browser opens to the HyperDX web app's `/search` page with URL parameters that reproduce the exact TUI state: | URL Parameter | Description | |---|---| | `source` | Active source ID | | `where` | User's search query + `TraceId:<id>` for traces | | `from` / `to` | Current time range | | `rowWhere` | SQL WHERE clause identifying the specific row (double-encoded) | | `rowSource` | Source ID for the expanded row | | `sidePanelTab` | Maps TUI tab: `overview` → `overview`, `columns` → `parsed`, `trace` → `trace` | | `eventRowWhere` | Pre-selects the specific span in the trace waterfall (trace tab only, double-encoded JSON) | ### Tab behavior - **Overview tab** → Opens side panel to Overview for the specific row - **Column Values tab** → Opens side panel to Parsed (Column Values) for the specific row - **Trace tab** → Opens side panel to Trace with waterfall, pre-selecting the highlighted span ### Source support - **Trace sources**: URL includes `TraceId` filter combined with the user's search query - **Log sources with trace correlation**: Full trace waterfall deep-linking supported - **Log sources without trace**: Opens with the user's search query and row identification (no trace filter) ## Files changed | File | Change | |---|---| | `packages/cli/src/utils/openUrl.ts` | **New** — Cross-platform browser open utility | | `packages/cli/src/api/eventQuery.ts` | `buildFullRowQuery` returns `rowWhere` alongside `ChSql` | | `packages/cli/src/components/EventViewer/useEventData.ts` | Exposes `expandedRowWhere` state | | `packages/cli/src/components/TraceWaterfall/types.ts` | Added `onSelectedNodeChange` callback prop | | `packages/cli/src/components/TraceWaterfall/TraceWaterfall.tsx` | Calls `onSelectedNodeChange` when selected span changes | | `packages/cli/src/components/EventViewer/DetailPanel.tsx` | Threads `onTraceSelectedNodeChange` to TraceWaterfall | | `packages/cli/src/components/EventViewer/types.ts` | Added `appUrl` to `EventViewerProps` | | `packages/cli/src/components/EventViewer/EventViewer.tsx` | Threads `appUrl`, `expandedRowWhere`, `traceSelectedNode`, `submittedQuery` | | `packages/cli/src/components/EventViewer/utils.ts` | Added `buildBrowserUrl` (full URL builder) and `buildSpanEventRowWhere` | | `packages/cli/src/components/EventViewer/useKeybindings.ts` | Added `o` keybinding with all new params | | `packages/cli/src/components/EventViewer/SubComponents.tsx` | Added `o` to help screen | | `packages/cli/src/App.tsx` | Passes `appUrl` to EventViewer | | `packages/cli/AGENTS.md` | Added `o` to keybindings table | ## Testing - Type check: `npx tsc --noEmit` passes - Lint: `yarn lint:fix` — 0 errors
This commit is contained in:
parent
07bb29e997
commit
fe3ab41c43
14 changed files with 294 additions and 3 deletions
5
.changeset/open-trace-in-browser.md
Normal file
5
.changeset/open-trace-in-browser.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/cli": patch
|
||||
---
|
||||
|
||||
Add `o` keybinding to open the current trace/span in the HyperDX web app from the TUI. Deep-links to the exact view with side panel, tab, and span selection preserved. Works for both trace and log sources.
|
||||
|
|
@ -156,6 +156,7 @@ Key expression mappings from the web frontend's `getConfig()`:
|
|||
| `s` | Edit SELECT clause in $EDITOR |
|
||||
| `t` | Edit time range in $EDITOR |
|
||||
| `f` | Toggle follow mode (live tail) |
|
||||
| `o` | Open trace in browser (detail panel) |
|
||||
| `w` | Toggle line wrap |
|
||||
| `A` (Shift+A) | Open alerts page |
|
||||
| `?` | Toggle help screen |
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ export default function App({ appUrl, query, sourceName, follow }: AppProps) {
|
|||
<EventViewer
|
||||
clickhouseClient={client.createClickHouseClient()}
|
||||
metadata={client.createMetadata()}
|
||||
appUrl={currentAppUrl}
|
||||
source={selectedSource}
|
||||
sources={eventSources}
|
||||
savedSearches={savedSearches}
|
||||
|
|
|
|||
|
|
@ -181,6 +181,12 @@ export interface FullRowQueryOptions {
|
|||
* WITH <aliasWith>
|
||||
* LIMIT 1
|
||||
*/
|
||||
export interface FullRowQueryResult {
|
||||
chSql: ChSql;
|
||||
/** The SQL WHERE clause that uniquely identifies the row (for browser URL) */
|
||||
rowWhere: string;
|
||||
}
|
||||
|
||||
export async function buildFullRowQuery(
|
||||
opts: FullRowQueryOptions & {
|
||||
/** The rendered ChSql from the table query (for alias resolution) */
|
||||
|
|
@ -189,7 +195,7 @@ export async function buildFullRowQuery(
|
|||
tableMeta: ColumnMetaType[];
|
||||
metadata: Metadata;
|
||||
},
|
||||
): Promise<ChSql> {
|
||||
): Promise<FullRowQueryResult> {
|
||||
const { source, row, tableChSql, tableMeta, metadata } = opts;
|
||||
|
||||
// Parse the rendered table SQL to get alias → expression mapping
|
||||
|
|
@ -223,5 +229,6 @@ export async function buildFullRowQuery(
|
|||
: {}),
|
||||
};
|
||||
|
||||
return renderChartConfig(config, metadata, source.querySettings);
|
||||
const chSql = await renderChartConfig(config, metadata, source.querySettings);
|
||||
return { chSql, rowWhere: rowWhereResult.where };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import ColumnValues from '@/components/ColumnValues';
|
|||
import ErrorDisplay from '@/components/ErrorDisplay';
|
||||
import RowOverview from '@/components/RowOverview';
|
||||
import TraceWaterfall from '@/components/TraceWaterfall';
|
||||
import type { SpanNode } from '@/components/TraceWaterfall/types';
|
||||
|
||||
import type { FormattedRow } from './types';
|
||||
import { SearchBar } from './SubComponents';
|
||||
|
|
@ -52,6 +53,8 @@ type DetailPanelProps = {
|
|||
onTraceChSqlChange?: (
|
||||
chSql: { sql: string; params: Record<string, unknown> } | null,
|
||||
) => void;
|
||||
/** Callback when the selected span/log node in the trace waterfall changes */
|
||||
onTraceSelectedNodeChange?: (node: SpanNode | null) => void;
|
||||
};
|
||||
|
||||
export function DetailPanel({
|
||||
|
|
@ -82,6 +85,7 @@ export function DetailPanel({
|
|||
scrollOffset,
|
||||
expandedRow,
|
||||
onTraceChSqlChange,
|
||||
onTraceSelectedNodeChange,
|
||||
}: DetailPanelProps) {
|
||||
// Extract the event timestamp from the full row data (or the raw
|
||||
// table row) so we can scope the trace waterfall date range tightly
|
||||
|
|
@ -243,6 +247,7 @@ export function DetailPanel({
|
|||
detailScrollOffset={traceDetailScrollOffset}
|
||||
detailMaxRows={detailMaxRows}
|
||||
onChSqlChange={onTraceChSqlChange}
|
||||
onSelectedNodeChange={onTraceSelectedNodeChange}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useState, useCallback, useRef, useMemo } from 'react';
|
|||
import { Box, useStdout } from 'ink';
|
||||
|
||||
import type { TimeRange } from '@/utils/editor';
|
||||
import type { SpanNode } from '@/components/TraceWaterfall/types';
|
||||
|
||||
import type { EventViewerProps, SwitchItem } from './types';
|
||||
import { getColumns, getDynamicColumns, formatDynamicRow } from './utils';
|
||||
|
|
@ -21,6 +22,7 @@ import { useKeybindings } from './useKeybindings';
|
|||
export default function EventViewer({
|
||||
clickhouseClient,
|
||||
metadata,
|
||||
appUrl,
|
||||
source,
|
||||
sources,
|
||||
savedSearches,
|
||||
|
|
@ -71,6 +73,9 @@ export default function EventViewer({
|
|||
null,
|
||||
);
|
||||
const [traceDetailExpanded, setTraceDetailExpanded] = useState(false);
|
||||
const [traceSelectedNode, setTraceSelectedNode] = useState<SpanNode | null>(
|
||||
null,
|
||||
);
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(() => {
|
||||
const now = new Date();
|
||||
return { start: new Date(now.getTime() - 60 * 60 * 1000), end: now };
|
||||
|
|
@ -90,6 +95,7 @@ export default function EventViewer({
|
|||
expandedRowError,
|
||||
expandedTraceId,
|
||||
expandedSpanId,
|
||||
expandedRowWhere,
|
||||
lastChSql,
|
||||
lastExpandedChSql,
|
||||
fetchNextPage,
|
||||
|
|
@ -178,7 +184,12 @@ export default function EventViewer({
|
|||
source,
|
||||
timeRange,
|
||||
customSelect,
|
||||
submittedQuery,
|
||||
fullDetailMaxRows,
|
||||
appUrl,
|
||||
expandedTraceId,
|
||||
expandedRowWhere,
|
||||
traceSelectedNode,
|
||||
switchItems,
|
||||
findActiveIndex,
|
||||
onSavedSearchSelect,
|
||||
|
|
@ -300,6 +311,7 @@ export default function EventViewer({
|
|||
scrollOffset={scrollOffset}
|
||||
expandedRow={expandedRow}
|
||||
onTraceChSqlChange={setTraceChSql}
|
||||
onTraceSelectedNodeChange={setTraceSelectedNode}
|
||||
/>
|
||||
) : (
|
||||
<TableView
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ export const HelpScreen = React.memo(function HelpScreen() {
|
|||
['s', 'Edit select clause in $EDITOR'],
|
||||
['D', 'Show generated SQL'],
|
||||
['f', 'Toggle follow mode (live tail)'],
|
||||
['o', 'Open trace in browser'],
|
||||
['w', 'Toggle line wrap'],
|
||||
['A (Shift+A)', 'Open alerts page'],
|
||||
['?', 'Toggle this help'],
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import type { TimeRange } from '@/utils/editor';
|
|||
export interface EventViewerProps {
|
||||
clickhouseClient: ProxyClickhouseClient;
|
||||
metadata: Metadata;
|
||||
/** HyperDX app URL (e.g. http://localhost:8080) for opening in browser */
|
||||
appUrl: string;
|
||||
source: SourceResponse;
|
||||
sources: SourceResponse[];
|
||||
savedSearches: SavedSearchResponse[];
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export interface UseEventDataReturn {
|
|||
expandedRowError: Error | null;
|
||||
expandedTraceId: string | null;
|
||||
expandedSpanId: string | null;
|
||||
/** SQL WHERE clause that uniquely identifies the expanded row (for browser URL) */
|
||||
expandedRowWhere: string | null;
|
||||
/** The last rendered ChSql (parameterized SQL + params) for the table query */
|
||||
lastChSql: { sql: string; params: Record<string, unknown> } | null;
|
||||
/** The last rendered ChSql for the expanded row (SELECT *) query */
|
||||
|
|
@ -72,6 +74,7 @@ export function useEventData({
|
|||
const [expandedRowError, setExpandedRowError] = useState<Error | null>(null);
|
||||
const [expandedTraceId, setExpandedTraceId] = useState<string | null>(null);
|
||||
const [expandedSpanId, setExpandedSpanId] = useState<string | null>(null);
|
||||
const [expandedRowWhere, setExpandedRowWhere] = useState<string | null>(null);
|
||||
|
||||
const lastTimestampRef = useRef<string | null>(null);
|
||||
const dateRangeRef = useRef<{ start: Date; end: Date } | null>(null);
|
||||
|
|
@ -239,6 +242,7 @@ export function useEventData({
|
|||
setExpandedRowError(null);
|
||||
setExpandedTraceId(null);
|
||||
setExpandedSpanId(null);
|
||||
setExpandedRowWhere(null);
|
||||
setLastExpandedChSql(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -256,7 +260,7 @@ export function useEventData({
|
|||
params: {},
|
||||
};
|
||||
const tableMeta = lastTableMetaRef.current ?? [];
|
||||
const chSql = await buildFullRowQuery({
|
||||
const { chSql, rowWhere } = await buildFullRowQuery({
|
||||
source,
|
||||
row: row as Record<string, unknown>,
|
||||
tableChSql,
|
||||
|
|
@ -264,6 +268,9 @@ export function useEventData({
|
|||
metadata,
|
||||
});
|
||||
setLastExpandedChSql(chSql);
|
||||
if (!cancelled) {
|
||||
setExpandedRowWhere(rowWhere);
|
||||
}
|
||||
const resultSet = await clickhouseClient.query({
|
||||
query: chSql.sql,
|
||||
query_params: chSql.params,
|
||||
|
|
@ -324,6 +331,7 @@ export function useEventData({
|
|||
expandedRowError,
|
||||
expandedTraceId,
|
||||
expandedSpanId,
|
||||
expandedRowWhere,
|
||||
lastChSql,
|
||||
lastExpandedChSql,
|
||||
fetchNextPage,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@ import {
|
|||
openEditorForTimeRange,
|
||||
type TimeRange,
|
||||
} from '@/utils/editor';
|
||||
import { openUrl } from '@/utils/openUrl';
|
||||
|
||||
import type { SpanNode } from '@/components/TraceWaterfall/types';
|
||||
|
||||
import type { EventRow, SwitchItem } from './types';
|
||||
import { buildBrowserUrl, buildSpanEventRowWhere } from './utils';
|
||||
|
||||
// ---- Types ---------------------------------------------------------
|
||||
|
||||
|
|
@ -31,8 +35,16 @@ export interface KeybindingParams {
|
|||
source: SourceResponse;
|
||||
timeRange: TimeRange;
|
||||
customSelect: string | undefined;
|
||||
/** The user's submitted search query (Lucene) */
|
||||
submittedQuery: string;
|
||||
fullDetailMaxRows: number;
|
||||
|
||||
// Browser integration
|
||||
appUrl: string;
|
||||
expandedTraceId: string | null;
|
||||
expandedRowWhere: string | null;
|
||||
traceSelectedNode: SpanNode | null;
|
||||
|
||||
// Tab switching
|
||||
switchItems: SwitchItem[];
|
||||
findActiveIndex: () => number;
|
||||
|
|
@ -94,7 +106,12 @@ export function useKeybindings(params: KeybindingParams): void {
|
|||
source,
|
||||
timeRange,
|
||||
customSelect,
|
||||
submittedQuery,
|
||||
fullDetailMaxRows,
|
||||
appUrl,
|
||||
expandedTraceId,
|
||||
expandedRowWhere,
|
||||
traceSelectedNode,
|
||||
switchItems,
|
||||
findActiveIndex,
|
||||
onSavedSearchSelect,
|
||||
|
|
@ -376,6 +393,43 @@ export function useKeybindings(params: KeybindingParams): void {
|
|||
return;
|
||||
}
|
||||
if (input === 'w') setWrapLines(w => !w);
|
||||
// o = open current trace/span in the browser
|
||||
if (input === 'o' && expandedRow !== null) {
|
||||
// Build eventRowWhere for trace tab when a span is selected
|
||||
let eventRowWhere: {
|
||||
id: string;
|
||||
type: string;
|
||||
aliasWith: never[];
|
||||
} | null = null;
|
||||
if (detailTab === 'trace' && traceSelectedNode) {
|
||||
const traceSource =
|
||||
source.kind === 'trace'
|
||||
? source
|
||||
: // For log sources viewing the trace tab, use the trace source
|
||||
// expressions. The node's kind tells us which source it came from.
|
||||
null;
|
||||
// Only build eventRowWhere if we have a trace source to reference
|
||||
if (traceSource) {
|
||||
eventRowWhere = {
|
||||
id: buildSpanEventRowWhere(traceSelectedNode, traceSource),
|
||||
type: traceSelectedNode.kind === 'log' ? 'log' : 'trace',
|
||||
aliasWith: [] as never[],
|
||||
};
|
||||
}
|
||||
}
|
||||
const url = buildBrowserUrl({
|
||||
appUrl,
|
||||
source,
|
||||
traceId: expandedTraceId,
|
||||
searchQuery: submittedQuery,
|
||||
timeRange,
|
||||
rowWhere: expandedRowWhere,
|
||||
detailTab,
|
||||
eventRowWhere,
|
||||
});
|
||||
openUrl(url);
|
||||
return;
|
||||
}
|
||||
// f = toggle follow mode (disabled in detail panel — follow is
|
||||
// automatically paused on expand and restored on close)
|
||||
if (input === 'f' && expandedRow === null) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import SqlString from 'sqlstring';
|
||||
|
||||
import type { SourceResponse } from '@/api/client';
|
||||
import type { TimeRange } from '@/utils/editor';
|
||||
import type { SpanNode } from '@/components/TraceWaterfall/types';
|
||||
import type { Column, EventRow, FormattedRow } from './types';
|
||||
|
||||
// ---- Column definitions per source kind ----------------------------
|
||||
|
|
@ -75,3 +79,155 @@ export function formatShortDate(d: Date): string {
|
|||
.replace('T', ' ')
|
||||
.replace(/\.\d{3}Z$/, '');
|
||||
}
|
||||
|
||||
// ---- Tab name mapping (TUI → web app) ------------------------------
|
||||
|
||||
/** Map TUI detail tab names to the web app's `sidePanelTab` URL values. */
|
||||
const SIDE_PANEL_TAB_MAP: Record<string, string> = {
|
||||
overview: 'overview',
|
||||
columns: 'parsed',
|
||||
trace: 'trace',
|
||||
};
|
||||
|
||||
// ---- Span event row WHERE builder -----------------------------------
|
||||
|
||||
/**
|
||||
* Build a SQL WHERE clause that identifies a specific span/log in the
|
||||
* trace waterfall, matching the web app's `eventRowWhere.id` format.
|
||||
*
|
||||
* The web app's waterfall query uses aliased columns. `processRowToWhereClause`
|
||||
* resolves aliases back to raw expressions via the aliasMap. We replicate
|
||||
* the same output using the source's expression mappings.
|
||||
*
|
||||
* @source packages/app/src/components/DBTraceWaterfallChart.tsx (getConfig + useEventsAroundFocus)
|
||||
*/
|
||||
export function buildSpanEventRowWhere(
|
||||
node: SpanNode,
|
||||
source: SourceResponse,
|
||||
): string {
|
||||
const spanNameExpr = source.spanNameExpression ?? 'SpanName';
|
||||
const tsExpr =
|
||||
source.displayedTimestampValueExpression ??
|
||||
source.timestampValueExpression ??
|
||||
'Timestamp';
|
||||
const spanIdExpr = source.spanIdExpression ?? 'SpanId';
|
||||
const serviceNameExpr = source.serviceNameExpression ?? 'ServiceName';
|
||||
const durationExpr = source.durationExpression ?? 'Duration';
|
||||
const precision = source.durationPrecision ?? 9;
|
||||
const parentSpanIdExpr = source.parentSpanIdExpression ?? 'ParentSpanId';
|
||||
const statusCodeExpr = source.statusCodeExpression ?? 'StatusCode';
|
||||
|
||||
// Convert raw duration to seconds (matching getDurationSecondsExpression)
|
||||
const durationSeconds = node.Duration / Math.pow(10, precision);
|
||||
|
||||
const clauses = [
|
||||
SqlString.format(`?=?`, [SqlString.raw(spanNameExpr), node.SpanName]),
|
||||
SqlString.format(`?=parseDateTime64BestEffort(?, 9)`, [
|
||||
SqlString.raw(tsExpr),
|
||||
node.Timestamp,
|
||||
]),
|
||||
SqlString.format(`?=?`, [SqlString.raw(spanIdExpr), node.SpanId]),
|
||||
SqlString.format(`?=?`, [SqlString.raw(serviceNameExpr), node.ServiceName]),
|
||||
SqlString.format(`(?)/?=?`, [
|
||||
SqlString.raw(durationExpr),
|
||||
SqlString.raw(`1e${precision}`),
|
||||
durationSeconds,
|
||||
]),
|
||||
SqlString.format(`?=?`, [
|
||||
SqlString.raw(parentSpanIdExpr),
|
||||
node.ParentSpanId,
|
||||
]),
|
||||
SqlString.format(`?=?`, [SqlString.raw(statusCodeExpr), node.StatusCode]),
|
||||
];
|
||||
|
||||
return clauses.join(' AND ');
|
||||
}
|
||||
|
||||
// ---- Browser URL builder -------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a URL that opens the current view in the HyperDX web app.
|
||||
*
|
||||
* Generates a `/search` URL with parameters that:
|
||||
* - Filter to the trace (if available)
|
||||
* - Open the side panel for the specific expanded row
|
||||
* - Select the correct tab (overview / column values / trace)
|
||||
* - Pre-select the specific span in the trace waterfall (if on trace tab)
|
||||
*
|
||||
* String/JSON URL values are double-encoded via `encodeURIComponent` to
|
||||
* match the web app's `parseAsStringEncoded` / `parseAsJsonEncoded` parsers.
|
||||
*
|
||||
* @source packages/app/src/utils/queryParsers.ts
|
||||
* @source packages/app/src/components/DBSqlRowTableWithSidebar.tsx
|
||||
* @source packages/app/src/components/DBRowSidePanel.tsx
|
||||
*/
|
||||
export function buildBrowserUrl({
|
||||
appUrl,
|
||||
source,
|
||||
traceId,
|
||||
searchQuery,
|
||||
timeRange,
|
||||
rowWhere,
|
||||
detailTab,
|
||||
eventRowWhere,
|
||||
}: {
|
||||
appUrl: string;
|
||||
source: SourceResponse;
|
||||
traceId: string | null;
|
||||
/** The user's current search query (Lucene) */
|
||||
searchQuery: string;
|
||||
timeRange: TimeRange;
|
||||
/** SQL WHERE clause identifying the expanded row (from useEventData) */
|
||||
rowWhere: string | null;
|
||||
/** Current detail tab in the TUI */
|
||||
detailTab: string;
|
||||
/** Identifies the selected span in the trace waterfall (trace tab only) */
|
||||
eventRowWhere: {
|
||||
id: string;
|
||||
type: string;
|
||||
aliasWith: never[];
|
||||
} | null;
|
||||
}): string {
|
||||
const params = new URLSearchParams({
|
||||
source: source.id,
|
||||
from: timeRange.start.getTime().toString(),
|
||||
to: timeRange.end.getTime().toString(),
|
||||
isLive: 'false',
|
||||
});
|
||||
|
||||
// Build the where clause: combine the user's search query with a
|
||||
// TraceId filter when viewing a trace.
|
||||
const whereParts: string[] = [];
|
||||
if (searchQuery) {
|
||||
whereParts.push(searchQuery);
|
||||
}
|
||||
if (traceId) {
|
||||
whereParts.push(`TraceId:${traceId}`);
|
||||
}
|
||||
if (whereParts.length > 0) {
|
||||
params.set('where', whereParts.join(' '));
|
||||
params.set('whereLanguage', 'lucene');
|
||||
}
|
||||
|
||||
// Add row identification for the side panel.
|
||||
// Values are pre-encoded with encodeURIComponent to match
|
||||
// parseAsStringEncoded's serialize (double-encoding).
|
||||
if (rowWhere) {
|
||||
params.set('rowWhere', encodeURIComponent(rowWhere));
|
||||
params.set('rowSource', source.id);
|
||||
}
|
||||
|
||||
// Map TUI tab name to web app tab name
|
||||
const webTab = SIDE_PANEL_TAB_MAP[detailTab] ?? 'overview';
|
||||
params.set('sidePanelTab', webTab);
|
||||
|
||||
// Add trace waterfall span selection (trace tab only)
|
||||
if (eventRowWhere) {
|
||||
params.set(
|
||||
'eventRowWhere',
|
||||
encodeURIComponent(JSON.stringify(eventRowWhere)),
|
||||
);
|
||||
}
|
||||
|
||||
return `${appUrl}/search?${params.toString()}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export default function TraceWaterfall({
|
|||
width: propWidth,
|
||||
maxRows: propMaxRows,
|
||||
onChSqlChange,
|
||||
onSelectedNodeChange,
|
||||
}: TraceWaterfallProps) {
|
||||
const { stdout } = useStdout();
|
||||
const termWidth = propWidth ?? stdout?.columns ?? 80;
|
||||
|
|
@ -167,6 +168,11 @@ export default function TraceWaterfall({
|
|||
fetchSelectedRow(selectedNode);
|
||||
}, [selectedNode?.SpanId, selectedNode?.Timestamp, selectedNode?.kind]);
|
||||
|
||||
// Notify parent when selected node changes (used for browser URL)
|
||||
useEffect(() => {
|
||||
onSelectedNodeChange?.(selectedNode ?? null);
|
||||
}, [selectedNode?.SpanId, selectedNode?.Timestamp, selectedNode?.kind]);
|
||||
|
||||
// ---- Render ------------------------------------------------------
|
||||
|
||||
if (loading) {
|
||||
|
|
|
|||
|
|
@ -67,4 +67,6 @@ export interface TraceWaterfallProps {
|
|||
onChSqlChange?: (
|
||||
chSql: { sql: string; params: Record<string, unknown> } | null,
|
||||
) => void;
|
||||
/** Callback when the selected span/log node changes (for browser URL) */
|
||||
onSelectedNodeChange?: (node: SpanNode | null) => void;
|
||||
}
|
||||
|
|
|
|||
31
packages/cli/src/utils/openUrl.ts
Normal file
31
packages/cli/src/utils/openUrl.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { exec } from 'child_process';
|
||||
|
||||
/**
|
||||
* Open a URL in the user's default browser.
|
||||
*
|
||||
* Uses platform-specific commands:
|
||||
* - macOS: `open <url>`
|
||||
* - Linux: `xdg-open <url>`
|
||||
* - Windows: `start "" "<url>"`
|
||||
*
|
||||
* Fire-and-forget — errors are silently ignored so the TUI
|
||||
* is never disrupted if the browser fails to launch.
|
||||
*/
|
||||
export function openUrl(url: string): void {
|
||||
const platform = process.platform;
|
||||
let cmd: string;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
cmd = `open ${JSON.stringify(url)}`;
|
||||
} else if (platform === 'win32') {
|
||||
cmd = `start "" ${JSON.stringify(url)}`;
|
||||
} else {
|
||||
// Linux and other Unix-like systems
|
||||
cmd = `xdg-open ${JSON.stringify(url)}`;
|
||||
}
|
||||
|
||||
exec(cmd, () => {
|
||||
// Intentionally swallow errors — headless servers, missing
|
||||
// display, etc. should not crash the TUI.
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue