diff --git a/public/app/features/explore/Logs/Logs.test.tsx b/public/app/features/explore/Logs/Logs.test.tsx index 93836280762..d00412560ee 100644 --- a/public/app/features/explore/Logs/Logs.test.tsx +++ b/public/app/features/explore/Logs/Logs.test.tsx @@ -75,6 +75,7 @@ describe('Logs', () => { Object.defineProperty(window, 'location', { value: { href: 'http://localhost:3000/explore?test', + search: '?test', }, writable: true, }); diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index ac07e2cacd5..84470d73a61 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -83,6 +83,7 @@ import LogsNavigation from './LogsNavigation'; import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap'; import { LogsVolumePanelList } from './LogsVolumePanelList'; import { SETTING_KEY_ROOT, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs'; +import { getExploreBaseUrl } from './utils/url'; interface Props extends Themeable2 { width: number; @@ -617,7 +618,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { // append changed urlState to baseUrl const serializedState = serializeStateToUrlParam(urlState); - const baseUrl = /.*(?=\/explore)/.exec(`${window.location.href}`)![0]; + const baseUrl = getExploreBaseUrl(); const url = urlUtil.renderUrl(`${baseUrl}/explore`, { left: serializedState }); await createAndCopyShortLink(url); @@ -1002,6 +1003,10 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { panelState={panelState?.logs} updatePanelState={updatePanelState} datasourceType={props.datasourceType} + displayedFields={displayedFields} + exploreId={props.exploreId} + absoluteRange={props.absoluteRange} + logRows={props.logRows} /> )} @@ -1056,6 +1061,8 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { onLogOptionsChange={onLogOptionsChange} filterLevels={filterLevels} timeRange={props.range} + exploreId={props.exploreId} + absoluteRange={props.absoluteRange} /> )} diff --git a/public/app/features/explore/Logs/LogsTable.test.tsx b/public/app/features/explore/Logs/LogsTable.test.tsx index ea203456008..cb38e9062de 100644 --- a/public/app/features/explore/Logs/LogsTable.test.tsx +++ b/public/app/features/explore/Logs/LogsTable.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { ComponentProps } from 'react'; -import { DataFrame, FieldType, LogsSortOrder, toUtc } from '@grafana/data'; +import { DataFrame, FieldType, LogsSortOrder, toUtc, urlUtil } from '@grafana/data'; import { mockTransformationsRegistry, organizeFieldsTransformer } from '@grafana/data/internal'; import { config } from '@grafana/runtime'; import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields'; @@ -362,4 +362,80 @@ describe('LogsTable', () => { } }); }); + + describe('Selected log line', () => { + it('should handle selected log line from URL parameter', async () => { + // Use getMockLokiFrame which has proper structure with id field + const testFrame = getMockLokiFrame(); + const logsFrame = parseLogsFrame(testFrame); + + // Get the second ID from the parsed frame to test selection of non-first row + const secondId = logsFrame?.idField?.values[1]; + + // Mock URL search params to include selectedLine + const mockGetSearchParams = jest.spyOn(urlUtil, 'getUrlSearchParams'); + mockGetSearchParams.mockReturnValue({ + selectedLine: JSON.stringify({ id: secondId, row: 1 }), + }); + + // Verify selectedLine is in the mocked URL params + const params = urlUtil.getUrlSearchParams(); + expect(params.selectedLine).toBeDefined(); + expect(params.selectedLine).toContain(secondId); + }); + + it('should clear selectedLine URL parameter after render', async () => { + // Mock locationService.partial instead of window.history.replaceState + const partialSpy = jest.spyOn(require('@grafana/runtime').locationService, 'partial'); + + // Use getMockLokiFrame which has proper structure + const testFrame = getMockLokiFrame(); + const logsFrame = parseLogsFrame(testFrame); + + // Get the first ID from the parsed frame + const firstId = logsFrame?.idField?.values[0]; + + // Mock URL search params with matching id + const mockGetSearchParams = jest.spyOn(urlUtil, 'getUrlSearchParams'); + mockGetSearchParams.mockReturnValue({ + selectedLine: JSON.stringify({ id: firstId, row: 0 }), + }); + + setup({ logsFrame }, testFrame); + + await waitFor(() => { + expect(partialSpy).toHaveBeenCalled(); + // Verify that selectedLine is set to undefined + const callArgs = partialSpy.mock.calls[0]; + expect(callArgs[0]).toEqual({ selectedLine: undefined }); + expect(callArgs[1]).toBe(true); // replace parameter + }); + }); + }); + + describe('Table action buttons', () => { + it('should render action buttons in first column when exploreId is provided', async () => { + setup({ + exploreId: 'test-explore', + }); + + await waitFor(() => { + const rows = screen.getAllByRole('row'); + expect(rows.length).toBeGreaterThan(1); // header + data rows + }); + + // Verify buttons are in the first column + const rows = screen.getAllByRole('row'); + const dataRows = rows.filter((row) => row.getAttribute('role') === 'row' && !row.getAttribute('aria-label')); + + dataRows.forEach((row) => { + const cells = row.querySelectorAll('[role="cell"]'); + const firstCell = cells[0]; + + // First cell should contain both action buttons + expect(firstCell.querySelector('button[aria-label="View log line"]')).toBeTruthy(); + expect(firstCell.querySelector('button[aria-label="Copy link to log line"]')).toBeTruthy(); + }); + }); + }); }); diff --git a/public/app/features/explore/Logs/LogsTable.tsx b/public/app/features/explore/Logs/LogsTable.tsx index adc17844bb3..96165ca03be 100644 --- a/public/app/features/explore/Logs/LogsTable.tsx +++ b/public/app/features/explore/Logs/LogsTable.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect, useState } from 'react'; +import { css } from '@emotion/css'; +import { useCallback, useEffect, useState, useMemo } from 'react'; import { lastValueFrom } from 'rxjs'; import { + urlUtil, applyFieldOverrides, CustomTransformOperator, DataFrame, @@ -16,14 +18,26 @@ import { TimeRange, transformDataFrame, ValueLinkConfig, + ExploreLogsPanelState, + AbsoluteTimeRange, + LogRowModel, + GrafanaTheme2, } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { AdHocFilterItem, Table, TableSortByFieldState } from '@grafana/ui'; +import { config, locationService } from '@grafana/runtime'; +import { + AdHocFilterItem, + CustomCellRendererProps, + TableSortByFieldState, + Table, + TableCellDisplayMode, + useStyles2, +} from '@grafana/ui'; import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal'; import { LogsFrame } from 'app/features/logs/logsFrame'; import { getFieldLinksForExplore } from '../utils/links'; +import { LogsTableActionButtons } from './LogsTableActionButtons'; import { FieldNameMeta } from './LogsTableWrap'; interface Props { @@ -41,6 +55,11 @@ interface Props { tableSortBy?: string; tableSortDir?: 'asc' | 'desc'; onSortByChange?: (sortBy: TableSortByFieldState[]) => void; + displayedFields?: string[]; + exploreId?: string; + panelState?: ExploreLogsPanelState; + absoluteRange?: AbsoluteTimeRange; + logRows?: LogRowModel[]; } export function LogsTable(props: Props) { @@ -58,7 +77,61 @@ export function LogsTable(props: Props) { onSortByChange, } = props; const [tableFrame, setTableFrame] = useState(undefined); + const [columnWidthMap, setColumnWidthMap] = useState>({}); const timeIndex = logsFrame?.timeField.index; + const styles = useStyles2(getStyles); + + // Extract selected log ID from URL parameter + const selectedLogInfo = useMemo(() => { + const { selectedLine } = urlUtil.getUrlSearchParams(); + + const param = Array.isArray(selectedLine) ? selectedLine[0] : selectedLine; + + if (typeof param !== 'string') { + return undefined; + } + + try { + const { id, row } = JSON.parse(param); + return { id, row }; + } catch (error) { + return undefined; + } + }, []); + + // Set the initial row index based on the selected log ID if selectedLine is present in the URL + const initialRowIndex = useMemo(() => { + if (!selectedLogInfo || !tableFrame || !selectedLogInfo.id) { + return undefined; + } + + // Search through all fields in tableFrame to find the one containing the ID + for (const field of tableFrame.fields) { + const lineIndex = field.values.findIndex((v: unknown) => v === selectedLogInfo.id); + if (lineIndex !== -1) { + return lineIndex; + } + } + + return undefined; + }, [selectedLogInfo, tableFrame]); + + // Clear the selectedLine URL parameter after table loads + useEffect(() => { + if (initialRowIndex !== undefined && tableFrame) { + // Remove selectedLine from URL using locationService (proper Grafana way) + locationService.partial({ selectedLine: undefined }, true); + } + }, [initialRowIndex, tableFrame]); + + const onColumnResize = useCallback((fieldDisplayName: string, width: number) => { + if (width > 0) { + setColumnWidthMap((prev) => ({ + ...prev, + [fieldDisplayName]: width, + })); + } + }, []); const prepareTableFrame = useCallback( (frame: DataFrame): DataFrame => { @@ -81,7 +154,21 @@ export function LogsTable(props: Props) { }, }); // `getLinks` and `applyFieldOverrides` are taken from TableContainer.tsx - for (const field of frameWithOverrides.fields) { + for (const [index, field] of frameWithOverrides.fields.entries()) { + // Hide ID field from visualization (it's only needed for row matching) + if (logsFrame?.idField && (field.name === logsFrame.idField.name || field.name === 'id')) { + field.config = { + ...field.config, + custom: { + ...field.config.custom, + hideFrom: { + ...field.config.custom?.hideFrom, + viz: true, + }, + }, + }; + } + field.getLinks = (config: ValueLinkConfig) => { return getFieldLinksForExplore({ field, @@ -91,13 +178,44 @@ export function LogsTable(props: Props) { dataFrame: sortedFrame!, }); }; + + // For the first field (time), wrap the cell to include action buttons + const isFirstField = index === 0; + field.config = { ...field.config, custom: { inspect: true, filterable: true, // This sets the columns to be filterable - width: getInitialFieldWidth(field), + width: columnWidthMap[field.name] ?? getInitialFieldWidth(field), ...field.config.custom, + cellOptions: isFirstField + ? { + type: TableCellDisplayMode.Custom, + cellComponent: (cellProps: CustomCellRendererProps) => ( + <> + + + {cellProps.field.display?.(cellProps.value).text ?? String(cellProps.value)} + + + ), + } + : field.config.custom?.cellOptions, + headerComponent: isFirstField + ? (headerProps: { defaultContent: React.ReactNode }) => ( +
{headerProps.defaultContent}
+ ) + : field.config.custom?.headerComponent, }, // This sets the individual field value as filterable filterable: isFieldFilterable(field, logsFrame?.bodyField.name ?? '', logsFrame?.timeField.name ?? ''), @@ -109,7 +227,22 @@ export function LogsTable(props: Props) { return frameWithOverrides; }, - [logsSortOrder, timeZone, splitOpen, range, logsFrame?.bodyField.name, logsFrame?.timeField.name, timeIndex] + [ + logsSortOrder, + timeZone, + splitOpen, + range, + columnWidthMap, + logsFrame, + timeIndex, + styles.firstColumnCell, + styles.firstColumnHeader, + props.displayedFields, + props.exploreId, + props.panelState, + props.absoluteRange, + props.logRows, + ] ); useEffect(() => { @@ -127,9 +260,24 @@ export function LogsTable(props: Props) { // Add the label filters to the transformations const transform = getLabelFiltersTransform(labelFilters); if (transform) { + // Ensure ID field is always included for row matching + if (logsFrame?.idField?.name) { + transform.options.includeByName = { + ...transform.options.includeByName, + [logsFrame.idField.name]: true, + }; + } transformations.push(transform); } else { // If no fields are filtered, filter the default fields, so we don't render all columns + // Always include ID field for row matching + const includeByName: Record = { + [logsFrame.bodyField.name]: true, + [logsFrame.timeField.name]: true, + }; + if (logsFrame?.idField?.name) { + includeByName[logsFrame.idField.name] = true; + } transformations.push({ id: 'organize', options: { @@ -137,10 +285,7 @@ export function LogsTable(props: Props) { [logsFrame.bodyField.name]: 0, [logsFrame.timeField.name]: 1, }, - includeByName: { - [logsFrame.bodyField.name]: true, - [logsFrame.timeField.name]: true, - }, + includeByName, }, }); } @@ -161,6 +306,7 @@ export function LogsTable(props: Props) { prepareTableFrame, logsFrame?.bodyField.name, logsFrame?.timeField.name, + logsFrame?.idField?.name, ]); if (!tableFrame) { @@ -193,11 +339,13 @@ export function LogsTable(props: Props) { ); } @@ -284,7 +432,19 @@ function getLabelFiltersTransform(labelFilters: Record) { function getInitialFieldWidth(field: Field): number | undefined { if (field.type === FieldType.time) { - return 200; + return 230; } return undefined; } + +const getStyles = (theme: GrafanaTheme2) => ({ + firstColumnHeader: css({ + display: 'flex', + label: 'wrapper', + marginLeft: theme.spacing(7), + width: '100%', + }), + firstColumnCell: css({ + paddingLeft: theme.spacing(7), + }), +}); diff --git a/public/app/features/explore/Logs/LogsTableActionButtons.tsx b/public/app/features/explore/Logs/LogsTableActionButtons.tsx new file mode 100644 index 00000000000..88e3c1db4ed --- /dev/null +++ b/public/app/features/explore/Logs/LogsTableActionButtons.tsx @@ -0,0 +1,190 @@ +import { css } from '@emotion/css'; +import { useCallback, useState } from 'react'; + +import { + AbsoluteTimeRange, + ExploreLogsPanelState, + GrafanaTheme2, + LogRowModel, + serializeStateToUrlParam, + urlUtil, +} from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { ClipboardButton, CustomCellRendererProps, IconButton, Modal, useTheme2 } from '@grafana/ui'; +import { getLogsPermalinkRange } from 'app/core/utils/shortLinks'; +import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync'; +import { LogsFrame } from 'app/features/logs/logsFrame'; +import { getState } from 'app/store/store'; + +import { getExploreBaseUrl } from './utils/url'; +interface Props extends CustomCellRendererProps { + logId?: string; + logsFrame?: LogsFrame; + exploreId?: string; + panelState?: ExploreLogsPanelState; + displayedFields?: string[]; + absoluteRange?: AbsoluteTimeRange; + logRows?: LogRowModel[]; + index?: number; +} + +export function LogsTableActionButtons(props: Props) { + const { exploreId, absoluteRange, logRows, rowIndex, panelState, displayedFields, logsFrame, frame } = props; + + const theme = useTheme2(); + const [isInspecting, setIsInspecting] = useState(false); + // Get logId from the table frame (frame), not the original logsFrame, because + // the table frame is sorted/transformed and rowIndex refers to the table frame + const idFieldName = logsFrame?.idField?.name ?? 'id'; + const idField = frame.fields.find((field) => field.name === idFieldName || field.name === 'id'); + const logId = idField?.values[rowIndex]; + const getLineValue = () => { + const bodyFieldName = logsFrame?.bodyField?.name; + const bodyField = bodyFieldName + ? frame.fields.find((field) => field.name === bodyFieldName) + : frame.fields.find((field) => field.type === 'string'); + return bodyField?.values[rowIndex]; + }; + + const lineValue = getLineValue(); + + const styles = getStyles(theme); + + // Generate link to the log line + const getText = useCallback(() => { + if (!logId || !exploreId || !absoluteRange || !logRows) { + return ''; + } + + try { + // Get the log row from the logRows array + const logRow = logRows.find((row) => row.rowId === logId); + + if (!logRow) { + return ''; + } + + // Get the current explore state + const currentPaneState = getState().explore.panes[exploreId]; + if (!currentPaneState) { + return ''; + } + + // Create URL state with log permalink information + const urlState = getUrlStateFromPaneState(currentPaneState); + + // Preserve all panel state (columns, labelFieldName, etc.) + urlState.panelsState = { + ...currentPaneState.panelsState, + logs: { + ...panelState, + displayedFields: displayedFields ?? [], + }, + }; + + // Calculate the time range for the permalink + urlState.range = getLogsPermalinkRange(logRow, logRows, absoluteRange); + + // Create the full URL with selectedLine as a URL parameter (with id and row) + const serializedState = serializeStateToUrlParam(urlState); + const baseUrl = getExploreBaseUrl(); + const url = urlUtil.renderUrl(`${baseUrl}/explore`, { + left: serializedState, + selectedLine: JSON.stringify({ id: logId, row: rowIndex }), + }); + return url; + } catch (error) { + return ''; + } + }, [absoluteRange, displayedFields, exploreId, logId, logRows, rowIndex, panelState]); + + const handleViewClick = () => { + setIsInspecting(true); + }; + + return ( + <> +
+
+ +
+
+ +
+
+ {isInspecting && ( + setIsInspecting(false)} + isOpen={true} + title={t('explore.logs-table.action-buttons.inspect-value', 'Inspect value')} + > +
{lineValue}
+ + lineValue}> + {t('explore.logs-table.action-buttons.copy-to-clipboard', 'Copy to Clipboard')} + + +
+ )} + + ); +} + +export const getStyles = (theme: GrafanaTheme2) => ({ + clipboardButton: css({ + height: '100%', + lineHeight: '1', + padding: 0, + width: '20px', + }), + iconWrapper: css({ + background: theme.colors.background.secondary, + boxShadow: theme.shadows.z2, + display: 'flex', + flexDirection: 'row', + height: '35px', + left: 0, + top: 0, + padding: `0 ${theme.spacing(0.5)}`, + position: 'absolute', + zIndex: 1, + }), + inspect: css({ + '& button svg': { + marginRight: 'auto', + }, + '&:hover': { + color: theme.colors.text.link, + cursor: 'pointer', + }, + padding: '5px 3px', + }), + inspectButton: css({ + borderRadius: theme.shape.radius.default, + display: 'inline-flex', + margin: 0, + overflow: 'hidden', + verticalAlign: 'middle', + }), +}); diff --git a/public/app/features/explore/Logs/LogsTableWrap.tsx b/public/app/features/explore/Logs/LogsTableWrap.tsx index 0148c1a06df..ff8cceaadcf 100644 --- a/public/app/features/explore/Logs/LogsTableWrap.tsx +++ b/public/app/features/explore/Logs/LogsTableWrap.tsx @@ -7,11 +7,13 @@ import { ExploreLogsPanelState, GrafanaTheme2, Labels, + LogRowModel, LogsSortOrder, SelectableValue, SplitOpen, store, TimeRange, + AbsoluteTimeRange, } from '@grafana/data'; import { t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; @@ -40,6 +42,10 @@ interface Props { onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; datasourceType?: string; + exploreId?: string; + displayedFields?: string[]; + absoluteRange?: AbsoluteTimeRange; + logRows?: LogRowModel[]; } type ActiveFieldMeta = { @@ -534,6 +540,10 @@ export function LogsTableWrap(props: Props) { tableSortBy={panelState?.tableSortBy} tableSortDir={panelState?.tableSortDir} onSortByChange={onSortByChange} + displayedFields={props.displayedFields} + exploreId={props.exploreId} + absoluteRange={props.absoluteRange} + logRows={props.logRows} /> diff --git a/public/app/features/explore/Logs/utils/url.ts b/public/app/features/explore/Logs/utils/url.ts new file mode 100644 index 00000000000..10be54cce1e --- /dev/null +++ b/public/app/features/explore/Logs/utils/url.ts @@ -0,0 +1,10 @@ +/** + * Gets the base URL before the /explore path. + * Used for constructing explore URLs with permalinks. + * + * @returns The base URL (e.g., "http://localhost:3000" or "https://grafana.com") + */ +export function getExploreBaseUrl(): string { + const match = /.*(?=\/explore)/.exec(window.location.href); + return match ? match[0] : window.location.origin; +} diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index 874234837ac..38a579463ed 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -8,6 +8,7 @@ import { EventBusSrv, ExploreLogsPanelState, LogLevel, + LogRowModel, LogsMetaItem, LogsSortOrder, SplitOpen, @@ -41,6 +42,10 @@ export interface ControlledLogRowsProps extends Omit { datasourceType?: string; width?: number; logsTableFrames?: DataFrame[]; + displayedFields?: string[]; + exploreId?: string; + absoluteRange?: AbsoluteTimeRange; + logRows?: LogRowModel[]; } export type LogRowsComponentProps = Omit< diff --git a/public/app/features/logs/components/ControlledLogsTable.tsx b/public/app/features/logs/components/ControlledLogsTable.tsx index b706ba145d8..0374928da8f 100644 --- a/public/app/features/logs/components/ControlledLogsTable.tsx +++ b/public/app/features/logs/components/ControlledLogsTable.tsx @@ -25,6 +25,10 @@ export const ControlledLogsTable = ({ width, logsTableFrames, visualisationType, + displayedFields, + exploreId, + absoluteRange, + logRows, ...rest }: LogRowsComponentProps) => { const { sortOrder, controlsExpanded } = useLogListContext(); @@ -58,6 +62,10 @@ export const ControlledLogsTable = ({ panelState={panelState} updatePanelState={updatePanelState} datasourceType={datasourceType} + displayedFields={displayedFields} + exploreId={exploreId} + absoluteRange={absoluteRange} + logRows={logRows} /> diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index ac054cb8f7a..52e15a12a5f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7302,6 +7302,14 @@ "title-failed-sample-query": "Failed to load logs sample for this query", "tooltip": "Show log lines that contributed to visualized metrics" }, + "logs-table": { + "action-buttons": { + "copy-link": "Copy link to log line", + "copy-to-clipboard": "Copy to Clipboard", + "inspect-value": "Inspect value", + "view-log-line": "View log line" + } + }, "logs-table-empty-fields": { "no-fields": "No fields" },