From a0c55e92eee2c26cadfd1f64e2f9376e04ae59a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Falksk=C3=A4r?= <457523+edvard-falkskar@users.noreply.github.com> Date: Wed, 4 Jun 2025 09:55:08 +0200 Subject: [PATCH] LogsView: Resource attributes links extension point (#105943) * LogsView: Resource attributes links extension point * Mocking usePluginLinks in tests * Update link button styling * LogListModel: sync with LogRowModel changes * Fix import --------- Co-authored-by: Matias Chomicki --- packages/grafana-data/src/types/logs.ts | 1 + .../src/types/pluginExtensions.ts | 1 + .../explore/Logs/LogsSamplePanel.test.tsx | 1 + .../logs/components/LogDetails.test.tsx | 34 +++++++++ .../features/logs/components/LogDetails.tsx | 73 ++++++++++++++++++- .../logs/components/LogDetailsRow.tsx | 9 ++- .../features/logs/components/LogRow.test.tsx | 1 + .../features/logs/components/LogRows.test.tsx | 1 + .../logs/components/panel/LogList.test.tsx | 7 ++ .../logs/components/panel/processing.test.ts | 15 ++++ .../logs/components/panel/processing.ts | 2 + public/app/features/logs/logsModel.ts | 5 +- .../app/features/logs/logsModel_parse.test.ts | 28 +++++++ .../app/plugins/panel/logs/LogsPanel.test.tsx | 1 + 14 files changed, 174 insertions(+), 5 deletions(-) diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts index bfd54b0ec6f..71b93ab7e39 100644 --- a/packages/grafana-data/src/types/logs.ts +++ b/packages/grafana-data/src/types/logs.ts @@ -99,6 +99,7 @@ export interface LogRowModel { uid: string; uniqueLabels?: Labels; datasourceType?: string; + datasourceUid?: string; } export interface LogsModel { diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 36ddc74ab22..47881a04110 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -193,6 +193,7 @@ export enum PluginExtensionPoints { TraceViewDetails = 'grafana/traceview/details', QueryEditorRowAdaptiveTelemetryV1 = 'grafana/query-editor-row/adaptivetelemetry/v1', TraceViewResourceAttributes = 'grafana/traceview/resource-attributes', + LogsViewResourceAttributes = 'grafana/logsview/resource-attributes', } export type PluginExtensionPanelContext = { diff --git a/public/app/features/explore/Logs/LogsSamplePanel.test.tsx b/public/app/features/explore/Logs/LogsSamplePanel.test.tsx index 1ef69a2d4a3..591eed6c179 100644 --- a/public/app/features/explore/Logs/LogsSamplePanel.test.tsx +++ b/public/app/features/explore/Logs/LogsSamplePanel.test.tsx @@ -18,6 +18,7 @@ jest.mock('@grafana/runtime', () => { return { ...jest.requireActual('@grafana/runtime'), reportInteraction: jest.fn(), + usePluginLinks: jest.fn().mockReturnValue({ links: [] }), }; }); diff --git a/public/app/features/logs/components/LogDetails.test.tsx b/public/app/features/logs/components/LogDetails.test.tsx index 89b6e6793e6..1ee66deb632 100644 --- a/public/app/features/logs/components/LogDetails.test.tsx +++ b/public/app/features/logs/components/LogDetails.test.tsx @@ -11,13 +11,22 @@ import { createDataFrame, DataFrameType, CoreApp, + PluginExtensionPoints, } from '@grafana/data'; +import { setPluginLinksHook } from '@grafana/runtime'; import { LogDetails, Props } from './LogDetails'; import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody'; import { createLogRow } from './__mocks__/logRow'; import { getLogRowStyles } from './getLogRowStyles'; +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + usePluginLinks: jest.fn().mockReturnValue({ links: [] }), + }; +}); + const setup = (propOverrides?: Partial, rowOverrides?: Partial) => { const theme = createTheme(); const styles = getLogRowStyles(theme); @@ -296,6 +305,31 @@ describe('LogDetails', () => { expect(screen.getByText('shouldShowLinkValue')).toBeInTheDocument(); }); + it('should load plugin links for logs view resource attributes extension point', () => { + const usePluginLinksMock = jest.fn().mockReturnValue({ links: [] }); + setPluginLinksHook(usePluginLinksMock); + jest.requireMock('@grafana/runtime').usePluginLinks = usePluginLinksMock; + + const rowOverrides = { + datasourceType: 'loki', + datasourceUid: 'grafanacloud-logs', + labels: { key1: 'label1', key2: 'label2' }, + }; + setup(undefined, rowOverrides); + + expect(usePluginLinksMock).toHaveBeenCalledWith({ + extensionPointId: PluginExtensionPoints.LogsViewResourceAttributes, + limitPerPlugin: 10, + context: { + datasource: { + type: 'loki', + uid: 'grafanacloud-logs', + }, + attributes: { key1: ['label1'], key2: ['label2'] }, + }, + }); + }); + describe('Label types', () => { const entry = 'test'; const labels = { diff --git a/public/app/features/logs/components/LogDetails.tsx b/public/app/features/logs/components/LogDetails.tsx index 72181c5c9f9..d2164178243 100644 --- a/public/app/features/logs/components/LogDetails.tsx +++ b/public/app/features/logs/components/LogDetails.tsx @@ -1,9 +1,19 @@ import { cx } from '@emotion/css'; -import { PureComponent } from 'react'; +import { PureComponent, useMemo } from 'react'; -import { CoreApp, DataFrame, DataFrameType, LogRowModel } from '@grafana/data'; +import { + CoreApp, + DataFrame, + DataFrameType, + IconName, + LinkModel, + LogRowModel, + PluginExtensionPoints, + PluginExtensionResourceAttributesContext, +} from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { t } from '@grafana/i18n/internal'; +import { usePluginLinks } from '@grafana/runtime'; import { PopoverContent, Themeable2, withTheme2 } from '@grafana/ui'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; @@ -35,8 +45,63 @@ export interface Props extends Themeable2 { onPinLine?: (row: LogRowModel) => void; pinLineButtonTooltipTitle?: PopoverContent; mode?: 'inline' | 'sidebar'; + links?: Record; } +interface LinkModelWithIcon extends LinkModel { + icon?: IconName; +} + +const useAttributesExtensionLinks = (row: LogRowModel) => { + // Stable context for useMemo inside usePluginLinks + const context: PluginExtensionResourceAttributesContext = useMemo(() => { + return { + attributes: Object.fromEntries(Object.entries(row.labels).map(([key, value]) => [key, [value]])), + datasource: { + type: row.datasourceType ?? '', + uid: row.datasourceUid ?? '', + }, + }; + }, [row.labels, row.datasourceType, row.datasourceUid]); + + const { links } = usePluginLinks({ + extensionPointId: PluginExtensionPoints.LogsViewResourceAttributes, + limitPerPlugin: 10, + context, + }); + + return useMemo(() => { + return links.reduce>((acc, link) => { + if (link.category) { + const linkModel: LinkModelWithIcon = { + href: link.path ?? '', + target: '_blank', + origin: undefined, + title: link.title, + onClick: link.onClick, + icon: link.icon, + }; + + if (acc[link.category]) { + acc[link.category].push(linkModel); + } else { + acc[link.category] = [linkModel]; + } + } + return acc; + }, {}); + }, [links]); +}; + +const withAttributesExtensionLinks = (Component: React.ComponentType) => { + function ComponentWithLinks(props: Props) { + const labelLinks = useAttributesExtensionLinks(props.row); + return ; + } + + return ComponentWithLinks; +}; + class UnThemedLogDetails extends PureComponent { render() { const { @@ -58,6 +123,7 @@ class UnThemedLogDetails extends PureComponent { styles, pinLineButtonTooltipTitle, mode = 'inline', + links, } = this.props; const levelStyles = getLogLevelStyles(theme, row.logLevel); const labels = row.labels ? row.labels : {}; @@ -151,6 +217,7 @@ class UnThemedLogDetails extends PureComponent { displayedFields={displayedFields} disableActions={false} isFilterLabelActive={this.props.isFilterLabelActive} + links={links?.[key]} /> ); })} @@ -246,5 +313,5 @@ class UnThemedLogDetails extends PureComponent { } } -export const LogDetails = withTheme2(UnThemedLogDetails); +export const LogDetails = withTheme2(withAttributesExtensionLinks(UnThemedLogDetails)); LogDetails.displayName = 'LogDetails'; diff --git a/public/app/features/logs/components/LogDetailsRow.tsx b/public/app/features/logs/components/LogDetailsRow.tsx index 5ba64b9f7b8..9d45b57797d 100644 --- a/public/app/features/logs/components/LogDetailsRow.tsx +++ b/public/app/features/logs/components/LogDetailsRow.tsx @@ -32,6 +32,10 @@ import { getLabelTypeFromRow } from '../utils'; import { LogLabelStats } from './LogLabelStats'; import { getLogRowStyles } from './getLogRowStyles'; +interface LinkModelWithIcon extends LinkModel { + icon?: IconName; +} + export interface Props extends Themeable2 { parsedValues: string[]; parsedKeys: string[]; @@ -40,7 +44,7 @@ export interface Props extends Themeable2 { isLabel?: boolean; onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; - links?: Array>; + links?: LinkModelWithIcon[]; getStats: () => LogLabelStatsModel[] | null; displayedFields?: string[]; onClickShowField?: (key: string) => void; @@ -391,6 +395,9 @@ class UnThemedLogDetailsRow extends PureComponent { typeof pinLineButtonTooltipTitle === 'object' && link.onClick ? pinLineButtonTooltipTitle : undefined, + variant: 'secondary', + fill: 'outline', + ...(link.icon && { icon: link.icon }), }} link={link} /> diff --git a/public/app/features/logs/components/LogRow.test.tsx b/public/app/features/logs/components/LogRow.test.tsx index 04e8f5ecb6a..dc28d2ee311 100644 --- a/public/app/features/logs/components/LogRow.test.tsx +++ b/public/app/features/logs/components/LogRow.test.tsx @@ -14,6 +14,7 @@ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), reportInteraction: (interactionName: string, properties?: Record | undefined) => reportInteraction(interactionName, properties), + usePluginLinks: jest.fn().mockReturnValue({ links: [] }), })); const theme = createTheme(); diff --git a/public/app/features/logs/components/LogRows.test.tsx b/public/app/features/logs/components/LogRows.test.tsx index 9f2df7f23c9..35d5cc4d9ea 100644 --- a/public/app/features/logs/components/LogRows.test.tsx +++ b/public/app/features/logs/components/LogRows.test.tsx @@ -24,6 +24,7 @@ jest.mock('@grafana/runtime', () => ({ logRowsPopoverMenu: true, }, }, + usePluginLinks: jest.fn().mockReturnValue({ links: [] }), })); describe('LogRows', () => { diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index 7e89421e907..5521597710d 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -7,6 +7,13 @@ import { createLogRow } from '../__mocks__/logRow'; import { LogList, Props } from './LogList'; +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + usePluginLinks: jest.fn().mockReturnValue({ links: [] }), + }; +}); + describe('LogList', () => { let logs: LogRowModel[], defaultProps: Props; beforeEach(() => { diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index 122d3546246..2a23a088570 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -80,6 +80,21 @@ describe('preProcessLogs', () => { }); }); + describe('LogListModel', () => { + test('Extends a LogRowModel', () => { + const logRowModel = createLogRow({ + uid: '2', + datasourceUid: 'test', + timeEpochMs: 2, + labels: { method: 'POST', status: '200' }, + entry: `35.191.12.195 - accounts.google.com:test@grafana.com [18/Mar/2025:08:58:38 +0000] 200 "POST /grafana/api/ds/query?ds_type=prometheus&requestId=SQR461 HTTP/1.1" 59460 "https://test.example.com/?orgId=1" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" "95.91.240.90, 34.107.247.24"`, + logLevel: LogLevel.critical, + }); + const logListModel = new LogListModel(logRowModel, { escape: false, timeZone: 'browser ' }); + expect(logListModel).toMatchObject(logRowModel); + }); + }); + test('Orders logs', () => { expect(processedLogs[0].uid).toBe('1'); expect(processedLogs[1].uid).toBe('2'); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index b666740f1bc..e2a9dc992f1 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -14,6 +14,7 @@ export class LogListModel implements LogRowModel { collapsed: boolean | undefined = undefined; datasourceType: string | undefined; dataFrame: DataFrame; + datasourceUid?: string; displayLevel: string; duplicates: number | undefined; entry: string; @@ -66,6 +67,7 @@ export class LogListModel implements LogRowModel { this.timeUtc = log.timeUtc; this.uid = log.uid; this.uniqueLabels = log.uniqueLabels; + this.datasourceUid = log.datasourceUid; // LogListModel this.displayLevel = logLevelToDisplayLevel(log.logLevel); diff --git a/public/app/features/logs/logsModel.ts b/public/app/features/logs/logsModel.ts index 13a10f80031..f653e6686d8 100644 --- a/public/app/features/logs/logsModel.ts +++ b/public/app/features/logs/logsModel.ts @@ -437,7 +437,9 @@ export function logSeriesToLogsModel( logLevel = getLogLevel(entry); } - const datasourceType = queries.find((query) => query.refId === series.refId)?.datasource?.type; + const datasource = queries.find((query) => query.refId === series.refId)?.datasource; + const datasourceType = datasource?.type; + const datasourceUid = datasource?.uid; const row: LogRowModel = { entryFieldIndex: stringField.index, @@ -459,6 +461,7 @@ export function logSeriesToLogsModel( // prepend refId to uid to make it unique across all series in a case when series contain duplicates uid: `${series.refId}_${idField ? idField.values[j] : j.toString()}`, datasourceType, + datasourceUid, }; if (idField !== null) { diff --git a/public/app/features/logs/logsModel_parse.test.ts b/public/app/features/logs/logsModel_parse.test.ts index e87b304c83c..e98d75befb2 100644 --- a/public/app/features/logs/logsModel_parse.test.ts +++ b/public/app/features/logs/logsModel_parse.test.ts @@ -76,6 +76,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( dataFrame: frames[0], rowId: 'id1', datasourceType: undefined, + datasourceUid: undefined, entry: 'line1', entryFieldIndex: 4, hasAnsi: false, @@ -103,6 +104,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( dataFrame: frames[0], rowId: 'id2', datasourceType: undefined, + datasourceUid: undefined, entry: 'line2', entryFieldIndex: 4, hasAnsi: false, @@ -130,6 +132,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( dataFrame: frames[0], rowId: 'id3', datasourceType: undefined, + datasourceUid: undefined, entry: 'line3', entryFieldIndex: 4, hasAnsi: false, @@ -287,6 +290,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( dataFrame: frames[0], rowId: 'id1', datasourceType: undefined, + datasourceUid: undefined, entry: 'line1', entryFieldIndex: 1, hasAnsi: false, @@ -315,6 +319,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( dataFrame: frames[1], rowId: 'id2', datasourceType: undefined, + datasourceUid: undefined, entry: 'line2', entryFieldIndex: 1, hasAnsi: false, @@ -343,6 +348,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( dataFrame: frames[2], rowId: 'id3', datasourceType: undefined, + datasourceUid: undefined, entry: 'line3', entryFieldIndex: 1, hasAnsi: false, @@ -438,6 +444,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( dataFrame: frames[0], rowId: 'id1', datasourceType: undefined, + datasourceUid: undefined, entry: 'line1', entryFieldIndex: 2, hasAnsi: false, @@ -467,6 +474,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( dataFrame: frames[0], rowId: 'id2', datasourceType: undefined, + datasourceUid: undefined, entry: 'line2', entryFieldIndex: 2, hasAnsi: false, @@ -496,6 +504,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( dataFrame: frames[0], rowId: 'id3', datasourceType: undefined, + datasourceUid: undefined, entry: 'line3', entryFieldIndex: 2, hasAnsi: false, @@ -628,6 +637,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line1', entryFieldIndex: 1, hasAnsi: false, @@ -648,6 +658,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line2', entryFieldIndex: 1, hasAnsi: false, @@ -668,6 +679,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line3', entryFieldIndex: 1, hasAnsi: false, @@ -720,6 +732,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line1', entryFieldIndex: 1, hasAnsi: false, @@ -740,6 +753,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line2', entryFieldIndex: 1, hasAnsi: false, @@ -760,6 +774,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line3', entryFieldIndex: 1, hasAnsi: false, @@ -818,6 +833,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line1', entryFieldIndex: 1, hasAnsi: false, @@ -838,6 +854,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line2', entryFieldIndex: 1, hasAnsi: false, @@ -858,6 +875,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line3', entryFieldIndex: 1, hasAnsi: false, @@ -911,6 +929,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line1', entryFieldIndex: 1, hasAnsi: false, @@ -931,6 +950,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line2', entryFieldIndex: 1, hasAnsi: false, @@ -951,6 +971,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line3', entryFieldIndex: 1, hasAnsi: false, @@ -1012,6 +1033,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line1', entryFieldIndex: 1, hasAnsi: false, @@ -1032,6 +1054,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line2', entryFieldIndex: 1, hasAnsi: false, @@ -1052,6 +1075,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line3', entryFieldIndex: 1, hasAnsi: false, @@ -1112,6 +1136,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( dataFrame: frames[0], rowId: 'id1', datasourceType: undefined, + datasourceUid: undefined, entry: 'line1', entryFieldIndex: 1, hasAnsi: false, @@ -1133,6 +1158,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( dataFrame: frames[0], rowId: 'id2', datasourceType: undefined, + datasourceUid: undefined, entry: 'line2', entryFieldIndex: 1, hasAnsi: false, @@ -1186,6 +1212,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line1', entryFieldIndex: 1, hasAnsi: false, @@ -1206,6 +1233,7 @@ describe('logSeriesToLogsModel should parse different logs-dataframe formats', ( { dataFrame: frames[0], datasourceType: undefined, + datasourceUid: undefined, entry: 'line2', entryFieldIndex: 1, hasAnsi: false, diff --git a/public/app/plugins/panel/logs/LogsPanel.test.tsx b/public/app/plugins/panel/logs/LogsPanel.test.tsx index 2127c0b7c51..6a299326f85 100644 --- a/public/app/plugins/panel/logs/LogsPanel.test.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.test.tsx @@ -43,6 +43,7 @@ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getAppEvents: jest.fn(), getDataSourceSrv: () => getDataSourceSrvMock(), + usePluginLinks: jest.fn().mockReturnValue({ links: [] }), })); const hasLogsContextSupport = jest.fn().mockImplementation((ds) => {