Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Ryan McKinley committed 2025-02-21 15:53:08 +03:00
commit 33621faf0a
71 files changed
+1402 -4235

No files matched your search

+9
View File
@@ -137,6 +137,13 @@ password =
# Example: mysql://user:secret@host:port/database
url =
# Set to true or false to enable or disable high availability mode.
# When it's set to false some functions will be simplified and only run in-process
# instead of relying on the database.
#
# Only set it to false if you run only a single instance of Grafana.
high_availability = true
# Max idle conn setting default is 2
max_idle_conn = 2
@@ -534,9 +541,11 @@ external_manage_link_url =
external_manage_link_name =
external_manage_info =
# Deprecated: Assign your viewers to editors.
# Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
viewers_can_edit = false
# Deprecated: Assign your editors to admins.
# Editors can administrate dashboard, folders and teams they create
editors_can_admin = false
+10
View File
@@ -136,6 +136,13 @@
# Example: mysql://user:secret@host:port/database
;url =
# Set to true or false to enable or disable high availability mode.
# When it's set to false some functions will be simplified and only run in-process
# instead of relying on the database.
#
# Only set it to false if you run only a single instance of Grafana.
;high_availability = true
# Max idle conn setting default is 2
;max_idle_conn = 2
@@ -535,9 +542,12 @@
;external_manage_link_name =
;external_manage_info =
# Deprecated: Assign your viewers to editors.
# Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
;viewers_can_edit = false
# Deprecated: Assign your editors to admins.
# Editors can administrate dashboard, folders and teams they create
;editors_can_admin = false
@@ -174,7 +174,6 @@ Experimental features might be changed or removed without prior notice.
| `queryServiceRewrite` | Rewrite requests targeting /ds/query to the query service |
| `queryServiceFromUI` | Routes requests to the new query service |
| `cachingOptimizeSerializationMemoryUsage` | If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. |
| `prometheusPromQAIL` | Prometheus and AI/ML to assist users in creating a query |
| `prometheusCodeModeMetricNamesSearch` | Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names |
| `alertmanagerRemoteSecondary` | Enable Grafana to sync configuration and state with a remote Alertmanager. |
| `alertmanagerRemotePrimary` | Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. |
@@ -159,19 +159,6 @@ describe('Prometheus query editor', () => {
e2e.components.DataSource.Prometheus.queryEditor.builder.metricsExplorer().should('exist');
});
// NEED TO COMPLETE QUEY ADVISOR WORK OR FIGURE OUT HOW TO ENABLE EXPERIMENTAL FEATURE TOGGLES
// it('should have a query advisor when enabled with feature toggle', () => {
// cy.window().then((win) => {
// win.localStorage.setItem('grafana.featureToggles', 'prometheusPromQAIL=0');
// navigateToEditor('Builder', 'prometheusBuilder');
// getResources();
// e2e.components.DataSource.Prometheus.queryEditor.builder.queryAdvisor().should('exist');
// });
// });
});
});
@@ -159,19 +159,6 @@ describe.skip('Prometheus query editor', () => {
e2e.components.DataSource.Prometheus.queryEditor.builder.metricsExplorer().should('exist');
});
// NEED TO COMPLETE QUEY ADVISOR WORK OR FIGURE OUT HOW TO ENABLE EXPERIMENTAL FEATURE TOGGLES
// it('should have a query advisor when enabled with feature toggle', () => {
// cy.window().then((win) => {
// win.localStorage.setItem('grafana.featureToggles', 'prometheusPromQAIL=0');
// navigateToEditor('Builder', 'prometheusBuilder');
// getResources();
// e2e.components.DataSource.Prometheus.queryEditor.builder.queryAdvisor().should('exist');
// });
// });
});
});
@@ -118,7 +118,6 @@ export interface FeatureToggles {
recoveryThreshold?: boolean;
lokiStructuredMetadata?: boolean;
cachingOptimizeSerializationMemoryUsage?: boolean;
prometheusPromQAIL?: boolean;
prometheusCodeModeMetricNamesSearch?: boolean;
addFieldFromCalculationStatFunctions?: boolean;
alertmanagerRemoteSecondary?: boolean;
-1
View File
@@ -40,7 +40,6 @@
"@floating-ui/react": "0.27.3",
"@grafana/data": "11.6.0-pre",
"@grafana/e2e-selectors": "11.6.0-pre",
"@grafana/llm": "0.12.0",
"@grafana/plugin-ui": "0.10.1",
"@grafana/runtime": "11.6.0-pre",
"@grafana/schema": "11.6.0-pre",
-1
View File
@@ -55,7 +55,6 @@ export { PromQueryEditorSelector } from './querybuilder/components/PromQueryEdit
export { PromQueryLegendEditor } from './querybuilder/components/PromQueryLegendEditor';
export { QueryPreview } from './querybuilder/components/QueryPreview';
export { MetricsModal } from './querybuilder/components/metrics-modal/MetricsModal';
export { PromQail } from './querybuilder/components/promQail/PromQail';
// SRC/
// Main export
@@ -11,7 +11,7 @@ import {
QueryHint,
TimeRange,
} from '@grafana/data';
import { config, TemplateSrv } from '@grafana/runtime';
import { TemplateSrv } from '@grafana/runtime';
import { PrometheusDatasource } from '../../datasource';
import PromQlLanguageProvider from '../../language_provider';
@@ -108,28 +108,6 @@ describe('PromQueryBuilder', () => {
await waitFor(() => expect(datasource.getVariables).toBeCalled());
});
it('checks if the LLM plugin is enabled when the `prometheusPromQAIL` feature is enabled', async () => {
jest.replaceProperty(config, 'featureToggles', {
prometheusPromQAIL: true,
});
const mockIsLLMPluginEnabled = jest.fn();
mockIsLLMPluginEnabled.mockResolvedValue(true);
jest.spyOn(require('./promQail/state/helpers'), 'isLLMPluginEnabled').mockImplementation(mockIsLLMPluginEnabled);
setup();
await waitFor(() => expect(mockIsLLMPluginEnabled).toHaveBeenCalledTimes(1));
});
it('does not check if the LLM plugin is enabled when the `prometheusPromQAIL` feature is disabled', async () => {
jest.replaceProperty(config, 'featureToggles', {
prometheusPromQAIL: false,
});
const mockIsLLMPluginEnabled = jest.fn();
mockIsLLMPluginEnabled.mockResolvedValue(true);
jest.spyOn(require('./promQail/state/helpers'), 'isLLMPluginEnabled').mockImplementation(mockIsLLMPluginEnabled);
setup();
await waitFor(() => expect(mockIsLLMPluginEnabled).toHaveBeenCalledTimes(0));
});
// <LegacyPrometheus>
it('tries to load labels when metric selected', async () => {
const { languageProvider } = setup();
@@ -1,12 +1,10 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx
import { css } from '@emotion/css';
import { memo, useEffect, useState } from 'react';
import { memo, useState } from 'react';
import { DataSourceApi, PanelData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EditorRow } from '@grafana/plugin-ui';
import { config } from '@grafana/runtime';
import { Drawer } from '@grafana/ui';
import { PrometheusDatasource } from '../../datasource';
import promqlGrammar from '../../promql';
@@ -24,9 +22,6 @@ import { PromVisualQuery } from '../types';
import { MetricsLabelsSection } from './MetricsLabelsSection';
import { NestedQueryList } from './NestedQueryList';
import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained';
import { PromQail } from './promQail/PromQail';
import { QueryAssistantButton } from './promQail/QueryAssistantButton';
import { isLLMPluginEnabled } from './promQail/state/helpers';
export interface PromQueryBuilderProps {
query: PromVisualQuery;
@@ -40,37 +35,13 @@ export interface PromQueryBuilderProps {
export const PromQueryBuilder = memo<PromQueryBuilderProps>((props) => {
const { datasource, query, onChange, onRunQuery, data, showExplain } = props;
const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
const [showDrawer, setShowDrawer] = useState<boolean>(false);
const [llmAppEnabled, updateLlmAppEnabled] = useState<boolean>(false);
const { prometheusPromQAIL } = config.featureToggles; // AI/ML + Prometheus
const lang = { grammar: promqlGrammar, name: 'promql' };
const initHints = datasource.getInitHints();
useEffect(() => {
async function checkLlms() {
const check = await isLLMPluginEnabled();
updateLlmAppEnabled(check);
}
if (prometheusPromQAIL) {
checkLlms();
}
}, [prometheusPromQAIL]);
return (
<>
{prometheusPromQAIL && showDrawer && (
<Drawer closeOnMaskClick={false} onClose={() => setShowDrawer(false)}>
<PromQail
query={query}
closeDrawer={() => setShowDrawer(false)}
onChange={onChange}
datasource={datasource}
/>
</Drawer>
)}
<EditorRow>
<MetricsLabelsSection query={query} onChange={onChange} datasource={datasource} />
</EditorRow>
@@ -108,15 +79,6 @@ export const PromQueryBuilder = memo<PromQueryBuilderProps>((props) => {
onRunQuery={onRunQuery}
highlightedOp={highlightedOp}
/>
{prometheusPromQAIL && (
<div
className={css({
padding: '0 0 0 6px',
})}
>
<QueryAssistantButton llmAppEnabled={llmAppEnabled} metric={query.metric} setShowDrawer={setShowDrawer} />
</div>
)}
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.hints}>
<QueryBuilderHints<PromVisualQuery>
datasource={datasource}
@@ -1,148 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/PromQail.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data';
import { PrometheusDatasource } from '../../../datasource';
import PromQlLanguageProvider from '../../../language_provider';
import { EmptyLanguageProviderMock } from '../../../language_provider.mock';
import { PromOptions } from '../../../types';
import { PromVisualQuery } from '../../types';
import { PromQail, queryAssistanttestIds } from './PromQail';
// don't care about interaction tracking in our unit tests
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
window.HTMLElement.prototype.scrollIntoView = jest.fn();
describe('PromQail', () => {
it('renders the drawer', async () => {
setup(defaultQuery);
await waitFor(() => {
expect(screen.getByText('Query advisor')).toBeInTheDocument();
});
});
it('shows an option to not show security warning', async () => {
setup(defaultQuery);
await waitFor(() => {
expect(screen.getByText("Don't show this message again")).toBeInTheDocument();
});
});
it('shows selected metric and asks for a prompt', async () => {
setup(defaultQuery);
await clickSecurityButton();
await waitFor(() => {
expect(screen.getByText('random_metric')).toBeInTheDocument();
expect(screen.getByText('Do you know what you want to query?')).toBeInTheDocument();
});
});
it('displays a prompt when the user knows what they want to query', async () => {
setup(defaultQuery);
await clickSecurityButton();
await waitFor(() => {
expect(screen.getByText('random_metric')).toBeInTheDocument();
expect(screen.getByText('Do you know what you want to query?')).toBeInTheDocument();
});
const aiPrompt = screen.getByTestId(queryAssistanttestIds.clickForAi);
await userEvent.click(aiPrompt);
await waitFor(() => {
expect(screen.getByText('What kind of data do you want to see with your metric?')).toBeInTheDocument();
});
});
it('does not display a prompt when choosing historical', async () => {
setup(defaultQuery);
await clickSecurityButton();
await waitFor(() => {
expect(screen.getByText('random_metric')).toBeInTheDocument();
expect(screen.getByText('Do you know what you want to query?')).toBeInTheDocument();
});
const historicalPrompt = screen.getByTestId(queryAssistanttestIds.clickForHistorical);
await userEvent.click(historicalPrompt);
await waitFor(() => {
expect(screen.queryByText('What kind of data do you want to see with your metric?')).toBeNull();
});
});
});
const defaultQuery: PromVisualQuery = {
metric: 'random_metric',
labels: [],
operations: [],
};
function createDatasource(withLabels?: boolean) {
const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider;
languageProvider.metricsMetadata = {
'all-metrics': {
type: 'all-metrics-type',
help: 'all-metrics-help',
},
a: {
type: 'counter',
help: 'a-metric-help',
},
a_bucket: {
type: 'counter',
help: 'for functions',
},
};
const datasource = new PrometheusDatasource(
{
url: '',
jsonData: {},
meta: {} as DataSourcePluginMeta,
} as DataSourceInstanceSettings<PromOptions>,
undefined,
languageProvider
);
return datasource;
}
function createProps(query: PromVisualQuery, datasource: PrometheusDatasource) {
return {
datasource,
onChange: jest.fn(),
closeDrawer: jest.fn(),
query: query,
};
}
function setup(query: PromVisualQuery) {
const withLabels: boolean = query.labels.length > 0;
const datasource = createDatasource(withLabels);
const props = createProps(query, datasource);
// render the drawer only
const { container } = render(<PromQail {...props} />);
return container;
}
async function clickSecurityButton() {
const securityInfoButton = screen.getByTestId(queryAssistanttestIds.securityInfoButton);
await userEvent.click(securityInfoButton);
}
@@ -1,616 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/PromQail.tsx
import { css, cx } from '@emotion/css';
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { useEffect, useReducer, useRef, useState } from 'react';
import { GrafanaTheme2, store } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Alert, Button, Checkbox, Input, Spinner, useTheme2 } from '@grafana/ui';
import { PrometheusDatasource } from '../../../datasource';
import { PromVisualQuery } from '../../types';
import { QuerySuggestionContainer } from './QuerySuggestionContainer';
// @ts-ignore until we can get these added for icons
import AI_Logo_color from './resources/AI_Logo_color.svg';
import { promQailExplain, promQailSuggest } from './state/helpers';
import { createInteraction, initialState } from './state/state';
import { Interaction, SuggestionType } from './types';
export type PromQailProps = {
query: PromVisualQuery;
closeDrawer: () => void;
onChange: (query: PromVisualQuery) => void;
datasource: PrometheusDatasource;
};
const SKIP_STARTING_MESSAGE = 'SKIP_STARTING_MESSAGE';
export const PromQail = (props: PromQailProps) => {
const { query, closeDrawer, onChange, datasource } = props;
const skipStartingMessage = store.getBool(SKIP_STARTING_MESSAGE, false);
const [state, dispatch] = useReducer(stateSlice.reducer, initialState(query, !skipStartingMessage));
const [labelNames, setLabelNames] = useState<string[]>([]);
const suggestions = state.interactions.reduce((acc, int) => acc + int.suggestions.length, 0);
const responsesEndRef = useRef(null);
const scrollToBottom = () => {
if (responsesEndRef) {
// @ts-ignore for React.MutableRefObject
responsesEndRef?.current?.scrollIntoView({ behavior: 'smooth' });
}
};
useEffect(() => {
// only scroll when an interaction has been added or the suggestions have been updated
scrollToBottom();
}, [state.interactions.length, suggestions]);
useEffect(() => {
const fetchLabels = async () => {
let labelsIndex: Record<string, string[]> = await datasource.languageProvider.fetchLabelsWithMatch(query.metric);
setLabelNames(Object.keys(labelsIndex));
};
fetchLabels();
}, [query, datasource]);
const theme = useTheme2();
const styles = getStyles(theme);
return (
<div className={styles.containerPadding}>
{/* Query Advisor */}
{/* header */}
<div className={styles.header}>
<h3>Query advisor</h3>
<Button icon="times" fill="text" variant="secondary" onClick={closeDrawer} />
</div>
{/* Starting message */}
<div>
<div className={styles.iconSection}>
<img src={AI_Logo_color} alt="AI logo color" /> Assistant
</div>
{state.showStartingMessage ? (
<>
<div className={styles.dataList}>
<ol>
<li className={styles.textPadding}>
Query Advisor suggests queries based on a metric and requests you type in.
</li>
<li className={styles.textPadding}>
Query Advisor sends Prometheus metrics, labels and metadata to the LLM provider you&#39;ve configured.
Be sure to align its usage with your company&#39;s internal policies.
</li>
<li className={styles.textPadding}>
An AI-suggested query may not fully answer your question. Always take a moment to understand a query
before you use it.
</li>
</ol>
</div>
<Alert
title={''}
severity={'info'}
key={'promqail-llm-app'}
className={cx(styles.textPadding, styles.noMargin)}
>
Query Advisor is currently in Private Preview. Feedback is appreciated and can be provided on explanations
and suggestions.
</Alert>
{/* don't show this message again, store in localstorage */}
<div className={styles.textPadding}>
<Checkbox
checked={state.indicateCheckbox}
value={state.indicateCheckbox}
onChange={() => {
const val = store.getBool(SKIP_STARTING_MESSAGE, false);
store.set(SKIP_STARTING_MESSAGE, !val);
dispatch(indicateCheckbox(!val));
}}
label="Don't show this message again"
/>
</div>
<div className={styles.rightButtonsWrapper}>
<div className={styles.rightButtons}>
<Button className={styles.leftButton} fill="outline" variant="secondary" onClick={closeDrawer}>
Cancel
</Button>
<Button
fill="solid"
variant="primary"
onClick={() => dispatch(showStartingMessage(false))}
data-testid={queryAssistanttestIds.securityInfoButton}
>
Continue
</Button>
</div>
</div>
</>
) : (
<div className={styles.bodySmall}>
{/* MAKE THIS TABLE RESPONSIVE */}
{/* FIT SUPER LONG METRICS AND LABELS IN HERE */}
<div className={styles.textPadding}>Here is the metric you have selected:</div>
<div className={styles.infoContainerWrapper}>
<div className={styles.infoContainer}>
<table className={styles.metricTable}>
<tbody>
<tr>
<td className={styles.metricTableName}>metric</td>
<td className={styles.metricTableValue}>{state.query.metric}</td>
<td>
<Button
fill="outline"
variant="secondary"
onClick={closeDrawer}
className={styles.metricTableButton}
size={'sm'}
>
Choose new metric
</Button>
</td>
</tr>
{state.query.labels.map((label, idx) => {
const text = idx === 0 ? 'labels' : '';
return (
<tr key={`${label.label}-${idx}`}>
<td>{text}</td>
<td className={styles.metricTableValue}>{`${label.label}${label.op}${label.value}`}</td>
<td> </td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Ask if you know what you want to query? */}
{!state.askForQueryHelp && state.interactions.length === 0 && (
<>
<div className={styles.queryQuestion}>Do you know what you want to query?</div>
<div className={styles.rightButtonsWrapper}>
<div className={styles.rightButtons}>
<Button
className={styles.leftButton}
fill="solid"
variant="secondary"
data-testid={queryAssistanttestIds.clickForHistorical}
onClick={() => {
const isLoading = true;
const suggestionType = SuggestionType.Historical;
dispatch(addInteraction({ suggestionType, isLoading }));
reportInteraction('grafana_prometheus_promqail_know_what_you_want_to_query', {
promVisualQuery: query,
doYouKnow: 'no',
});
promQailSuggest(dispatch, 0, query, labelNames, datasource);
}}
>
No
</Button>
<Button
fill="solid"
variant="primary"
data-testid={queryAssistanttestIds.clickForAi}
onClick={() => {
reportInteraction('grafana_prometheus_promqail_know_what_you_want_to_query', {
promVisualQuery: query,
doYouKnow: 'yes',
});
const isLoading = false;
const suggestionType = SuggestionType.AI;
dispatch(addInteraction({ suggestionType, isLoading }));
}}
>
Yes
</Button>
</div>
</div>
</>
)}
{state.interactions.map((interaction: Interaction, idx: number) => {
return (
<div key={idx}>
{interaction.suggestionType === SuggestionType.AI ? (
<>
<div className={styles.textPadding}>What kind of data do you want to see with your metric?</div>
<div className={cx(styles.secondaryText, styles.bottomMargin)}>
<div>You do not need to enter in a metric or a label again in the prompt.</div>
<div>Example: I want to monitor request latency, not errors.</div>
</div>
<div className={styles.inputPadding}>
<Input
value={interaction.prompt}
spellCheck={false}
placeholder="Enter prompt"
disabled={interaction.suggestions.length > 0}
onChange={(e) => {
const prompt = e.currentTarget.value;
const payload = {
idx: idx,
interaction: { ...interaction, prompt },
};
dispatch(updateInteraction(payload));
}}
/>
</div>
{interaction.suggestions.length === 0 ? (
interaction.isLoading ? (
<>
<div className={styles.loadingMessageContainer}>
Waiting for OpenAI <Spinner className={styles.floatRight} />
</div>
</>
) : (
<>
<div className={styles.rightButtonsWrapper}>
<div className={styles.rightButtons}>
<Button
className={styles.leftButton}
fill="outline"
variant="secondary"
onClick={closeDrawer}
>
Cancel
</Button>
<Button
className={styles.leftButton}
fill="outline"
variant="secondary"
onClick={() => {
// JUST SUGGEST QUERIES AND SHOW THE LIST
const newInteraction: Interaction = {
...interaction,
suggestionType: SuggestionType.Historical,
isLoading: true,
};
const payload = {
idx: idx,
interaction: newInteraction,
};
reportInteraction('grafana_prometheus_promqail_suggest_query_instead', {
promVisualQuery: query,
});
dispatch(updateInteraction(payload));
promQailSuggest(dispatch, idx, query, labelNames, datasource, newInteraction);
}}
>
Suggest queries instead
</Button>
<Button
fill="solid"
variant="primary"
data-testid={queryAssistanttestIds.submitPrompt + idx}
onClick={() => {
const newInteraction: Interaction = {
...interaction,
isLoading: true,
};
const payload = {
idx: idx,
interaction: newInteraction,
};
reportInteraction('grafana_prometheus_promqail_prompt_submitted', {
promVisualQuery: query,
prompt: interaction.prompt,
});
dispatch(updateInteraction(payload));
// add the suggestions in the API call
promQailSuggest(dispatch, idx, query, labelNames, datasource, interaction);
}}
>
Submit
</Button>
</div>
</div>
</>
)
) : (
// LIST OF SUGGESTED QUERIES FROM AI
<QuerySuggestionContainer
suggestionType={SuggestionType.AI}
querySuggestions={interaction.suggestions}
closeDrawer={closeDrawer}
nextInteraction={() => {
const isLoading = false;
const suggestionType = SuggestionType.AI;
dispatch(addInteraction({ suggestionType, isLoading }));
}}
queryExplain={(suggIdx: number) =>
interaction.suggestions[suggIdx].explanation === ''
? promQailExplain(dispatch, idx, query, interaction, suggIdx, datasource)
: interaction.suggestions[suggIdx].explanation
}
onChange={onChange}
prompt={interaction.prompt ?? ''}
/>
)}
</>
) : // HISTORICAL SUGGESTIONS
interaction.isLoading ? (
<>
<div className={styles.loadingMessageContainer}>
Waiting for OpenAI <Spinner className={styles.floatRight} />
</div>
</>
) : (
// LIST OF SUGGESTED QUERIES FROM HISTORICAL DATA
<QuerySuggestionContainer
suggestionType={SuggestionType.Historical}
querySuggestions={interaction.suggestions}
closeDrawer={closeDrawer}
nextInteraction={() => {
const isLoading = false;
const suggestionType = SuggestionType.AI;
dispatch(addInteraction({ suggestionType, isLoading }));
}}
queryExplain={(suggIdx: number) =>
interaction.suggestions[suggIdx].explanation === ''
? promQailExplain(dispatch, idx, query, interaction, suggIdx, datasource)
: interaction.suggestions[suggIdx].explanation
}
onChange={onChange}
prompt={interaction.prompt ?? ''}
/>
)}
</div>
);
})}
</div>
)}
</div>
<div ref={responsesEndRef} />
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
sectionPadding: css({
padding: '20px',
}),
header: css({
display: 'flex',
button: {
marginLeft: 'auto',
},
}),
iconSection: css({
padding: '0 0 10px 0',
color: `${theme.colors.text.secondary}`,
img: {
paddingRight: '4px',
},
}),
rightButtonsWrapper: css({
display: 'flex',
}),
rightButtons: css({
marginLeft: 'auto',
}),
leftButton: css({
marginRight: '10px',
}),
dataList: css({
padding: '0px 28px 0px 28px',
}),
textPadding: css({
paddingBottom: '12px',
}),
containerPadding: css({
padding: '28px',
}),
infoContainer: css({
border: `${theme.colors.border.strong}`,
padding: '16px',
backgroundColor: `${theme.colors.background.secondary}`,
borderRadius: `8px`,
borderBottomLeftRadius: 0,
}),
infoContainerWrapper: css({
paddingBottom: '24px',
}),
metricTable: css({
width: '100%',
}),
metricTableName: css({
width: '15%',
}),
metricTableValue: css({
fontFamily: `${theme.typography.fontFamilyMonospace}`,
fontSize: `${theme.typography.bodySmall.fontSize}`,
overflow: 'scroll',
textWrap: 'nowrap',
maxWidth: '150px',
width: '60%',
maskImage: `linear-gradient(to right, rgba(0, 0, 0, 1) 90%, rgba(0, 0, 0, 0))`,
}),
metricTableButton: css({
float: 'right',
}),
queryQuestion: css({
textAlign: 'end',
padding: '8px 0',
}),
secondaryText: css({
color: `${theme.colors.text.secondary}`,
}),
loadingMessageContainer: css({
border: `${theme.colors.border.strong}`,
padding: `16px`,
backgroundColor: `${theme.colors.background.secondary}`,
marginBottom: `20px`,
borderRadius: `8px`,
color: `${theme.colors.text.secondary}`,
fontStyle: 'italic',
}),
floatRight: css({
float: 'right',
}),
codeText: css({
fontFamily: `${theme.typography.fontFamilyMonospace}`,
fontSize: `${theme.typography.bodySmall.fontSize}`,
}),
bodySmall: css({
fontSize: `${theme.typography.bodySmall.fontSize}`,
}),
explainPadding: css({
paddingLeft: '26px',
}),
bottomMargin: css({
marginBottom: '20px',
}),
topPadding: css({
paddingTop: '22px',
}),
doc: css({
textDecoration: 'underline',
}),
afterButtons: css({
display: 'flex',
justifyContent: 'flex-end',
}),
feedbackStyle: css({
margin: 0,
textAlign: 'right',
paddingTop: '22px',
paddingBottom: '22px',
}),
nextInteractionHeight: css({
height: '88px',
}),
center: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}),
inputPadding: css({
paddingBottom: '24px',
}),
querySuggestion: css({
display: 'flex',
flexWrap: 'nowrap',
}),
longCode: css({
width: '90%',
textWrap: 'nowrap',
overflow: 'scroll',
maskImage: `linear-gradient(to right, rgba(0, 0, 0, 1) 90%, rgba(0, 0, 0, 0))`,
div: {
display: 'inline-block',
},
}),
useButton: css({
marginLeft: 'auto',
}),
suggestionFeedback: css({
textAlign: 'left',
}),
feedbackQuestion: css({
display: 'flex',
padding: '8px 0px',
h6: { marginBottom: 0 },
i: {
marginTop: '1px',
},
}),
explationTextInput: css({
paddingLeft: '24px',
}),
submitFeedback: css({
padding: '16px 0',
}),
noMargin: css({
margin: 0,
}),
enableButtonTooltip: css({
padding: 8,
}),
enableButtonTooltipText: css({
color: `${theme.colors.text.secondary}`,
ul: {
marginLeft: 16,
},
}),
link: css({
color: `${theme.colors.text.link} !important`,
}),
};
};
export const queryAssistanttestIds = {
promQail: 'prom-qail',
securityInfoButton: 'security-info-button',
clickForHistorical: 'click-for-historical',
clickForAi: 'click-for-ai',
submitPrompt: 'submit-prompt',
refinePrompt: 'refine-prompt',
};
const stateSlice = createSlice({
name: 'metrics-modal-state',
initialState: initialState(),
reducers: {
showExplainer: (state, action: PayloadAction<boolean>) => {
state.showExplainer = action.payload;
},
showStartingMessage: (state, action: PayloadAction<boolean>) => {
state.showStartingMessage = action.payload;
},
indicateCheckbox: (state, action: PayloadAction<boolean>) => {
state.indicateCheckbox = action.payload;
},
askForQueryHelp: (state, action: PayloadAction<boolean>) => {
state.askForQueryHelp = action.payload;
},
/*
* start working on a collection of interactions
* {
* askForhelp y n
* prompt question
* queries querySuggestions
* }
*
*/
addInteraction: (state, action: PayloadAction<{ suggestionType: SuggestionType; isLoading: boolean }>) => {
// AI or Historical?
const interaction = createInteraction(action.payload.suggestionType, action.payload.isLoading);
const interactions = state.interactions;
state.interactions = interactions.concat([interaction]);
},
updateInteraction: (state, action: PayloadAction<{ idx: number; interaction: Interaction }>) => {
// update the interaction by index
// will most likely be the last interaction but we might update previous by giving them cues of helpful or not
const index = action.payload.idx;
const updInteraction = action.payload.interaction;
state.interactions = state.interactions.map((interaction: Interaction, idx: number) => {
if (idx === index) {
return updInteraction;
}
return interaction;
});
},
},
});
// actions to update the state
export const { showStartingMessage, indicateCheckbox, addInteraction, updateInteraction } = stateSlice.actions;
@@ -1,51 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QueryAssistantButton.test.tsx
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryAssistantButton } from './QueryAssistantButton';
const setShowDrawer = jest.fn(() => {});
describe('QueryAssistantButton', () => {
it('renders the button', async () => {
const props = createProps(true, 'metric', setShowDrawer);
render(<QueryAssistantButton {...props} />);
expect(screen.getByText('Get query suggestions')).toBeInTheDocument();
});
it('shows the LLM app disabled message when LLM app is not set up with vector DB', async () => {
const props = createProps(false, 'metric', setShowDrawer);
render(<QueryAssistantButton {...props} />);
const button = screen.getByText('Get query suggestions');
await userEvent.hover(button);
await waitFor(() => {
expect(screen.getByText('Install and enable the LLM plugin')).toBeInTheDocument();
});
});
it('shows the message to select a metric when LLM is enabled and no metric is selected', async () => {
const props = createProps(true, '', setShowDrawer);
render(<QueryAssistantButton {...props} />);
const button = screen.getByText('Get query suggestions');
await userEvent.hover(button);
await waitFor(() => {
expect(screen.getByText('First, select a metric.')).toBeInTheDocument();
});
});
it('calls setShowDrawer when button is clicked', async () => {
const props = createProps(true, 'metric', setShowDrawer);
render(<QueryAssistantButton {...props} />);
const button = screen.getByText('Get query suggestions');
fireEvent.click(button);
expect(setShowDrawer).toHaveBeenCalled();
});
});
function createProps(llmAppEnabled: boolean, metric: string, setShowDrawer: () => void) {
return {
llmAppEnabled,
metric,
setShowDrawer,
};
}
@@ -1,86 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QueryAssistantButton.tsx
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { Button, Tooltip, useTheme2 } from '@grafana/ui';
import { getStyles } from './PromQail';
import AI_Logo_color from './resources/AI_Logo_color.svg';
export type Props = {
llmAppEnabled: boolean;
metric: string;
setShowDrawer: (show: boolean) => void;
};
export function QueryAssistantButton(props: Props) {
const { llmAppEnabled, metric, setShowDrawer } = props;
const llmAppDisabled = !llmAppEnabled;
const noMetricSelected = !metric;
const theme = useTheme2();
const styles = getStyles(theme);
const button = () => {
return (
<Button
variant={'secondary'}
onClick={() => {
reportInteraction('grafana_prometheus_promqail_ai_button_clicked', {
metric: metric,
});
setShowDrawer(true);
}}
disabled={!metric || !llmAppEnabled}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.queryAdvisor}
>
<img height={16} src={AI_Logo_color} alt="AI logo black and white" />
{'\u00A0'}Get query suggestions
</Button>
);
};
const selectMetricMessage = (
<Tooltip content={'First, select a metric.'} placement={'bottom-end'}>
{button()}
</Tooltip>
);
const llmAppMessage = (
<Tooltip
interactive={true}
placement={'auto-end'}
content={
<div className={styles.enableButtonTooltip}>
<h6>Query Advisor is disabled</h6>
<div className={styles.enableButtonTooltipText}>To enable Query Advisor you must:</div>
<div className={styles.enableButtonTooltipText}>
<ul>
<li>
<a
href={'https://grafana.com/docs/grafana-cloud/alerting-and-irm/machine-learning/llm-plugin/'}
target="_blank"
rel="noreferrer noopener"
className={styles.link}
>
Install and enable the LLM plugin
</a>
</li>
<li>Select a metric</li>
</ul>
</div>
</div>
}
>
{button()}
</Tooltip>
);
if (llmAppDisabled) {
return llmAppMessage;
} else if (noMetricSelected) {
return selectMetricMessage;
} else {
return button();
}
}
@@ -1,102 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QuerySuggestionContainer.tsx
import { cx } from '@emotion/css';
import { useState } from 'react';
import { Button, useTheme2 } from '@grafana/ui';
import { PromVisualQuery } from '../../types';
import { getStyles, queryAssistanttestIds } from './PromQail';
import { QuerySuggestionItem } from './QuerySuggestionItem';
import { QuerySuggestion, SuggestionType } from './types';
export type Props = {
querySuggestions: QuerySuggestion[];
suggestionType: SuggestionType;
closeDrawer: () => void;
nextInteraction: () => void;
queryExplain: (idx: number) => void;
onChange: (query: PromVisualQuery) => void;
prompt: string;
};
export function QuerySuggestionContainer(props: Props) {
const { suggestionType, querySuggestions, closeDrawer, nextInteraction, queryExplain, onChange, prompt } = props;
const [hasNextInteraction, updateHasNextInteraction] = useState<boolean>(false);
const theme = useTheme2();
const styles = getStyles(theme);
let text, secondaryText, refineText;
if (suggestionType === SuggestionType.Historical) {
text = `Here are ${querySuggestions.length} query suggestions:`;
refineText = 'I want to write a prompt';
} else if (suggestionType === SuggestionType.AI) {
text = text = 'Here is your query suggestion:';
secondaryText =
'This query is based off of natural language descriptions of the most commonly used PromQL queries.';
refineText = 'Refine prompt';
}
return (
<>
{suggestionType === SuggestionType.Historical ? (
<div className={styles.bottomMargin}>{text}</div>
) : (
<>
<div className={styles.textPadding}>{text}</div>
<div className={cx(styles.secondaryText, styles.bottomMargin)}>{secondaryText}</div>
</>
)}
<div className={styles.infoContainerWrapper}>
<div className={styles.infoContainer}>
{querySuggestions.map((qs: QuerySuggestion, idx: number) => {
return (
<QuerySuggestionItem
historical={suggestionType === SuggestionType.Historical}
querySuggestion={qs}
key={idx}
order={idx + 1}
queryExplain={queryExplain}
onChange={onChange}
closeDrawer={closeDrawer}
last={idx === querySuggestions.length - 1}
// for feedback rudderstack events
allSuggestions={querySuggestions.reduce((acc: string, qs: QuerySuggestion) => {
return acc + '$$' + qs.query;
}, '')}
prompt={prompt ?? ''}
/>
);
})}
</div>
</div>
{!hasNextInteraction && (
<div className={styles.nextInteractionHeight}>
<div className={cx(styles.afterButtons, styles.textPadding)}>
<Button
onClick={() => {
updateHasNextInteraction(true);
nextInteraction();
}}
data-testid={queryAssistanttestIds.refinePrompt}
fill="outline"
variant="secondary"
size="md"
>
{refineText}
</Button>
</div>
<div className={cx(styles.textPadding, styles.floatRight)}>
<Button fill="outline" variant="secondary" size="md" onClick={closeDrawer}>
Cancel
</Button>
</div>
</div>
)}
</>
);
}
@@ -1,322 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/QuerySuggestionItem.tsx
import { cx } from '@emotion/css';
import { FormEvent, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Button, RadioButtonList, Spinner, TextArea, Toggletip, useTheme2 } from '@grafana/ui';
import { buildVisualQueryFromString } from '../../parsing';
import { PromVisualQuery } from '../../types';
import { getStyles } from './PromQail';
import { QuerySuggestion } from './types';
export type Props = {
querySuggestion: QuerySuggestion;
order: number;
queryExplain: (idx: number) => void;
historical: boolean;
onChange: (query: PromVisualQuery) => void;
closeDrawer: () => void;
last: boolean;
prompt: string;
allSuggestions: string | undefined;
};
const suggestionOptions: SelectableValue[] = [
{ label: 'Yes', value: 'yes' },
{ label: 'No', value: 'no' },
];
const explationOptions: SelectableValue[] = [
{ label: 'Too vague', value: 'too vague' },
{ label: 'Too technical', value: 'too technical' },
{ label: 'Inaccurate', value: 'inaccurate' },
{ label: 'Other', value: 'other' },
];
export function QuerySuggestionItem(props: Props) {
const { querySuggestion, order, queryExplain, historical, onChange, closeDrawer, last, allSuggestions, prompt } =
props;
const [showExp, updShowExp] = useState<boolean>(false);
const [gaveExplanationFeedback, updateGaveExplanationFeedback] = useState<boolean>(false);
const [gaveSuggestionFeedback, updateGaveSuggestionFeedback] = useState<boolean>(false);
const [suggestionFeedback, setSuggestionFeedback] = useState({
radioInput: '',
text: '',
});
const [explanationFeedback, setExplanationFeedback] = useState({
radioInput: '',
text: '',
});
const theme = useTheme2();
const styles = getStyles(theme);
const { query, explanation } = querySuggestion;
const feedbackToggleTip = (type: string) => {
const updateRadioFeedback = (value: string) => {
if (type === 'explanation') {
setExplanationFeedback({
...explanationFeedback,
radioInput: value,
});
} else {
setSuggestionFeedback({
...suggestionFeedback,
radioInput: value,
});
}
};
const updateTextFeedback = (e: FormEvent<HTMLTextAreaElement>) => {
if (type === 'explanation') {
setExplanationFeedback({
...explanationFeedback,
text: e.currentTarget.value,
});
} else {
setSuggestionFeedback({
...suggestionFeedback,
text: e.currentTarget.value,
});
}
};
const disabledButton = () =>
type === 'explanation' ? !explanationFeedback.radioInput : !suggestionFeedback.radioInput;
const questionOne =
type === 'explanation' ? 'Why was the explanation not helpful?' : 'Were the query suggestions helpful?';
return (
<div className={styles.suggestionFeedback}>
<div>
<div className={styles.feedbackQuestion}>
<h6>{questionOne}</h6>
<i>(Required)</i>
</div>
<RadioButtonList
name="default"
options={type === 'explanation' ? explationOptions : suggestionOptions}
value={type === 'explanation' ? explanationFeedback.radioInput : suggestionFeedback.radioInput}
onChange={updateRadioFeedback}
/>
</div>
<div className={cx(type === 'explanation' && styles.explationTextInput)}>
{type !== 'explanation' && (
<div className={styles.feedbackQuestion}>
<h6>How can we improve the query suggestions?</h6>
</div>
)}
<TextArea
type="text"
aria-label="Promqail suggestion text"
placeholder="Enter your feedback"
value={type === 'explanation' ? explanationFeedback.text : suggestionFeedback.text}
onChange={updateTextFeedback}
cols={100}
/>
</div>
<div className={styles.submitFeedback}>
<Button
variant="primary"
size="sm"
disabled={disabledButton()}
onClick={() => {
// submit the rudderstack event
if (type === 'explanation') {
explanationFeedbackEvent(
explanationFeedback.radioInput,
explanationFeedback.text,
querySuggestion,
historical,
prompt
);
updateGaveExplanationFeedback(true);
} else {
suggestionFeedbackEvent(
suggestionFeedback.radioInput,
suggestionFeedback.text,
allSuggestions ?? '',
historical,
prompt
);
updateGaveSuggestionFeedback(true);
}
}}
>
Submit
</Button>
</div>
</div>
);
};
return (
<>
<div className={styles.querySuggestion}>
<div title={query} className={cx(styles.codeText, styles.longCode)}>
{`${order}. ${query}`}
</div>
<div className={styles.useButton}>
<Button
variant="primary"
size="sm"
onClick={() => {
reportInteraction('grafana_prometheus_promqail_use_query_button_clicked', {
query: querySuggestion.query,
});
const pvq = buildVisualQueryFromString(querySuggestion.query);
// check for errors!
onChange(pvq.query);
closeDrawer();
}}
>
Use
</Button>
</div>
</div>
<div>
<Button
fill="text"
variant="secondary"
icon={showExp ? 'angle-up' : 'angle-down'}
onClick={() => {
updShowExp(!showExp);
queryExplain(order - 1);
}}
className={cx(styles.bodySmall)}
size="sm"
>
Explainer
</Button>
{!showExp && order !== 5 && <div className={styles.textPadding}></div>}
{showExp && !querySuggestion.explanation && (
<div className={styles.center}>
<Spinner />
</div>
)}
{showExp && querySuggestion.explanation && (
<>
<div className={cx(styles.bodySmall, styles.explainPadding)}>
<div className={styles.textPadding}>This query is trying to answer the question:</div>
<div className={styles.textPadding}>{explanation}</div>
<div className={styles.textPadding}>
Learn more with this{' '}
<a
className={styles.doc}
href={'https://prometheus.io/docs/prometheus/latest/querying/examples/#query-examples'}
target="_blank"
rel="noopener noreferrer"
>
Prometheus doc
</a>
</div>
<div className={cx(styles.rightButtons, styles.secondaryText)}>
Was this explanation helpful?
<div className={styles.floatRight}>
{!gaveExplanationFeedback ? (
<>
<Button
fill="outline"
variant="secondary"
size="sm"
className={styles.leftButton}
onClick={() => {
explanationFeedbackEvent('Yes', '', querySuggestion, historical, prompt);
updateGaveExplanationFeedback(true);
}}
>
Yes
</Button>
<Toggletip
aria-label="Suggestion feedback"
content={feedbackToggleTip('explanation')}
placement="bottom-end"
closeButton={true}
>
<Button fill="outline" variant="secondary" size="sm">
No
</Button>
</Toggletip>
</>
) : (
'Thank you for your feedback!'
)}
</div>
</div>
</div>
{!last && <hr />}
</>
)}
{last && (
<div className={cx(styles.feedbackStyle)}>
{!gaveSuggestionFeedback ? (
<Toggletip
aria-label="Suggestion feedback"
content={feedbackToggleTip('suggestion')}
placement="bottom-end"
closeButton={true}
>
<Button fill="outline" variant="secondary" size="sm">
Give feedback on suggestions
</Button>
</Toggletip>
) : (
// do this weird thing because the toggle tip doesn't allow an extra close function
<Button fill="outline" variant="secondary" size="sm" disabled={true}>
Thank you for your feedback!
</Button>
)}
</div>
)}
</div>
</>
);
}
function explanationFeedbackEvent(
radioInputFeedback: string,
textFeedback: string,
querySuggestion: QuerySuggestion,
historical: boolean,
prompt: string
) {
const event = 'grafana_prometheus_promqail_explanation_feedback';
reportInteraction(event, {
helpful: radioInputFeedback,
textFeedback: textFeedback,
suggestionType: historical ? 'historical' : 'AI',
query: querySuggestion.query,
explanation: querySuggestion.explanation,
prompt: prompt,
});
}
function suggestionFeedbackEvent(
radioInputFeedback: string,
textFeedback: string,
allSuggestions: string,
historical: boolean,
prompt: string
) {
const event = 'grafana_prometheus_promqail_suggestion_feedback';
reportInteraction(event, {
helpful: radioInputFeedback,
textFeedback: textFeedback,
suggestionType: historical ? 'historical' : 'AI',
allSuggestions: allSuggestions,
prompt: prompt,
});
}
@@ -1 +0,0 @@
export * from './PromQail';
@@ -1,115 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/prompts.ts
export const ExplainSystemPrompt = `You are an expert in Prometheus, the event monitoring and alerting application.
You are given relevant PromQL documentation, a type and description for a Prometheus metric, and a PromQL query on that metric. Using the provided information for reference, please explain what the output of a given query is in 1 sentences. Do not walk through what the functions do separately, make your answer concise.
Input will be in the form:
PromQL Documentation:
<PromQL documentation>
PromQL Metrics Metadata:
<metric_name>(<metric type of the metric queried>): <description of what the metric means>
PromQL Expression:
<PromQL query>
Examples of input and output
----------
PromQL Documentation:
A counter is a cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart. For example, you can use a counter to represent the number of requests served, tasks completed, or errors.
topk (largest k elements by sample value)
sum (calculate sum over dimensions)
rate(v range-vector) calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for.
PromQL Metrics Metadata:
traces_exporter_sent_spans(counter): Number of spans successfully sent to destination.
PromQL Expression:
topk(3, sum by(cluster) (rate(traces_exporter_sent_spans{exporter="otlp"}[5m])))
This query is trying to answer the question:
What is the top 3 clusters that have successfully sent the most number of spans to the destination?
`;
export type ExplainUserPromptParams = {
documentation: string;
metricName: string;
metricType: string;
metricMetadata: string;
query: string;
};
export function GetExplainUserPrompt({
documentation,
metricName,
metricType,
metricMetadata,
query,
}: ExplainUserPromptParams): string {
if (documentation === '') {
documentation = 'No documentation provided.';
}
if (metricMetadata === '') {
metricMetadata = 'No description provided.';
}
return `
PromQL Documentation:
${documentation}
PromQL Metrics Metadata:
${metricName}(${metricType}): ${metricMetadata}
PromQL Expression:
${query}
This query is trying to answer the question:
`;
}
export const SuggestSystemPrompt = `You are a Prometheus Query Language (PromQL) expert assistant inside Grafana.
When the user asks a question, respond with a valid PromQL query and only the query.
To help you answer the question, you will receive:
- List of potentially relevant PromQL templates with descriptions, ranked by semantic search score
- Prometheus metric
- Metric type
- Available Prometheus metric labels
- User question
Policy:
- Do not invent labels names, you can only use the available labels
- For rate queries, use the $__rate_interval variable`;
// rewrite with a type
export type SuggestUserPromptParams = {
promql: string;
question: string;
metricType: string;
labels: string;
templates: string;
};
export function GetSuggestUserPrompt({
promql,
question,
metricType,
labels,
templates,
}: SuggestUserPromptParams): string {
if (templates === '') {
templates = 'No templates provided.';
} else {
templates = templates.replace(/\n/g, '\n ');
}
return `Relevant PromQL templates:
${templates}
Prometheus metric: ${promql}
Metric type: ${metricType}
Available Prometheus metric labels: ${labels}
User question: ${question}
\`\`\`promql`;
}
@@ -1,4 +0,0 @@
<svg width="17" height="18" viewBox="0 0 17 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.03027 12.6328C4.88965 12.6328 4.00293 11.9844 4.00293 10.8047C4.00293 9.44141 5.16699 9.14453 6.30371 9.01172C7.38184 8.88672 7.81934 8.89453 7.81934 8.46875V8.44141C7.81934 7.76172 7.43262 7.36719 6.67871 7.36719C5.89355 7.36719 5.45605 7.77734 5.28418 8.20312L4.18652 7.95312C4.57715 6.85937 5.57715 6.42188 6.66309 6.42188C7.61621 6.42188 8.99121 6.76953 8.99121 8.51563V12.5H7.85059V11.6797H7.80371C7.58105 12.1289 7.02246 12.6328 6.03027 12.6328ZM6.28418 11.6953C7.25684 11.6953 7.82324 11.0469 7.82324 10.3359V9.5625C7.65527 9.73047 6.75684 9.83203 6.37793 9.88281C5.70215 9.97266 5.14746 10.1953 5.14746 10.8203C5.14746 11.3984 5.62402 11.6953 6.28418 11.6953ZM10.5469 12.5V6.5H11.7148V12.5H10.5469ZM11.1367 5.57422C10.7305 5.57422 10.3984 5.26172 10.3984 4.87891C10.3984 4.49609 10.7305 4.17969 11.1367 4.17969C11.5391 4.17969 11.875 4.49609 11.875 4.87891C11.875 5.26172 11.5391 5.57422 11.1367 5.57422Z" fill="white"/>
<path d="M5 0.875H12C14.5543 0.875 16.625 2.94568 16.625 5.5V12.5C16.625 15.0543 14.5543 17.125 12 17.125H0.375V5.5C0.375 2.94568 2.44568 0.875 5 0.875Z" stroke="white" stroke-width="0.75"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,11 +0,0 @@
<svg width="26" height="27" viewBox="0 0 26 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 9.5C0 4.52944 4.02944 0.5 9 0.5H17C21.9706 0.5 26 4.52944 26 9.5V15.5C26 20.4706 21.9706 24.5 17 24.5H0V9.5Z" fill="url(#paint0_linear_68_17626)"/>
<path d="M8.91193 18.7053C7.14915 18.7053 5.77876 17.7031 5.77876 15.88C5.77876 13.7731 7.57777 13.3143 9.33452 13.109C11.0007 12.9158 11.6768 12.9279 11.6768 12.2699V12.2276C11.6768 11.1772 11.0792 10.5675 9.91406 10.5675C8.70064 10.5675 8.0245 11.2013 7.75888 11.8594L6.0625 11.473C6.66619 9.78267 8.21165 9.10653 9.88992 9.10653C11.3629 9.10653 13.4879 9.64382 13.4879 12.3423V18.5H11.7251V17.2322H11.6527C11.3086 17.9265 10.4453 18.7053 8.91193 18.7053ZM9.30433 17.2564C10.8075 17.2564 11.6829 16.2543 11.6829 15.1555V13.9602C11.4233 14.2198 10.0348 14.3768 9.44922 14.4553C8.40483 14.5941 7.54759 14.9382 7.54759 15.9041C7.54759 16.7976 8.28409 17.2564 9.30433 17.2564ZM15.8921 18.5V9.22727H17.6972V18.5H15.8921ZM16.8037 7.79652C16.1759 7.79652 15.6627 7.31357 15.6627 6.72195C15.6627 6.13033 16.1759 5.64133 16.8037 5.64133C17.4255 5.64133 17.9447 6.13033 17.9447 6.72195C17.9447 7.31357 17.4255 7.79652 16.8037 7.79652Z" fill="white"/>
<path d="M0 24.5H3L0 26.5V24.5Z" fill="#5B5CC2"/>
<defs>
<linearGradient id="paint0_linear_68_17626" x1="4.76666" y1="-5.1" x2="24.472" y2="5.4613" gradientUnits="userSpaceOnUse">
<stop offset="0.0333246" stop-color="#965AFB"/>
<stop offset="1" stop-color="#096174"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,73 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/helpers.test.ts
import { openai, vector } from '@grafana/llm';
import { guessMetricType, isLLMPluginEnabled } from './helpers';
// Mock the grafana llms module
jest.mock('@grafana/llm', () => ({
openai: {
health: jest.fn(),
},
vector: {
health: jest.fn(),
},
}));
describe('isLLMPluginEnabled', () => {
it('should return true if LLM plugin is enabled', async () => {
jest.mocked(openai.health).mockResolvedValue({ ok: true, configured: true });
jest.mocked(vector.health).mockResolvedValue({ ok: true, enabled: true });
const enabled = await isLLMPluginEnabled();
expect(enabled).toBe(true);
});
it('should return false if LLM plugin is not enabled', async () => {
jest.mocked(openai.health).mockResolvedValue({ ok: false, configured: false });
jest.mocked(vector.health).mockResolvedValue({ ok: false, enabled: false });
const enabled = await isLLMPluginEnabled();
expect(enabled).toBe(false);
});
it('should return false if LLM plugin is enabled but health check fails', async () => {
jest.mocked(openai.health).mockResolvedValue({ ok: false, configured: true });
jest.mocked(vector.health).mockResolvedValue({ ok: false, enabled: true });
const enabled = await isLLMPluginEnabled();
expect(enabled).toBe(false);
});
});
const metricListWithType = [
// below is summary metric family
['go_gc_duration_seconds', 'summary'],
['go_gc_duration_seconds_count', 'summary'],
['go_gc_duration_seconds_sum', 'summary'],
// below is histogram metric family
['go_gc_heap_allocs_by_size_bytes_total_bucket', 'histogram'],
['go_gc_heap_allocs_by_size_bytes_total_count', 'histogram'],
['go_gc_heap_allocs_by_size_bytes_total_sum', 'histogram'],
// below are counters
['go_gc_heap_allocs_bytes_total', 'counter'],
['scrape_samples_post_metric_relabeling', 'counter'],
// below are gauges
['go_gc_heap_goal_bytes', 'gauge'],
['nounderscorename', 'gauge'],
// below is both a histogram & summary
['alertmanager_http_response_size_bytes', 'histogram,summary'],
['alertmanager_http_response_size_bytes_bucket', 'histogram,summary'],
['alertmanager_http_response_size_bytes_count', 'histogram,summary'],
['alertmanager_http_response_size_bytes_sum', 'histogram,summary'],
];
const metricList = metricListWithType.map((item) => item[0]);
describe('guessMetricType', () => {
it.each(metricListWithType)("where input is '%s'", (metric: string, metricType: string) => {
expect(guessMetricType(metric, metricList)).toBe(metricType);
});
});
@@ -1,415 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/helpers.ts
import { AnyAction } from 'redux';
import { openai, vector } from '@grafana/llm';
import { reportInteraction } from '@grafana/runtime';
import { PrometheusDatasource } from '../../../../datasource';
import { getMetadataHelp, getMetadataType } from '../../../../language_provider';
import { promQueryModeller } from '../../../PromQueryModeller';
import { buildVisualQueryFromString } from '../../../parsing';
import { PromVisualQuery } from '../../../types';
import { updateInteraction } from '../PromQail';
import {
ExplainSystemPrompt,
GetExplainUserPrompt,
SuggestSystemPrompt,
GetSuggestUserPrompt,
SuggestUserPromptParams,
} from '../prompts';
import { Interaction, QuerySuggestion, SuggestionType } from '../types';
import { createInteraction } from './state';
import { getTemplateSuggestions } from './templates';
const OPENAI_MODEL_NAME = 'gpt-3.5-turbo-1106';
const promQLTemplatesCollection = 'grafana.promql.templates';
interface TemplateSearchResult {
description: string | null;
metric_type: string | null;
promql: string | null;
}
export function getExplainMessage(query: string, metric: string, datasource: PrometheusDatasource): openai.Message[] {
let metricMetadata = '';
let metricType = '';
const pvq = buildVisualQueryFromString(query);
if (datasource.languageProvider.metricsMetadata) {
metricType = getMetadataType(metric, datasource.languageProvider.metricsMetadata) ?? '';
metricMetadata = getMetadataHelp(metric, datasource.languageProvider.metricsMetadata) ?? '';
}
const documentationBody = pvq.query.operations
.map((op) => {
const def = promQueryModeller.getOperationDef(op.id);
if (!def) {
return '';
}
const title = def.renderer(op, def, '<expr>');
const body = def.explainHandler ? def.explainHandler(op, def) : def.documentation;
if (!body) {
return '';
}
return `### ${title}:\n${body}`;
})
.filter((item) => item !== '')
.join('\n');
return [
{ role: 'system', content: ExplainSystemPrompt },
{
role: 'user',
content: GetExplainUserPrompt({
documentation: documentationBody,
metricName: metric,
metricType: metricType,
metricMetadata: metricMetadata,
query: query,
}),
},
];
}
function getSuggestMessages({
promql,
question,
metricType,
labels,
templates,
}: SuggestUserPromptParams): openai.Message[] {
return [
{ role: 'system', content: SuggestSystemPrompt },
{ role: 'user', content: GetSuggestUserPrompt({ promql, question, metricType, labels, templates }) },
];
}
/**
* Calls the API and adds suggestions to the interaction
*
* @param dispatch
* @param idx
* @param interaction
* @returns
*/
export async function promQailExplain(
dispatch: React.Dispatch<AnyAction>,
idx: number,
query: PromVisualQuery,
interaction: Interaction,
suggIdx: number,
datasource: PrometheusDatasource
) {
const suggestedQuery = interaction.suggestions[suggIdx].query;
const promptMessages = getExplainMessage(suggestedQuery, query.metric, datasource);
const interactionToUpdate = interaction;
return openai
.streamChatCompletions({
model: OPENAI_MODEL_NAME,
messages: promptMessages,
temperature: 0,
})
.pipe(openai.accumulateContent())
.subscribe((response) => {
const updatedSuggestions = interactionToUpdate.suggestions.map((sg: QuerySuggestion, sidx: number) => {
if (suggIdx === sidx) {
return {
query: interactionToUpdate.suggestions[suggIdx].query,
explanation: response,
};
}
return sg;
});
const payload = {
idx,
interaction: {
...interactionToUpdate,
suggestions: updatedSuggestions,
explanationIsLoading: false,
},
};
dispatch(updateInteraction(payload));
});
}
/**
* Check if sublist is fully contained in the superlist
*
* @param sublist
* @param superlist
* @returns true if fully contained, else false
*/
function isContainedIn(sublist: string[], superlist: string[]): boolean {
for (const item of sublist) {
if (!superlist.includes(item)) {
return false;
}
}
return true;
}
/**
* Guess the type of a metric, based on its name and its relation to other metrics available
*
* @param metric - name of metric whose type to guess
* @param allMetrics - list of all available metrics
* @returns - the guess of the type (string): counter,gauge,summary,histogram,'histogram,summary'
*/
export function guessMetricType(metric: string, allMetrics: string[]): string {
const synthetic_metrics = new Set<string>([
'up',
'scrape_duration_seconds',
'scrape_samples_post_metric_relabeling',
'scrape_series_added',
'scrape_samples_scraped',
'ALERTS',
'ALERTS_FOR_STATE',
]);
if (synthetic_metrics.has(metric)) {
// these are all known to be counters
return 'counter';
}
if (metric.startsWith(':')) {
// probably recording rule
return 'gauge';
}
if (metric.endsWith('_info')) {
// typically series of 1s only, the labels are the useful part. TODO: add 'info' type
return 'counter';
}
if (metric.endsWith('_created') || metric.endsWith('_total')) {
// prometheus naming style recommends counters to have these suffixes.
return 'counter';
}
const underscoreIndex = metric.lastIndexOf('_');
if (underscoreIndex < 0) {
// No underscores in the name at all, very little info to go on. Guess
return 'gauge';
}
// See if the suffix is histogram-y or summary-y
const [root, suffix] = [metric.slice(0, underscoreIndex), metric.slice(underscoreIndex + 1)];
if (['bucket', 'count', 'sum'].includes(suffix)) {
// Might be histogram + summary
let familyMetrics = [`${root}_bucket`, `${root}_count`, `${root}_sum`, root];
if (isContainedIn(familyMetrics, allMetrics)) {
return 'histogram,summary';
}
// Might be a histogram, if so all these metrics should exist too:
familyMetrics = [`${root}_bucket`, `${root}_count`, `${root}_sum`];
if (isContainedIn(familyMetrics, allMetrics)) {
return 'histogram';
}
// Or might be a summary
familyMetrics = [`${root}_sum`, `${root}_count`, root];
if (isContainedIn(familyMetrics, allMetrics)) {
return 'summary';
}
// Otherwise it's probably just a counter!
return 'counter';
}
// One case above doesn't catch: summary or histogram,summary where the non-suffixed metric is chosen
const familyMetrics = [`${metric}_sum`, `${metric}_count`, metric];
if (isContainedIn(familyMetrics, allMetrics)) {
if (allMetrics.includes(`${metric}_bucket`)) {
return 'histogram,summary';
} else {
return 'summary';
}
}
// All else fails, guess gauge
return 'gauge';
}
/**
* Generate a suitable filter structure for the VectorDB call
* @param types: list of metric types to include in the result
* @returns the structure to pass to the vectorDB call.
*/
function generateMetricTypeFilters(types: string[]) {
return types.map((type) => ({
metric_type: {
$eq: type,
},
}));
}
/**
* Taking in a metric name, try to guess its corresponding metric _family_ name
* @param metric name
* @returns metric family name
*/
function guessMetricFamily(metric: string): string {
if (metric.endsWith('_bucket') || metric.endsWith('_count') || metric.endsWith('_sum')) {
return metric.slice(0, metric.lastIndexOf('_'));
}
return metric;
}
/**
* Check if the LLM plugin is enabled.
* Used in the PromQueryBuilder to enable/disable the button based on openai and vector db checks
* @returns true if the LLM plugin is enabled.
*/
export async function isLLMPluginEnabled(): Promise<boolean> {
// Check if the LLM plugin is enabled.
// If not, we won't be able to make requests, so return early.
const openaiEnabled = openai.health().then((response) => response.ok);
const vectorEnabled = vector.health().then((response) => response.ok);
// combine 2 promises
return Promise.all([openaiEnabled, vectorEnabled]).then((results) => {
return results.every((result) => result);
});
}
/**
* Calls the API and adds suggestions to the interaction
*
* @param dispatch
* @param idx
* @param interaction
* @returns
*/
export async function promQailSuggest(
dispatch: React.Dispatch<AnyAction>,
idx: number,
query: PromVisualQuery,
labelNames: string[],
datasource: PrometheusDatasource,
interaction?: Interaction
) {
const interactionToUpdate = interaction ? interaction : createInteraction(SuggestionType.Historical);
// Decide metric type
let metricType = '';
// Makes sure we loaded the metadata for metrics. Usually this is done in the start() method of the
// provider but we only need the metadata here.
if (!datasource.languageProvider.metricsMetadata) {
await datasource.languageProvider.loadMetricsMetadata();
}
if (datasource.languageProvider.metricsMetadata) {
// `datasource.languageProvider.metricsMetadata` is a list of metric family names (with desired type)
// from the datasource metadata endoint, but unfortunately the expanded _sum, _count, _bucket raw
// metric names are also generated and populating this list (all of type counter). We want the metric
// family type, so need to guess the metric family name from the chosen metric name, and test if that
// metric family has a type specified.
const metricFamilyGuess = guessMetricFamily(query.metric);
metricType = getMetadataType(metricFamilyGuess, datasource.languageProvider.metricsMetadata) ?? '';
}
if (metricType === '') {
// fallback to heuristic guess
metricType = guessMetricType(query.metric, datasource.languageProvider.metrics);
}
if (interactionToUpdate.suggestionType === SuggestionType.Historical) {
return new Promise<void>((resolve) => {
return setTimeout(() => {
const suggestions = getTemplateSuggestions(
query.metric,
metricType,
promQueryModeller.renderLabels(query.labels)
);
const payload = {
idx,
interaction: { ...interactionToUpdate, suggestions: suggestions, isLoading: false },
};
dispatch(updateInteraction(payload));
resolve();
}, 1000);
});
} else {
type SuggestionBody = {
metric: string;
labels: string;
prompt?: string;
};
// get all available labels
const metricLabels = await datasource.languageProvider.fetchLabelsWithMatch(query.metric);
let feedTheAI: SuggestionBody = {
metric: query.metric,
// drop __name__ label because it's not useful
labels: Object.keys(metricLabels)
.filter((label) => label !== '__name__')
.join(','),
};
// @ts-ignore llms types issue
let results: Array<llms.vector.SearchResult<TemplateSearchResult>> = [];
if (interaction?.suggestionType === SuggestionType.AI) {
feedTheAI = { ...feedTheAI, prompt: interaction.prompt };
// @ts-ignore llms types issue
results = await llms.vector.search<TemplateSearchResult>({
query: interaction.prompt,
collection: promQLTemplatesCollection,
topK: 5,
filter: {
$or: generateMetricTypeFilters(metricType.split(',').concat(['*'])),
},
});
reportInteraction('grafana_prometheus_promqail_vector_results', {
metric: query.metric,
prompt: interaction.prompt,
results: results,
});
// TODO: handle errors from vector search
}
const resultsString = results
.map((r) => {
return `${r.payload.promql} | ${r.payload.description} (score=${(r.score * 100).toFixed(1)})`;
})
.join('\n');
const promptMessages = getSuggestMessages({
promql: query.metric,
question: interaction ? interaction.prompt : '',
metricType: metricType,
labels: labelNames.join(', '),
templates: resultsString,
});
return openai
.streamChatCompletions({
model: OPENAI_MODEL_NAME,
messages: promptMessages,
temperature: 0.5,
})
.pipe(openai.accumulateContent())
.subscribe((response) => {
const payload = {
idx,
interaction: {
...interactionToUpdate,
suggestions: [
{
query: response,
explanation: '',
},
],
isLoading: false,
},
};
dispatch(updateInteraction(payload));
});
}
}
@@ -1,44 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/state.ts
import { PromVisualQuery } from '../../../types';
import { Interaction, SuggestionType } from '../types';
/**
* Initial state for PromQAIL
* @param query the prometheus query with metric and possible labels
*/
export function initialState(query?: PromVisualQuery, showStartingMessage?: boolean): PromQailState {
return {
query: query ?? {
metric: '',
labels: [],
operations: [],
},
showExplainer: false,
showStartingMessage: showStartingMessage ?? true,
indicateCheckbox: false,
askForQueryHelp: false,
interactions: [],
};
}
/**
* The PromQAIL state object
*/
export interface PromQailState {
query: PromVisualQuery;
showExplainer: boolean;
showStartingMessage: boolean;
indicateCheckbox: boolean;
askForQueryHelp: boolean;
interactions: Interaction[];
}
export function createInteraction(suggestionType: SuggestionType, isLoading?: boolean): Interaction {
return {
suggestionType: suggestionType,
prompt: '',
suggestions: [],
isLoading: isLoading ?? false,
explanationIsLoading: false,
};
}
@@ -1,342 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/state/templates.ts
import { QuerySuggestion } from '../types';
interface TemplateData {
template: string;
description: string;
}
export const generalTemplates: TemplateData[] = [
{
template: 'metric_a{}',
description: 'Get the data for "metric_a"',
},
{
template: 'avg by(c) (metric_a{})',
description: 'Average of all series in "metric_a" grouped by the label "c"',
},
{
template: 'count by(d) (metric_a{})',
description: 'Number of series in the metric "metric_a" grouped by the label "d"',
},
{
template: 'sum by(g) (sum_over_time(metric_a{}[1h]))',
description:
'For each series in the metric "metric_a", sum all values over 1 hour, then group those series by label "g" and sum.',
},
{
template: 'count(metric_a{})',
description: 'Count of series in the metric "metric_a"',
},
{
template: '(metric_a{})',
description: 'Get the data for "metric_a"',
},
{
template: 'count_over_time(metric_a{}[1h])',
description: 'Number of series of metric_a in a 1 hour interval',
},
{
template: 'changes(metric_a{}[1m])',
description: 'Number of times the values of each series in metric_a have changed in 1 minute periods',
},
{
template: 'count(count by(g) (metric_a{}))',
description: 'Total number of series in metric_a',
},
{
template: 'last_over_time(metric_a{}[1h])',
description: 'For each series in metric_a, get the last value in the 1 hour period.',
},
{
template: 'sum by(g) (count_over_time(metric_a{}[1h]))',
description: 'Grouped sum over the label "g" of the number of series of metric_a in a 1 hour period',
},
{
template: 'count(metric_a{} == 99)',
description: 'Number of series of metric_a that have value 99',
},
{
template: 'min(metric_a{})',
description: 'At each timestamp, find the minimum of all series of the metric "metric_a"',
},
{
template: 'metric_a{} != 99',
description: 'Series of metric_a which do not have the value 99',
},
{
template: 'metric_a{} - 99',
description: 'metric_a minus 99',
},
{
template: 'quantile_over_time(0.99,metric_a{}[1h])',
description: 'The 99th quantile of values of metric_a in 1 hour',
},
{
template: 'count_values("aaaa",metric_a{})',
description: 'Count number of label values for a label named "aaaa"',
},
{
template: 'quantile by(l) (1,metric_a)',
description: 'Quantile of series in the metric "metric_a" grouped by the label "l"',
},
];
export const counterTemplates: TemplateData[] = [
{
template: 'sum by(d) (rate(metric_a{}[1h]))',
description:
'Sum of the rate of increase or decrease of the metric "metric_a" per 1 hour period, grouped by the label "d"',
},
{
template: 'rate(metric_a{}[1m])',
description: 'Rate of change of the metric "metric_a" over 1 minute',
},
{
template: 'sum by(a) (increase(metric_a{}[5m]))',
description:
'Taking the metric "metric_a" find the increase in 5 minute periods of each series and aggregate sum over the label "a"',
},
{
template: 'sum(rate(metric_a{}[1m]))',
description: 'Total rate of change of all series of metric "metric_a" in 1 minute intervals',
},
{
template: 'sum(increase(metric_a{}[10m]))',
description: 'Total increase for each series of metric "metric_a" in 10 minute intervals',
},
{
template: 'increase(metric_a{}[1h])',
description: 'Increase in all series of "metric_a" in 1 hour period',
},
{
template: 'sum by(d) (irate(metric_a{}[1h]))',
description: 'Sum of detailed rate of change of the metric "metric_a" over 1 hour grouped by label "d"',
},
{
template: 'irate(metric_a{}[1h])',
description: 'Detailed rate of change of the metric "metric_a" over 1 hour',
},
{
template: 'avg by(d) (rate(metric_a{}[1h]))',
description:
'Taking the rate of change of the metric "metric_a" in a 1 hour period, group by the label "d" and find the average of each group',
},
{
template: 'topk(5,sum by(g) (rate(metric_a{}[1h])))',
description: 'Top 5 of the summed groups "g" of the rate of change of metric_a',
},
{
template: 'sum(rate(metric_a{}[1h])) / sum(rate(metric_a{}[1h]))',
description: 'Relative sums of metric_a with different labels',
},
{
template: 'histogram_quantile(99,rate(metric_a{}[1h]))',
description: '99th percentile of the rate of change of metric_a in 1 hour periods',
},
{
template: 'avg(rate(metric_a{}[1m]))',
description: 'Average of the rate of all series of metric_a in 1 minute periods',
},
{
template: 'rate(metric_a{}[5m]) > 99',
description: 'Show series of metric_a only if their rate over 5 minutes is greater than 99',
},
{
template: 'count by(g) (rate(metric_a{}[1h]))',
description: 'Count of series of metric_a over all labels "g"',
},
];
export const histogramTemplates: TemplateData[] = [
{
template: 'histogram_quantile(99,sum by(le) (rate(metric_a{}[1h])))',
description:
'Calculate the rate at which the metric "metric_a" is increasing or decreasing, summed over each bucket label "le", and then calculates the 99th percentile of those rates.',
},
{
template: 'histogram_quantile(99,sum by(g) (metric_a{}))',
description: '99th percentile of the sum of metric_a grouped by label "g"',
},
{
template: 'histogram_quantile(99,sum by(g) (irate(metric_a{}[1h])))',
description: '99th percentile of the grouped by "g" sum of the rate of each series in metric_a in an hour',
},
{
template: 'histogram_quantile(99,metric_a{})',
description: '99th percentile of metric_a',
},
];
export const gaugeTemplates: TemplateData[] = [
{
template: 'sum by(c) (metric_a{})',
description: 'Sum the metric "metric_a" by each value in label "c"',
},
{
template: 'sum(metric_a{})',
description: 'Total sum of all the series of the metric named "metric_a"',
},
{
template: 'max by(dd) (metric_a{})',
description: 'Grouping the series the metric "metric_a" by the label "dd", get the maximum value of each group',
},
{
template: 'max(metric_a{})',
description: 'Maximum value of all series of the metric "metric_a" ',
},
{
template: 'avg(metric_a{})',
description: 'Average value of all the series of metric "metric_a"',
},
{
template: 'metric_a{} > 99',
description: 'Show only the series of metric "metric_a" which currently have value greater than 99',
},
{
template: 'metric_a{} / 99',
description: 'Values for "metric_a" all divided by 99',
},
{
template: 'metric_a{} == 99',
description: 'Show series of metric_a that have value 99',
},
{
template: 'sum_over_time(metric_a{}[1h])',
description: 'Sum each series of metric_a over 1 hour',
},
{
template: 'avg_over_time(metric_a{}[1h])',
description: 'Average of each series of metric_a in a 1 hour period',
},
{
template: 'sum(sum_over_time(metric_a{}[1h]))',
description: 'Sum of all values in all series in a 1 hour period',
},
{
template: 'delta(metric_a{}[1m])',
description: 'Span or delta (maximum - minimum) of values of the metric "metric_a" in a 1 minute period. ',
},
{
template: 'avg by(g) (avg_over_time(metric_a{}[1h]))',
description:
'For 1 hour, take each series and find the average, then group by label "g" and find the average of each group',
},
{
template: 'max_over_time(metric_a{}[1h])',
description: 'Maximum values of each series in metric "metric_a" in a 1 hour period',
},
{
template: 'metric_a{} * 99',
description: 'Values of metric_a multiplied by 99',
},
{
template: 'metric_a{} < 99',
description: 'Series of metric_a that have values less than 99',
},
{
template: 'max by() (max_over_time(metric_a{}[1h]))',
description: 'Find maximum value of all series in 1 hour periods',
},
{
template: 'topk(99,metric_a{})',
description: 'First 5 series of metric_a that have the highest values',
},
{
template: 'min by(g) (metric_a{})',
description: 'Minimum values of the series of metric_a grouped by label "g"',
},
{
template: 'topk(10,sum by(g) (metric_a{}))',
description: "Top 10 of the series of metric_a grouped and summed by the label 'g'",
},
{
template: 'avg(avg_over_time(metric_a{}[1h]))',
description: 'Average of all values inside a 1 hour period',
},
{
template: 'quantile by(h) (0.95,metric_a{})',
description: 'Calculate 95th percentile of metric_a when aggregated by the label "h"',
},
{
template: 'avg by(g) (metric_a{} > 99)',
description:
'Taking all series of metric_a with value greater than 99, group by label "g" and find the average of each group',
},
{
template: 'sum(metric_a{}) / 99',
description: 'Sum of all series of metric_a divided by 99',
},
{
template: 'count(sum by(g) (metric_a{}))',
description: 'Number of series of metric_a grouped by the label "g"',
},
{
template: 'max(max_over_time(metric_a{}[1h]))',
description: 'Find the max value of all series of metric_a in a 1 hour period',
},
];
function processTemplate(templateData: TemplateData, metric: string, labels: string): QuerySuggestion {
return {
query: templateData.template.replace('metric_a', metric).replace('{}', labels),
explanation: templateData.description.replace('metric_a', metric),
};
}
export function getTemplateSuggestions(metricName: string, metricType: string, labels: string): QuerySuggestion[] {
let templateSuggestions: QuerySuggestion[] = [];
switch (metricType) {
case 'counter':
templateSuggestions = templateSuggestions.concat(
counterTemplates
.map((t) => processTemplate(t, metricName, labels))
.sort(() => Math.random() - 0.5)
.slice(0, 2)
);
templateSuggestions = templateSuggestions.concat(
generalTemplates
.map((t) => processTemplate(t, metricName, labels))
.sort(() => Math.random() - 0.5)
.slice(0, 3)
);
break;
case 'gauge':
templateSuggestions = templateSuggestions.concat(
gaugeTemplates
.map((t) => processTemplate(t, metricName, labels))
.sort(() => Math.random() - 0.5)
.slice(0, 2)
);
templateSuggestions = templateSuggestions.concat(
generalTemplates
.map((t) => processTemplate(t, metricName, labels))
.sort(() => Math.random() - 0.5)
.slice(0, 3)
);
break;
case 'histogram':
templateSuggestions = templateSuggestions.concat(
histogramTemplates
.map((t) => processTemplate(t, metricName, labels))
.sort(() => Math.random() - 0.5)
.slice(0, 2)
);
templateSuggestions = templateSuggestions.concat(
generalTemplates
.map((t) => processTemplate(t, metricName, labels))
.sort(() => Math.random() - 0.5)
.slice(0, 3)
);
break;
default:
templateSuggestions = templateSuggestions.concat(
generalTemplates
.map((t) => processTemplate(t, metricName, labels))
.sort(() => Math.random() - 0.5)
.slice(0, 5)
);
break;
}
return templateSuggestions;
}
@@ -1,18 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/promQail/types.ts
export type QuerySuggestion = {
query: string;
explanation: string;
};
export enum SuggestionType {
Historical = 'historical',
AI = 'AI',
}
export type Interaction = {
prompt: string;
suggestionType: SuggestionType;
suggestions: QuerySuggestion[];
isLoading: boolean;
explanationIsLoading: boolean;
};
+6 -1
View File
@@ -16,7 +16,12 @@ type Dashboard struct {
metav1.ObjectMeta `json:"metadata,omitempty"`
// The dashboard body (unstructured for now)
Spec common.Unstructured `json:"spec"`
Spec DashboardSpec `json:"spec"`
}
type DashboardSpec struct {
Title string `json:"title"`
common.Unstructured `json:",inline"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
@@ -1,22 +1,31 @@
package v1alpha1
package v0alpha1
import (
conversion "k8s.io/apimachinery/pkg/conversion"
klog "k8s.io/klog/v2"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
dashboard "github.com/grafana/grafana/pkg/apis/dashboard"
"github.com/grafana/grafana/pkg/apis/dashboard/migration"
"github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
)
func Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(in *common.Unstructured, out *DashboardSpec, s conversion.Scope) error {
func Convert_dashboard_DashboardSpec_To_v0alpha1_Unstructured(in *dashboard.DashboardSpec, out *common.Unstructured, s conversion.Scope) error {
*out = in.Unstructured
if in.Title != "" {
out.Object["title"] = in.Title
}
return nil
}
func Convert_v0alpha1_Unstructured_To_dashboard_DashboardSpec(in *common.Unstructured, out *dashboard.DashboardSpec, s conversion.Scope) error {
out.Unstructured = *in
err := migration.Migrate(out.Unstructured.Object, schemaversion.LATEST_VERSION)
if err != nil {
return err
}
t, ok := out.Unstructured.Object["title"].(string)
t, ok := out.Object["title"].(string)
if !ok {
klog.V(5).Infof("unstructured dashboard title field is not a string %v", t)
return nil // skip setting the title if it's not a string in the unstructured object
@@ -24,11 +33,3 @@ func Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(in *common.Unstruct
out.Title = t
return nil
}
func Convert_v1alpha1_DashboardSpec_To_v0alpha1_Unstructured(in *DashboardSpec, out *common.Unstructured, s conversion.Scope) error {
*out = in.Unstructured
if in.Title != "" {
out.Object["title"] = in.Title
}
return nil
}
@@ -1,4 +1,4 @@
package v2alpha1
package v0alpha1
import (
"encoding/json"
@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/require"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/dashboard"
)
func TestConvertDashboardVersions(t *testing.T) {
@@ -43,7 +44,7 @@ func TestConvertDashboardVersions(t *testing.T) {
},
"timepicker": {},
"timezone": "utc",
"title": "New dashboard",
"title": "New",
"uid": "be3ymutzclgqod",
"version": 1,
"weekStart": ""
@@ -51,17 +52,17 @@ func TestConvertDashboardVersions(t *testing.T) {
object := common.Unstructured{}
err := json.Unmarshal(dashboardV0Spec, &object.Object)
require.NoError(t, err)
result := DashboardSpec{}
// convert v0 to v2, where we should extract the title & all other elements should be copied
err = Convert_v0alpha1_Unstructured_To_v2alpha1_DashboardSpec(&object, &result, nil)
in := dashboard.DashboardSpec{Title: "New dashboard", Unstructured: object}
result := common.Unstructured{}
err = Convert_dashboard_DashboardSpec_To_v0alpha1_Unstructured(&in, &result, nil)
require.NoError(t, err)
require.Equal(t, result.Title, "New dashboard")
require.Equal(t, result.Unstructured, object)
require.Equal(t, result.Unstructured.Object["refresh"], "", "schemaVersion migration not applied. refresh should be an empty string")
// now convert back & ensure it is the same
object2 := common.Unstructured{}
err = Convert_v2alpha1_DashboardSpec_To_v0alpha1_Unstructured(&result, &object2, nil)
object2 := *(result.DeepCopy())
result2 := dashboard.DashboardSpec{}
err = Convert_v0alpha1_Unstructured_To_dashboard_DashboardSpec(&object2, &result2, nil)
require.NoError(t, err)
require.Equal(t, object, object2)
require.Equal(t, result2.Title, "New dashboard")
require.Equal(t, result2.Unstructured.Object["schemaVersion"], 41, "schemaVersion migration not applied.")
require.Equal(t, result2.Unstructured.Object["refresh"], "", "schemaVersion migration not applied. refresh should be an empty string")
}
@@ -12,6 +12,7 @@ import (
unsafe "unsafe"
datav0alpha1 "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
dashboard "github.com/grafana/grafana/pkg/apis/dashboard"
conversion "k8s.io/apimachinery/pkg/conversion"
runtime "k8s.io/apimachinery/pkg/runtime"
@@ -159,6 +160,16 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddConversionFunc((*dashboard.DashboardSpec)(nil), (*commonv0alpha1.Unstructured)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_dashboard_DashboardSpec_To_v0alpha1_Unstructured(a.(*dashboard.DashboardSpec), b.(*commonv0alpha1.Unstructured), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*commonv0alpha1.Unstructured)(nil), (*dashboard.DashboardSpec)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v0alpha1_Unstructured_To_dashboard_DashboardSpec(a.(*commonv0alpha1.Unstructured), b.(*dashboard.DashboardSpec), scope)
}); err != nil {
return err
}
return nil
}
@@ -218,7 +229,9 @@ func Convert_dashboard_AnnotationPermission_To_v0alpha1_AnnotationPermission(in
func autoConvert_v0alpha1_Dashboard_To_dashboard_Dashboard(in *Dashboard, out *dashboard.Dashboard, s conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
out.Spec = in.Spec
if err := Convert_v0alpha1_Unstructured_To_dashboard_DashboardSpec(&in.Spec, &out.Spec, s); err != nil {
return err
}
return nil
}
@@ -229,7 +242,9 @@ func Convert_v0alpha1_Dashboard_To_dashboard_Dashboard(in *Dashboard, out *dashb
func autoConvert_dashboard_Dashboard_To_v0alpha1_Dashboard(in *dashboard.Dashboard, out *Dashboard, s conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
out.Spec = in.Spec
if err := Convert_dashboard_DashboardSpec_To_v0alpha1_Unstructured(&in.Spec, &out.Spec, s); err != nil {
return err
}
return nil
}
@@ -274,7 +289,17 @@ func Convert_dashboard_DashboardAccess_To_v0alpha1_DashboardAccess(in *dashboard
func autoConvert_v0alpha1_DashboardList_To_dashboard_DashboardList(in *DashboardList, out *dashboard.DashboardList, s conversion.Scope) error {
out.ListMeta = in.ListMeta
out.Items = *(*[]dashboard.Dashboard)(unsafe.Pointer(&in.Items))
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]dashboard.Dashboard, len(*in))
for i := range *in {
if err := Convert_v0alpha1_Dashboard_To_dashboard_Dashboard(&(*in)[i], &(*out)[i], s); err != nil {
return err
}
}
} else {
out.Items = nil
}
return nil
}
@@ -285,7 +310,17 @@ func Convert_v0alpha1_DashboardList_To_dashboard_DashboardList(in *DashboardList
func autoConvert_dashboard_DashboardList_To_v0alpha1_DashboardList(in *dashboard.DashboardList, out *DashboardList, s conversion.Scope) error {
out.ListMeta = in.ListMeta
out.Items = *(*[]Dashboard)(unsafe.Pointer(&in.Items))
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]Dashboard, len(*in))
for i := range *in {
if err := Convert_dashboard_Dashboard_To_v0alpha1_Dashboard(&(*in)[i], &(*out)[i], s); err != nil {
return err
}
}
} else {
out.Items = nil
}
return nil
}
@@ -1,67 +0,0 @@
package v1alpha1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
func TestConvertDashboardVersions(t *testing.T) {
dashboardV0Spec := []byte(`{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"type": "dashboard"
}
]
},
"refresh": true,
"description": "",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 11711,
"links": [],
"panels": [],
"preload": false,
"schemaVersion": 39,
"tags": [],
"templating": {
"list": []
},
"timepicker": {},
"timezone": "utc",
"title": "New dashboard",
"uid": "be3ymutzclgqod",
"version": 1,
"weekStart": ""
}`)
object := common.Unstructured{}
err := json.Unmarshal(dashboardV0Spec, &object.Object)
require.NoError(t, err)
result := DashboardSpec{}
// convert v0 to v1, where we should extract the title & all other elements should be copied
err = Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(&object, &result, nil)
require.NoError(t, err)
require.Equal(t, result.Title, "New dashboard")
require.Equal(t, result.Unstructured, object)
require.Equal(t, result.Unstructured.Object["refresh"], "", "schemaVersion migration not applied. refresh should be an empty string")
// now convert back & ensure it is the same
object2 := common.Unstructured{}
err = Convert_v1alpha1_DashboardSpec_To_v0alpha1_Unstructured(&result, &object2, nil)
require.NoError(t, err)
require.Equal(t, object, object2)
}
@@ -11,8 +11,7 @@ import (
url "net/url"
unsafe "unsafe"
datav0alpha1 "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
v0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
v0alpha1 "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
dashboard "github.com/grafana/grafana/pkg/apis/dashboard"
conversion "k8s.io/apimachinery/pkg/conversion"
runtime "k8s.io/apimachinery/pkg/runtime"
@@ -75,6 +74,16 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*DashboardSpec)(nil), (*dashboard.DashboardSpec)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1alpha1_DashboardSpec_To_dashboard_DashboardSpec(a.(*DashboardSpec), b.(*dashboard.DashboardSpec), scope)
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*dashboard.DashboardSpec)(nil), (*DashboardSpec)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_dashboard_DashboardSpec_To_v1alpha1_DashboardSpec(a.(*dashboard.DashboardSpec), b.(*DashboardSpec), scope)
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*DashboardVersionInfo)(nil), (*dashboard.DashboardVersionInfo)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1alpha1_DashboardVersionInfo_To_dashboard_DashboardVersionInfo(a.(*DashboardVersionInfo), b.(*dashboard.DashboardVersionInfo), scope)
}); err != nil {
@@ -160,16 +169,6 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v0alpha1.Unstructured)(nil), (*DashboardSpec)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(a.(*v0alpha1.Unstructured), b.(*DashboardSpec), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*DashboardSpec)(nil), (*v0alpha1.Unstructured)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1alpha1_DashboardSpec_To_v0alpha1_Unstructured(a.(*DashboardSpec), b.(*v0alpha1.Unstructured), scope)
}); err != nil {
return err
}
return nil
}
@@ -229,7 +228,7 @@ func Convert_dashboard_AnnotationPermission_To_v1alpha1_AnnotationPermission(in
func autoConvert_v1alpha1_Dashboard_To_dashboard_Dashboard(in *Dashboard, out *dashboard.Dashboard, s conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
if err := Convert_v1alpha1_DashboardSpec_To_v0alpha1_Unstructured(&in.Spec, &out.Spec, s); err != nil {
if err := Convert_v1alpha1_DashboardSpec_To_dashboard_DashboardSpec(&in.Spec, &out.Spec, s); err != nil {
return err
}
return nil
@@ -242,7 +241,7 @@ func Convert_v1alpha1_Dashboard_To_dashboard_Dashboard(in *Dashboard, out *dashb
func autoConvert_dashboard_Dashboard_To_v1alpha1_Dashboard(in *dashboard.Dashboard, out *Dashboard, s conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
if err := Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(&in.Spec, &out.Spec, s); err != nil {
if err := Convert_dashboard_DashboardSpec_To_v1alpha1_DashboardSpec(&in.Spec, &out.Spec, s); err != nil {
return err
}
return nil
@@ -289,17 +288,7 @@ func Convert_dashboard_DashboardAccess_To_v1alpha1_DashboardAccess(in *dashboard
func autoConvert_v1alpha1_DashboardList_To_dashboard_DashboardList(in *DashboardList, out *dashboard.DashboardList, s conversion.Scope) error {
out.ListMeta = in.ListMeta
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]dashboard.Dashboard, len(*in))
for i := range *in {
if err := Convert_v1alpha1_Dashboard_To_dashboard_Dashboard(&(*in)[i], &(*out)[i], s); err != nil {
return err
}
}
} else {
out.Items = nil
}
out.Items = *(*[]dashboard.Dashboard)(unsafe.Pointer(&in.Items))
return nil
}
@@ -310,17 +299,7 @@ func Convert_v1alpha1_DashboardList_To_dashboard_DashboardList(in *DashboardList
func autoConvert_dashboard_DashboardList_To_v1alpha1_DashboardList(in *dashboard.DashboardList, out *DashboardList, s conversion.Scope) error {
out.ListMeta = in.ListMeta
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]Dashboard, len(*in))
for i := range *in {
if err := Convert_dashboard_Dashboard_To_v1alpha1_Dashboard(&(*in)[i], &(*out)[i], s); err != nil {
return err
}
}
} else {
out.Items = nil
}
out.Items = *(*[]Dashboard)(unsafe.Pointer(&in.Items))
return nil
}
@@ -329,6 +308,28 @@ func Convert_dashboard_DashboardList_To_v1alpha1_DashboardList(in *dashboard.Das
return autoConvert_dashboard_DashboardList_To_v1alpha1_DashboardList(in, out, s)
}
func autoConvert_v1alpha1_DashboardSpec_To_dashboard_DashboardSpec(in *DashboardSpec, out *dashboard.DashboardSpec, s conversion.Scope) error {
out.Title = in.Title
out.Unstructured = in.Unstructured
return nil
}
// Convert_v1alpha1_DashboardSpec_To_dashboard_DashboardSpec is an autogenerated conversion function.
func Convert_v1alpha1_DashboardSpec_To_dashboard_DashboardSpec(in *DashboardSpec, out *dashboard.DashboardSpec, s conversion.Scope) error {
return autoConvert_v1alpha1_DashboardSpec_To_dashboard_DashboardSpec(in, out, s)
}
func autoConvert_dashboard_DashboardSpec_To_v1alpha1_DashboardSpec(in *dashboard.DashboardSpec, out *DashboardSpec, s conversion.Scope) error {
out.Title = in.Title
out.Unstructured = in.Unstructured
return nil
}
// Convert_dashboard_DashboardSpec_To_v1alpha1_DashboardSpec is an autogenerated conversion function.
func Convert_dashboard_DashboardSpec_To_v1alpha1_DashboardSpec(in *dashboard.DashboardSpec, out *DashboardSpec, s conversion.Scope) error {
return autoConvert_dashboard_DashboardSpec_To_v1alpha1_DashboardSpec(in, out, s)
}
func autoConvert_v1alpha1_DashboardVersionInfo_To_dashboard_DashboardVersionInfo(in *DashboardVersionInfo, out *dashboard.DashboardVersionInfo, s conversion.Scope) error {
out.Version = in.Version
out.ParentVersion = in.ParentVersion
@@ -466,8 +467,8 @@ func autoConvert_v1alpha1_LibraryPanelSpec_To_dashboard_LibraryPanelSpec(in *Lib
out.Description = in.Description
out.Options = in.Options
out.FieldConfig = in.FieldConfig
out.Datasource = (*datav0alpha1.DataSourceRef)(unsafe.Pointer(in.Datasource))
out.Targets = *(*[]datav0alpha1.DataQuery)(unsafe.Pointer(&in.Targets))
out.Datasource = (*v0alpha1.DataSourceRef)(unsafe.Pointer(in.Datasource))
out.Targets = *(*[]v0alpha1.DataQuery)(unsafe.Pointer(&in.Targets))
return nil
}
@@ -483,8 +484,8 @@ func autoConvert_dashboard_LibraryPanelSpec_To_v1alpha1_LibraryPanelSpec(in *das
out.Description = in.Description
out.Options = in.Options
out.FieldConfig = in.FieldConfig
out.Datasource = (*datav0alpha1.DataSourceRef)(unsafe.Pointer(in.Datasource))
out.Targets = *(*[]datav0alpha1.DataQuery)(unsafe.Pointer(&in.Targets))
out.Datasource = (*v0alpha1.DataSourceRef)(unsafe.Pointer(in.Datasource))
out.Targets = *(*[]v0alpha1.DataQuery)(unsafe.Pointer(&in.Targets))
return nil
}
-34
View File
@@ -1,34 +0,0 @@
package v2alpha1
import (
conversion "k8s.io/apimachinery/pkg/conversion"
klog "k8s.io/klog/v2"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/dashboard/migration"
"github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
)
func Convert_v0alpha1_Unstructured_To_v2alpha1_DashboardSpec(in *common.Unstructured, out *DashboardSpec, s conversion.Scope) error {
out.Unstructured = *in
err := migration.Migrate(out.Unstructured.Object, schemaversion.LATEST_VERSION)
if err != nil {
return err
}
t, ok := out.Unstructured.Object["title"].(string)
if !ok {
klog.V(5).Infof("unstructured dashboard title field is not a string %v", t)
return nil // skip setting the title if it's not a string in the unstructured object
}
out.Title = t
return nil
}
func Convert_v2alpha1_DashboardSpec_To_v0alpha1_Unstructured(in *DashboardSpec, out *common.Unstructured, s conversion.Scope) error {
*out = in.Unstructured
if in.Title != "" {
out.Object["title"] = in.Title
}
return nil
}
@@ -11,8 +11,7 @@ import (
url "net/url"
unsafe "unsafe"
datav0alpha1 "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
v0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
v0alpha1 "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
dashboard "github.com/grafana/grafana/pkg/apis/dashboard"
conversion "k8s.io/apimachinery/pkg/conversion"
runtime "k8s.io/apimachinery/pkg/runtime"
@@ -75,6 +74,16 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*DashboardSpec)(nil), (*dashboard.DashboardSpec)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v2alpha1_DashboardSpec_To_dashboard_DashboardSpec(a.(*DashboardSpec), b.(*dashboard.DashboardSpec), scope)
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*dashboard.DashboardSpec)(nil), (*DashboardSpec)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_dashboard_DashboardSpec_To_v2alpha1_DashboardSpec(a.(*dashboard.DashboardSpec), b.(*DashboardSpec), scope)
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*DashboardVersionInfo)(nil), (*dashboard.DashboardVersionInfo)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v2alpha1_DashboardVersionInfo_To_dashboard_DashboardVersionInfo(a.(*DashboardVersionInfo), b.(*dashboard.DashboardVersionInfo), scope)
}); err != nil {
@@ -160,16 +169,6 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddConversionFunc((*v0alpha1.Unstructured)(nil), (*DashboardSpec)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v0alpha1_Unstructured_To_v2alpha1_DashboardSpec(a.(*v0alpha1.Unstructured), b.(*DashboardSpec), scope)
}); err != nil {
return err
}
if err := s.AddConversionFunc((*DashboardSpec)(nil), (*v0alpha1.Unstructured)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v2alpha1_DashboardSpec_To_v0alpha1_Unstructured(a.(*DashboardSpec), b.(*v0alpha1.Unstructured), scope)
}); err != nil {
return err
}
return nil
}
@@ -229,7 +228,7 @@ func Convert_dashboard_AnnotationPermission_To_v2alpha1_AnnotationPermission(in
func autoConvert_v2alpha1_Dashboard_To_dashboard_Dashboard(in *Dashboard, out *dashboard.Dashboard, s conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
if err := Convert_v2alpha1_DashboardSpec_To_v0alpha1_Unstructured(&in.Spec, &out.Spec, s); err != nil {
if err := Convert_v2alpha1_DashboardSpec_To_dashboard_DashboardSpec(&in.Spec, &out.Spec, s); err != nil {
return err
}
return nil
@@ -242,7 +241,7 @@ func Convert_v2alpha1_Dashboard_To_dashboard_Dashboard(in *Dashboard, out *dashb
func autoConvert_dashboard_Dashboard_To_v2alpha1_Dashboard(in *dashboard.Dashboard, out *Dashboard, s conversion.Scope) error {
out.ObjectMeta = in.ObjectMeta
if err := Convert_v0alpha1_Unstructured_To_v2alpha1_DashboardSpec(&in.Spec, &out.Spec, s); err != nil {
if err := Convert_dashboard_DashboardSpec_To_v2alpha1_DashboardSpec(&in.Spec, &out.Spec, s); err != nil {
return err
}
return nil
@@ -289,17 +288,7 @@ func Convert_dashboard_DashboardAccess_To_v2alpha1_DashboardAccess(in *dashboard
func autoConvert_v2alpha1_DashboardList_To_dashboard_DashboardList(in *DashboardList, out *dashboard.DashboardList, s conversion.Scope) error {
out.ListMeta = in.ListMeta
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]dashboard.Dashboard, len(*in))
for i := range *in {
if err := Convert_v2alpha1_Dashboard_To_dashboard_Dashboard(&(*in)[i], &(*out)[i], s); err != nil {
return err
}
}
} else {
out.Items = nil
}
out.Items = *(*[]dashboard.Dashboard)(unsafe.Pointer(&in.Items))
return nil
}
@@ -310,17 +299,7 @@ func Convert_v2alpha1_DashboardList_To_dashboard_DashboardList(in *DashboardList
func autoConvert_dashboard_DashboardList_To_v2alpha1_DashboardList(in *dashboard.DashboardList, out *DashboardList, s conversion.Scope) error {
out.ListMeta = in.ListMeta
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]Dashboard, len(*in))
for i := range *in {
if err := Convert_dashboard_Dashboard_To_v2alpha1_Dashboard(&(*in)[i], &(*out)[i], s); err != nil {
return err
}
}
} else {
out.Items = nil
}
out.Items = *(*[]Dashboard)(unsafe.Pointer(&in.Items))
return nil
}
@@ -329,6 +308,28 @@ func Convert_dashboard_DashboardList_To_v2alpha1_DashboardList(in *dashboard.Das
return autoConvert_dashboard_DashboardList_To_v2alpha1_DashboardList(in, out, s)
}
func autoConvert_v2alpha1_DashboardSpec_To_dashboard_DashboardSpec(in *DashboardSpec, out *dashboard.DashboardSpec, s conversion.Scope) error {
out.Title = in.Title
out.Unstructured = in.Unstructured
return nil
}
// Convert_v2alpha1_DashboardSpec_To_dashboard_DashboardSpec is an autogenerated conversion function.
func Convert_v2alpha1_DashboardSpec_To_dashboard_DashboardSpec(in *DashboardSpec, out *dashboard.DashboardSpec, s conversion.Scope) error {
return autoConvert_v2alpha1_DashboardSpec_To_dashboard_DashboardSpec(in, out, s)
}
func autoConvert_dashboard_DashboardSpec_To_v2alpha1_DashboardSpec(in *dashboard.DashboardSpec, out *DashboardSpec, s conversion.Scope) error {
out.Title = in.Title
out.Unstructured = in.Unstructured
return nil
}
// Convert_dashboard_DashboardSpec_To_v2alpha1_DashboardSpec is an autogenerated conversion function.
func Convert_dashboard_DashboardSpec_To_v2alpha1_DashboardSpec(in *dashboard.DashboardSpec, out *DashboardSpec, s conversion.Scope) error {
return autoConvert_dashboard_DashboardSpec_To_v2alpha1_DashboardSpec(in, out, s)
}
func autoConvert_v2alpha1_DashboardVersionInfo_To_dashboard_DashboardVersionInfo(in *DashboardVersionInfo, out *dashboard.DashboardVersionInfo, s conversion.Scope) error {
out.Version = in.Version
out.ParentVersion = in.ParentVersion
@@ -466,8 +467,8 @@ func autoConvert_v2alpha1_LibraryPanelSpec_To_dashboard_LibraryPanelSpec(in *Lib
out.Description = in.Description
out.Options = in.Options
out.FieldConfig = in.FieldConfig
out.Datasource = (*datav0alpha1.DataSourceRef)(unsafe.Pointer(in.Datasource))
out.Targets = *(*[]datav0alpha1.DataQuery)(unsafe.Pointer(&in.Targets))
out.Datasource = (*v0alpha1.DataSourceRef)(unsafe.Pointer(in.Datasource))
out.Targets = *(*[]v0alpha1.DataQuery)(unsafe.Pointer(&in.Targets))
return nil
}
@@ -483,8 +484,8 @@ func autoConvert_dashboard_LibraryPanelSpec_To_v2alpha1_LibraryPanelSpec(in *das
out.Description = in.Description
out.Options = in.Options
out.FieldConfig = in.FieldConfig
out.Datasource = (*datav0alpha1.DataSourceRef)(unsafe.Pointer(in.Datasource))
out.Targets = *(*[]datav0alpha1.DataQuery)(unsafe.Pointer(&in.Targets))
out.Datasource = (*v0alpha1.DataSourceRef)(unsafe.Pointer(in.Datasource))
out.Targets = *(*[]v0alpha1.DataQuery)(unsafe.Pointer(&in.Targets))
return nil
}
@@ -127,6 +127,23 @@ func (in *DashboardList) DeepCopyObject() runtime.Object {
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardSpec) DeepCopyInto(out *DashboardSpec) {
*out = *in
in.Unstructured.DeepCopyInto(&out.Unstructured)
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSpec.
func (in *DashboardSpec) DeepCopy() *DashboardSpec {
if in == nil {
return nil
}
out := new(DashboardSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardVersionInfo) DeepCopyInto(out *DashboardVersionInfo) {
*out = *in
-6
View File
@@ -3,9 +3,6 @@ package apiregistry
import (
"github.com/grafana/grafana/pkg/registry/apis/alerting/notifications"
dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard"
dashboardv0alpha1 "github.com/grafana/grafana/pkg/registry/apis/dashboard/v0alpha1"
dashboardv1alpha1 "github.com/grafana/grafana/pkg/registry/apis/dashboard/v1alpha1"
dashboardv2alpha1 "github.com/grafana/grafana/pkg/registry/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
@@ -22,9 +19,6 @@ type Service struct{}
// and give each builder the chance to register itself with the main server
func ProvideRegistryServiceSink(
_ *dashboardinternal.DashboardsAPIBuilder,
_ *dashboardv0alpha1.DashboardsAPIBuilder,
_ *dashboardv1alpha1.DashboardsAPIBuilder,
_ *dashboardv2alpha1.DashboardsAPIBuilder,
_ *dashboardsnapshot.SnapshotsAPIBuilder,
_ *featuretoggle.FeatureFlagAPIBuilder,
_ *datasource.DataSourceAPIBuilder,
@@ -12,3 +12,7 @@ func ToInternalDashboard(scheme *runtime.Scheme, obj runtime.Object) (*dashboard
}
return dash, nil
}
func FromInternalDashboard(scheme *runtime.Scheme, dash *dashboard.Dashboard, obj runtime.Object) error {
return scheme.Convert(dash, obj, nil)
}
@@ -46,9 +46,7 @@ func TestConvertDashboardVersionsToInternal(t *testing.T) {
Labels: labels,
ResourceVersion: rv,
},
Spec: common.Unstructured{
Object: body,
},
Spec: dashboardinternal.DashboardSpec{Title: title, Unstructured: common.Unstructured{Object: body}},
}
dashV0 := &dashboardv0alpha1.Dashboard{
ObjectMeta: v1.ObjectMeta{
+1 -1
View File
@@ -28,7 +28,7 @@ func NewDashboardLargeObjectSupport(scheme *runtime.Scheme) *apistore.BasicLarge
}
old := dash.Spec.Object
spec := commonV0.Unstructured{Object: make(map[string]any)}
dash.Spec = spec
dash.Spec = dashboard.DashboardSpec{Unstructured: spec}
dash.SetManagedFields(nil) // this could be bigger than the object!
keep := []string{"title", "description", "tags", "schemaVersion"}
+1 -1
View File
@@ -53,7 +53,7 @@ func TestLargeDashboardSupport(t *testing.T) {
small, err := json.MarshalIndent(&dash.Spec, "", " ")
require.NoError(t, err)
require.JSONEq(t, `{
"schemaVersion": 33,
"schemaVersion": 41,
"title": "Panel tests - All panels",
"tags": ["gdev","panel-tests","all-panels"]
}`, string(small))
+1 -6
View File
@@ -10,7 +10,6 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/generic"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/apiserver/pkg/storage"
@@ -25,20 +24,16 @@ type LatestConnector interface {
rest.StorageMetadata
}
func NewLatestConnector(unified resource.ResourceClient, gr schema.GroupResource, opts generic.RESTOptions, scheme *runtime.Scheme) LatestConnector {
func NewLatestConnector(unified resource.ResourceClient, gr schema.GroupResource) LatestConnector {
return &latestREST{
unified: unified,
gr: gr,
opts: opts,
scheme: scheme,
}
}
type latestREST struct {
unified resource.ResourceClient
gr schema.GroupResource
opts generic.RESTOptions
scheme *runtime.Scheme
}
func (l *latestREST) New() runtime.Object {
@@ -17,7 +17,6 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/generic"
)
func TestLatest(t *testing.T) {
@@ -31,7 +30,6 @@ func TestLatest(t *testing.T) {
r := &latestREST{
unified: mockClient,
gr: gr,
opts: generic.RESTOptions{},
}
t.Run("no namespace in context", func(t *testing.T) {
@@ -6,11 +6,13 @@ import (
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/stretchr/testify/require"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
"github.com/grafana/grafana/pkg/services/provisioning"
)
func TestScanRow(t *testing.T) {
@@ -50,8 +52,10 @@ func TestScanRow(t *testing.T) {
require.NotNil(t, row)
require.Equal(t, "Test Dashboard", row.Dash.Name)
require.Equal(t, version, row.RV) // rv should be the dashboard version
require.Equal(t, v0alpha1.Unstructured{
Object: map[string]interface{}{"key": "value"},
require.Equal(t, dashboardinternal.DashboardSpec{
Unstructured: common.Unstructured{
Object: map[string]interface{}{"key": "value"},
},
}, row.Dash.Spec)
require.Equal(t, "default", row.Dash.Namespace)
require.Equal(t, &continueToken{orgId: int64(1), id: id}, row.token)
+263 -30
View File
@@ -4,77 +4,135 @@ import (
"context"
"errors"
"fmt"
"maps"
"path"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/utils"
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
dashboardv1alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1"
dashboardv2alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/prometheus/client_golang/prometheus"
)
var (
_ builder.APIGroupBuilder = (*DashboardsAPIBuilder)(nil)
_ builder.OpenAPIPostProcessor = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupBuilder = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupVersionsProvider = (*DashboardsAPIBuilder)(nil)
_ builder.OpenAPIPostProcessor = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupRouteProvider = (*DashboardsAPIBuilder)(nil)
)
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct {
ProvisioningDashboardService dashboards.DashboardProvisioningService
dashboardService dashboards.DashboardService
features featuremgmt.FeatureToggles
accessControl accesscontrol.AccessControl
legacy *DashboardStorage
unified resource.ResourceClient
dashboardProvisioningService dashboards.DashboardProvisioningService
scheme *runtime.Scheme
search *SearchHandler
log log.Logger
reg prometheus.Registerer
}
func RegisterAPIService(
cfg *setting.Cfg,
features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
dashboardService dashboards.DashboardService,
provisioningDashboardService dashboards.DashboardProvisioningService,
accessControl accesscontrol.AccessControl,
provisioning provisioning.ProvisioningService,
dashStore dashboards.Store,
reg prometheus.Registerer,
sql db.DB,
tracing *tracing.TracingService,
unified resource.ResourceClient,
dual dualwrite.Service,
sorter sort.Service,
) *DashboardsAPIBuilder {
softDelete := features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore)
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
builder := &DashboardsAPIBuilder{
ProvisioningDashboardService: provisioningDashboardService,
log: log.New("grafana-apiserver.dashboards"),
dashboardService: dashboardService,
features: features,
accessControl: accessControl,
unified: unified,
dashboardProvisioningService: provisioningDashboardService,
search: NewSearchHandler(tracing, dual, legacyDashboardSearcher, unified, features),
legacy: &DashboardStorage{
Resource: dashboardinternal.DashboardResourceInfo,
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, softDelete, sorter),
TableConverter: dashboardinternal.DashboardResourceInfo.TableConverter(),
Features: features,
},
reg: reg,
}
apiregistration.RegisterAPI(builder)
return builder
}
func (b *DashboardsAPIBuilder) GetGroupVersion() schema.GroupVersion {
return dashboardinternal.DashboardResourceInfo.GroupVersion()
}
func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return nil // no authorizer
}
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
if err := dashboardinternal.AddToScheme(scheme); err != nil {
return err
}
return scheme.SetVersionPriority(
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
return []schema.GroupVersion{
dashboardv0alpha1.DashboardResourceInfo.GroupVersion(),
dashboardv1alpha1.DashboardResourceInfo.GroupVersion(),
dashboardv2alpha1.DashboardResourceInfo.GroupVersion(),
)
}
}
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
return nil
}
func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return nil
}
func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
return oas, nil
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
b.scheme = scheme
if err := dashboardinternal.AddToScheme(scheme); err != nil {
return err
}
if err := dashboardv0alpha1.AddToScheme(scheme); err != nil {
return err
}
if err := dashboardv1alpha1.AddToScheme(scheme); err != nil {
return err
}
if err := dashboardv2alpha1.AddToScheme(scheme); err != nil {
return err
}
return scheme.SetVersionPriority(b.GetGroupVersions()...)
}
// Validate will prevent deletion of provisioned dashboards, unless the grace period is set to 0, indicating a force deletion
@@ -93,7 +151,7 @@ func (b *DashboardsAPIBuilder) Validate(ctx context.Context, a admission.Attribu
return fmt.Errorf("%v: %w", "failed to parse namespace", err)
}
provisioningData, err := b.ProvisioningDashboardService.GetProvisionedDashboardDataByDashboardUID(ctx, nsInfo.OrgID, a.GetName())
provisioningData, err := b.dashboardProvisioningService.GetProvisionedDashboardDataByDashboardUID(ctx, nsInfo.OrgID, a.GetName())
if err != nil {
if errors.Is(err, dashboards.ErrProvisionedDashboardNotFound) {
return nil
@@ -110,3 +168,178 @@ func (b *DashboardsAPIBuilder) Validate(ctx context.Context, a admission.Attribu
return nil
}
// Mutate removes any internal ID set in the spec & adds it as a label
func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
op := a.GetOperation()
if op != admission.Create && op != admission.Update {
return nil
}
obj := a.GetObject()
dash, ok := obj.(*dashboardinternal.Dashboard)
if !ok {
return fmt.Errorf("mutation error: expected *dashboardinternal.Dashboard, got %T", obj)
}
if id, ok := dash.Spec.Object["id"].(float64); ok {
delete(dash.Spec.Object, "id")
if id != 0 {
meta, err := utils.MetaAccessor(obj)
if err != nil {
return err
}
meta.SetDeprecatedInternalID(int64(id)) // nolint:staticcheck
}
}
return nil
}
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
internalDashResourceInfo := dashboardinternal.DashboardResourceInfo
legacyStore, err := b.legacy.NewStore(opts.Scheme, opts.OptsGetter, b.reg)
if err != nil {
return err
}
storageOpts := apistore.StorageOptions{
RequireDeprecatedInternalID: true,
}
// Split dashboards when they are large
var largeObjects apistore.LargeObjectSupport
if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) {
largeObjects = NewDashboardLargeObjectSupport(opts.Scheme)
storageOpts.LargeObjectSupport = largeObjects
}
opts.StorageOptions(internalDashResourceInfo.GroupResource(), storageOpts)
// v0alpha1
storage, err := b.storageForVersion(opts, legacyStore, largeObjects, func() runtime.Object {
return &dashboardv0alpha1.DashboardWithAccessInfo{}
})
if err != nil {
return err
}
apiGroupInfo.VersionedResourcesStorageMap[dashboardv0alpha1.VERSION] = storage
// v1alpha1
storage, err = b.storageForVersion(opts, legacyStore, largeObjects, func() runtime.Object {
return &dashboardv1alpha1.DashboardWithAccessInfo{}
})
if err != nil {
return err
}
apiGroupInfo.VersionedResourcesStorageMap[dashboardv1alpha1.VERSION] = storage
// v2alpha1
storage, err = b.storageForVersion(opts, legacyStore, largeObjects, func() runtime.Object {
return &dashboardv2alpha1.DashboardWithAccessInfo{}
})
if err != nil {
return err
}
apiGroupInfo.VersionedResourcesStorageMap[dashboardv2alpha1.VERSION] = storage
return nil
}
func (b *DashboardsAPIBuilder) storageForVersion(
opts builder.APIGroupOptions,
legacyStore grafanarest.LegacyStorage,
largeObjects apistore.LargeObjectSupport,
newDTOFunc func() runtime.Object,
) (map[string]rest.Storage, error) {
var (
err error
scheme = opts.Scheme
dualWriteBuilder = opts.DualWriteBuilder
internalDashResourceInfo = dashboardinternal.DashboardResourceInfo
libraryPanelResourceInfo = dashboardinternal.LibraryPanelResourceInfo
)
storage := map[string]rest.Storage{}
storage[internalDashResourceInfo.StoragePath()] = legacyStore
// Dual writes if a RESTOptionsGetter is provided
if dualWriteBuilder != nil {
store, err := grafanaregistry.NewRegistryStore(scheme, internalDashResourceInfo, opts.OptsGetter)
if err != nil {
return nil, err
}
storage[internalDashResourceInfo.StoragePath()], err = dualWriteBuilder(internalDashResourceInfo.GroupResource(), legacyStore, store)
if err != nil {
return nil, err
}
}
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesRestore) {
storage[internalDashResourceInfo.StoragePath("restore")] = NewRestoreConnector(
b.unified,
internalDashResourceInfo.GroupResource(),
)
storage[internalDashResourceInfo.StoragePath("latest")] = NewLatestConnector(
b.unified,
internalDashResourceInfo.GroupResource(),
)
}
// Register the DTO endpoint that will consolidate all dashboard bits
storage[internalDashResourceInfo.StoragePath("dto")], err = NewDTOConnector(
storage[internalDashResourceInfo.StoragePath()],
largeObjects,
b.legacy.Access,
b.unified,
b.accessControl,
scheme,
newDTOFunc,
)
if err != nil {
return nil, err
}
// Expose read only library panels
storage[libraryPanelResourceInfo.StoragePath()] = &LibraryPanelStore{
Access: b.legacy.Access,
ResourceInfo: dashboardinternal.LibraryPanelResourceInfo,
}
return storage, nil
}
func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
defs := dashboardv0alpha1.GetOpenAPIDefinitions(ref)
maps.Copy(defs, dashboardv1alpha1.GetOpenAPIDefinitions(ref))
maps.Copy(defs, dashboardv2alpha1.GetOpenAPIDefinitions(ref))
return defs
}
}
func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
// The plugin description
oas.Info.Description = "Grafana dashboards as resources"
for _, gv := range b.GetGroupVersions() {
version := gv.Version
// Hide cluster-scoped resources
root := path.Join("/apis/", dashboardinternal.GROUP, version)
delete(oas.Paths.Paths, path.Join(root, "dashboards"))
delete(oas.Paths.Paths, path.Join(root, "watch", "dashboards"))
if version == dashboardv0alpha1.VERSION {
sub := oas.Paths.Paths[path.Join(root, "search", "{name}")]
oas.Paths.Paths[path.Join(root, "search")] = sub
delete(oas.Paths.Paths, path.Join(root, "search", "{name}"))
}
}
return oas, nil
}
func (b *DashboardsAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
return b.search.GetAPIRoutes(defs)
}
+1 -1
View File
@@ -128,7 +128,7 @@ func TestDashboardAPIBuilder_Validate(t *testing.T) {
fakeService := &dashboards.FakeDashboardProvisioning{}
fakeService.On("GetProvisionedDashboardDataByDashboardUID", mock.Anything, mock.Anything, mock.Anything).Return(tt.dashboardResponse, tt.dashboardErrorResponse).Once()
b := &DashboardsAPIBuilder{
ProvisioningDashboardService: fakeService,
dashboardProvisioningService: fakeService,
}
err := b.Validate(context.Background(), admission.NewAttributesRecord(
tt.inputObj,
+1 -4
View File
@@ -11,7 +11,6 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/generic"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/apiserver/pkg/storage"
@@ -25,18 +24,16 @@ type RestoreConnector interface {
rest.StorageMetadata
}
func NewRestoreConnector(unified resource.ResourceClient, gr schema.GroupResource, opts generic.RESTOptions) RestoreConnector {
func NewRestoreConnector(unified resource.ResourceClient, gr schema.GroupResource) RestoreConnector {
return &restoreREST{
unified: unified,
gr: gr,
opts: opts,
}
}
type restoreREST struct {
unified resource.ResourceClient
gr schema.GroupResource
opts generic.RESTOptions
}
func (r *restoreREST) New() runtime.Object {
@@ -17,7 +17,6 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/generic"
)
type mockResourceClient struct {
@@ -58,7 +57,6 @@ func TestRestore(t *testing.T) {
r := &restoreREST{
unified: mockClient,
gr: gr,
opts: generic.RESTOptions{},
}
t.Run("no namespace in context", func(t *testing.T) {
@@ -1,256 +0,0 @@
package v0alpha1
import (
"context"
"fmt"
"github.com/prometheus/client_golang/prometheus"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
"github.com/grafana/grafana/pkg/apimachinery/utils"
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
var (
_ builder.APIGroupBuilder = (*DashboardsAPIBuilder)(nil)
_ builder.OpenAPIPostProcessor = (*DashboardsAPIBuilder)(nil)
)
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct {
dashboard.DashboardsAPIBuilder
dashboardService dashboards.DashboardService
features featuremgmt.FeatureToggles
accessControl accesscontrol.AccessControl
legacy *dashboard.DashboardStorage
search *dashboard.SearchHandler
unified resource.ResourceClient
log log.Logger
reg prometheus.Registerer
}
func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
dashboardService dashboards.DashboardService,
provisioningDashboardService dashboards.DashboardProvisioningService,
accessControl accesscontrol.AccessControl,
provisioning provisioning.ProvisioningService,
dashStore dashboards.Store,
reg prometheus.Registerer,
sql db.DB,
tracing *tracing.TracingService,
unified resource.ResourceClient,
dual dualwrite.Service,
sorter sort.Service,
) *DashboardsAPIBuilder {
softDelete := features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore)
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
builder := &DashboardsAPIBuilder{
log: log.New("grafana-apiserver.dashboards.v0alpha1"),
DashboardsAPIBuilder: dashboard.DashboardsAPIBuilder{
ProvisioningDashboardService: provisioningDashboardService,
},
dashboardService: dashboardService,
features: features,
accessControl: accessControl,
unified: unified,
search: dashboard.NewSearchHandler(tracing, dual, legacyDashboardSearcher, unified, features),
legacy: &dashboard.DashboardStorage{
Resource: dashboardv0alpha1.DashboardResourceInfo,
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, softDelete, sorter),
TableConverter: dashboardv0alpha1.DashboardResourceInfo.TableConverter(),
Features: features,
},
reg: reg,
}
apiregistration.RegisterAPI(builder)
return builder
}
func (b *DashboardsAPIBuilder) GetGroupVersion() schema.GroupVersion {
return dashboardv0alpha1.DashboardResourceInfo.GroupVersion()
}
func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return dashboard.GetAuthorizer(b.dashboardService, b.log)
}
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
return dashboardv0alpha1.AddToScheme(scheme)
}
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
scheme := opts.Scheme
optsGetter := opts.OptsGetter
dualWriteBuilder := opts.DualWriteBuilder
dash := b.legacy.Resource
legacyStore, err := b.legacy.NewStore(scheme, optsGetter, b.reg)
if err != nil {
return err
}
defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{})
if err != nil {
return err
}
storageOpts := apistore.StorageOptions{
RequireDeprecatedInternalID: true,
InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) {
internal := &dashboardinternal.Dashboard{}
obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal)
if err != nil {
return nil, err
}
err = scheme.Convert(obj, desiredObj, nil)
return desiredObj, err
}),
}
// Split dashboards when they are large
var largeObjects apistore.LargeObjectSupport
if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) {
largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme)
storageOpts.LargeObjectSupport = largeObjects
}
opts.StorageOptions(dash.GroupResource(), storageOpts)
storage := map[string]rest.Storage{}
storage[dash.StoragePath()] = legacyStore
// Dual writes if a RESTOptionsGetter is provided
if dualWriteBuilder != nil {
store, err := grafanaregistry.NewRegistryStore(scheme, dash, optsGetter)
if err != nil {
return err
}
storage[dash.StoragePath()], err = dualWriteBuilder(dash.GroupResource(), legacyStore, store)
if err != nil {
return err
}
}
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesRestore) {
storage[dash.StoragePath("restore")] = dashboard.NewRestoreConnector(
b.unified,
dashboardv0alpha1.DashboardResourceInfo.GroupResource(),
defaultOpts,
)
storage[dash.StoragePath("latest")] = dashboard.NewLatestConnector(
b.unified,
dashboardv0alpha1.DashboardResourceInfo.GroupResource(),
defaultOpts,
scheme,
)
}
// Register the DTO endpoint that will consolidate all dashboard bits
storage[dash.StoragePath("dto")], err = dashboard.NewDTOConnector(
storage[dash.StoragePath()],
largeObjects,
b.legacy.Access,
b.unified,
b.accessControl,
scheme,
func() runtime.Object { return &dashboardv0alpha1.DashboardWithAccessInfo{} },
)
if err != nil {
return err
}
// Expose read only library panels
storage[dashboardv0alpha1.LibraryPanelResourceInfo.StoragePath()] = &dashboard.LibraryPanelStore{
Access: b.legacy.Access,
ResourceInfo: dashboardv0alpha1.LibraryPanelResourceInfo,
}
apiGroupInfo.VersionedResourcesStorageMap[dashboardv0alpha1.VERSION] = storage
return nil
}
func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return dashboardv0alpha1.GetOpenAPIDefinitions
}
func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
// The plugin description
oas.Info.Description = "Grafana dashboards as resources"
// The root api URL
root := "/apis/" + b.GetGroupVersion().String() + "/"
// Hide the ability to list or watch across all tenants
delete(oas.Paths.Paths, root+dashboardv0alpha1.DashboardResourceInfo.GroupResource().Resource)
delete(oas.Paths.Paths, root+"watch/"+dashboardv0alpha1.DashboardResourceInfo.GroupResource().Resource)
// Resolve the empty name
sub := oas.Paths.Paths[root+"search/{name}"]
oas.Paths.Paths[root+"search"] = sub
delete(oas.Paths.Paths, root+"search/{name}")
return oas, nil
}
func (b *DashboardsAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
return b.search.GetAPIRoutes(defs)
}
// Mutate removes any internal ID set in the spec & adds it as a label
func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
op := a.GetOperation()
if op == admission.Create || op == admission.Update {
obj := a.GetObject()
dash, ok := obj.(*dashboardv0alpha1.Dashboard)
if !ok {
return fmt.Errorf("expected v0alpha1 dashboard")
}
if id, ok := dash.Spec.Object["id"].(float64); ok {
delete(dash.Spec.Object, "id")
if id != 0 {
meta, err := utils.MetaAccessor(obj)
if err != nil {
return err
}
meta.SetDeprecatedInternalID(int64(id)) // nolint:staticcheck
}
}
}
return nil
}
@@ -1,162 +0,0 @@
package v0alpha1
import (
"context"
"testing"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/services/user"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/admission"
)
func TestDashboardAPIBuilder_Mutate(t *testing.T) {
tests := []struct {
name string
verb string
input *v0alpha1.Dashboard
expected *v0alpha1.Dashboard
}{
{
name: "should remove id and add as label in create",
verb: "CREATE",
input: &v0alpha1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"id": float64(1),
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v0alpha1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{"grafana.app/deprecatedInternalID": "1"},
},
},
},
{
name: "should remove id and add as label in update",
verb: "UPDATE",
input: &v0alpha1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"id": float64(1),
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v0alpha1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{"grafana.app/deprecatedInternalID": "1"},
},
},
},
{
name: "should only remove id ",
verb: "UPDATE",
input: &v0alpha1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"id": float64(1),
"testing": "this",
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v0alpha1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"testing": "this",
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{"grafana.app/deprecatedInternalID": "1"},
},
},
},
{
name: "should not set label if id is 0",
verb: "CREATE",
input: &v0alpha1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"id": float64(0),
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v0alpha1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
},
}
b := &DashboardsAPIBuilder{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := b.Mutate(context.Background(), admission.NewAttributesRecord(
tt.input,
nil,
v0alpha1.DashboardResourceInfo.GroupVersionKind(),
"stacks-123",
tt.input.Name,
v0alpha1.DashboardResourceInfo.GroupVersionResource(),
"",
admission.Operation(tt.verb),
nil,
true,
&user.SignedInUser{},
), nil)
require.NoError(t, err)
require.Equal(t, tt.expected, tt.input)
})
}
}
@@ -1,239 +0,0 @@
package v1alpha1
import (
"context"
"fmt"
"github.com/prometheus/client_golang/prometheus"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"github.com/grafana/grafana/pkg/apimachinery/utils"
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
dashboardv1alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
var (
_ builder.APIGroupBuilder = (*DashboardsAPIBuilder)(nil)
_ builder.OpenAPIPostProcessor = (*DashboardsAPIBuilder)(nil)
)
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct {
dashboard.DashboardsAPIBuilder
dashboardService dashboards.DashboardService
features featuremgmt.FeatureToggles
accessControl accesscontrol.AccessControl
legacy *dashboard.DashboardStorage
unified resource.ResourceClient
log log.Logger
reg prometheus.Registerer
}
func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
dashboardService dashboards.DashboardService,
provisioningDashboardService dashboards.DashboardProvisioningService,
accessControl accesscontrol.AccessControl,
provisioning provisioning.ProvisioningService,
dashStore dashboards.Store,
reg prometheus.Registerer,
sql db.DB,
tracing *tracing.TracingService,
unified resource.ResourceClient,
sorter sort.Service,
) *DashboardsAPIBuilder {
softDelete := features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore)
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
builder := &DashboardsAPIBuilder{
log: log.New("grafana-apiserver.dashboards.v1alpha1"),
DashboardsAPIBuilder: dashboard.DashboardsAPIBuilder{
ProvisioningDashboardService: provisioningDashboardService,
},
dashboardService: dashboardService,
features: features,
accessControl: accessControl,
unified: unified,
legacy: &dashboard.DashboardStorage{
Resource: dashboardv1alpha1.DashboardResourceInfo,
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, softDelete, sorter),
TableConverter: dashboardv1alpha1.DashboardResourceInfo.TableConverter(),
Features: features,
},
reg: reg,
}
apiregistration.RegisterAPI(builder)
return builder
}
func (b *DashboardsAPIBuilder) GetGroupVersion() schema.GroupVersion {
return dashboardv1alpha1.DashboardResourceInfo.GroupVersion()
}
func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return dashboard.GetAuthorizer(b.dashboardService, b.log)
}
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
return dashboardv1alpha1.AddToScheme(scheme)
}
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
scheme := opts.Scheme
optsGetter := opts.OptsGetter
dualWriteBuilder := opts.DualWriteBuilder
dash := b.legacy.Resource
legacyStore, err := b.legacy.NewStore(scheme, optsGetter, b.reg)
if err != nil {
return err
}
defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{})
if err != nil {
return err
}
storageOpts := apistore.StorageOptions{
RequireDeprecatedInternalID: true,
InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) {
internal := &dashboardinternal.Dashboard{}
obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal)
if err != nil {
return nil, err
}
err = scheme.Convert(obj, desiredObj, nil)
return desiredObj, err
}),
}
// Split dashboards when they are large
var largeObjects apistore.LargeObjectSupport
if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) {
largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme)
storageOpts.LargeObjectSupport = largeObjects
}
opts.StorageOptions(dash.GroupResource(), storageOpts)
storage := map[string]rest.Storage{}
storage[dash.StoragePath()] = legacyStore
// Dual writes if a RESTOptionsGetter is provided
if dualWriteBuilder != nil {
store, err := grafanaregistry.NewRegistryStore(scheme, dash, optsGetter)
if err != nil {
return err
}
storage[dash.StoragePath()], err = dualWriteBuilder(dash.GroupResource(), legacyStore, store)
if err != nil {
return err
}
}
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesRestore) {
storage[dash.StoragePath("restore")] = dashboard.NewRestoreConnector(
b.unified,
dashboardv1alpha1.DashboardResourceInfo.GroupResource(),
defaultOpts,
)
storage[dash.StoragePath("latest")] = dashboard.NewLatestConnector(
b.unified,
dashboardv1alpha1.DashboardResourceInfo.GroupResource(),
defaultOpts,
scheme,
)
}
// Register the DTO endpoint that will consolidate all dashboard bits
storage[dash.StoragePath("dto")], err = dashboard.NewDTOConnector(
storage[dash.StoragePath()],
largeObjects,
b.legacy.Access,
b.unified,
b.accessControl,
scheme,
func() runtime.Object { return &dashboardv1alpha1.DashboardWithAccessInfo{} },
)
if err != nil {
return err
}
// Expose read only library panels
storage[dashboardv1alpha1.LibraryPanelResourceInfo.StoragePath()] = &dashboard.LibraryPanelStore{
Access: b.legacy.Access,
ResourceInfo: dashboardv1alpha1.LibraryPanelResourceInfo,
}
apiGroupInfo.VersionedResourcesStorageMap[dashboardv1alpha1.VERSION] = storage
return nil
}
func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return dashboardv1alpha1.GetOpenAPIDefinitions
}
func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
// The plugin description
oas.Info.Description = "Grafana dashboards as resources"
// The root api URL
root := "/apis/" + b.GetGroupVersion().String() + "/"
// Hide the ability to list or watch across all tenants
delete(oas.Paths.Paths, root+dashboardv1alpha1.DashboardResourceInfo.GroupResource().Resource)
delete(oas.Paths.Paths, root+"watch/"+dashboardv1alpha1.DashboardResourceInfo.GroupResource().Resource)
return oas, nil
}
// Mutate removes any internal ID set in the spec & adds it as a label
func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
op := a.GetOperation()
if op == admission.Create || op == admission.Update {
obj := a.GetObject()
dash, ok := obj.(*dashboardv1alpha1.Dashboard)
if !ok {
return fmt.Errorf("expected v1alpha1 dashboard")
}
if id, ok := dash.Spec.Object["id"].(float64); ok {
delete(dash.Spec.Object, "id")
if id != 0 {
meta, err := utils.MetaAccessor(obj)
if err != nil {
return err
}
meta.SetDeprecatedInternalID(int64(id)) // nolint:staticcheck
}
}
}
return nil
}
@@ -1,186 +0,0 @@
package v1alpha1
import (
"context"
"testing"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/pkg/services/user"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/admission"
)
func TestDashboardAPIBuilder_Mutate(t *testing.T) {
tests := []struct {
name string
verb string
input *v1alpha1.Dashboard
expected *v1alpha1.Dashboard
}{
{
name: "should remove id and add as label in create",
verb: "CREATE",
input: &v1alpha1.Dashboard{
Spec: v1alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{
"id": float64(1),
},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v1alpha1.Dashboard{
Spec: v1alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{"grafana.app/deprecatedInternalID": "1"},
},
},
},
{
name: "should remove id and add as label in update",
verb: "UPDATE",
input: &v1alpha1.Dashboard{
Spec: v1alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{
"id": float64(1),
},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v1alpha1.Dashboard{
Spec: v1alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{"grafana.app/deprecatedInternalID": "1"},
},
},
},
{
name: "should only remove id ",
verb: "UPDATE",
input: &v1alpha1.Dashboard{
Spec: v1alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{
"id": float64(1),
"testing": "this",
},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v1alpha1.Dashboard{
Spec: v1alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{
"testing": "this",
},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{"grafana.app/deprecatedInternalID": "1"},
},
},
},
{
name: "should not set label if id is 0",
verb: "CREATE",
input: &v1alpha1.Dashboard{
Spec: v1alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{
"id": float64(0),
},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v1alpha1.Dashboard{
Spec: v1alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
},
}
b := &DashboardsAPIBuilder{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := b.Mutate(context.Background(), admission.NewAttributesRecord(
tt.input,
nil,
v1alpha1.DashboardResourceInfo.GroupVersionKind(),
"stacks-123",
tt.input.Name,
v1alpha1.DashboardResourceInfo.GroupVersionResource(),
"",
admission.Operation(tt.verb),
nil,
true,
&user.SignedInUser{},
), nil)
require.NoError(t, err)
require.Equal(t, tt.expected, tt.input)
})
}
}
@@ -1,239 +0,0 @@
package v2alpha1
import (
"context"
"fmt"
"github.com/prometheus/client_golang/prometheus"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"github.com/grafana/grafana/pkg/apimachinery/utils"
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
dashboardv2alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
var (
_ builder.APIGroupBuilder = (*DashboardsAPIBuilder)(nil)
_ builder.OpenAPIPostProcessor = (*DashboardsAPIBuilder)(nil)
)
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct {
dashboard.DashboardsAPIBuilder
dashboardService dashboards.DashboardService
features featuremgmt.FeatureToggles
accessControl accesscontrol.AccessControl
legacy *dashboard.DashboardStorage
unified resource.ResourceClient
log log.Logger
reg prometheus.Registerer
}
func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
dashboardService dashboards.DashboardService,
provisioningDashboardService dashboards.DashboardProvisioningService,
accessControl accesscontrol.AccessControl,
provisioning provisioning.ProvisioningService,
dashStore dashboards.Store,
reg prometheus.Registerer,
sql db.DB,
tracing *tracing.TracingService,
unified resource.ResourceClient,
sorter sort.Service,
) *DashboardsAPIBuilder {
softDelete := features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore)
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
builder := &DashboardsAPIBuilder{
log: log.New("grafana-apiserver.dashboards.v2alpha1"),
DashboardsAPIBuilder: dashboard.DashboardsAPIBuilder{
ProvisioningDashboardService: provisioningDashboardService,
},
dashboardService: dashboardService,
features: features,
accessControl: accessControl,
unified: unified,
legacy: &dashboard.DashboardStorage{
Resource: dashboardv2alpha1.DashboardResourceInfo,
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, softDelete, sorter),
TableConverter: dashboardv2alpha1.DashboardResourceInfo.TableConverter(),
Features: features,
},
reg: reg,
}
apiregistration.RegisterAPI(builder)
return builder
}
func (b *DashboardsAPIBuilder) GetGroupVersion() schema.GroupVersion {
return dashboardv2alpha1.DashboardResourceInfo.GroupVersion()
}
func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return dashboard.GetAuthorizer(b.dashboardService, b.log)
}
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
return dashboardv2alpha1.AddToScheme(scheme)
}
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
scheme := opts.Scheme
optsGetter := opts.OptsGetter
dualWriteBuilder := opts.DualWriteBuilder
dash := b.legacy.Resource
legacyStore, err := b.legacy.NewStore(scheme, optsGetter, b.reg)
if err != nil {
return err
}
defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{})
if err != nil {
return err
}
storageOpts := apistore.StorageOptions{
RequireDeprecatedInternalID: true,
InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) {
internal := &dashboardinternal.Dashboard{}
obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal)
if err != nil {
return nil, err
}
err = scheme.Convert(obj, desiredObj, nil)
return desiredObj, err
}),
}
// Split dashboards when they are large
var largeObjects apistore.LargeObjectSupport
if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) {
largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme)
storageOpts.LargeObjectSupport = largeObjects
}
opts.StorageOptions(dash.GroupResource(), storageOpts)
storage := map[string]rest.Storage{}
storage[dash.StoragePath()] = legacyStore
// Dual writes if a RESTOptionsGetter is provided
if dualWriteBuilder != nil {
store, err := grafanaregistry.NewRegistryStore(scheme, dash, optsGetter)
if err != nil {
return err
}
storage[dash.StoragePath()], err = dualWriteBuilder(dash.GroupResource(), legacyStore, store)
if err != nil {
return err
}
}
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesRestore) {
storage[dash.StoragePath("restore")] = dashboard.NewRestoreConnector(
b.unified,
dashboardv2alpha1.DashboardResourceInfo.GroupResource(),
defaultOpts,
)
storage[dash.StoragePath("latest")] = dashboard.NewLatestConnector(
b.unified,
dashboardv2alpha1.DashboardResourceInfo.GroupResource(),
defaultOpts,
scheme,
)
}
// Register the DTO endpoint that will consolidate all dashboard bits
storage[dash.StoragePath("dto")], err = dashboard.NewDTOConnector(
storage[dash.StoragePath()],
largeObjects,
b.legacy.Access,
b.unified,
b.accessControl,
scheme,
func() runtime.Object { return &dashboardv2alpha1.DashboardWithAccessInfo{} },
)
if err != nil {
return err
}
// Expose read only library panels
storage[dashboardv2alpha1.LibraryPanelResourceInfo.StoragePath()] = &dashboard.LibraryPanelStore{
Access: b.legacy.Access,
ResourceInfo: dashboardv2alpha1.LibraryPanelResourceInfo,
}
apiGroupInfo.VersionedResourcesStorageMap[dashboardv2alpha1.VERSION] = storage
return nil
}
func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return dashboardv2alpha1.GetOpenAPIDefinitions
}
func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
// The plugin description
oas.Info.Description = "Grafana dashboards as resources"
// The root api URL
root := "/apis/" + b.GetGroupVersion().String() + "/"
// Hide the ability to list or watch across all tenants
delete(oas.Paths.Paths, root+dashboardv2alpha1.DashboardResourceInfo.GroupResource().Resource)
return oas, nil
}
// Mutate removes any internal ID set in the spec & adds it as a label
func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
op := a.GetOperation()
if op == admission.Create || op == admission.Update {
obj := a.GetObject()
dash, ok := obj.(*dashboardv2alpha1.Dashboard)
if !ok {
return fmt.Errorf("expected v2alpha1 dashboard")
}
if id, ok := dash.Spec.Object["id"].(float64); ok {
delete(dash.Spec.Object, "id")
if id != 0 {
meta, err := utils.MetaAccessor(obj)
if err != nil {
return err
}
meta.SetDeprecatedInternalID(int64(id)) // nolint:staticcheck
}
}
}
return nil
}
@@ -1,186 +0,0 @@
package v2alpha1
import (
"context"
"testing"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/pkg/services/user"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/admission"
)
func TestDashboardAPIBuilder_Mutate(t *testing.T) {
tests := []struct {
name string
verb string
input *v2alpha1.Dashboard
expected *v2alpha1.Dashboard
}{
{
name: "should remove id and add as label in create",
verb: "CREATE",
input: &v2alpha1.Dashboard{
Spec: v2alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{
"id": float64(1),
},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v2alpha1.Dashboard{
Spec: v2alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{"grafana.app/deprecatedInternalID": "1"},
},
},
},
{
name: "should remove id and add as label in update",
verb: "UPDATE",
input: &v2alpha1.Dashboard{
Spec: v2alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{
"id": float64(1),
},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v2alpha1.Dashboard{
Spec: v2alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{"grafana.app/deprecatedInternalID": "1"},
},
},
},
{
name: "should only remove id ",
verb: "UPDATE",
input: &v2alpha1.Dashboard{
Spec: v2alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{
"id": float64(1),
"testing": "this",
},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v2alpha1.Dashboard{
Spec: v2alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{
"testing": "this",
},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{"grafana.app/deprecatedInternalID": "1"},
},
},
},
{
name: "should not set label if id is 0",
verb: "CREATE",
input: &v2alpha1.Dashboard{
Spec: v2alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{
"id": float64(0),
},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
expected: &v2alpha1.Dashboard{
Spec: v2alpha1.DashboardSpec{
Title: "test",
Unstructured: common.Unstructured{
Object: map[string]interface{}{},
},
},
TypeMeta: metav1.TypeMeta{
Kind: "Dashboard",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
},
}
b := &DashboardsAPIBuilder{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := b.Mutate(context.Background(), admission.NewAttributesRecord(
tt.input,
nil,
v2alpha1.DashboardResourceInfo.GroupVersionKind(),
"stacks-123",
tt.input.Name,
v2alpha1.DashboardResourceInfo.GroupVersionResource(),
"",
admission.Operation(tt.verb),
nil,
true,
&user.SignedInUser{},
), nil)
require.NoError(t, err)
require.Equal(t, tt.expected, tt.input)
})
}
}
-6
View File
@@ -5,9 +5,6 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/alerting/notifications"
dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard"
dashboardv0alpha1 "github.com/grafana/grafana/pkg/registry/apis/dashboard/v0alpha1"
dashboardv1alpha1 "github.com/grafana/grafana/pkg/registry/apis/dashboard/v1alpha1"
dashboardv2alpha1 "github.com/grafana/grafana/pkg/registry/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
@@ -30,9 +27,6 @@ var WireSet = wire.NewSet(
// Each must be added here *and* in the ServiceSink above
dashboardinternal.RegisterAPIService,
dashboardv0alpha1.RegisterAPIService,
dashboardv1alpha1.RegisterAPIService,
dashboardv2alpha1.RegisterAPIService,
dashboardsnapshot.RegisterAPIService,
featuretoggle.RegisterAPIService,
datasource.RegisterAPIService,
-7
View File
@@ -750,13 +750,6 @@ var (
Owner: grafanaOperatorExperienceSquad,
FrontendOnly: false,
},
{
Name: "prometheusPromQAIL",
Description: "Prometheus and AI/ML to assist users in creating a query",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaOSSBigTent,
},
{
Name: "prometheusCodeModeMetricNamesSearch",
Description: "Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names",
@@ -228,7 +228,6 @@ pluginsInstrumentationStatusSource,2023-10-17T08:27:45Z,2024-02-21T11:57:40Z,f50
teamHttpHeaders,2023-10-17T10:23:54Z,,be5ba6813209b5b24e955e0f761032cb5826b578,Eric Leijonmarck
costManagementUi,2023-10-17T16:15:51Z,2024-01-08T14:25:11Z,de1ed216f4bbf6f341aa22b144fa66d583a63981,Adam Bannach
managedPluginsInstall,2023-10-18T13:17:03Z,,43add83d1a84fe0945860a9cc700df92c7e8341f,Hugo Kiyodi Oshiro
prometheusPromQAIL,2023-10-19T15:45:32Z,,5580d061019bee46ea2e69c94041f3da14585ceb,Brendan O'Handley
cloudWatchBatchQueries,2023-10-20T19:09:41Z,,ecbc52f51529e1f35e26895db1a10f8a1c2f4244,Isabella Siu
alertingContactPointsV2,2023-10-25T13:57:53Z,2023-11-30T12:37:14Z,e12e40fc2493160338237b0b94e72fa530a78ef4,Gilles De Mey
alertmanagerRemoteOnly,2023-10-30T16:27:08Z,,363830883cb1f5de30f7015df5cba419df47468e,Santiago
1 #name created deleted hash author
228 teamHttpHeaders 2023-10-17T10:23:54Z be5ba6813209b5b24e955e0f761032cb5826b578 Eric Leijonmarck
229 costManagementUi 2023-10-17T16:15:51Z 2024-01-08T14:25:11Z de1ed216f4bbf6f341aa22b144fa66d583a63981 Adam Bannach
230 managedPluginsInstall 2023-10-18T13:17:03Z 43add83d1a84fe0945860a9cc700df92c7e8341f Hugo Kiyodi Oshiro
prometheusPromQAIL 2023-10-19T15:45:32Z 5580d061019bee46ea2e69c94041f3da14585ceb Brendan O'Handley
231 cloudWatchBatchQueries 2023-10-20T19:09:41Z ecbc52f51529e1f35e26895db1a10f8a1c2f4244 Isabella Siu
232 alertingContactPointsV2 2023-10-25T13:57:53Z 2023-11-30T12:37:14Z e12e40fc2493160338237b0b94e72fa530a78ef4 Gilles De Mey
233 alertmanagerRemoteOnly 2023-10-30T16:27:08Z 363830883cb1f5de30f7015df5cba419df47468e Santiago
-1
View File
@@ -99,7 +99,6 @@ cloudWatchBatchQueries,preview,@grafana/aws-datasources,false,false,false
recoveryThreshold,GA,@grafana/alerting-squad,false,true,false
lokiStructuredMetadata,GA,@grafana/observability-logs,false,false,false
cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,false,false,false
prometheusPromQAIL,experimental,@grafana/oss-big-tent,false,false,true
prometheusCodeModeMetricNamesSearch,experimental,@grafana/oss-big-tent,false,false,true
addFieldFromCalculationStatFunctions,GA,@grafana/dataviz-squad,false,false,true
alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
99 recoveryThreshold GA @grafana/alerting-squad false true false
100 lokiStructuredMetadata GA @grafana/observability-logs false false false
101 cachingOptimizeSerializationMemoryUsage experimental @grafana/grafana-operator-experience-squad false false false
prometheusPromQAIL experimental @grafana/oss-big-tent false false true
102 prometheusCodeModeMetricNamesSearch experimental @grafana/oss-big-tent false false true
103 addFieldFromCalculationStatFunctions GA @grafana/dataviz-squad false false true
104 alertmanagerRemoteSecondary experimental @grafana/alerting-squad false false false
-4
View File
@@ -407,10 +407,6 @@ const (
// If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses.
FlagCachingOptimizeSerializationMemoryUsage = "cachingOptimizeSerializationMemoryUsage"
// FlagPrometheusPromQAIL
// Prometheus and AI/ML to assist users in creating a query
FlagPrometheusPromQAIL = "prometheusPromQAIL"
// FlagPrometheusCodeModeMetricNamesSearch
// Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names
FlagPrometheusCodeModeMetricNamesSearch = "prometheusCodeModeMetricNamesSearch"
-16
View File
@@ -3310,22 +3310,6 @@
"expression": "true"
}
},
{
"metadata": {
"name": "prometheusPromQAIL",
"resourceVersion": "1735845919509",
"creationTimestamp": "2023-10-19T15:45:32Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-01-02 19:25:19.509884 +0000 UTC"
}
},
"spec": {
"description": "Prometheus and AI/ML to assist users in creating a query",
"stage": "experimental",
"codeowner": "@grafana/oss-big-tent",
"frontend": true
}
},
{
"metadata": {
"name": "prometheusRunQueriesInParallel",
+10 -2
View File
@@ -295,8 +295,8 @@ type Cfg struct {
// DistributedCache
RemoteCacheOptions *RemoteCacheSettings
ViewersCanEdit bool
EditorsCanAdmin bool
ViewersCanEdit bool // Deprecated: no longer used
EditorsCanAdmin bool // Deprecated: no longer used
ApiKeyMaxSecondsToLive int64
@@ -1732,8 +1732,16 @@ func readUserSettings(iniFile *ini.File, cfg *Cfg) error {
cfg.ExternalUserMngAnalytics = users.Key("external_manage_analytics").MustBool(false)
cfg.ExternalUserMngAnalyticsParams = valueAsString(users, "external_manage_analytics_params", "")
// Deprecated
cfg.ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false)
if cfg.ViewersCanEdit {
cfg.Logger.Warn("[Deprecated] The viewers_can_edit configuration setting is deprecated. Please upgrade viewers to editors.")
}
// Deprecated
cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false)
if cfg.EditorsCanAdmin {
cfg.Logger.Warn("[Deprecated] The editors_can_admin configuration setting is deprecated. Please upgrade editors to admin.")
}
userInviteMaxLifetimeVal := valueAsString(users, "user_invite_max_lifetime_duration", "24h")
userInviteMaxLifetimeDuration, err := gtime.ParseDuration(userInviteMaxLifetimeVal)
+17 -25
View File
@@ -6,6 +6,7 @@
package apistore
import (
"bytes"
"context"
"errors"
"fmt"
@@ -47,7 +48,6 @@ var _ storage.Interface = (*Storage)(nil)
// Optional settings that apply to a single resource
type StorageOptions struct {
LargeObjectSupport LargeObjectSupport
InternalConversion func([]byte, runtime.Object) (runtime.Object, error)
RequireDeprecatedInternalID bool
}
@@ -148,9 +148,6 @@ func (s *Storage) Versioner() storage.Versioner {
}
func (s *Storage) convertToObject(data []byte, obj runtime.Object) (runtime.Object, error) {
if s.opts.InternalConversion != nil {
return s.opts.InternalConversion(data, obj)
}
obj, _, err := s.codec.Decode(data, nil, obj)
return obj, err
}
@@ -180,7 +177,7 @@ func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, ou
return resource.GetError(rsp.Error)
}
if err := copyModifiedObjectToDestination(obj, out); err != nil {
if _, err := s.convertToObject(req.Value, out); err != nil {
return err
}
@@ -294,7 +291,7 @@ func (s *Storage) Watch(ctx context.Context, key string, opts storage.ListOption
}
reporter := apierrors.NewClientErrorReporter(500, "WATCH", "")
decoder := newStreamDecoder(client, s.newFunc, predicate, s.codec, cancelWatch, s.opts.InternalConversion)
decoder := newStreamDecoder(client, s.newFunc, predicate, s.codec, cancelWatch)
return watch.NewStreamWatcher(decoder, reporter), nil
}
@@ -538,15 +535,22 @@ func (s *Storage) GuaranteedUpdate(
}
if unchanged {
if err := copyModifiedObjectToDestination(updatedObj, destination); err != nil {
var buf bytes.Buffer
if err = s.codec.Encode(updatedObj, &buf); err != nil {
return err
}
if _, err := s.convertToObject(buf.Bytes(), destination); err != nil {
return err
}
return nil
}
rv := int64(0)
var (
value []byte
rv int64
)
if created {
value, err := s.prepareObjectForStorage(ctx, updatedObj)
value, err = s.prepareObjectForStorage(ctx, updatedObj)
if err != nil {
return err
}
@@ -562,10 +566,11 @@ func (s *Storage) GuaranteedUpdate(
}
rv = rsp2.ResourceVersion
} else {
req.Value, err = s.prepareObjectForUpdate(ctx, updatedObj, existingObj)
value, err = s.prepareObjectForUpdate(ctx, updatedObj, existingObj)
if err != nil {
return err
}
req.Value = value
rsp2, err := s.store.Update(ctx, req)
if err != nil {
return resource.GetError(resource.AsErrorResult(err))
@@ -576,11 +581,11 @@ func (s *Storage) GuaranteedUpdate(
rv = rsp2.ResourceVersion
}
if err := s.versioner.UpdateObject(updatedObj, uint64(rv)); err != nil {
if _, err := s.convertToObject(value, destination); err != nil {
return err
}
if err := copyModifiedObjectToDestination(updatedObj, destination); err != nil {
if err := s.versioner.UpdateObject(destination, uint64(rv)); err != nil {
return err
}
@@ -634,16 +639,3 @@ func (s *Storage) validateMinimumResourceVersion(minimumResourceVersion string,
}
return nil
}
func copyModifiedObjectToDestination(updatedObj runtime.Object, destination runtime.Object) error {
u, err := conversion.EnforcePtr(updatedObj)
if err != nil {
return fmt.Errorf("unable to enforce updated object pointer: %w", err)
}
d, err := conversion.EnforcePtr(destination)
if err != nil {
return fmt.Errorf("unable to enforce destination pointer: %w", err)
}
d.Set(u)
return nil
}
+13 -19
View File
@@ -19,33 +19,27 @@ import (
)
type streamDecoder struct {
client resource.ResourceStore_WatchClient
newFunc func() runtime.Object
predicate storage.SelectionPredicate
codec runtime.Codec
cancelWatch context.CancelFunc
done sync.WaitGroup
internalConversion func([]byte, runtime.Object) (runtime.Object, error)
client resource.ResourceStore_WatchClient
newFunc func() runtime.Object
predicate storage.SelectionPredicate
codec runtime.Codec
cancelWatch context.CancelFunc
done sync.WaitGroup
}
func newStreamDecoder(client resource.ResourceStore_WatchClient, newFunc func() runtime.Object, predicate storage.SelectionPredicate, codec runtime.Codec, cancelWatch context.CancelFunc, internalConversion func([]byte, runtime.Object) (runtime.Object, error)) *streamDecoder {
func newStreamDecoder(client resource.ResourceStore_WatchClient, newFunc func() runtime.Object, predicate storage.SelectionPredicate, codec runtime.Codec, cancelWatch context.CancelFunc) *streamDecoder {
return &streamDecoder{
client: client,
newFunc: newFunc,
predicate: predicate,
codec: codec,
cancelWatch: cancelWatch,
internalConversion: internalConversion,
client: client,
newFunc: newFunc,
predicate: predicate,
codec: codec,
cancelWatch: cancelWatch,
}
}
func (d *streamDecoder) toObject(w *resource.WatchEvent_Resource) (runtime.Object, error) {
var obj runtime.Object
var err error
if d.internalConversion != nil {
obj, err = d.internalConversion(w.Value, d.newFunc())
} else {
obj, _, err = d.codec.Decode(w.Value, nil, d.newFunc())
}
obj, _, err = d.codec.Decode(w.Value, nil, d.newFunc())
if err == nil {
accessor, err := utils.MetaAccessor(obj)
if err != nil {
@@ -1,34 +0,0 @@
package apistore
import (
"testing"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
)
func TestStreamDecoder(t *testing.T) {
t.Run("toObject should handle internal conversion", func(t *testing.T) {
called := false
internalConversion := func(data []byte, obj runtime.Object) (runtime.Object, error) {
called = true
return obj, nil
}
decoder := &streamDecoder{
newFunc: func() runtime.Object { return &unstructured.Unstructured{} },
internalConversion: internalConversion,
}
event := &resource.WatchEvent_Resource{
Value: []byte("test"),
}
obj, err := decoder.toObject(event)
require.NoError(t, err)
require.NotNil(t, obj)
require.True(t, called, "internal conversion function should have been called")
})
}
+90 -141
View File
@@ -39,6 +39,7 @@ type BackendOptions struct {
Tracer trace.Tracer
PollingInterval time.Duration
WatchBufferSize int
IsHA bool
}
func NewBackend(opts BackendOptions) (Backend, error) {
@@ -50,26 +51,29 @@ func NewBackend(opts BackendOptions) (Backend, error) {
}
ctx, cancel := context.WithCancel(context.Background())
pollingInterval := opts.PollingInterval
if pollingInterval == 0 {
pollingInterval = defaultPollingInterval
if opts.PollingInterval == 0 {
opts.PollingInterval = defaultPollingInterval
}
if opts.WatchBufferSize == 0 {
opts.WatchBufferSize = defaultWatchBufferSize
}
return &backend{
isHA: opts.IsHA,
done: ctx.Done(),
cancel: cancel,
log: log.New("sql-resource-server"),
tracer: opts.Tracer,
dbProvider: opts.DBProvider,
pollingInterval: pollingInterval,
pollingInterval: opts.PollingInterval,
watchBufferSize: opts.WatchBufferSize,
batchLock: &batchLock{running: make(map[string]bool)},
}, nil
}
type backend struct {
//general
isHA bool
// server lifecycle
done <-chan struct{}
cancel context.CancelFunc
@@ -90,6 +94,7 @@ type backend struct {
//stream chan *resource.WatchEvent
pollingInterval time.Duration
watchBufferSize int
notifier eventNotifier
}
func (b *backend) Init(ctx context.Context) error {
@@ -112,6 +117,13 @@ func (b *backend) initLocked(ctx context.Context) error {
return fmt.Errorf("no dialect for driver %q", driverName)
}
// Initialize notifier after dialect is set up
notifier, err := newNotifier(b)
if err != nil {
return fmt.Errorf("failed to create notifier: %w", err)
}
b.notifier = notifier
return b.db.PingContext(ctx)
}
@@ -187,11 +199,11 @@ func (b *backend) create(ctx context.Context, event resource.WriteEvent) (int64,
defer span.End()
var newVersion int64
guid := uuid.New().String()
folder := ""
if event.Object != nil {
folder = event.Object.GetFolder()
}
err := b.db.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
folder := ""
if event.Object != nil {
folder = event.Object.GetFolder()
}
// 1. Insert into resource
if _, err := dbutil.Exec(ctx, tx, sqlResourceInsert, sqlResourceRequest{
SQLTemplate: sqltemplate.New(b.dialect),
@@ -240,7 +252,21 @@ func (b *backend) create(ctx context.Context, event resource.WriteEvent) (int64,
return nil
})
return newVersion, err
if err != nil {
return 0, err
}
b.notifier.send(ctx, &resource.WrittenEvent{
Type: event.Type,
Key: event.Key,
PreviousRV: event.PreviousRV,
Value: event.Value,
ResourceVersion: newVersion,
Folder: folder,
})
return newVersion, nil
}
func (b *backend) update(ctx context.Context, event resource.WriteEvent) (int64, error) {
@@ -248,11 +274,11 @@ func (b *backend) update(ctx context.Context, event resource.WriteEvent) (int64,
defer span.End()
var newVersion int64
guid := uuid.New().String()
folder := ""
if event.Object != nil {
folder = event.Object.GetFolder()
}
err := b.db.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
folder := ""
if event.Object != nil {
folder = event.Object.GetFolder()
}
// 1. Update resource
_, err := dbutil.Exec(ctx, tx, sqlResourceUpdate, sqlResourceRequest{
SQLTemplate: sqltemplate.New(b.dialect),
@@ -303,7 +329,20 @@ func (b *backend) update(ctx context.Context, event resource.WriteEvent) (int64,
return nil
})
return newVersion, err
if err != nil {
return 0, err
}
b.notifier.send(ctx, &resource.WrittenEvent{
Type: event.Type,
Key: event.Key,
PreviousRV: event.PreviousRV,
Value: event.Value,
ResourceVersion: newVersion,
Folder: folder,
})
return newVersion, nil
}
func (b *backend) delete(ctx context.Context, event resource.WriteEvent) (int64, error) {
@@ -311,12 +350,11 @@ func (b *backend) delete(ctx context.Context, event resource.WriteEvent) (int64,
defer span.End()
var newVersion int64
guid := uuid.New().String()
folder := ""
if event.Object != nil {
folder = event.Object.GetFolder()
}
err := b.db.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
folder := ""
if event.Object != nil {
folder = event.Object.GetFolder()
}
// 1. delete from resource
_, err := dbutil.Exec(ctx, tx, sqlResourceDelete, sqlResourceRequest{
SQLTemplate: sqltemplate.New(b.dialect),
@@ -358,7 +396,20 @@ func (b *backend) delete(ctx context.Context, event resource.WriteEvent) (int64,
return nil
})
return newVersion, err
if err != nil {
return 0, err
}
b.notifier.send(ctx, &resource.WrittenEvent{
Type: event.Type,
Key: event.Key,
PreviousRV: event.PreviousRV,
Value: event.Value,
ResourceVersion: newVersion,
Folder: folder,
})
return newVersion, nil
}
func (b *backend) restore(ctx context.Context, event resource.WriteEvent) (int64, error) {
@@ -366,12 +417,11 @@ func (b *backend) restore(ctx context.Context, event resource.WriteEvent) (int64
defer span.End()
var newVersion int64
guid := uuid.New().String()
folder := ""
if event.Object != nil {
folder = event.Object.GetFolder()
}
err := b.db.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
folder := ""
if event.Object != nil {
folder = event.Object.GetFolder()
}
// 1. Re-create resource
// Note: we may want to replace the write event with a create event, tbd.
if _, err := dbutil.Exec(ctx, tx, sqlResourceInsert, sqlResourceRequest{
@@ -435,7 +485,20 @@ func (b *backend) restore(ctx context.Context, event resource.WriteEvent) (int64
return nil
})
return newVersion, err
if err != nil {
return 0, err
}
b.notifier.send(ctx, &resource.WrittenEvent{
Type: event.Type,
Key: event.Key,
PreviousRV: event.PreviousRV,
Value: event.Value,
ResourceVersion: newVersion,
Folder: folder,
})
return newVersion, nil
}
func (b *backend) ReadResource(ctx context.Context, req *resource.ReadRequest) *resource.BackendReadResponse {
@@ -707,68 +770,7 @@ func (b *backend) getHistory(ctx context.Context, req *resource.ListRequest, cb
}
func (b *backend) WatchWriteEvents(ctx context.Context) (<-chan *resource.WrittenEvent, error) {
// Get the latest RV
since, err := b.listLatestRVs(ctx)
if err != nil {
return nil, fmt.Errorf("watch, get latest resource version: %w", err)
}
// Start the poller
stream := make(chan *resource.WrittenEvent, b.watchBufferSize)
go b.poller(ctx, since, stream)
return stream, nil
}
func (b *backend) poller(ctx context.Context, since groupResourceRV, stream chan<- *resource.WrittenEvent) {
t := time.NewTicker(b.pollingInterval)
defer close(stream)
defer t.Stop()
isSQLite := b.dialect.DialectName() == "sqlite"
for {
select {
case <-b.done:
return
case <-t.C:
// Block polling duffing import to avoid database locked issues
if isSQLite && b.batchLock.Active() {
continue
}
ctx, span := b.tracer.Start(ctx, tracePrefix+"poller")
// List the latest RVs
grv, err := b.listLatestRVs(ctx)
if err != nil {
b.log.Error("poller get latest resource version", "err", err)
t.Reset(b.pollingInterval)
continue
}
for group, items := range grv {
for resource := range items {
// If we haven't seen this resource before, we start from 0
if _, ok := since[group]; !ok {
since[group] = make(map[string]int64)
}
if _, ok := since[group][resource]; !ok {
since[group][resource] = 0
}
// Poll for new events
next, err := b.poll(ctx, group, resource, since[group][resource], stream)
if err != nil {
b.log.Error("polling for resource", "err", err)
t.Reset(b.pollingInterval)
continue
}
if next > since[group][resource] {
since[group][resource] = next
}
}
}
t.Reset(b.pollingInterval)
span.End()
}
}
return b.notifier.notify(ctx)
}
// listLatestRVs returns the latest resource version for each (Group, Resource) pair.
@@ -817,59 +819,6 @@ func fetchLatestRV(ctx context.Context, x db.ContextExecer, d sqltemplate.Dialec
return res.ResourceVersion, nil
}
func (b *backend) poll(ctx context.Context, grp string, res string, since int64, stream chan<- *resource.WrittenEvent) (int64, error) {
ctx, span := b.tracer.Start(ctx, tracePrefix+"poll")
defer span.End()
start := time.Now()
var records []*historyPollResponse
err := b.db.WithTx(ctx, ReadCommittedRO, func(ctx context.Context, tx db.Tx) error {
var err error
records, err = dbutil.Query(ctx, tx, sqlResourceHistoryPoll, &sqlResourceHistoryPollRequest{
SQLTemplate: sqltemplate.New(b.dialect),
Resource: res,
Group: grp,
SinceResourceVersion: since,
Response: &historyPollResponse{},
})
return err
})
if err != nil {
return 0, fmt.Errorf("poll history: %w", err)
}
end := time.Now()
resource.NewStorageMetrics().PollerLatency.Observe(end.Sub(start).Seconds())
var nextRV int64
for _, rec := range records {
if rec.Key.Group == "" || rec.Key.Resource == "" || rec.Key.Name == "" {
return nextRV, fmt.Errorf("missing key in response")
}
nextRV = rec.ResourceVersion
prevRV := rec.PreviousRV
if prevRV == nil {
prevRV = new(int64)
}
stream <- &resource.WrittenEvent{
Value: rec.Value,
Key: &resource.ResourceKey{
Namespace: rec.Key.Namespace,
Group: rec.Key.Group,
Resource: rec.Key.Resource,
Name: rec.Key.Name,
},
Type: resource.WatchEvent_Type(rec.Action),
PreviousRV: *prevRV,
Folder: rec.Folder,
ResourceVersion: rec.ResourceVersion,
// Timestamp: , // TODO: add timestamp
}
b.log.Debug("poller sent event to stream", "namespace", rec.Key.Namespace, "group", rec.Key.Group, "resource", rec.Key.Resource, "name", rec.Key.Name, "action", rec.Action, "rv", rec.ResourceVersion)
}
return nextRV, nil
}
// resourceVersionAtomicInc atomically increases the version of a kind within a transaction.
// TODO: Ideally we should attempt to update the RV in the resource and resource_history tables
// in a single roundtrip. This would reduce the latency of the operation, and also increase the
+119
View File
@@ -0,0 +1,119 @@
package sql
import (
"context"
"sync"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
"github.com/grafana/grafana/pkg/storage/unified/sql/dbutil"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
type eventNotifier interface {
notify(ctx context.Context) (<-chan *resource.WrittenEvent, error)
// send will forward an event to all subscribers who want to be notified.
//
// Note: depending on the implementation, send might be noop and new events
// will be fetched from an external source.
send(ctx context.Context, event *resource.WrittenEvent)
close()
}
func newNotifier(b *backend) (eventNotifier, error) {
if b.isHA {
b.log.Info("Using polling notifier")
notifier, err := newPollingNotifier(&pollingNotifierConfig{
pollingInterval: b.pollingInterval,
watchBufferSize: b.watchBufferSize,
log: b.log,
tracer: b.tracer,
batchLock: b.batchLock,
listLatestRVs: b.listLatestRVs,
historyPoll: func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
var records []*historyPollResponse
err := b.db.WithTx(ctx, ReadCommittedRO, func(ctx context.Context, tx db.Tx) error {
var err error
records, err = dbutil.Query(ctx, tx, sqlResourceHistoryPoll, &sqlResourceHistoryPollRequest{
SQLTemplate: sqltemplate.New(b.dialect),
Resource: res,
Group: grp,
SinceResourceVersion: since,
Response: &historyPollResponse{},
})
return err
})
return records, err
},
done: b.done,
dialect: b.dialect,
})
if err != nil {
return nil, err
}
return notifier, nil
}
b.log.Info("Using channel notifier")
return newChannelNotifier(b.watchBufferSize, b.log), nil
}
type channelNotifier struct {
log log.Logger
bufferSize int
mu sync.RWMutex
subscribers map[chan *resource.WrittenEvent]bool
}
func newChannelNotifier(bufferSize int, log log.Logger) *channelNotifier {
return &channelNotifier{
subscribers: make(map[chan *resource.WrittenEvent]bool),
log: log,
bufferSize: bufferSize,
}
}
func (n *channelNotifier) notify(ctx context.Context) (<-chan *resource.WrittenEvent, error) {
events := make(chan *resource.WrittenEvent, n.bufferSize)
n.mu.Lock()
n.subscribers[events] = true
n.mu.Unlock()
go func() {
<-ctx.Done()
n.mu.Lock()
if n.subscribers[events] {
delete(n.subscribers, events)
close(events)
}
n.mu.Unlock()
}()
return events, nil
}
func (n *channelNotifier) send(_ context.Context, event *resource.WrittenEvent) {
n.mu.RLock()
defer n.mu.RUnlock()
for ch := range n.subscribers {
select {
case ch <- event:
default:
n.log.Warn("Dropped event notification for subscriber - channel full")
}
}
}
func (n *channelNotifier) close() {
n.mu.Lock()
defer n.mu.Unlock()
for ch := range n.subscribers {
close(ch)
}
n.subscribers = make(map[chan *resource.WrittenEvent]bool)
}
+216
View File
@@ -0,0 +1,216 @@
package sql
import (
"context"
"fmt"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
"go.opentelemetry.io/otel/trace"
)
var (
// Validation errors.
errHistoryPollRequired = fmt.Errorf("historyPoll is required")
errListLatestRVsRequired = fmt.Errorf("listLatestRVs is required")
errBatchLockRequired = fmt.Errorf("batchLock is required")
errTracerRequired = fmt.Errorf("tracer is required")
errLogRequired = fmt.Errorf("log is required")
errInvalidWatchBufferSize = fmt.Errorf("watchBufferSize must be greater than 0")
errInvalidPollingInterval = fmt.Errorf("pollingInterval must be greater than 0")
errDoneRequired = fmt.Errorf("done is required")
errDialectRequired = fmt.Errorf("dialect is required")
)
// pollingNotifier is a notifier that polls the database for new events.
type pollingNotifier struct {
dialect sqltemplate.Dialect
pollingInterval time.Duration
watchBufferSize int
log log.Logger
tracer trace.Tracer
batchLock *batchLock
listLatestRVs func(ctx context.Context) (groupResourceRV, error)
historyPoll func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error)
done <-chan struct{}
}
type pollingNotifierConfig struct {
dialect sqltemplate.Dialect
pollingInterval time.Duration
watchBufferSize int
log log.Logger
tracer trace.Tracer
batchLock *batchLock
listLatestRVs func(ctx context.Context) (groupResourceRV, error)
historyPoll func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error)
done <-chan struct{}
}
func (cfg *pollingNotifierConfig) validate() error {
if cfg.historyPoll == nil {
return errHistoryPollRequired
}
if cfg.listLatestRVs == nil {
return errListLatestRVsRequired
}
if cfg.batchLock == nil {
return errBatchLockRequired
}
if cfg.tracer == nil {
return errTracerRequired
}
if cfg.log == nil {
return errLogRequired
}
if cfg.watchBufferSize <= 0 {
return errInvalidWatchBufferSize
}
if cfg.pollingInterval <= 0 {
return errInvalidPollingInterval
}
if cfg.done == nil {
return errDoneRequired
}
if cfg.dialect == nil {
return errDialectRequired
}
return nil
}
func newPollingNotifier(cfg *pollingNotifierConfig) (*pollingNotifier, error) {
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid polling notifier config: %w", err)
}
return &pollingNotifier{
dialect: cfg.dialect,
pollingInterval: cfg.pollingInterval,
watchBufferSize: cfg.watchBufferSize,
log: cfg.log,
tracer: cfg.tracer,
batchLock: cfg.batchLock,
listLatestRVs: cfg.listLatestRVs,
historyPoll: cfg.historyPoll,
done: cfg.done,
}, nil
}
func (p *pollingNotifier) notify(ctx context.Context) (<-chan *resource.WrittenEvent, error) {
since, err := p.listLatestRVs(ctx)
if err != nil {
return nil, fmt.Errorf("watch, get latest resource version: %w", err)
}
stream := make(chan *resource.WrittenEvent, p.watchBufferSize)
go p.poller(ctx, since, stream)
return stream, nil
}
func (p *pollingNotifier) poller(ctx context.Context, since groupResourceRV, stream chan<- *resource.WrittenEvent) {
t := time.NewTicker(p.pollingInterval)
defer close(stream)
defer t.Stop()
for {
select {
case <-p.done:
return
case <-t.C:
ctx, span := p.tracer.Start(ctx, tracePrefix+"poller")
// List the latest RVs to see if any of those are not have been seen before.
grv, err := p.listLatestRVs(ctx)
if err != nil {
p.log.Error("poller get latest resource version", "err", err)
t.Reset(p.pollingInterval)
continue
}
for group, items := range grv {
for resource := range items {
// If we haven't seen this resource before, we start from 0.
if _, ok := since[group]; !ok {
since[group] = make(map[string]int64)
}
if _, ok := since[group][resource]; !ok {
since[group][resource] = 0
}
// Poll for new events.
next, err := p.poll(ctx, group, resource, since[group][resource], stream)
if err != nil {
p.log.Error("polling for resource", "err", err)
t.Reset(p.pollingInterval)
continue
}
if next > since[group][resource] {
since[group][resource] = next
}
}
}
t.Reset(p.pollingInterval)
span.End()
}
}
}
func (p *pollingNotifier) poll(ctx context.Context, grp string, res string, since int64, stream chan<- *resource.WrittenEvent) (int64, error) {
ctx, span := p.tracer.Start(ctx, tracePrefix+"poll")
defer span.End()
start := time.Now()
records, err := p.historyPoll(ctx, grp, res, since)
if err != nil {
return 0, fmt.Errorf("poll history: %w", err)
}
resource.NewStorageMetrics().PollerLatency.Observe(time.Since(start).Seconds())
var nextRV int64
for _, rec := range records {
if rec.Key.Group == "" || rec.Key.Resource == "" || rec.Key.Name == "" {
return nextRV, fmt.Errorf("missing key in response")
}
nextRV = rec.ResourceVersion
prevRV := rec.PreviousRV
if prevRV == nil {
prevRV = new(int64)
}
stream <- &resource.WrittenEvent{
Value: rec.Value,
Key: &resource.ResourceKey{
Namespace: rec.Key.Namespace,
Group: rec.Key.Group,
Resource: rec.Key.Resource,
Name: rec.Key.Name,
},
Type: resource.WatchEvent_Type(rec.Action),
PreviousRV: *prevRV,
Folder: rec.Folder,
ResourceVersion: rec.ResourceVersion,
// Timestamp: , // TODO: add timestamp
}
p.log.Debug("poller sent event to stream",
"namespace", rec.Key.Namespace,
"group", rec.Key.Group,
"resource", rec.Key.Resource,
"name", rec.Key.Name,
"action", rec.Action,
"rv", rec.ResourceVersion)
}
return nextRV, nil
}
func (p *pollingNotifier) send(_ context.Context, _ *resource.WrittenEvent) {
// No-op for polling strategy - changes are detected via polling.
}
func (p *pollingNotifier) close() {
// No-op for polling strategy.
}
@@ -0,0 +1,360 @@
package sql
import (
"context"
"testing"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace/noop"
)
func TestPollingNotifierConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config *pollingNotifierConfig
expectedErr error
}{
{
name: "valid config",
config: &pollingNotifierConfig{
historyPoll: func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
return nil, nil
},
listLatestRVs: func(ctx context.Context) (groupResourceRV, error) { return nil, nil },
batchLock: &batchLock{},
tracer: noop.NewTracerProvider().Tracer("test"),
log: log.NewNopLogger(),
watchBufferSize: 10,
pollingInterval: time.Second,
done: make(chan struct{}),
dialect: sqltemplate.SQLite,
},
expectedErr: nil,
},
{
name: "missing historyPoll",
config: &pollingNotifierConfig{
listLatestRVs: func(ctx context.Context) (groupResourceRV, error) { return nil, nil },
batchLock: &batchLock{},
tracer: noop.NewTracerProvider().Tracer("test"),
log: log.NewNopLogger(),
watchBufferSize: 10,
pollingInterval: time.Second,
done: make(chan struct{}),
dialect: sqltemplate.SQLite,
},
expectedErr: errHistoryPollRequired,
},
{
name: "missing listLatestRVs",
config: &pollingNotifierConfig{
historyPoll: func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
return nil, nil
},
batchLock: &batchLock{},
tracer: noop.NewTracerProvider().Tracer("test"),
log: log.NewNopLogger(),
watchBufferSize: 10,
pollingInterval: time.Second,
done: make(chan struct{}),
dialect: sqltemplate.SQLite,
},
expectedErr: errListLatestRVsRequired,
},
{
name: "missing batchLock",
config: &pollingNotifierConfig{
historyPoll: func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
return nil, nil
},
listLatestRVs: func(ctx context.Context) (groupResourceRV, error) { return nil, nil },
tracer: noop.NewTracerProvider().Tracer("test"),
log: log.NewNopLogger(),
watchBufferSize: 10,
pollingInterval: time.Second,
done: make(chan struct{}),
dialect: sqltemplate.SQLite,
},
expectedErr: errBatchLockRequired,
},
{
name: "missing tracer",
config: &pollingNotifierConfig{
historyPoll: func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
return nil, nil
},
listLatestRVs: func(ctx context.Context) (groupResourceRV, error) { return nil, nil },
batchLock: &batchLock{},
log: log.NewNopLogger(),
watchBufferSize: 10,
pollingInterval: time.Second,
done: make(chan struct{}),
dialect: sqltemplate.SQLite,
},
expectedErr: errTracerRequired,
},
{
name: "missing logger",
config: &pollingNotifierConfig{
historyPoll: func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
return nil, nil
},
listLatestRVs: func(ctx context.Context) (groupResourceRV, error) { return nil, nil },
batchLock: &batchLock{},
tracer: noop.NewTracerProvider().Tracer("test"),
watchBufferSize: 10,
pollingInterval: time.Second,
done: make(chan struct{}),
dialect: sqltemplate.SQLite,
},
expectedErr: errLogRequired,
},
{
name: "invalid watch buffer size",
config: &pollingNotifierConfig{
historyPoll: func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
return nil, nil
},
listLatestRVs: func(ctx context.Context) (groupResourceRV, error) { return nil, nil },
batchLock: &batchLock{},
tracer: noop.NewTracerProvider().Tracer("test"),
log: log.NewNopLogger(),
watchBufferSize: 0,
pollingInterval: time.Second,
done: make(chan struct{}),
dialect: sqltemplate.SQLite,
},
expectedErr: errInvalidWatchBufferSize,
},
{
name: "invalid polling interval",
config: &pollingNotifierConfig{
historyPoll: func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
return nil, nil
},
listLatestRVs: func(ctx context.Context) (groupResourceRV, error) { return nil, nil },
batchLock: &batchLock{},
tracer: noop.NewTracerProvider().Tracer("test"),
log: log.NewNopLogger(),
watchBufferSize: 10,
pollingInterval: 0,
done: make(chan struct{}),
dialect: sqltemplate.SQLite,
},
expectedErr: errInvalidPollingInterval,
},
{
name: "missing done channel",
config: &pollingNotifierConfig{
historyPoll: func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
return nil, nil
},
listLatestRVs: func(ctx context.Context) (groupResourceRV, error) { return nil, nil },
batchLock: &batchLock{},
tracer: noop.NewTracerProvider().Tracer("test"),
log: log.NewNopLogger(),
watchBufferSize: 10,
pollingInterval: time.Second,
dialect: sqltemplate.SQLite,
},
expectedErr: errDoneRequired,
},
{
name: "missing dialect",
config: &pollingNotifierConfig{
historyPoll: func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
return nil, nil
},
listLatestRVs: func(ctx context.Context) (groupResourceRV, error) { return nil, nil },
batchLock: &batchLock{},
tracer: noop.NewTracerProvider().Tracer("test"),
log: log.NewNopLogger(),
watchBufferSize: 10,
pollingInterval: time.Second,
done: make(chan struct{}),
},
expectedErr: errDialectRequired,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.config.validate()
if tt.expectedErr != nil {
require.ErrorIs(t, err, tt.expectedErr)
} else {
require.NoError(t, err)
}
})
}
}
func TestPollingNotifier(t *testing.T) {
t.Parallel()
t.Run("notify returns channel and starts polling", func(t *testing.T) {
t.Parallel()
done := make(chan struct{})
defer close(done)
testEvent := &historyPollResponse{
Key: resource.ResourceKey{
Namespace: "test-ns",
Group: "test-group",
Resource: "test-resource",
Name: "test-name",
},
ResourceVersion: 2,
Folder: "test-folder",
Value: []byte(`{"test": "data"}`),
Action: 1,
}
var latestRVsCalled bool
listLatestRVs := func(ctx context.Context) (groupResourceRV, error) {
latestRVsCalled = true
return groupResourceRV{
"test-group": map[string]int64{
"test-resource": 0,
},
}, nil
}
var historyPollCalled bool
historyPoll := func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
historyPollCalled = true
require.Equal(t, "test-group", grp)
require.Equal(t, "test-resource", res)
require.Equal(t, int64(0), since)
return []*historyPollResponse{testEvent}, nil
}
cfg := &pollingNotifierConfig{
dialect: sqltemplate.SQLite,
pollingInterval: 10 * time.Millisecond,
watchBufferSize: 10,
log: log.NewNopLogger(),
tracer: noop.NewTracerProvider().Tracer("test"),
batchLock: &batchLock{},
listLatestRVs: listLatestRVs,
historyPoll: historyPoll,
done: done,
}
notifier, err := newPollingNotifier(cfg)
require.NoError(t, err)
require.NotNil(t, notifier)
events, err := notifier.notify(context.Background())
require.NoError(t, err)
require.NotNil(t, events)
select {
case event := <-events:
require.NotNil(t, event)
require.Equal(t, "test-ns", event.Key.Namespace)
require.Equal(t, "test-group", event.Key.Group)
require.Equal(t, "test-resource", event.Key.Resource)
require.Equal(t, "test-name", event.Key.Name)
require.Equal(t, int64(2), event.ResourceVersion)
require.Equal(t, "test-folder", event.Folder)
require.True(t, latestRVsCalled, "listLatestRVs should be called")
require.True(t, historyPollCalled, "historyPoll should be called")
case <-time.After(100 * time.Millisecond):
t.Fatal("timeout waiting for event")
}
})
t.Run("handles polling errors gracefully", func(t *testing.T) {
t.Parallel()
done := make(chan struct{})
defer close(done)
listLatestRVs := func(ctx context.Context) (groupResourceRV, error) {
return groupResourceRV{
"test-group": map[string]int64{
"test-resource": 0,
},
}, nil
}
historyPoll := func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
return nil, errTest
}
cfg := &pollingNotifierConfig{
dialect: sqltemplate.SQLite,
pollingInterval: 10 * time.Millisecond,
watchBufferSize: 10,
log: log.NewNopLogger(),
tracer: noop.NewTracerProvider().Tracer("test"),
batchLock: &batchLock{},
listLatestRVs: listLatestRVs,
historyPoll: historyPoll,
done: done,
}
notifier, err := newPollingNotifier(cfg)
require.NoError(t, err)
require.NotNil(t, notifier)
events, err := notifier.notify(context.Background())
require.NoError(t, err)
require.NotNil(t, events)
// Verify channel remains open despite error
select {
case _, ok := <-events:
require.True(t, ok, "channel should remain open")
case <-time.After(50 * time.Millisecond):
// Expected - no events due to error
}
})
t.Run("stops polling when done channel is closed", func(t *testing.T) {
t.Parallel()
done := make(chan struct{})
cfg := &pollingNotifierConfig{
dialect: sqltemplate.SQLite,
pollingInterval: 10 * time.Millisecond,
watchBufferSize: 10,
log: log.NewNopLogger(),
tracer: noop.NewTracerProvider().Tracer("test"),
batchLock: &batchLock{},
listLatestRVs: func(ctx context.Context) (groupResourceRV, error) { return nil, nil },
historyPoll: func(ctx context.Context, grp string, res string, since int64) ([]*historyPollResponse, error) {
return nil, nil
},
done: done,
}
notifier, err := newPollingNotifier(cfg)
require.NoError(t, err)
require.NotNil(t, notifier)
events, err := notifier.notify(context.Background())
require.NoError(t, err)
require.NotNil(t, events)
close(done)
select {
case _, ok := <-events:
require.False(t, ok, "events channel should be closed")
case <-time.After(50 * time.Millisecond):
t.Fatal("timeout waiting for events channel to close")
}
})
}
+71
View File
@@ -0,0 +1,71 @@
package sql
import (
"context"
"testing"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/stretchr/testify/require"
)
func TestChannelNotifier(t *testing.T) {
t.Run("should notify subscribers of events", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
n := newChannelNotifier(5, log.NewNopLogger())
events, err := n.notify(ctx)
require.NoError(t, err)
testEvent := &resource.WrittenEvent{
Type: resource.WatchEvent_ADDED,
Key: &resource.ResourceKey{
Group: "test",
Resource: "test",
Name: "test1",
Namespace: "test",
},
ResourceVersion: 1,
}
n.send(ctx, testEvent)
select {
case event := <-events:
require.Equal(t, testEvent, event)
case <-ctx.Done():
t.Fatal("timeout waiting for event")
}
})
t.Run("should drop events when buffer is full", func(t *testing.T) {
bufferSize := 2
n := newChannelNotifier(bufferSize, log.NewNopLogger())
events, err := n.notify(context.Background())
require.NoError(t, err)
for i := 0; i < bufferSize+1; i++ {
n.send(context.Background(), &resource.WrittenEvent{
ResourceVersion: int64(i),
})
}
require.Equal(t, bufferSize, len(events))
})
t.Run("should close subscriber channels when context cancelled", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
n := newChannelNotifier(5, log.NewNopLogger())
events, err := n.notify(ctx)
require.NoError(t, err)
cancel()
_, ok := <-events
require.False(t, ok, "channel should be closed")
})
}
+12 -1
View File
@@ -10,6 +10,7 @@ import (
infraDB "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
@@ -43,7 +44,17 @@ func NewResourceServer(db infraDB.DB, cfg *setting.Cfg,
if err != nil {
return nil, err
}
store, err := NewBackend(BackendOptions{DBProvider: eDB, Tracer: tracer})
dbCfg := cfg.SectionWithEnvOverrides("database")
// Check in the config if HA is enabled by default we always assume a HA setup.
isHA := dbCfg.Key("high_availability").MustBool(true)
// SQLite is not possible to run in HA, so we set it to false.
databaseType := dbCfg.Key("type").MustString(migrator.SQLite)
if databaseType == migrator.SQLite {
isHA = false
}
store, err := NewBackend(BackendOptions{DBProvider: eDB, Tracer: tracer, IsHA: isHA})
if err != nil {
return nil, err
}
@@ -39,6 +39,24 @@ func TestIntegrationSQLStorageBackend(t *testing.T) {
backend, err := sql.NewBackend(sql.BackendOptions{
DBProvider: eDB,
IsHA: true,
})
require.NoError(t, err)
require.NotNil(t, backend)
err = backend.Init(testutil.NewDefaultTestContext(t))
require.NoError(t, err)
return backend
})
// Run single instance tests with in-process notifier.
unitest.RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
dbstore := infraDB.InitTestDB(t)
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
require.NoError(t, err)
require.NotNil(t, eDB)
backend, err := sql.NewBackend(sql.BackendOptions{
DBProvider: eDB,
IsHA: false,
})
require.NoError(t, err)
require.NotNil(t, backend)
@@ -109,6 +109,11 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
public clearSelection() {
const dashboard = getDashboardSceneFor(this);
if (this.state.selection?.getFirstObject() === dashboard) {
return;
}
this.setState({
selection: new ElementSelection([[dashboard.state.uid!, dashboard.getRef()]]),
selectionContext: {
-1
View File
@@ -3614,7 +3614,6 @@ __metadata:
"@floating-ui/react": "npm:0.27.3"
"@grafana/data": "npm:11.6.0-pre"
"@grafana/e2e-selectors": "npm:11.6.0-pre"
"@grafana/llm": "npm:0.12.0"
"@grafana/plugin-ui": "npm:0.10.1"
"@grafana/runtime": "npm:11.6.0-pre"
"@grafana/schema": "npm:11.6.0-pre"