Compare commits
3 Commits
sriram/SQL
...
ivana/assi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1082baf575 | ||
|
|
9c1d97cf85 | ||
|
|
67db8e7ff1 |
@@ -10,6 +10,7 @@ import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||
import NativeScrollbar, { DivScrollElement } from 'app/core/components/NativeScrollbar';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
|
||||
import { DashboardInsightsOverlay } from '../scene/DashboardInsightsOverlay';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { NavToolbarActions } from '../scene/NavToolbarActions';
|
||||
import { PublicDashboardBadge } from '../scene/new-toolbar/actions/PublicDashboardBadge';
|
||||
@@ -41,6 +42,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
|
||||
<NavToolbarActions dashboard={dashboard} />
|
||||
<div className={styles.controlsWrapperSticky}>{controls}</div>
|
||||
<div className={styles.body}>{body}</div>
|
||||
<DashboardInsightsOverlay dashboard={dashboard} />
|
||||
</div>
|
||||
</NativeScrollbar>
|
||||
);
|
||||
@@ -116,6 +118,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
|
||||
<DashboardEditPaneRenderer editPane={editPane} dashboard={dashboard} isDocked={sidebarContext.isDocked} />
|
||||
</Sidebar>
|
||||
</div>
|
||||
<DashboardInsightsOverlay dashboard={dashboard} />
|
||||
</ElementSelectionContext.Provider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { createAssistantContextItem, OpenAssistantButton } from '@grafana/assistant';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { llm } from '@grafana/llm';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Icon, Spinner, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
|
||||
interface Props {
|
||||
dashboard: DashboardScene;
|
||||
}
|
||||
|
||||
enum StreamStatus {
|
||||
IDLE = 'idle',
|
||||
GENERATING = 'generating',
|
||||
COMPLETED = 'completed',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
/**
|
||||
* A floating overlay that displays AI-generated insights about the dashboard.
|
||||
* Automatically fetches and shows a summary when the dashboard loads.
|
||||
*/
|
||||
export function DashboardInsightsOverlay({ dashboard }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [summary, setSummary] = useState('');
|
||||
const [streamStatus, setStreamStatus] = useState<StreamStatus>(StreamStatus.IDLE);
|
||||
const [isLLMEnabled, setIsLLMEnabled] = useState<boolean | null>(null);
|
||||
const hasAutoTriggered = useRef(false);
|
||||
|
||||
const { uid, title, description } = dashboard.useState();
|
||||
|
||||
// Build the dashboard context for the LLM
|
||||
const dashboardContext = useMemo(() => {
|
||||
const saveModel = dashboard.getSaveModel();
|
||||
const panels =
|
||||
'panels' in saveModel && saveModel.panels
|
||||
? saveModel.panels.map((panel) => ({
|
||||
title: panel.title,
|
||||
type: panel.type,
|
||||
description: 'description' in panel ? panel.description : undefined,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return `Dashboard: "${title}"
|
||||
${description ? `Description: ${description}` : ''}
|
||||
Panels (${panels.length}):
|
||||
${panels.map((p, i) => `- ${p.title || 'Untitled'} (${p.type})${p.description ? `: ${p.description}` : ''}`).join('\n')}`;
|
||||
}, [dashboard, title, description]);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
if (streamStatus === StreamStatus.GENERATING) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStreamStatus(StreamStatus.GENERATING);
|
||||
setSummary('');
|
||||
|
||||
reportInteraction('grafana_dashboard_insights_summary_requested', {
|
||||
origin: 'dashboard-overlay',
|
||||
dashboardUid: uid,
|
||||
});
|
||||
|
||||
const messages: llm.Message[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You analyze Grafana dashboards. Be extremely concise. No filler words. No repetition. Just key insights in 1-2 short sentences. Start directly with the insight, no preamble.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `What does this dashboard monitor and what's notable about it?\n\n${dashboardContext}`,
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
let accumulatedContent = '';
|
||||
|
||||
const stream = llm.streamChatCompletions({
|
||||
model: llm.Model.LARGE,
|
||||
messages,
|
||||
});
|
||||
|
||||
stream.pipe(llm.accumulateContent()).subscribe({
|
||||
next: (content) => {
|
||||
accumulatedContent = content;
|
||||
setSummary(content);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to generate dashboard summary:', error);
|
||||
setStreamStatus(StreamStatus.ERROR);
|
||||
},
|
||||
complete: () => {
|
||||
setSummary(accumulatedContent);
|
||||
setStreamStatus(StreamStatus.COMPLETED);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate dashboard summary:', error);
|
||||
setStreamStatus(StreamStatus.ERROR);
|
||||
}
|
||||
}, [streamStatus, uid, dashboardContext]);
|
||||
|
||||
// Create assistant context for opening the full assistant
|
||||
const assistantContext = useMemo(() => {
|
||||
const saveModel = dashboard.getSaveModel();
|
||||
|
||||
return createAssistantContextItem('structured', {
|
||||
title: `Dashboard: ${title}`,
|
||||
data: {
|
||||
dashboard: {
|
||||
uid,
|
||||
title,
|
||||
description,
|
||||
panels:
|
||||
'panels' in saveModel && saveModel.panels
|
||||
? saveModel.panels.map((panel) => ({
|
||||
id: panel.id,
|
||||
title: panel.title,
|
||||
type: panel.type,
|
||||
description: 'description' in panel ? panel.description : undefined,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [dashboard, uid, title, description]);
|
||||
|
||||
// Check if LLM is enabled
|
||||
useEffect(() => {
|
||||
llm.health().then((response) => {
|
||||
setIsLLMEnabled(response.ok);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-trigger summary on mount
|
||||
useEffect(() => {
|
||||
if (isLLMEnabled && uid && !hasAutoTriggered.current && streamStatus === StreamStatus.IDLE) {
|
||||
hasAutoTriggered.current = true;
|
||||
// Small delay to ensure dashboard is loaded
|
||||
const timer = setTimeout(() => {
|
||||
fetchSummary();
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return;
|
||||
}, [isLLMEnabled, uid, streamStatus, fetchSummary]);
|
||||
|
||||
// Don't render if LLM is not enabled or checking, or no UID
|
||||
if (isLLMEnabled === null || !isLLMEnabled || !uid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<div className={styles.minimizedOverlay}>
|
||||
<button
|
||||
className={styles.minimizedButton}
|
||||
onClick={() => setIsMinimized(false)}
|
||||
title={t('dashboard.insights.expand', 'Expand insights')}
|
||||
>
|
||||
<Icon name="ai-sparkle" size="sm" />
|
||||
<span>{t('dashboard.insights.title', 'Dashboard Insights')}</span>
|
||||
<Icon name="angle-up" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} data-testid="dashboard-insights-overlay">
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerTitle}>
|
||||
<Icon name="ai-sparkle" size="sm" />
|
||||
<span>{t('dashboard.insights.title', 'Dashboard Insights')}</span>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.headerButton}
|
||||
onClick={() => setIsMinimized(true)}
|
||||
title={t('dashboard.insights.minimize', 'Minimize')}
|
||||
>
|
||||
<Icon name="angle-down" size="md" />
|
||||
</button>
|
||||
<button
|
||||
className={styles.headerButton}
|
||||
onClick={() => setIsVisible(false)}
|
||||
title={t('dashboard.insights.close', 'Close')}
|
||||
>
|
||||
<Icon name="times" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{streamStatus === StreamStatus.GENERATING && !summary && (
|
||||
<div className={styles.loading}>
|
||||
<Spinner size="sm" />
|
||||
<span>{t('dashboard.insights.analyzing', 'Analyzing dashboard...')}</span>
|
||||
</div>
|
||||
)}
|
||||
{streamStatus === StreamStatus.ERROR && (
|
||||
<div className={styles.error}>
|
||||
<span>{t('dashboard.insights.error', 'Failed to generate insights')}</span>
|
||||
<Button size="sm" variant="secondary" onClick={fetchSummary}>
|
||||
{t('dashboard.insights.retry', 'Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{summary && (
|
||||
<div className={styles.summary}>
|
||||
<p>{summary}</p>
|
||||
{streamStatus === StreamStatus.GENERATING && <Spinner size="xs" inline />}
|
||||
</div>
|
||||
)}
|
||||
{streamStatus === StreamStatus.COMPLETED && (
|
||||
<div className={styles.actions}>
|
||||
<OpenAssistantButton
|
||||
prompt={`Analyze this dashboard "${title}" and provide detailed insights. What is its purpose and what key observations are there?`}
|
||||
origin="dashboard"
|
||||
context={[assistantContext]}
|
||||
title={t('dashboard.insights.analyze-in-assistant', 'Analyze dashboard in Assistant')}
|
||||
size="sm"
|
||||
onClick={() => setIsVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
overlay: css({
|
||||
position: 'fixed',
|
||||
bottom: theme.spacing(3),
|
||||
right: theme.spacing(3),
|
||||
width: 340,
|
||||
maxWidth: 'calc(100vw - 48px)',
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
boxShadow: theme.shadows.z3,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
zIndex: theme.zIndex.modal,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
minimizedOverlay: css({
|
||||
position: 'fixed',
|
||||
bottom: theme.spacing(3),
|
||||
right: theme.spacing(3),
|
||||
zIndex: theme.zIndex.modal,
|
||||
}),
|
||||
minimizedButton: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(1, 2),
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
boxShadow: theme.shadows.z3,
|
||||
cursor: 'pointer',
|
||||
color: theme.colors.text.primary,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.action.hover,
|
||||
},
|
||||
}),
|
||||
header: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: theme.spacing(1.5, 2),
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
headerTitle: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
color: theme.colors.text.primary,
|
||||
}),
|
||||
headerActions: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.5),
|
||||
}),
|
||||
headerButton: css({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: theme.spacing(0.5),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: theme.colors.text.secondary,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.action.hover,
|
||||
color: theme.colors.text.primary,
|
||||
},
|
||||
}),
|
||||
content: css({
|
||||
padding: theme.spacing(2),
|
||||
minHeight: 60,
|
||||
}),
|
||||
loading: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
error: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
color: theme.colors.error.text,
|
||||
}),
|
||||
summary: css({
|
||||
color: theme.colors.text.primary,
|
||||
lineHeight: 1.6,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
|
||||
'& p': {
|
||||
margin: 0,
|
||||
},
|
||||
}),
|
||||
actions: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: theme.spacing(1),
|
||||
paddingTop: theme.spacing(1),
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton';
|
||||
import { ManagedDashboardNavBarBadge } from './ManagedDashboardNavBarBadge';
|
||||
import { LeftActions } from './new-toolbar/LeftActions';
|
||||
import { RightActions } from './new-toolbar/RightActions';
|
||||
import { AnalyzeDashboardButton } from './new-toolbar/actions/AnalyzeDashboardButton';
|
||||
import { PublicDashboardBadge } from './new-toolbar/actions/PublicDashboardBadge';
|
||||
|
||||
interface Props {
|
||||
@@ -161,6 +162,12 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
addDynamicActions(toolbarActions, dynamicDashNavActions.right, 'icon-actions');
|
||||
}
|
||||
|
||||
toolbarActions.push({
|
||||
group: 'icon-actions',
|
||||
condition: uid && isShowingDashboard && !isEditing,
|
||||
render: () => <AnalyzeDashboardButton key="analyze-dashboard-button" dashboard={dashboard} />,
|
||||
});
|
||||
|
||||
toolbarActions.push({
|
||||
group: 'add-panel',
|
||||
condition: isEditingAndShowingDashboard,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dynamicDashNavActions } from '../../utils/registerDynamicDashNavAction'
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { ManagedDashboardNavBarBadge } from '../ManagedDashboardNavBarBadge';
|
||||
|
||||
import { AnalyzeDashboardButton } from './actions/AnalyzeDashboardButton';
|
||||
import { OpenSnapshotOriginButton } from './actions/OpenSnapshotOriginButton';
|
||||
import { PublicDashboardBadge } from './actions/PublicDashboardBadge';
|
||||
import { StarButton } from './actions/StarButton';
|
||||
@@ -50,6 +51,12 @@ export const LeftActions = ({ dashboard }: { dashboard: DashboardScene }) => {
|
||||
group: 'actions',
|
||||
condition: isSnapshot && !isEditingDashboard,
|
||||
},
|
||||
{
|
||||
key: 'analyze-dashboard-button',
|
||||
component: AnalyzeDashboardButton,
|
||||
group: 'actions',
|
||||
condition: hasUid && isShowingDashboard && !isEditingDashboard,
|
||||
},
|
||||
],
|
||||
dashboard
|
||||
);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createAssistantContextItem, OpenAssistantProps, useAssistant } from '@grafana/assistant';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { ToolbarButton } from '@grafana/ui';
|
||||
|
||||
import { ToolbarActionProps } from '../types';
|
||||
|
||||
/**
|
||||
* A toolbar button that opens the Assistant to analyze the current dashboard.
|
||||
* Automatically creates context from dashboard data and opens the assistant with a prompt
|
||||
* to analyze and provide insights about the dashboard.
|
||||
*/
|
||||
export const AnalyzeDashboardButton = ({ dashboard }: ToolbarActionProps) => {
|
||||
const { isAvailable, openAssistant } = useAssistant();
|
||||
|
||||
if (!isAvailable || !openAssistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AnalyzeDashboardButtonView dashboard={dashboard} openAssistant={openAssistant} />;
|
||||
};
|
||||
|
||||
function AnalyzeDashboardButtonView({
|
||||
dashboard,
|
||||
openAssistant,
|
||||
}: ToolbarActionProps & {
|
||||
openAssistant: (props: OpenAssistantProps) => void;
|
||||
}) {
|
||||
const { uid, title, description } = dashboard.useState();
|
||||
|
||||
// Create dashboard context from dashboard data
|
||||
const dashboardContext = useMemo(() => {
|
||||
const saveModel = dashboard.getSaveModel();
|
||||
|
||||
return createAssistantContextItem('structured', {
|
||||
title: `Dashboard: ${title}`,
|
||||
data: {
|
||||
dashboard: {
|
||||
uid,
|
||||
title,
|
||||
description,
|
||||
// Include panel info for analysis
|
||||
panels:
|
||||
'panels' in saveModel && saveModel.panels
|
||||
? saveModel.panels.map((panel) => ({
|
||||
id: panel.id,
|
||||
title: panel.title,
|
||||
type: panel.type,
|
||||
description: 'description' in panel ? panel.description : undefined,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [dashboard, uid, title, description]);
|
||||
|
||||
// Generate the analysis prompt
|
||||
const analyzePrompt = useMemo(() => {
|
||||
return `Analyze this dashboard "${title}" and provide insights.
|
||||
- Summarize the purpose and content of this dashboard
|
||||
- Review the panels and their visualizations
|
||||
- Suggest improvements for better data visibility or organization
|
||||
- Identify any potential issues or optimization opportunities`;
|
||||
}, [title]);
|
||||
|
||||
const handleClick = () => {
|
||||
reportInteraction('grafana_assistant_app_analyze_dashboard_button_clicked', {
|
||||
origin: 'dashboard',
|
||||
dashboardUid: uid,
|
||||
dashboardTitle: title,
|
||||
});
|
||||
|
||||
openAssistant({
|
||||
origin: 'dashboard',
|
||||
mode: 'assistant',
|
||||
prompt: analyzePrompt,
|
||||
context: [dashboardContext],
|
||||
autoSend: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
icon="ai-sparkle"
|
||||
tooltip={t('dashboard.toolbar.analyze-dashboard', 'Analyze dashboard with Assistant')}
|
||||
onClick={handleClick}
|
||||
data-testid="analyze-dashboard-button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user