mirror of
https://github.com/grafana/grafana.git
synced 2025-08-02 23:23:10 +08:00
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:
@ -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:*",
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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')}
|
||||
|
@ -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 = {
|
||||
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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}
|
||||
|
@ -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', () => {
|
||||
|
@ -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 = {
|
||||
|
@ -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(),
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
15
yarn.lock
15
yarn.lock
@ -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:*"
|
||||
|
Reference in New Issue
Block a user