LogLine: Add Explain in Assistant option to explain single log lines (#108387)

* LogLine: Add `Explain in Assistant` option

* LogLine: Improve menu and prompt

* LogLine: Mock `@grafana/assistant` in tests

* LogLine: Mock `@grafana/assistant` in tests

* i18n

* LogLine: Expose via loglistcontext

* LogLine: Mock in LogLineDetails

* revert

* revert changes

* LogLine: Fix LogListContext mock

* LogLineControls: Add context props

* LogLineControls: Mock `@grafana/assistant`

* LogLineControls: Remove from props

* LogLineControls: Lint menu

* LogsPanel: Mock `@grafana/assistant`
This commit is contained in:
Sven Grossmann
2025-07-23 12:12:47 +02:00
committed by GitHub
parent 838391cd31
commit f657044afb
14 changed files with 154 additions and 3 deletions

View File

@ -271,6 +271,7 @@
"@formatjs/intl-durationformat": "^0.7.0",
"@glideapps/glide-data-grid": "^6.0.0",
"@grafana/alerting": "workspace:*",
"@grafana/assistant": "0.0.7",
"@grafana/aws-sdk": "0.7.1",
"@grafana/azure-sdk": "0.0.7",
"@grafana/data": "workspace:*",

View File

@ -14,6 +14,11 @@ import { defaultProps, defaultValue } from './__mocks__/LogListContext';
import { LogListModel } from './processing';
import { LogLineVirtualization } from './virtualization';
jest.mock('@grafana/assistant', () => ({
...jest.requireActual('@grafana/assistant'),
useAssistant: jest.fn(() => [true, jest.fn()]),
}));
jest.mock('./LogListContext');
jest.mock('../LogDetails');

View File

@ -23,6 +23,13 @@ import { LogLineDetails, Props } from './LogLineDetails';
import { LogListContext, LogListContextData } from './LogListContext';
import { defaultValue } from './__mocks__/LogListContext';
jest.mock('@grafana/assistant', () => {
return {
...jest.requireActual('@grafana/assistant'),
useAssistant: jest.fn().mockReturnValue([true, jest.fn()]),
};
});
jest.mock('@grafana/runtime', () => {
return {
...jest.requireActual('@grafana/runtime'),

View File

@ -37,6 +37,8 @@ export const LogLineDetailsHeader = ({ focusLogLine, log, search, onSearch }: Pr
onPinLine,
onUnpinLine,
wrapLogMessage,
isAssistantAvailable,
openAssistantByLog,
} = useLogListContext();
const pinned = useLogIsPinned(log);
const styles = useStyles2(getStyles, detailsMode, wrapLogMessage);
@ -149,6 +151,16 @@ export const LogLineDetailsHeader = ({ focusLogLine, log, search, onSearch }: Pr
suffix={search !== '' ? clearSearch : undefined}
/>
<div className={styles.icons}>
{isAssistantAvailable && (
<IconButton
tooltip={t('logs.log-line-details.open-assistant', 'Explain this log line in Assistant')}
tooltipPlacement="top"
size="md"
name="ai-sparkle"
onClick={() => openAssistantByLog?.(log)}
tabIndex={0}
/>
)}
{focusLogLine && (
<IconButton
tooltip={t('logs.log-line-details.scroll-to-logline', 'Scroll to log line')}

View File

@ -13,6 +13,16 @@ import { LogListModel } from './processing';
jest.mock('./LogListContext');
jest.mock('@grafana/assistant', () => ({
...jest.requireActual('@grafana/assistant'),
useAssistant: jest.fn(() => [true, jest.fn()]),
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
isAssistantAvailable: true,
}));
const theme = createTheme();
const styles = getStyles(theme);
const contextProps = {

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, MouseEvent } from 'react';
import { MouseEvent, useCallback, useMemo, useRef } from 'react';
import { LogRowContextOptions, LogRowModel } from '@grafana/data';
import { t } from '@grafana/i18n';
@ -45,6 +45,8 @@ export const LogLineMenu = ({ log, styles }: Props) => {
logLineMenuCustomItems = [],
logSupportsContext,
toggleDetails,
isAssistantAvailable,
openAssistantByLog,
} = useLogListContext();
const pinned = useLogIsPinned(log);
const menuRef = useRef(null);
@ -108,6 +110,13 @@ export const LogLineMenu = ({ log, styles }: Props) => {
{onPermalinkClick && log.rowId !== undefined && log.uid && (
<Menu.Item onClick={copyLinkToLogLine} label={t('logs.log-line-menu.copy-link', 'Copy link to log line')} />
)}
{isAssistantAvailable && (
<Menu.Item
onClick={() => openAssistantByLog?.(log)}
icon="ai-sparkle"
label={t('logs.log-line-menu.open-assistant', 'Explain this log line in Assistant')}
/>
)}
{logLineMenuCustomItems.map((item, i) => {
if (isDivider(item)) {
return <Menu.Divider key={i} />;
@ -134,6 +143,8 @@ export const LogLineMenu = ({ log, styles }: Props) => {
showContext,
toggleLogDetails,
togglePinning,
isAssistantAvailable,
openAssistantByLog,
]
);

View File

@ -9,6 +9,11 @@ import { createLogRow } from '../mocks/logRow';
import { LogList, Props } from './LogList';
jest.mock('@grafana/assistant', () => ({
...jest.requireActual('@grafana/assistant'),
useAssistant: jest.fn(() => [true, jest.fn()]),
}));
jest.mock('@grafana/runtime', () => {
return {
...jest.requireActual('@grafana/runtime'),

View File

@ -11,6 +11,7 @@ import {
useState,
} from 'react';
import { createContext as createAssistantContext, ItemDataType, useAssistant } from '@grafana/assistant';
import {
CoreApp,
DataFrame,
@ -22,10 +23,11 @@ import {
shallowCompare,
store,
} from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { t } from '@grafana/i18n';
import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
import { PopoverContent } from '@grafana/ui';
import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils';
import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils';
import { getDisplayedFieldsForLogs } from '../otel/formats';
import { LogLineDetailsMode } from './LogLineDetails';
@ -63,6 +65,8 @@ export interface LogListContextData extends Omit<Props, 'containerElement' | 'lo
setWrapLogMessage: (showTime: boolean) => void;
showDetails: LogListModel[];
toggleDetails: (log: LogListModel) => void;
isAssistantAvailable: boolean;
openAssistantByLog: ((log: LogListModel) => void) | undefined;
}
export const LogListContext = createContext<LogListContextData>({
@ -100,6 +104,8 @@ export const LogListContext = createContext<LogListContextData>({
syntaxHighlighting: true,
toggleDetails: () => {},
wrapLogMessage: false,
isAssistantAvailable: false,
openAssistantByLog: () => {},
});
export const useLogListContextData = (key: keyof LogListContextData) => {
@ -239,6 +245,47 @@ export const LogListContextProvider = ({
const [showDetails, setShowDetails] = useState<LogListModel[]>([]);
const [detailsWidth, setDetailsWidthState] = useState(getDetailsWidth(containerElement, logOptionsStorageKey));
const [detailsMode, setDetailsMode] = useState<LogLineDetailsMode>(detailsModeProp ?? 'sidebar');
const [isAssistantAvailable, openAssistant] = useAssistant();
const openAssistantByLog = useCallback(
async (log: LogListModel) => {
if (!openAssistant) {
return;
}
const datasource = await getDataSourceSrv().get(log.datasourceUid);
const context = [];
if (datasource) {
context.push(
createAssistantContext(ItemDataType.Datasource, {
datasourceUid: datasource.uid,
datasourceName: datasource.name,
datasourceType: datasource.type,
})
);
}
openAssistant({
prompt: `${t('logs.log-line-menu.log-line-explainer', 'Explain this log line in a concise way')}:
\`\`\`
${log.entry.replaceAll('`', '\\`')}
\`\`\`
`,
context: [
...context,
createAssistantContext(ItemDataType.Structured, {
title: t('logs.log-line-menu.log-line', 'Log line'),
data: {
labels: log.labels,
value: log.entry,
timestamp: log.timestamp,
},
}),
],
});
},
[openAssistant]
);
useEffect(() => {
if (noInteractions) {
@ -569,6 +616,8 @@ export const LogListContextProvider = ({
syntaxHighlighting: logListState.syntaxHighlighting,
toggleDetails,
wrapLogMessage: logListState.wrapLogMessage,
isAssistantAvailable,
openAssistantByLog,
}}
>
{children}

View File

@ -17,6 +17,13 @@ jest.mock('../../utils', () => ({
downloadLogs: jest.fn(),
}));
jest.mock('@grafana/assistant', () => {
return {
...jest.requireActual('@grafana/assistant'),
useAssistant: jest.fn().mockReturnValue([true, jest.fn()]),
};
});
const fontSize: LogListFontSize = 'default';
const contextProps = {
app: CoreApp.Unknown,
@ -31,6 +38,8 @@ const contextProps = {
sortOrder: LogsSortOrder.Ascending,
syntaxHighlighting: false,
wrapLogMessage: false,
isAssistantAvailable: false,
openAssistantByLog: () => {},
};
describe('LogListControls', () => {

View File

@ -7,6 +7,13 @@ import { LogLineDetailsMode } from '../LogLineDetails';
import { LogListContextData, Props } from '../LogListContext';
import { LogListModel } from '../processing';
jest.mock('@grafana/assistant', () => {
return {
...jest.requireActual('@grafana/assistant'),
useAssistant: jest.fn().mockReturnValue([true, jest.fn()]),
};
});
export const LogListContext = createContext<LogListContextData>({
app: CoreApp.Unknown,
closeDetails: () => {},
@ -43,6 +50,8 @@ export const LogListContext = createContext<LogListContextData>({
setDetailsMode: function (mode: LogLineDetailsMode): void {
throw new Error('Function not implemented.');
},
isAssistantAvailable: false,
openAssistantByLog: () => {},
});
export const useLogListContextData = (key: keyof LogListContextData) => {
@ -97,6 +106,8 @@ export const defaultValue: LogListContextData = {
showTime: false,
sortOrder: LogsSortOrder.Ascending,
wrapLogMessage: false,
isAssistantAvailable: false,
openAssistantByLog: () => {},
};
export const defaultProps: Props = {

View File

@ -18,6 +18,11 @@ import { LogsPanel } from './LogsPanel';
type LogsPanelProps = ComponentProps<typeof LogsPanel>;
jest.mock('@grafana/assistant', () => ({
...jest.requireActual('@grafana/assistant'),
useAssistant: jest.fn(() => [true, jest.fn()]),
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getAppEvents: jest.fn(),

View File

@ -57,6 +57,13 @@ jest.mock('@grafana/data', () => ({
hasLogsContextSupport: (ds: MockDataSourceApi) => hasLogsContextSupport(ds),
}));
jest.mock('@grafana/assistant', () => {
return {
...jest.requireActual('@grafana/assistant'),
useAssistant: jest.fn().mockReturnValue([true, jest.fn()]),
};
});
const defaultProps = {
data: {
error: undefined,

View File

@ -9331,6 +9331,7 @@
"move-displayed-field-down": "Move down",
"move-displayed-field-up": "Move up",
"no-details": "No fields to display.",
"open-assistant": "Explain this log line in Assistant",
"pin-line": "Pin log",
"remove-displayed-field": "Remove field",
"remove-log": "Remove log",
@ -9349,6 +9350,9 @@
"copy-log": "Copy log line",
"hide-details": "Show log details",
"icon-label": "Log menu",
"log-line": "Log line",
"log-line-explainer": "Explain this log line in a concise way",
"open-assistant": "Explain this log line in Assistant",
"pin-to-outline": "Pin log",
"show-context": "Show context",
"show-details": "Hide log details",

View File

@ -3064,6 +3064,20 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/assistant@npm:0.0.7":
version: 0.0.7
resolution: "@grafana/assistant@npm:0.0.7"
peerDependencies:
"@grafana/data": ">=12.0.0"
"@grafana/runtime": ">=12.0.0"
"@grafana/scenes": ">=5.41.0"
"@grafana/ui": ">=12.0.0"
react: ">=18.0.0"
rxjs: ">=7.0.0"
checksum: 10/c83523a032cb748867f683e87ad5b00a04bb4918ae5c7aafe5587b418b73b769a92de948335f0d9055d06036004e7a9794722f627cab3111b076eb27423d4344
languageName: node
linkType: hard
"@grafana/async-query-data@npm:0.4.1":
version: 0.4.1
resolution: "@grafana/async-query-data@npm:0.4.1"
@ -17832,6 +17846,7 @@ __metadata:
"@formatjs/intl-durationformat": "npm:^0.7.0"
"@glideapps/glide-data-grid": "npm:^6.0.0"
"@grafana/alerting": "workspace:*"
"@grafana/assistant": "npm:0.0.7"
"@grafana/aws-sdk": "npm:0.7.1"
"@grafana/azure-sdk": "npm:0.0.7"
"@grafana/data": "workspace:*"