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 <matyax@gmail.com>
This commit is contained in:
Edvard Falkskär 2025-06-04 09:55:08 +02:00 committed by GitHub
parent 219461a58d
commit a0c55e92ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 174 additions and 5 deletions

View File

@ -99,6 +99,7 @@ export interface LogRowModel {
uid: string;
uniqueLabels?: Labels;
datasourceType?: string;
datasourceUid?: string;
}
export interface LogsModel {

View File

@ -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 = {

View File

@ -18,6 +18,7 @@ jest.mock('@grafana/runtime', () => {
return {
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
};
});

View File

@ -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<Props>, rowOverrides?: Partial<LogRowModel>) => {
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 = {

View File

@ -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<string, LinkModel[]>;
}
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<Record<string, LinkModelWithIcon[]>>((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<Props>) => {
function ComponentWithLinks(props: Props) {
const labelLinks = useAttributesExtensionLinks(props.row);
return <Component {...props} links={labelLinks} />;
}
return ComponentWithLinks;
};
class UnThemedLogDetails extends PureComponent<Props> {
render() {
const {
@ -58,6 +123,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
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<Props> {
displayedFields={displayedFields}
disableActions={false}
isFilterLabelActive={this.props.isFilterLabelActive}
links={links?.[key]}
/>
);
})}
@ -246,5 +313,5 @@ class UnThemedLogDetails extends PureComponent<Props> {
}
}
export const LogDetails = withTheme2(UnThemedLogDetails);
export const LogDetails = withTheme2(withAttributesExtensionLinks(UnThemedLogDetails));
LogDetails.displayName = 'LogDetails';

View File

@ -32,6 +32,10 @@ import { getLabelTypeFromRow } from '../utils';
import { LogLabelStats } from './LogLabelStats';
import { getLogRowStyles } from './getLogRowStyles';
interface LinkModelWithIcon extends LinkModel<Field> {
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<LinkModel<Field>>;
links?: LinkModelWithIcon[];
getStats: () => LogLabelStatsModel[] | null;
displayedFields?: string[];
onClickShowField?: (key: string) => void;
@ -391,6 +395,9 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
typeof pinLineButtonTooltipTitle === 'object' && link.onClick
? pinLineButtonTooltipTitle
: undefined,
variant: 'secondary',
fill: 'outline',
...(link.icon && { icon: link.icon }),
}}
link={link}
/>

View File

@ -14,6 +14,7 @@ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: (interactionName: string, properties?: Record<string, unknown> | undefined) =>
reportInteraction(interactionName, properties),
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
}));
const theme = createTheme();

View File

@ -24,6 +24,7 @@ jest.mock('@grafana/runtime', () => ({
logRowsPopoverMenu: true,
},
},
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
}));
describe('LogRows', () => {

View File

@ -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(() => {

View File

@ -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');

View File

@ -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);

View File

@ -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) {

View File

@ -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,

View File

@ -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) => {