Skip to content

Commit a40c02b

Browse files
authored
fix(DiffFlameGraph): Fix the "Explain Flame Graph" (AI) feature (#129)
1 parent 3359752 commit a40c02b

File tree

16 files changed

+881
-80
lines changed

16 files changed

+881
-80
lines changed
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { css } from '@emotion/css';
2+
import { GrafanaTheme2 } from '@grafana/data';
3+
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
4+
import { Alert, Button, IconButton, Spinner, useStyles2 } from '@grafana/ui';
5+
import { getProfileMetric, ProfileMetricId } from '@shared/infrastructure/profile-metrics/getProfileMetric';
6+
import { DomainHookReturnValue } from '@shared/types/DomainHookReturnValue';
7+
import { InlineBanner } from '@shared/ui/InlineBanner';
8+
import { Panel } from '@shared/ui/Panel/Panel';
9+
import React from 'react';
10+
11+
import { ProfilesDataSourceVariable } from '../../domain/variables/ProfilesDataSourceVariable';
12+
import { getSceneVariableValue } from '../../helpers/getSceneVariableValue';
13+
import { AiReply } from './components/AiReply';
14+
import { FollowUpForm } from './components/FollowUpForm';
15+
import { useOpenAiChatCompletions } from './domain/useOpenAiChatCompletions';
16+
import { FetchParams, useFetchDotProfiles } from './infrastructure/useFetchDotProfiles';
17+
18+
interface SceneAiPanelState extends SceneObjectState {}
19+
20+
export class SceneAiPanel extends SceneObjectBase<SceneAiPanelState> {
21+
constructor() {
22+
super({ key: 'ai-panel' });
23+
}
24+
25+
validateFetchParams(isDiff: boolean, fetchParams: FetchParams) {
26+
let params = fetchParams;
27+
let error;
28+
29+
if (isDiff && fetchParams.length !== 2) {
30+
error = new Error(
31+
`Invalid number of fetch parameters for analyzing the diff flame graph (${fetchParams.length})!`
32+
);
33+
params = [];
34+
} else if (!isDiff && fetchParams.length !== 1) {
35+
error = new Error(`Invalid number of fetch parameters for analyzing the flame graph (${fetchParams.length})!`);
36+
params = [];
37+
}
38+
39+
return { params, error };
40+
}
41+
42+
useSceneAiPanel = (isDiff: boolean, fetchParams: FetchParams): DomainHookReturnValue => {
43+
const dataSourceUid = sceneGraph.findByKeyAndType(this, 'dataSource', ProfilesDataSourceVariable).useState()
44+
.value as string;
45+
46+
const { params, error: validationError } = this.validateFetchParams(isDiff, fetchParams);
47+
48+
const { error: fetchError, isFetching, profiles } = useFetchDotProfiles(dataSourceUid, params);
49+
50+
const profileMetricId = getSceneVariableValue(this, 'profileMetricId');
51+
const profileType = getProfileMetric(profileMetricId as ProfileMetricId).type;
52+
53+
const { reply, error: llmError, retry } = useOpenAiChatCompletions(profileType, profiles);
54+
55+
return {
56+
data: {
57+
validationError,
58+
isLoading: isFetching || (!isFetching && !fetchError && !llmError && !reply.text.trim()),
59+
fetchError,
60+
llmError,
61+
reply,
62+
shouldDisplayReply: Boolean(reply?.hasStarted || reply?.hasFinished),
63+
shouldDisplayFollowUpForm: !fetchError && !llmError && Boolean(reply?.hasFinished),
64+
},
65+
actions: {
66+
retry,
67+
submitFollowupQuestion(question: string) {
68+
reply.askFollowupQuestion(question);
69+
},
70+
},
71+
};
72+
};
73+
74+
static Component = ({
75+
model,
76+
isDiff,
77+
fetchParams,
78+
onClose,
79+
}: SceneComponentProps<SceneAiPanel> & {
80+
isDiff: boolean;
81+
fetchParams: FetchParams;
82+
onClose: () => void;
83+
}) => {
84+
const styles = useStyles2(getStyles);
85+
const { data, actions } = model.useSceneAiPanel(isDiff, fetchParams);
86+
87+
return (
88+
<Panel
89+
className={styles.sidePanel}
90+
title="Flame graph analysis"
91+
isLoading={data.isLoading}
92+
headerActions={
93+
<IconButton
94+
title="Close panel"
95+
name="times-circle"
96+
variant="secondary"
97+
aria-label="close"
98+
onClick={onClose}
99+
/>
100+
}
101+
dataTestId="ai-panel"
102+
>
103+
<div className={styles.content}>
104+
{data.validationError && (
105+
<InlineBanner severity="error" title="Validation error!" errors={[data.validationError]} />
106+
)}
107+
108+
{data.fetchError && (
109+
<InlineBanner
110+
severity="error"
111+
title="Error while loading profile data!"
112+
message="Sorry for any inconvenience, please try again later."
113+
errors={[data.fetchError]}
114+
/>
115+
)}
116+
117+
{data.shouldDisplayReply && <AiReply reply={data.reply} />}
118+
119+
{data.isLoading && (
120+
<>
121+
<Spinner inline />
122+
&nbsp;Analyzing...
123+
</>
124+
)}
125+
126+
{data.llmError && (
127+
<Alert title="An error occured while generating content using OpenAI!" severity="warning">
128+
<div>
129+
<div>
130+
<p>{data.llmError.message}</p>
131+
<p>
132+
Sorry for any inconvenience, please retry or if the problem persists, contact your organization
133+
admin.
134+
</p>
135+
</div>
136+
</div>
137+
<Button className={styles.retryButton} variant="secondary" fill="outline" onClick={() => actions.retry()}>
138+
Retry
139+
</Button>
140+
</Alert>
141+
)}
142+
143+
{data.shouldDisplayFollowUpForm && <FollowUpForm onSubmit={actions.submitFollowupQuestion} />}
144+
</div>
145+
</Panel>
146+
);
147+
};
148+
}
149+
150+
const getStyles = (theme: GrafanaTheme2) => ({
151+
sidePanel: css`
152+
flex: 1 0 50%;
153+
margin-left: 8px;
154+
max-width: calc(50% - 4px);
155+
`,
156+
title: css`
157+
margin: -4px 0 4px 0;
158+
`,
159+
content: css`
160+
padding: ${theme.spacing(1)};
161+
`,
162+
retryButton: css`
163+
float: right;
164+
`,
165+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { css } from '@emotion/css';
2+
import { IconName } from '@grafana/data';
3+
import { Button, useStyles2 } from '@grafana/ui';
4+
import { reportInteraction } from '@shared/domain/reportInteraction';
5+
import React, { ReactNode } from 'react';
6+
7+
import { useFetchLlmPluginStatus } from './infrastructure/useFetchLlmPluginStatus';
8+
9+
type AIButtonProps = {
10+
children: ReactNode;
11+
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
12+
disabled?: boolean;
13+
interactionName: string;
14+
};
15+
16+
export function AIButton({ children, onClick, disabled, interactionName }: AIButtonProps) {
17+
const styles = useStyles2(getStyles);
18+
const { isEnabled, error, isFetching } = useFetchLlmPluginStatus();
19+
20+
let icon: IconName = 'ai';
21+
let title = '';
22+
23+
if (error) {
24+
icon = 'shield-exclamation';
25+
title = 'Grafana LLM plugin missing or not configured!';
26+
} else if (isFetching) {
27+
icon = 'fa fa-spinner';
28+
title = 'Checking the status of the Grafana LLM plugin...';
29+
}
30+
31+
return (
32+
<Button
33+
className={styles.aiButton}
34+
size="md"
35+
fill="text"
36+
icon={icon}
37+
title={isEnabled ? 'Ask FlameGrot AI' : title}
38+
disabled={!isEnabled || disabled}
39+
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
40+
reportInteraction(interactionName);
41+
onClick(event);
42+
}}
43+
>
44+
{children}
45+
</Button>
46+
);
47+
}
48+
49+
const getStyles = () => ({
50+
aiButton: css`
51+
padding: 0 4px;
52+
`,
53+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { llms } from '@grafana/experimental';
2+
import { useQuery } from '@tanstack/react-query';
3+
4+
export function useFetchLlmPluginStatus() {
5+
const { data, isFetching, error } = useQuery({
6+
queryKey: ['llm'],
7+
queryFn: () => llms.openai.enabled(),
8+
});
9+
10+
if (error) {
11+
console.error('Error while checking the status of the Grafana LLM plugin!');
12+
console.error(error);
13+
}
14+
15+
return { isEnabled: Boolean(data), isFetching, error };
16+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { css } from '@emotion/css';
2+
import { useStyles2 } from '@grafana/ui';
3+
import Markdown from 'markdown-to-jsx';
4+
import React, { ReactNode } from 'react';
5+
6+
import { OpenAiReply } from '../domain/useOpenAiChatCompletions';
7+
8+
// yeah, I know...
9+
const setNativeValue = (element: Element, value: string) => {
10+
const valueSetter = Object!.getOwnPropertyDescriptor(element, 'value')!.set;
11+
const prototypeValueSetter = Object!.getOwnPropertyDescriptor(Object.getPrototypeOf(element), 'value')!.set;
12+
13+
if (valueSetter && valueSetter !== prototypeValueSetter) {
14+
prototypeValueSetter!.call(element, value);
15+
} else {
16+
valueSetter!.call(element, value);
17+
}
18+
};
19+
20+
const onClickSearchTerm = (event: any) => {
21+
const searchInputElement = document.querySelector('[placeholder^="Search"]');
22+
23+
if (searchInputElement === null) {
24+
console.error('Cannot find search input element!');
25+
return;
26+
}
27+
28+
const value = event.target.textContent.trim();
29+
30+
setNativeValue(searchInputElement, value);
31+
32+
searchInputElement.dispatchEvent(new Event('input', { bubbles: true }));
33+
};
34+
35+
const SearchTerm = ({ children }: { children: ReactNode }) => {
36+
const styles = useStyles2(getStyles);
37+
38+
// If the code block contains newlines, don't make it a search link
39+
if (typeof children === 'string' && children.includes('\n')) {
40+
return <code>{children}</code>;
41+
}
42+
43+
return (
44+
<code className={styles.searchLink} title="Search for this node" onClick={onClickSearchTerm}>
45+
{children}
46+
</code>
47+
);
48+
};
49+
50+
const MARKDOWN_OPTIONS = {
51+
overrides: {
52+
code: {
53+
component: SearchTerm,
54+
},
55+
},
56+
};
57+
58+
type AiReplyProps = {
59+
reply: OpenAiReply['reply'];
60+
};
61+
62+
export function AiReply({ reply }: AiReplyProps) {
63+
const styles = useStyles2(getStyles);
64+
65+
return (
66+
<div className={styles.container}>
67+
{reply?.messages
68+
?.filter((message) => message.role !== 'system')
69+
.map((message) => (
70+
<>
71+
<div className={styles.reply}>
72+
<Markdown options={MARKDOWN_OPTIONS}>{message.content}</Markdown>
73+
</div>
74+
<hr />
75+
</>
76+
))}
77+
78+
<div className={styles.reply}>
79+
<Markdown options={MARKDOWN_OPTIONS}>{reply.text}</Markdown>
80+
</div>
81+
</div>
82+
);
83+
}
84+
85+
const getStyles = () => ({
86+
container: css`
87+
width: 100%;
88+
height: 100%;
89+
`,
90+
reply: css`
91+
font-size: 13px;
92+
93+
& ol,
94+
& ul {
95+
margin: 0 0 16px 24px;
96+
}
97+
`,
98+
searchLink: css`
99+
color: rgb(255, 136, 51);
100+
border: 1px solid transparent;
101+
padding: 2px 4px;
102+
cursor: pointer;
103+
font-size: 13px;
104+
105+
&:hover,
106+
&:focus,
107+
&:active {
108+
box-sizing: border-box;
109+
border: 1px solid rgb(255, 136, 51, 0.8);
110+
border-radius: 4px;
111+
}
112+
`,
113+
});

0 commit comments

Comments
 (0)