Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
62 files changed
+2386
-1344
No files matched your search
@@ -541,6 +541,9 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Combobox/ValuePill.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: Documentation CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
paths: ["docs/sources/**"]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -76,6 +76,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `alertingApiServer` | Register Alerting APIs with the K8s API server | Yes |
|
||||
| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes |
|
||||
| `newFiltersUI` | Enables new combobox style UI for the Ad hoc filters variable in scenes architecture | Yes |
|
||||
| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | Yes |
|
||||
| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | Yes |
|
||||
| `userStorageAPI` | Enables the user storage API | Yes |
|
||||
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
|
||||
@@ -84,6 +85,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `reportingUseRawTimeRange` | Uses the original report or dashboard time range instead of making an absolute transformation | Yes |
|
||||
| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes |
|
||||
| `azureMonitorEnableUserAuth` | Enables user auth for Azure Monitor datasource only | Yes |
|
||||
| `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | Yes |
|
||||
| `lokiLabelNamesQueryApi` | Defaults to using the Loki `/labels` API instead of `/series` | Yes |
|
||||
|
||||
## Public preview feature toggles
|
||||
@@ -213,7 +215,6 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `exploreLogsLimitedTimeRange` | Used in Explore Logs to limit the time range |
|
||||
| `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages |
|
||||
| `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time |
|
||||
| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions |
|
||||
| `rolePickerDrawer` | Enables the new role picker drawer design |
|
||||
| `pluginsSriChecks` | Enables SRI checks for plugin assets |
|
||||
| `unifiedStorageBigObjectsSupport` | Enables to save big objects in blob storage |
|
||||
@@ -225,7 +226,6 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `enableSCIM` | Enables SCIM support for user and group management |
|
||||
| `crashDetection` | Enables browser crash detection reporting to Faro. |
|
||||
| `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy |
|
||||
| `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section |
|
||||
| `useV2DashboardsAPI` | Use the v2 kubernetes API in the frontend for dashboards |
|
||||
| `unifiedHistory` | Displays the navigation history so the user can navigate back to previous pages |
|
||||
| `investigationsBackend` | Enable the investigations backend API |
|
||||
@@ -233,6 +233,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `k8SFolderMove` | Enable folder's api server move |
|
||||
| `teamHttpHeadersMimir` | Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams |
|
||||
| `queryLibraryDashboards` | Enables Query Library feature in Dashboards |
|
||||
| `grafanaAdvisor` | Enables Advisor app |
|
||||
| `elasticsearchImprovedParsing` | Enables less memory intensive Elasticsearch result parsing |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as e2e from '@grafana/e2e-selectors';
|
||||
import { expect, test } from '@grafana/plugin-e2e';
|
||||
|
||||
// let's disable the feature toggles for now, otherwise the getAlertRuleQueryRow fails and I don't see any other way to get the query row
|
||||
test.use({ featureToggles: { alertingQueryAndExpressionsStepMode: false, alertingNotificationsStepMode: false } });
|
||||
test('should evaluate to false if entire request returns 200 but partial query result is invalid', async ({
|
||||
page,
|
||||
alertRuleEditPage,
|
||||
|
||||
@@ -253,5 +253,6 @@ export interface FeatureToggles {
|
||||
ABTestFeatureToggleA?: boolean;
|
||||
ABTestFeatureToggleB?: boolean;
|
||||
queryLibraryDashboards?: boolean;
|
||||
grafanaAdvisor?: boolean;
|
||||
elasticsearchImprovedParsing?: boolean;
|
||||
}
|
||||
@@ -26,17 +26,18 @@ import { Flamegraph } from '@grafana/flamegraph';
|
||||
|
||||
#### Props
|
||||
|
||||
| Name | Type | Description |
|
||||
| ------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| data | DataFrame | DataFrame with the profile data. Optional, if missing or empty the flamegraph is not rendered |
|
||||
| stickyHeader | boolean | Whether the header should be sticky and be always visible on the top when scrolling. |
|
||||
| getTheme | () => GrafanaTheme2 | Provides a theme for the visualization on which colors and some sizes are based. |
|
||||
| onTableSymbolClick | (symbol: string) => void | Interaction hook that can be used to report on the interaction. Fires when user click on a name in the table. |
|
||||
| onViewSelected | (view: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the view to show (table/graph/both) |
|
||||
| onTextAlignSelected | (align: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the text align. |
|
||||
| onTableSort | (sort: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the teble sorting. |
|
||||
| extraHeaderElements | React.ReactNode | Elements that will be shown in the header on the right side of the header buttons. Useful for additional functionality. |
|
||||
| vertical | boolean | If true the flamegraph will be rendered on top of the table. |
|
||||
| Name | Type | Description |
|
||||
| --------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| data | DataFrame | DataFrame with the profile data. Optional, if missing or empty the flamegraph is not rendered |
|
||||
| stickyHeader | boolean | Whether the header should be sticky and be always visible on the top when scrolling. |
|
||||
| getTheme | () => GrafanaTheme2 | Provides a theme for the visualization on which colors and some sizes are based. |
|
||||
| onTableSymbolClick | (symbol: string) => void | Interaction hook that can be used to report on the interaction. Fires when user click on a name in the table. |
|
||||
| onViewSelected | (view: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the view to show (table/graph/both) |
|
||||
| onTextAlignSelected | (align: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the text align. |
|
||||
| onTableSort | (sort: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the teble sorting. |
|
||||
| extraHeaderElements | React.ReactNode | Elements that will be shown in the header on the right side of the header buttons. Useful for additional functionality. |
|
||||
| vertical | boolean | If true the flamegraph will be rendered on top of the table. |
|
||||
| keepFocusOnDataChange | boolean | If true any focused block will stay focused when the profile data changes. Same for the sandwich view. |
|
||||
|
||||
##### DataFrame schema
|
||||
|
||||
|
||||
@@ -136,40 +136,46 @@ const FlameGraph = ({
|
||||
search,
|
||||
selectedView,
|
||||
};
|
||||
const canvas = levelsCallers ? (
|
||||
<>
|
||||
<div className={styles.sandwichCanvasWrapper}>
|
||||
<div className={styles.sandwichMarker}>
|
||||
Callers
|
||||
<Icon className={styles.sandwichMarkerIcon} name={'arrow-down'} />
|
||||
</div>
|
||||
<FlameGraphCanvas
|
||||
{...commonCanvasProps}
|
||||
root={levelsCallers[levelsCallers.length - 1][0]}
|
||||
depth={levelsCallers.length}
|
||||
direction={'parents'}
|
||||
// We do not support collapsing in sandwich view for now.
|
||||
collapsing={false}
|
||||
/>
|
||||
</div>
|
||||
let canvas = null;
|
||||
|
||||
<div className={styles.sandwichCanvasWrapper}>
|
||||
<div className={cx(styles.sandwichMarker, styles.sandwichMarkerCalees)}>
|
||||
<Icon className={styles.sandwichMarkerIcon} name={'arrow-up'} />
|
||||
Callees
|
||||
if (levelsCallers?.length) {
|
||||
canvas = (
|
||||
<>
|
||||
<div className={styles.sandwichCanvasWrapper}>
|
||||
<div className={styles.sandwichMarker}>
|
||||
Callers
|
||||
<Icon className={styles.sandwichMarkerIcon} name={'arrow-down'} />
|
||||
</div>
|
||||
<FlameGraphCanvas
|
||||
{...commonCanvasProps}
|
||||
root={levelsCallers[levelsCallers.length - 1][0]}
|
||||
depth={levelsCallers.length}
|
||||
direction={'parents'}
|
||||
// We do not support collapsing in sandwich view for now.
|
||||
collapsing={false}
|
||||
/>
|
||||
</div>
|
||||
<FlameGraphCanvas
|
||||
{...commonCanvasProps}
|
||||
root={levels[0][0]}
|
||||
depth={levels.length}
|
||||
direction={'children'}
|
||||
collapsing={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<FlameGraphCanvas {...commonCanvasProps} root={levels[0][0]} depth={levels.length} direction={'children'} />
|
||||
);
|
||||
|
||||
<div className={styles.sandwichCanvasWrapper}>
|
||||
<div className={cx(styles.sandwichMarker, styles.sandwichMarkerCalees)}>
|
||||
<Icon className={styles.sandwichMarkerIcon} name={'arrow-up'} />
|
||||
Callees
|
||||
</div>
|
||||
<FlameGraphCanvas
|
||||
{...commonCanvasProps}
|
||||
root={levels[0][0]}
|
||||
depth={levels.length}
|
||||
direction={'children'}
|
||||
collapsing={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else if (levels?.length) {
|
||||
canvas = (
|
||||
<FlameGraphCanvas {...commonCanvasProps} root={levels[0][0]} depth={levels.length} direction={'children'} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.graph}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
import { memo, ReactNode } from 'react';
|
||||
|
||||
import { getValueFormat, GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, IconButton, useStyles2 } from '@grafana/ui';
|
||||
import { Icon, IconButton, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { ClickedItemData } from '../types';
|
||||
|
||||
@@ -42,43 +42,50 @@ const FlameGraphMetadata = memo(
|
||||
|
||||
if (sandwichedLabel) {
|
||||
parts.push(
|
||||
<span key={'sandwich'}>
|
||||
<Icon size={'sm'} name={'angle-right'} />
|
||||
<div className={styles.metadataPill}>
|
||||
<Icon size={'sm'} name={'gf-show-context'} />{' '}
|
||||
<span className={styles.metadataPillName}>
|
||||
{sandwichedLabel.substring(sandwichedLabel.lastIndexOf('/') + 1)}
|
||||
</span>
|
||||
<IconButton
|
||||
className={styles.pillCloseButton}
|
||||
name={'times'}
|
||||
size={'sm'}
|
||||
onClick={onSandwichPillClick}
|
||||
tooltip={'Remove sandwich view'}
|
||||
aria-label={'Remove sandwich view'}
|
||||
/>
|
||||
<Tooltip key={'sandwich'} content={sandwichedLabel} placement="top">
|
||||
<div>
|
||||
<Icon size={'sm'} name={'angle-right'} />
|
||||
<div className={styles.metadataPill}>
|
||||
<Icon size={'sm'} name={'gf-show-context'} />{' '}
|
||||
<span className={styles.metadataPillName}>
|
||||
{sandwichedLabel.substring(sandwichedLabel.lastIndexOf('/') + 1)}
|
||||
</span>
|
||||
<IconButton
|
||||
className={styles.pillCloseButton}
|
||||
name={'times'}
|
||||
size={'sm'}
|
||||
onClick={onSandwichPillClick}
|
||||
tooltip={'Remove sandwich view'}
|
||||
aria-label={'Remove sandwich view'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (focusedItem) {
|
||||
const percentValue = Math.round(10000 * (focusedItem.item.value / totalTicks)) / 100;
|
||||
const percentValue = totalTicks > 0 ? Math.round(10000 * (focusedItem.item.value / totalTicks)) / 100 : 0;
|
||||
const iconName = percentValue > 0 ? 'eye' : 'exclamation-circle';
|
||||
|
||||
parts.push(
|
||||
<span key={'focus'}>
|
||||
<Icon size={'sm'} name={'angle-right'} />
|
||||
<div className={styles.metadataPill}>
|
||||
<Icon size={'sm'} name={'eye'} /> {percentValue}% of total
|
||||
<IconButton
|
||||
className={styles.pillCloseButton}
|
||||
name={'times'}
|
||||
size={'sm'}
|
||||
onClick={onFocusPillClick}
|
||||
tooltip={'Remove focus'}
|
||||
aria-label={'Remove focus'}
|
||||
/>
|
||||
<Tooltip key={'focus'} content={focusedItem.label} placement="top">
|
||||
<div>
|
||||
<Icon size={'sm'} name={'angle-right'} />
|
||||
<div className={styles.metadataPill}>
|
||||
<Icon size={'sm'} name={iconName} />
|
||||
{percentValue}% of total
|
||||
<IconButton
|
||||
className={styles.pillCloseButton}
|
||||
name={'times'}
|
||||
size={'sm'}
|
||||
onClick={onFocusPillClick}
|
||||
tooltip={'Remove focus'}
|
||||
aria-label={'Remove focus'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,8 +114,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
margin: theme.spacing(0, 0.5),
|
||||
}),
|
||||
metadata: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '8px 0',
|
||||
textAlign: 'center',
|
||||
}),
|
||||
metadataPillName: css({
|
||||
label: 'metadataPillName',
|
||||
|
||||
@@ -73,6 +73,10 @@ export type Props = {
|
||||
* Disable behaviour where similar items in the same stack will be collapsed into single item.
|
||||
*/
|
||||
disableCollapsing?: boolean;
|
||||
/**
|
||||
* Whether or not to keep any focused item when the profile data changes.
|
||||
*/
|
||||
keepFocusOnDataChange?: boolean;
|
||||
};
|
||||
|
||||
const FlameGraphContainer = ({
|
||||
@@ -87,6 +91,7 @@ const FlameGraphContainer = ({
|
||||
vertical,
|
||||
showFlameGraphOnly,
|
||||
disableCollapsing,
|
||||
keepFocusOnDataChange,
|
||||
getExtraContextMenuButtons,
|
||||
}: Props) => {
|
||||
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
|
||||
@@ -133,14 +138,44 @@ const FlameGraphContainer = ({
|
||||
setRangeMax(1);
|
||||
}, [setFocusedItemData, setRangeMax, setRangeMin]);
|
||||
|
||||
function resetSandwich() {
|
||||
const resetSandwich = useCallback(() => {
|
||||
setSandwichItem(undefined);
|
||||
}
|
||||
}, [setSandwichItem]);
|
||||
|
||||
useEffect(() => {
|
||||
resetFocus();
|
||||
resetSandwich();
|
||||
}, [data, resetFocus]);
|
||||
if (!keepFocusOnDataChange) {
|
||||
resetFocus();
|
||||
resetSandwich();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataContainer && focusedItemData) {
|
||||
const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0];
|
||||
|
||||
if (item) {
|
||||
setFocusedItemData({ ...focusedItemData, item });
|
||||
|
||||
const levels = dataContainer.getLevels();
|
||||
const totalViewTicks = levels.length ? levels[0][0].value : 0;
|
||||
setRangeMin(item.start / totalViewTicks);
|
||||
setRangeMax((item.start + item.value) / totalViewTicks);
|
||||
} else {
|
||||
setFocusedItemData({
|
||||
...focusedItemData,
|
||||
item: {
|
||||
start: 0,
|
||||
value: 0,
|
||||
itemIndexes: [],
|
||||
children: [],
|
||||
level: 0,
|
||||
},
|
||||
});
|
||||
|
||||
setRangeMin(0);
|
||||
setRangeMax(1);
|
||||
}
|
||||
}
|
||||
}, [dataContainer, keepFocusOnDataChange]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onSymbolClick = useCallback(
|
||||
(symbol: string) => {
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"@floating-ui/react": "0.27.3",
|
||||
"@grafana/data": "11.5.0-pre",
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/faro-web-sdk": "1.12.2",
|
||||
"@grafana/faro-web-sdk": "1.12.3",
|
||||
"@grafana/runtime": "11.5.0-pre",
|
||||
"@grafana/schema": "11.5.0-pre",
|
||||
"@grafana/ui": "11.5.0-pre",
|
||||
|
||||
@@ -43,7 +43,7 @@ DashboardV2Spec: {
|
||||
// Configured template variables.
|
||||
variables: [...VariableKind]
|
||||
|
||||
elements: [ElementReference.name]: PanelKind // |* more element types in the future
|
||||
elements: [ElementReference.name]: Element
|
||||
|
||||
annotations: [...AnnotationQueryKind]
|
||||
|
||||
@@ -58,6 +58,20 @@ DashboardV2Spec: {
|
||||
revision?: uint16
|
||||
}
|
||||
|
||||
// Supported dashboard elements
|
||||
Element: PanelKind | LibraryPanelKind // |* more element types in the future
|
||||
|
||||
LibraryPanelKind: {
|
||||
kind: "LibraryPanel"
|
||||
spec: LibraryPanelSpec
|
||||
}
|
||||
|
||||
LibraryPanelSpec: {
|
||||
// Library panel name
|
||||
name: string
|
||||
// Library panel UID
|
||||
uid: string
|
||||
}
|
||||
|
||||
AnnotationPanelFilter: {
|
||||
// Should the specified panels be included or excluded
|
||||
|
||||
@@ -182,6 +182,13 @@ export const handyTestingSchema: DashboardV2Spec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'library-panel-1': {
|
||||
kind: 'LibraryPanel',
|
||||
spec: {
|
||||
uid: 'library-panel-1',
|
||||
name: 'Library Panel',
|
||||
},
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
kind: 'GridLayout',
|
||||
@@ -205,6 +212,19 @@ export const handyTestingSchema: DashboardV2Spec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'GridLayoutItem',
|
||||
spec: {
|
||||
element: {
|
||||
kind: 'ElementReference',
|
||||
name: 'library-panel-1',
|
||||
},
|
||||
height: 100,
|
||||
width: 200,
|
||||
x: 0,
|
||||
y: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -31,8 +31,7 @@ export interface DashboardV2Spec {
|
||||
timeSettings: TimeSettingsSpec;
|
||||
// Configured template variables.
|
||||
variables: VariableKind[];
|
||||
// |* more element types in the future
|
||||
elements: Record<string, PanelKind>;
|
||||
elements: Record<string, Element>;
|
||||
annotations: AnnotationQueryKind[];
|
||||
layout: GridLayoutKind;
|
||||
// Version of the JSON schema, incremented each time a Grafana update brings
|
||||
@@ -58,6 +57,34 @@ export const defaultDashboardV2Spec = (): DashboardV2Spec => ({
|
||||
schemaVersion: 39,
|
||||
});
|
||||
|
||||
// Supported dashboard elements
|
||||
// |* more element types in the future
|
||||
export type Element = PanelKind | LibraryPanelKind;
|
||||
|
||||
export const defaultElement = (): Element => (defaultPanelKind());
|
||||
|
||||
export interface LibraryPanelKind {
|
||||
kind: "LibraryPanel";
|
||||
spec: LibraryPanelSpec;
|
||||
}
|
||||
|
||||
export const defaultLibraryPanelKind = (): LibraryPanelKind => ({
|
||||
kind: "LibraryPanel",
|
||||
spec: defaultLibraryPanelSpec(),
|
||||
});
|
||||
|
||||
export interface LibraryPanelSpec {
|
||||
// Library panel name
|
||||
name: string;
|
||||
// Library panel UID
|
||||
uid: string;
|
||||
}
|
||||
|
||||
export const defaultLibraryPanelSpec = (): LibraryPanelSpec => ({
|
||||
name: "",
|
||||
uid: "",
|
||||
});
|
||||
|
||||
export interface AnnotationPanelFilter {
|
||||
// Should the specified panels be included or excluded
|
||||
exclude?: boolean;
|
||||
|
||||
@@ -13,11 +13,11 @@ const meta: Meta<typeof MultiCombobox> = {
|
||||
|
||||
const commonArgs = {
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
{ label: 'Option 4', value: 'option4' },
|
||||
{ label: 'Option 5', value: 'option5' },
|
||||
{ label: 'wasd - 1', value: 'option1' },
|
||||
{ label: 'wasd - 2', value: 'option2' },
|
||||
{ label: 'wasd - 3', value: 'option3' },
|
||||
{ label: 'asdf - 1', value: 'option4' },
|
||||
{ label: 'asdf - 2', value: 'option5' },
|
||||
],
|
||||
value: ['option2'],
|
||||
placeholder: 'Select multiple options...',
|
||||
|
||||
@@ -36,8 +36,8 @@ describe('MultiCombobox', () => {
|
||||
const input = screen.getByRole('combobox');
|
||||
user.click(input);
|
||||
expect(await screen.findByText('A')).toBeInTheDocument();
|
||||
expect(await screen.findByText('B')).toBeInTheDocument();
|
||||
expect(await screen.findByText('C')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with value', () => {
|
||||
@@ -85,6 +85,7 @@ describe('MultiCombobox', () => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<ControlledMultiCombobox options={options} value={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
@@ -111,4 +112,49 @@ describe('MultiCombobox', () => {
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
expect(await screen.findByText('d')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('all option', () => {
|
||||
it('should render all option', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
render(<MultiCombobox width={200} options={options} value={['a']} onChange={jest.fn()} enableAllOption />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
expect(await screen.findByRole('option', { name: 'All' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should select all option', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
const onChange = jest.fn();
|
||||
render(<MultiCombobox width={200} options={options} value={['a']} onChange={onChange} enableAllOption />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.click(await screen.findByText('All'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should deselect all option', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<MultiCombobox width={200} options={options} value={['a', 'b', 'c']} onChange={onChange} enableAllOption />
|
||||
);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.click(await screen.findByRole('option', { name: 'All' }));
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCombobox, useMultipleSelection } from 'downshift';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { t } from '../../utils/i18n';
|
||||
import { Checkbox } from '../Forms/Checkbox';
|
||||
import { Box } from '../Layout/Box/Box';
|
||||
import { Stack } from '../Layout/Stack/Stack';
|
||||
@@ -20,17 +21,20 @@ import { itemFilter, itemToString } from './filter';
|
||||
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
||||
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
|
||||
import { useComboboxFloat } from './useComboboxFloat';
|
||||
import { useMeasureMulti } from './useMeasureMulti';
|
||||
import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti';
|
||||
|
||||
export const ALL_OPTION_VALUE = '__GRAFANA_INTERNAL_MULTICOMBOBOX_ALL_OPTION__';
|
||||
|
||||
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
|
||||
value?: T[] | Array<ComboboxOption<T>>;
|
||||
onChange: (items?: T[]) => void;
|
||||
enableAllOption?: boolean;
|
||||
}
|
||||
|
||||
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
|
||||
|
||||
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
|
||||
const { options, placeholder, onChange, value, width, invalid, loading, disabled } = props;
|
||||
const { options, placeholder, onChange, value, width, enableAllOption, invalid, loading, disabled } = props;
|
||||
const isAsync = typeof options === 'function';
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
@@ -45,15 +49,30 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const [baseItems, baseSetItems] = useState(isAsync ? [] : options);
|
||||
const allOptionItem = useMemo(() => {
|
||||
return {
|
||||
label:
|
||||
inputValue === ''
|
||||
? t('multicombobox.all.title', 'All')
|
||||
: t('multicombobox.all.title-filtered', 'All (filtered)'),
|
||||
// Type casting needed to make this work when T is a number
|
||||
value: ALL_OPTION_VALUE,
|
||||
} as ComboboxOption<T>;
|
||||
}, [inputValue]);
|
||||
|
||||
const items = useMemo(() => baseItems.filter(itemFilter(inputValue)), [baseItems, inputValue]);
|
||||
const baseItems = useMemo(() => {
|
||||
return isAsync ? [] : enableAllOption ? [allOptionItem, ...options] : options;
|
||||
}, [options, enableAllOption, allOptionItem, isAsync]);
|
||||
|
||||
// TODO: Improve this with async
|
||||
useEffect(() => {
|
||||
baseSetItems(isAsync ? [] : options);
|
||||
}, [options, isAsync]);
|
||||
const items = useMemo(() => {
|
||||
const newItems = baseItems.filter(itemFilter(inputValue));
|
||||
|
||||
if (enableAllOption && newItems.length === 1 && newItems[0] === allOptionItem) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return newItems;
|
||||
}, [baseItems, inputValue, enableAllOption, allOptionItem]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
|
||||
@@ -125,6 +144,25 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
switch (type) {
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
// Handle All functionality
|
||||
if (newSelectedItem?.value === ALL_OPTION_VALUE) {
|
||||
const allFilteredSelected = selectedItems.length === items.length - 1;
|
||||
let newSelectedItems = allFilteredSelected && inputValue === '' ? [] : baseItems.slice(1);
|
||||
|
||||
if (!allFilteredSelected && inputValue !== '') {
|
||||
// Select all currently filtered items and deduplicate
|
||||
newSelectedItems = [...new Set([...selectedItems, ...items.slice(1)])];
|
||||
}
|
||||
|
||||
if (allFilteredSelected && inputValue !== '') {
|
||||
// Deselect all currently filtered items
|
||||
const filteredSet = new Set(items.slice(1).map((item) => item.value));
|
||||
newSelectedItems = selectedItems.filter((item) => !filteredSet.has(item.value));
|
||||
}
|
||||
|
||||
onChange(getComboboxOptionsValues(newSelectedItems));
|
||||
break;
|
||||
}
|
||||
if (newSelectedItem) {
|
||||
if (!isOptionSelected(newSelectedItem)) {
|
||||
onChange(getComboboxOptionsValues([...selectedItems, newSelectedItem]));
|
||||
@@ -136,6 +174,8 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
case useCombobox.stateChangeTypes.InputChange:
|
||||
setInputValue(newInputValue ?? '');
|
||||
break;
|
||||
case useCombobox.stateChangeTypes.InputClick:
|
||||
setIsOpen(true);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -145,13 +185,15 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
const virtualizerOptions = {
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (index: number) => (items[index].description ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT),
|
||||
estimateSize: (index: number) =>
|
||||
'description' in items[index] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT,
|
||||
overscan: VIRTUAL_OVERSCAN_ITEMS,
|
||||
};
|
||||
|
||||
const rowVirtualizer = useVirtualizer(virtualizerOptions);
|
||||
|
||||
const visibleItems = isOpen ? selectedItems : selectedItems.slice(0, shownItems);
|
||||
// Selected items that show up in the input field
|
||||
const visibleItems = isOpen ? selectedItems.slice(0, MAX_SHOWN_ITEMS) : selectedItems.slice(0, shownItems);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
@@ -159,7 +201,6 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
style={{ width: width === 'auto' ? undefined : width }}
|
||||
className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })}
|
||||
ref={measureRef}
|
||||
onClick={() => !disabled && selectedItems.length > 0 && setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className={multiStyles.pillWrapper}>
|
||||
{visibleItems.map((item, index) => (
|
||||
@@ -174,7 +215,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
{itemToString(item)}
|
||||
</ValuePill>
|
||||
))}
|
||||
{selectedItems.length > shownItems && !isOpen && (
|
||||
{selectedItems.length > visibleItems.length && (
|
||||
<Box display="flex" direction="row" marginLeft={0.5} gap={1} ref={counterMeasureRef}>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<Text>...</Text>
|
||||
@@ -182,7 +223,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
interactive
|
||||
content={
|
||||
<>
|
||||
{selectedItems.slice(shownItems).map((item) => (
|
||||
{selectedItems.slice(visibleItems.length).map((item) => (
|
||||
<div key={item.value}>{itemToString(item)}</div>
|
||||
))}
|
||||
</>
|
||||
@@ -193,15 +234,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
</Box>
|
||||
)}
|
||||
<input
|
||||
className={cx(multiStyles.input, {
|
||||
[multiStyles.inputClosed]: !isOpen && selectedItems.length > 0,
|
||||
})}
|
||||
className={multiStyles.input}
|
||||
{...getInputProps(
|
||||
getDropdownProps({
|
||||
disabled,
|
||||
preventKeyAction: isOpen,
|
||||
placeholder: selectedItems.length > 0 ? undefined : placeholder,
|
||||
onFocus: () => setIsOpen(true),
|
||||
onFocus: () => !disabled && setIsOpen(true),
|
||||
})
|
||||
)}
|
||||
/>
|
||||
@@ -227,6 +266,10 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
const itemProps = getItemProps({ item, index });
|
||||
const isSelected = isOptionSelected(item);
|
||||
const id = 'multicombobox-option-' + item.value.toString();
|
||||
const isAll = item.value === ALL_OPTION_VALUE;
|
||||
const allItemsSelected =
|
||||
items[0]?.value === ALL_OPTION_VALUE && selectedItems.length === items.length - 1;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${item.value}-${index}`}
|
||||
@@ -238,13 +281,23 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Checkbox
|
||||
key={id}
|
||||
value={isSelected}
|
||||
value={allItemsSelected || isSelected}
|
||||
indeterminate={isAll && selectedItems.length > 0 && !allItemsSelected}
|
||||
aria-labelledby={id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<OptionListItem option={item} id={id} />
|
||||
<OptionListItem
|
||||
label={
|
||||
isAll
|
||||
? (item.label ?? item.value.toString()) +
|
||||
(isAll && inputValue !== '' ? ` (${items.length - 1})` : '')
|
||||
: (item.label ?? item.value.toString())
|
||||
}
|
||||
description={item?.description}
|
||||
id={id}
|
||||
/>
|
||||
</Stack>
|
||||
</li>
|
||||
);
|
||||
@@ -262,31 +315,29 @@ function getSelectedItemsFromValue<T extends string | number>(
|
||||
value: T[] | Array<ComboboxOption<T>>,
|
||||
options: Array<ComboboxOption<T>>
|
||||
) {
|
||||
if (!isComboboxOptions(value)) {
|
||||
const resultingItems: Array<ComboboxOption<T> | undefined> = [];
|
||||
if (isComboboxOptions(value)) {
|
||||
return value;
|
||||
}
|
||||
const valueMap = new Map(value.map((val, index) => [val, index]));
|
||||
const resultingItems: Array<ComboboxOption<T>> = [];
|
||||
|
||||
for (const item of options) {
|
||||
for (const [index, val] of value.entries()) {
|
||||
if (val === item.value) {
|
||||
resultingItems[index] = item;
|
||||
}
|
||||
}
|
||||
if (resultingItems.length === value.length && !resultingItems.includes(undefined)) {
|
||||
// We found all items for the values
|
||||
break;
|
||||
}
|
||||
for (const option of options) {
|
||||
const index = valueMap.get(option.value);
|
||||
if (index !== undefined) {
|
||||
resultingItems[index] = option;
|
||||
valueMap.delete(option.value);
|
||||
}
|
||||
|
||||
// Handle values that are not in options
|
||||
for (const [index, val] of value.entries()) {
|
||||
if (resultingItems[index] === undefined) {
|
||||
resultingItems[index] = { value: val };
|
||||
}
|
||||
if (valueMap.size === 0) {
|
||||
// We found all values
|
||||
break;
|
||||
}
|
||||
return resultingItems.filter((item) => item !== undefined); // TODO: Not actually needed, but TS complains
|
||||
}
|
||||
|
||||
return value;
|
||||
// Handle items that are not in options
|
||||
for (const [val, index] of valueMap) {
|
||||
resultingItems[index] = { value: val };
|
||||
}
|
||||
return resultingItems;
|
||||
}
|
||||
|
||||
function isComboboxOptions<T extends string | number>(
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useStyles2 } from '../../themes';
|
||||
|
||||
import { ComboboxOption } from './Combobox';
|
||||
import { getComboboxStyles } from './getComboboxStyles';
|
||||
|
||||
interface Props {
|
||||
option: ComboboxOption<string | number>;
|
||||
label: string;
|
||||
description?: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const OptionListItem = ({ option, id }: Props) => {
|
||||
export const OptionListItem = ({ label, description, id }: Props) => {
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
return (
|
||||
<div className={styles.optionBody}>
|
||||
<span className={styles.optionLabel} id={id}>
|
||||
{option.label ?? option.value}
|
||||
{label}
|
||||
</span>
|
||||
{option.description && <span className={styles.optionDescription}>{option.description}</span>}
|
||||
{description && <span className={styles.optionDescription}>{description}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ComboboxOption } from './Combobox';
|
||||
import { ALL_OPTION_VALUE } from './MultiCombobox';
|
||||
|
||||
export function itemToString<T extends string | number>(item?: ComboboxOption<T> | null) {
|
||||
if (!item) {
|
||||
@@ -17,7 +18,8 @@ export function itemFilter<T extends string | number>(inputValue: string) {
|
||||
return (
|
||||
!inputValue ||
|
||||
item.label?.toLowerCase().includes(lowerCasedInputValue) ||
|
||||
item.value?.toString().toLowerCase().includes(lowerCasedInputValue)
|
||||
item.value?.toString().toLowerCase().includes(lowerCasedInputValue) ||
|
||||
item.value.toString() === ALL_OPTION_VALUE
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -39,12 +39,7 @@ export const getMultiComboboxStyles = (
|
||||
outline: 'none',
|
||||
},
|
||||
}),
|
||||
inputClosed: css({
|
||||
width: 0,
|
||||
flexGrow: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}),
|
||||
|
||||
pillWrapper: css({
|
||||
display: 'inline-flex',
|
||||
flexWrap: isOpen ? 'wrap' : 'nowrap',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ComboboxOption } from './Combobox';
|
||||
const FONT_SIZE = 12;
|
||||
const EXTRA_PILL_SIZE = 50;
|
||||
const EXTRA_PILL_DISABLED_SIZE = 10;
|
||||
export const MAX_SHOWN_ITEMS = 15;
|
||||
|
||||
/**
|
||||
* Updates the number of shown items in the multi combobox based on the available width.
|
||||
@@ -34,8 +35,8 @@ export function useMeasureMulti<T extends string | number>(
|
||||
(disabled ? EXTRA_PILL_DISABLED_SIZE : EXTRA_PILL_SIZE);
|
||||
if (currWidth > maxWidth) {
|
||||
// If there is no space for that item, show the current number of items,
|
||||
// but always show at least 1 item
|
||||
setShownItems(i || 1);
|
||||
// but always show at least 1 item. Cap at maximum number of items.
|
||||
setShownItems(Math.min(i, MAX_SHOWN_ITEMS) || 1);
|
||||
break;
|
||||
}
|
||||
if (i === selectedItems.length - 1) {
|
||||
|
||||
@@ -59,6 +59,8 @@ type Service interface {
|
||||
DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error
|
||||
// SyncUserRoles adds provided roles to user
|
||||
SyncUserRoles(ctx context.Context, orgID int64, cmd SyncUserRolesCommand) error
|
||||
// GetStaicRoles returns a map where key organization role and value is a static rbac role.
|
||||
GetStaticRoles(ctx context.Context) map[string]*RoleDTO
|
||||
}
|
||||
|
||||
//go:generate mockery --name Store --structname MockStore --outpkg actest --filename store_mock.go --output ./actest/
|
||||
|
||||
@@ -464,7 +464,15 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error {
|
||||
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
|
||||
for br := range accesscontrol.BuiltInRolesWithParents(registration.Grants) {
|
||||
if basicRole, ok := s.roles[br]; ok {
|
||||
basicRole.Permissions = append(basicRole.Permissions, registration.Role.Permissions...)
|
||||
for _, p := range registration.Role.Permissions {
|
||||
perm := accesscontrol.Permission{
|
||||
Action: p.Action,
|
||||
Scope: p.Scope,
|
||||
}
|
||||
|
||||
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
|
||||
basicRole.Permissions = append(basicRole.Permissions, perm)
|
||||
}
|
||||
} else {
|
||||
s.log.Error("Unknown builtin role", "builtInRole", br)
|
||||
}
|
||||
@@ -770,10 +778,14 @@ func (s *Service) DeleteExternalServiceRole(ctx context.Context, externalService
|
||||
return s.store.DeleteExternalServiceRole(ctx, slug)
|
||||
}
|
||||
|
||||
func (*Service) SyncUserRoles(ctx context.Context, orgID int64, cmd accesscontrol.SyncUserRolesCommand) error {
|
||||
func (s *Service) SyncUserRoles(ctx context.Context, orgID int64, cmd accesscontrol.SyncUserRolesCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetStaticRoles(ctx context.Context) map[string]*accesscontrol.RoleDTO {
|
||||
return s.roles
|
||||
}
|
||||
|
||||
func (s *Service) GetRoleByName(ctx context.Context, orgID int64, roleName string) (*accesscontrol.RoleDTO, error) {
|
||||
_, span := tracer.Start(ctx, "accesscontrol.acimpl.GetRoleByName")
|
||||
defer span.End()
|
||||
|
||||
@@ -373,6 +373,7 @@ func TestService_RegisterFixedRoles(t *testing.T) {
|
||||
builtinRole, ok := ac.roles[br]
|
||||
assert.True(t, ok)
|
||||
for _, expectedPermission := range registration.Role.Permissions {
|
||||
expectedPermission.Kind, expectedPermission.Attribute, expectedPermission.Identifier = accesscontrol.SplitScope(expectedPermission.Scope)
|
||||
assert.Contains(t, builtinRole.Permissions, expectedPermission)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ type Calls struct {
|
||||
}
|
||||
|
||||
type Mock struct {
|
||||
accesscontrol.Service
|
||||
accesscontrol.AccessControl
|
||||
|
||||
// Unless an override is provided, permissions will be returned by GetUserPermissions
|
||||
permissions []accesscontrol.Permission
|
||||
// Unless an override is provided, builtInRoles will be returned by GetUserBuiltInRoles
|
||||
|
||||
@@ -19,7 +19,9 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/authz/rbac"
|
||||
"github.com/grafana/grafana/pkg/services/authz/rbac/store"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/grpcserver"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -31,8 +33,12 @@ const authzServiceAudience = "authzService"
|
||||
|
||||
// ProvideAuthZClient provides an AuthZ client and creates the AuthZ service.
|
||||
func ProvideAuthZClient(
|
||||
cfg *setting.Cfg, features featuremgmt.FeatureToggles, grpcServer grpcserver.Provider,
|
||||
tracer tracing.Tracer, db db.DB,
|
||||
cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles,
|
||||
grpcServer grpcserver.Provider,
|
||||
tracer tracing.Tracer,
|
||||
db db.DB,
|
||||
acService accesscontrol.Service,
|
||||
) (authzlib.AccessClient, error) {
|
||||
// FIXME: SECURITY: Enable authorization. DO NOT MERGE until fixed.
|
||||
if features.IsEnabledGlobally(featuremgmt.FlagProvisioning) && !setting.IsEnterprise {
|
||||
@@ -50,16 +56,25 @@ func ProvideAuthZClient(
|
||||
return nil, errors.New("authZGRPCServer feature toggle is required for cloud and grpc mode")
|
||||
}
|
||||
|
||||
// Register the server
|
||||
sql := legacysql.NewDatabaseProvider(db)
|
||||
server := rbac.NewService(sql, legacy.NewLegacySQLStores(sql), log.New("authz-grpc-server"), tracer)
|
||||
|
||||
switch authCfg.mode {
|
||||
case ModeGRPC:
|
||||
return newGrpcLegacyClient(authCfg, tracer)
|
||||
case ModeCloud:
|
||||
return newCloudLegacyClient(authCfg, tracer)
|
||||
default:
|
||||
sql := legacysql.NewDatabaseProvider(db)
|
||||
|
||||
// Register the server
|
||||
server := rbac.NewService(
|
||||
sql,
|
||||
legacy.NewLegacySQLStores(sql),
|
||||
store.NewUnionPermissionStore(
|
||||
store.NewStaticPermissionStore(acService),
|
||||
store.NewSQLPermissionStore(sql, tracer),
|
||||
),
|
||||
log.New("authz-grpc-server"),
|
||||
tracer,
|
||||
)
|
||||
return newInProcLegacyClient(server, tracer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,10 @@ type Service struct {
|
||||
authzv1.UnimplementedAuthzServiceServer
|
||||
authzextv1.UnimplementedAuthzExtentionServiceServer
|
||||
|
||||
store store.Store
|
||||
identityStore legacy.LegacyIdentityStore
|
||||
actionMapper *mappers.K8sRbacMapper
|
||||
store store.Store
|
||||
permissionStore store.PermissionStore
|
||||
identityStore legacy.LegacyIdentityStore
|
||||
actionMapper *mappers.K8sRbacMapper
|
||||
|
||||
logger log.Logger
|
||||
tracer tracing.Tracer
|
||||
@@ -58,19 +59,27 @@ type Service struct {
|
||||
sf *singleflight.Group
|
||||
}
|
||||
|
||||
func NewService(sql legacysql.LegacyDatabaseProvider, identityStore legacy.LegacyIdentityStore, logger log.Logger, tracer tracing.Tracer) *Service {
|
||||
func NewService(
|
||||
sql legacysql.LegacyDatabaseProvider,
|
||||
identityStore legacy.LegacyIdentityStore,
|
||||
permissionStore store.PermissionStore,
|
||||
logger log.Logger,
|
||||
tracer tracing.Tracer,
|
||||
|
||||
) *Service {
|
||||
return &Service{
|
||||
store: store.NewStore(sql, tracer),
|
||||
identityStore: identityStore,
|
||||
actionMapper: mappers.NewK8sRbacMapper(),
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
idCache: localcache.New(longCacheTTL, longCleanupInterval),
|
||||
permCache: localcache.New(shortCacheTTL, shortCleanupInterval),
|
||||
teamCache: localcache.New(shortCacheTTL, shortCleanupInterval),
|
||||
basicRoleCache: localcache.New(longCacheTTL, longCleanupInterval),
|
||||
folderCache: localcache.New(shortCacheTTL, shortCleanupInterval),
|
||||
sf: new(singleflight.Group),
|
||||
store: store.NewStore(sql, tracer),
|
||||
permissionStore: permissionStore,
|
||||
identityStore: identityStore,
|
||||
actionMapper: mappers.NewK8sRbacMapper(),
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
idCache: localcache.New(longCacheTTL, longCleanupInterval),
|
||||
permCache: localcache.New(shortCacheTTL, shortCleanupInterval),
|
||||
teamCache: localcache.New(shortCacheTTL, shortCleanupInterval),
|
||||
basicRoleCache: localcache.New(longCacheTTL, longCleanupInterval),
|
||||
folderCache: localcache.New(shortCacheTTL, shortCleanupInterval),
|
||||
sf: new(singleflight.Group),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +291,7 @@ func (s *Service) getUserPermissions(ctx context.Context, ns claims.NamespaceInf
|
||||
IsServerAdmin: basicRoles.IsAdmin,
|
||||
}
|
||||
|
||||
permissions, err := s.store.GetUserPermissions(ctx, ns, userPermQuery)
|
||||
permissions, err := s.permissionStore.GetUserPermissions(ctx, ns, userPermQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -311,7 +320,7 @@ func (s *Service) getAnonymousPermissions(ctx context.Context, ns claims.Namespa
|
||||
}
|
||||
|
||||
res, err, _ := s.sf.Do(anonPermKey+"_getAnonymousPermissions", func() (interface{}, error) {
|
||||
permissions, err := s.store.GetUserPermissions(ctx, ns, store.PermissionsQuery{Action: action, ActionSets: actionSets, Role: "Viewer"})
|
||||
permissions, err := s.permissionStore.GetUserPermissions(ctx, ns, store.PermissionsQuery{Action: action, ActionSets: actionSets, Role: "Viewer"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -286,10 +286,11 @@ func TestService_getUserBasicRole(t *testing.T) {
|
||||
}
|
||||
|
||||
s := &Service{
|
||||
basicRoleCache: cacheService,
|
||||
store: store,
|
||||
logger: log.New("test"),
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
basicRoleCache: cacheService,
|
||||
store: store,
|
||||
permissionStore: store,
|
||||
logger: log.New("test"),
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
}
|
||||
|
||||
role, err := s.getUserBasicRole(ctx, ns, userIdentifiers)
|
||||
@@ -361,16 +362,17 @@ func TestService_getUserPermissions(t *testing.T) {
|
||||
}
|
||||
|
||||
s := &Service{
|
||||
store: store,
|
||||
identityStore: &fakeIdentityStore{teams: []int64{1, 2}},
|
||||
actionMapper: mappers.NewK8sRbacMapper(),
|
||||
logger: log.New("test"),
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
idCache: localcache.New(longCacheTTL, longCleanupInterval),
|
||||
permCache: cacheService,
|
||||
sf: new(singleflight.Group),
|
||||
basicRoleCache: localcache.New(longCacheTTL, longCleanupInterval),
|
||||
teamCache: localcache.New(shortCacheTTL, shortCleanupInterval),
|
||||
store: store,
|
||||
permissionStore: store,
|
||||
identityStore: &fakeIdentityStore{teams: []int64{1, 2}},
|
||||
actionMapper: mappers.NewK8sRbacMapper(),
|
||||
logger: log.New("test"),
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
idCache: localcache.New(longCacheTTL, longCleanupInterval),
|
||||
permCache: cacheService,
|
||||
sf: new(singleflight.Group),
|
||||
basicRoleCache: localcache.New(longCacheTTL, longCleanupInterval),
|
||||
teamCache: localcache.New(shortCacheTTL, shortCleanupInterval),
|
||||
}
|
||||
|
||||
perms, err := s.getUserPermissions(ctx, ns, claims.TypeUser, userID.UID, action)
|
||||
@@ -437,11 +439,12 @@ func TestService_buildFolderTree(t *testing.T) {
|
||||
store := &fakeStore{folders: tc.folders}
|
||||
|
||||
s := &Service{
|
||||
store: store,
|
||||
folderCache: cacheService,
|
||||
logger: log.New("test"),
|
||||
sf: new(singleflight.Group),
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
store: store,
|
||||
permissionStore: store,
|
||||
folderCache: cacheService,
|
||||
logger: log.New("test"),
|
||||
sf: new(singleflight.Group),
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
}
|
||||
|
||||
tree, err := s.buildFolderTree(ctx, ns)
|
||||
|
||||
@@ -10,16 +10,6 @@ type BasicRole struct {
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
type PermissionsQuery struct {
|
||||
OrgID int64
|
||||
UserID int64
|
||||
Action string
|
||||
ActionSets []string
|
||||
TeamIDs []int64
|
||||
Role string
|
||||
IsServerAdmin bool
|
||||
}
|
||||
|
||||
type BasicRoleQuery struct {
|
||||
UserID int64
|
||||
OrgID int64
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/authlib/claims"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||
)
|
||||
|
||||
type PermissionStore interface {
|
||||
GetUserPermissions(ctx context.Context, ns claims.NamespaceInfo, query PermissionsQuery) ([]accesscontrol.Permission, error)
|
||||
}
|
||||
|
||||
type PermissionsQuery struct {
|
||||
OrgID int64
|
||||
UserID int64
|
||||
Action string
|
||||
ActionSets []string
|
||||
TeamIDs []int64
|
||||
Role string
|
||||
IsServerAdmin bool
|
||||
}
|
||||
|
||||
func NewSQLPermissionStore(sql legacysql.LegacyDatabaseProvider, tracer tracing.Tracer) *SQLPermissionsStore {
|
||||
return &SQLPermissionsStore{sql, tracer}
|
||||
}
|
||||
|
||||
var _ PermissionStore = (*SQLPermissionsStore)(nil)
|
||||
|
||||
type SQLPermissionsStore struct {
|
||||
sql legacysql.LegacyDatabaseProvider
|
||||
tracer tracing.Tracer
|
||||
}
|
||||
|
||||
var sqlUserPerms = mustTemplate("permission_query.sql")
|
||||
|
||||
type getPermissionsQuery struct {
|
||||
sqltemplate.SQLTemplate
|
||||
Query *PermissionsQuery
|
||||
|
||||
PermissionTable string
|
||||
UserRoleTable string
|
||||
TeamRoleTable string
|
||||
BuiltinRoleTable string
|
||||
}
|
||||
|
||||
func (r getPermissionsQuery) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newGetPermissions(sql *legacysql.LegacyDatabaseHelper, q *PermissionsQuery) getPermissionsQuery {
|
||||
return getPermissionsQuery{
|
||||
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
|
||||
Query: q,
|
||||
PermissionTable: sql.Table("permission"),
|
||||
UserRoleTable: sql.Table("user_role"),
|
||||
TeamRoleTable: sql.Table("team_role"),
|
||||
BuiltinRoleTable: sql.Table("builtin_role"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLPermissionsStore) GetUserPermissions(ctx context.Context, ns claims.NamespaceInfo, query PermissionsQuery) ([]accesscontrol.Permission, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "authz_direct_db.database.GetUserPermissions")
|
||||
defer span.End()
|
||||
|
||||
sql, err := s.sql(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query.OrgID = ns.OrgID
|
||||
req := newGetPermissions(sql, &query)
|
||||
q, err := sqltemplate.Execute(sqlUserPerms, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if res != nil {
|
||||
_ = res.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
var perms []accesscontrol.Permission
|
||||
for res.Next() {
|
||||
var perm accesscontrol.Permission
|
||||
if err := res.Scan(&perm.Kind, &perm.Attribute, &perm.Identifier, &perm.Scope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perms = append(perms, perm)
|
||||
}
|
||||
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
var _ PermissionStore = (*StaticPermissionStore)(nil)
|
||||
|
||||
func NewStaticPermissionStore(ac accesscontrol.Service) *StaticPermissionStore {
|
||||
return &StaticPermissionStore{ac}
|
||||
}
|
||||
|
||||
type StaticPermissionStore struct {
|
||||
ac accesscontrol.Service
|
||||
}
|
||||
|
||||
func (s *StaticPermissionStore) GetUserPermissions(ctx context.Context, ns claims.NamespaceInfo, query PermissionsQuery) ([]accesscontrol.Permission, error) {
|
||||
roles := []string{query.Role}
|
||||
if query.IsServerAdmin {
|
||||
roles = append(roles, "Grafana Admin")
|
||||
}
|
||||
|
||||
static := s.ac.GetStaticRoles(ctx)
|
||||
|
||||
var permissions []accesscontrol.Permission
|
||||
for _, name := range roles {
|
||||
r, ok := static[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, p := range r.Permissions {
|
||||
if p.Action == query.Action {
|
||||
permissions = append(permissions, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
var _ PermissionStore = (*UnionPermissionStore)(nil)
|
||||
|
||||
func NewUnionPermissionStore(stores ...PermissionStore) *UnionPermissionStore {
|
||||
return &UnionPermissionStore{stores}
|
||||
}
|
||||
|
||||
type UnionPermissionStore struct {
|
||||
stores []PermissionStore
|
||||
}
|
||||
|
||||
func (u *UnionPermissionStore) GetUserPermissions(ctx context.Context, ns claims.NamespaceInfo, query PermissionsQuery) ([]accesscontrol.Permission, error) {
|
||||
var permissions []accesscontrol.Permission
|
||||
for _, s := range u.stores {
|
||||
result, err := s.GetUserPermissions(ctx, ns, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
permissions = append(permissions, result...)
|
||||
}
|
||||
return permissions, nil
|
||||
}
|
||||
@@ -14,7 +14,6 @@ var (
|
||||
sqlTemplatesFS embed.FS
|
||||
sqlTemplates = template.Must(template.New("sql").ParseFS(sqlTemplatesFS, `*.sql`))
|
||||
|
||||
sqlUserPerms = mustTemplate("permission_query.sql")
|
||||
sqlQueryBasicRoles = mustTemplate("basic_role_query.sql")
|
||||
sqlUserIdentifiers = mustTemplate("user_identifier_query.sql")
|
||||
sqlFolders = mustTemplate("folder_query.sql")
|
||||
@@ -67,31 +66,6 @@ func newGetBasicRoles(sql *legacysql.LegacyDatabaseHelper, q *BasicRoleQuery) ge
|
||||
}
|
||||
}
|
||||
|
||||
type getPermissionsQuery struct {
|
||||
sqltemplate.SQLTemplate
|
||||
Query *PermissionsQuery
|
||||
|
||||
PermissionTable string
|
||||
UserRoleTable string
|
||||
TeamRoleTable string
|
||||
BuiltinRoleTable string
|
||||
}
|
||||
|
||||
func (r getPermissionsQuery) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newGetPermissions(sql *legacysql.LegacyDatabaseHelper, q *PermissionsQuery) getPermissionsQuery {
|
||||
return getPermissionsQuery{
|
||||
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
|
||||
Query: q,
|
||||
PermissionTable: sql.Table("permission"),
|
||||
UserRoleTable: sql.Table("user_role"),
|
||||
TeamRoleTable: sql.Table("team_role"),
|
||||
BuiltinRoleTable: sql.Table("builtin_role"),
|
||||
}
|
||||
}
|
||||
|
||||
type getFoldersQuery struct {
|
||||
sqltemplate.SQLTemplate
|
||||
Query *FolderQuery
|
||||
|
||||
@@ -7,13 +7,11 @@ import (
|
||||
"golang.org/x/net/context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
GetUserPermissions(ctx context.Context, ns claims.NamespaceInfo, query PermissionsQuery) ([]accesscontrol.Permission, error)
|
||||
GetUserIdentifiers(ctx context.Context, query UserIdentifierQuery) (*UserIdentifiers, error)
|
||||
GetBasicRoles(ctx context.Context, ns claims.NamespaceInfo, query BasicRoleQuery) (*BasicRole, error)
|
||||
GetFolders(ctx context.Context, ns claims.NamespaceInfo) ([]Folder, error)
|
||||
@@ -31,44 +29,6 @@ func NewStore(sql legacysql.LegacyDatabaseProvider, tracer tracing.Tracer) *Stor
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StoreImpl) GetUserPermissions(ctx context.Context, ns claims.NamespaceInfo, query PermissionsQuery) ([]accesscontrol.Permission, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "authz_direct_db.database.GetUserPermissions")
|
||||
defer span.End()
|
||||
|
||||
sql, err := s.sql(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query.OrgID = ns.OrgID
|
||||
req := newGetPermissions(sql, &query)
|
||||
q, err := sqltemplate.Execute(sqlUserPerms, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if res != nil {
|
||||
_ = res.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
var perms []accesscontrol.Permission
|
||||
for res.Next() {
|
||||
var perm accesscontrol.Permission
|
||||
if err := res.Scan(&perm.Kind, &perm.Attribute, &perm.Identifier, &perm.Scope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perms = append(perms, perm)
|
||||
}
|
||||
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
func (s *StoreImpl) GetUserIdentifiers(ctx context.Context, query UserIdentifierQuery) (*UserIdentifiers, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "authz_direct_db.database.GetUserIdentifiers")
|
||||
defer span.End()
|
||||
|
||||
@@ -8,12 +8,19 @@ import (
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/rbac"
|
||||
"github.com/grafana/grafana/pkg/services/authz/rbac/store"
|
||||
"github.com/grafana/grafana/pkg/services/grpcserver"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
)
|
||||
|
||||
func RegisterRBACAuthZService(handler grpcserver.Provider, db legacysql.LegacyDatabaseProvider, tracer tracing.Tracer) {
|
||||
server := rbac.NewService(db, legacy.NewLegacySQLStores(db), log.New("authz-grpc-server"), tracer)
|
||||
server := rbac.NewService(
|
||||
db,
|
||||
legacy.NewLegacySQLStores(db),
|
||||
store.NewSQLPermissionStore(db, tracer),
|
||||
log.New("authz-grpc-server"),
|
||||
tracer,
|
||||
)
|
||||
|
||||
srv := handler.GetServer()
|
||||
authzv1.RegisterAuthzServiceServer(srv, server)
|
||||
|
||||
@@ -78,7 +78,6 @@ func (_m *FakeDashboardStore) CountInOrg(_a0 context.Context, _a1 int64) (int64,
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
|
||||
// CountDashboardsInFolders provides a mock function with given fields: ctx, request
|
||||
func (_m *FakeDashboardStore) CountDashboardsInFolders(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) {
|
||||
ret := _m.Called(ctx, request)
|
||||
|
||||
@@ -1473,15 +1473,17 @@ var (
|
||||
{
|
||||
Name: "alertingQueryAndExpressionsStepMode",
|
||||
Description: "Enables step mode for alerting queries and expressions",
|
||||
Stage: FeatureStageExperimental,
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
Owner: grafanaAlertingSquad,
|
||||
FrontendOnly: true,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "improvedExternalSessionHandling",
|
||||
Description: "Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves.",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Owner: identityAccessTeam,
|
||||
Name: "improvedExternalSessionHandling",
|
||||
Description: "Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves.",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Owner: identityAccessTeam,
|
||||
AllowSelfServe: true,
|
||||
},
|
||||
{
|
||||
Name: "useSessionStorageForRedirection",
|
||||
@@ -1646,9 +1648,10 @@ var (
|
||||
{
|
||||
Name: "alertingNotificationsStepMode",
|
||||
Description: "Enables simplified step mode in the notifications section",
|
||||
Stage: FeatureStageExperimental,
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
Owner: grafanaAlertingSquad,
|
||||
FrontendOnly: true,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "useV2DashboardsAPI",
|
||||
@@ -1714,10 +1717,11 @@ var (
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "improvedExternalSessionHandlingSAML",
|
||||
Description: "Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly.",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Owner: identityAccessTeam,
|
||||
Name: "improvedExternalSessionHandlingSAML",
|
||||
Description: "Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly.",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Owner: identityAccessTeam,
|
||||
AllowSelfServe: true,
|
||||
},
|
||||
{
|
||||
Name: "teamHttpHeadersMimir",
|
||||
@@ -1750,6 +1754,12 @@ var (
|
||||
Owner: grafanaFrontendPlatformSquad,
|
||||
AllowSelfServe: false,
|
||||
},
|
||||
{
|
||||
Name: "grafanaAdvisor",
|
||||
Description: "Enables Advisor app",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaPluginsPlatformSquad,
|
||||
},
|
||||
{
|
||||
Name: "elasticsearchImprovedParsing",
|
||||
Description: "Enables less memory intensive Elasticsearch result parsing",
|
||||
|
||||
@@ -194,7 +194,7 @@ homeSetupGuide,experimental,@grafana/growth-and-onboarding,false,false,true
|
||||
appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false
|
||||
appSidecar,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||
groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false
|
||||
alertingQueryAndExpressionsStepMode,experimental,@grafana/alerting-squad,false,false,true
|
||||
alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true
|
||||
improvedExternalSessionHandling,preview,@grafana/identity-access-team,false,false,false
|
||||
useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,false
|
||||
rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false
|
||||
@@ -219,7 +219,7 @@ jaegerBackendMigration,experimental,@grafana/oss-big-tent,false,false,false
|
||||
reportingUseRawTimeRange,GA,@grafana/sharing-squad,false,false,false
|
||||
alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true
|
||||
azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false
|
||||
alertingNotificationsStepMode,experimental,@grafana/alerting-squad,false,false,true
|
||||
alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true
|
||||
useV2DashboardsAPI,experimental,@grafana/dashboards-squad,false,true,false
|
||||
feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false
|
||||
@@ -234,4 +234,5 @@ teamHttpHeadersMimir,experimental,@grafana/identity-access-team,false,false,fals
|
||||
ABTestFeatureToggleA,experimental,@grafana/sharing-squad,false,false,false
|
||||
ABTestFeatureToggleB,experimental,@grafana/sharing-squad,false,false,false
|
||||
queryLibraryDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||
grafanaAdvisor,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false
|
||||
|
@@ -947,6 +947,10 @@ const (
|
||||
// Enables Query Library feature in Dashboards
|
||||
FlagQueryLibraryDashboards = "queryLibraryDashboards"
|
||||
|
||||
// FlagGrafanaAdvisor
|
||||
// Enables Advisor app
|
||||
FlagGrafanaAdvisor = "grafanaAdvisor"
|
||||
|
||||
// FlagElasticsearchImprovedParsing
|
||||
// Enables less memory intensive Elasticsearch result parsing
|
||||
FlagElasticsearchImprovedParsing = "elasticsearchImprovedParsing"
|
||||
|
||||
@@ -276,17 +276,18 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "alertingNotificationsStepMode",
|
||||
"resourceVersion": "1732272457848",
|
||||
"resourceVersion": "1737362059637",
|
||||
"creationTimestamp": "2024-11-06T09:35:49Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-11-22 10:47:37.848635 +0000 UTC"
|
||||
"grafana.app/updatedTimestamp": "2025-01-20 08:34:19.63725 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables simplified step mode in the notifications section",
|
||||
"stage": "experimental",
|
||||
"stage": "GA",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"frontend": true
|
||||
"frontend": true,
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -308,14 +309,18 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "alertingQueryAndExpressionsStepMode",
|
||||
"resourceVersion": "1725978395461",
|
||||
"creationTimestamp": "2024-09-26T06:33:14Z"
|
||||
"resourceVersion": "1737362059637",
|
||||
"creationTimestamp": "2024-09-26T06:33:14Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-20 08:34:19.63725 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables step mode for alerting queries and expressions",
|
||||
"stage": "experimental",
|
||||
"stage": "GA",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"frontend": true
|
||||
"frontend": true,
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1738,6 +1743,18 @@
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "grafanaAdvisor",
|
||||
"resourceVersion": "1737365459765",
|
||||
"creationTimestamp": "2025-01-20T09:30:59Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables Advisor app",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/plugins-platform-backend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "grafanaManagedRecordingRules",
|
||||
@@ -1847,31 +1864,33 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "improvedExternalSessionHandling",
|
||||
"resourceVersion": "1736440595516",
|
||||
"resourceVersion": "1737370880023",
|
||||
"creationTimestamp": "2024-09-17T10:54:39Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-09 16:36:35.516462 +0000 UTC"
|
||||
"grafana.app/updatedTimestamp": "2025-01-20 11:01:20.02358 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves.",
|
||||
"stage": "preview",
|
||||
"codeowner": "@grafana/identity-access-team"
|
||||
"codeowner": "@grafana/identity-access-team",
|
||||
"allowSelfServe": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "improvedExternalSessionHandlingSAML",
|
||||
"resourceVersion": "1736440619329",
|
||||
"resourceVersion": "1737370880023",
|
||||
"creationTimestamp": "2025-01-09T16:33:07Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-09 16:36:59.329967 +0000 UTC"
|
||||
"grafana.app/updatedTimestamp": "2025-01-20 11:01:20.02358 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly.",
|
||||
"stage": "preview",
|
||||
"codeowner": "@grafana/identity-access-team"
|
||||
"codeowner": "@grafana/identity-access-team",
|
||||
"allowSelfServe": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
type SyncRuleStatePersister struct {
|
||||
log log.Logger
|
||||
store InstanceStore
|
||||
doNotSaveNormalState bool
|
||||
}
|
||||
|
||||
func NewSyncRuleStatePersisiter(log log.Logger, cfg ManagerCfg) StatePersister {
|
||||
return &SyncRuleStatePersister{
|
||||
log: log,
|
||||
store: cfg.InstanceStore,
|
||||
doNotSaveNormalState: cfg.DoNotSaveNormalState,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *SyncRuleStatePersister) Async(_ context.Context, _ AlertInstancesProvider) {
|
||||
a.log.Debug("Async: No-Op")
|
||||
}
|
||||
|
||||
func (a *SyncRuleStatePersister) Sync(ctx context.Context, span trace.Span, ruleKey models.AlertRuleKeyWithGroup, states StateTransitions) {
|
||||
if a.store == nil || len(states) == 0 {
|
||||
return
|
||||
}
|
||||
logger := a.log.FromContext(ctx)
|
||||
|
||||
instancesToSave := make([]models.AlertInstance, 0, len(states))
|
||||
|
||||
for _, s := range states {
|
||||
if s.IsStale() {
|
||||
continue
|
||||
}
|
||||
|
||||
if a.doNotSaveNormalState && IsNormalStateWithNoReason(s.State) && !s.Changed() {
|
||||
continue
|
||||
}
|
||||
|
||||
key, err := s.GetAlertInstanceKey()
|
||||
if err != nil {
|
||||
logger.Error("Failed to create a key for alert state to save it. The state will be ignored ", "cacheID", s.CacheID, "error", err, "labels", s.Labels.String(), "rule_uid", ruleKey.UID, "rule_group", ruleKey.RuleGroup)
|
||||
continue
|
||||
}
|
||||
|
||||
instance := models.AlertInstance{
|
||||
AlertInstanceKey: key,
|
||||
Labels: models.InstanceLabels(s.Labels),
|
||||
CurrentState: models.InstanceStateType(s.State.State.String()),
|
||||
CurrentReason: s.StateReason,
|
||||
LastEvalTime: s.LastEvaluationTime,
|
||||
CurrentStateSince: s.StartsAt,
|
||||
CurrentStateEnd: s.EndsAt,
|
||||
ResolvedAt: s.ResolvedAt,
|
||||
LastSentAt: s.LastSentAt,
|
||||
ResultFingerprint: s.ResultFingerprint.String(),
|
||||
}
|
||||
|
||||
instancesToSave = append(instancesToSave, instance)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
logger.Debug("Saving alert states", "count", len(instancesToSave))
|
||||
err := a.store.SaveAlertInstancesForRule(ctx, ruleKey, instancesToSave)
|
||||
if err != nil {
|
||||
logger.Error("Failed to save alert rule state", "error", err, "duration", time.Since(start))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("Saving alert states done", "count", len(instancesToSave), "duration", time.Since(start))
|
||||
span.AddEvent("saved alert rule state")
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
func TestSyncRuleStatePersister_Sync(t *testing.T) {
|
||||
const orgID = int64(1)
|
||||
const ruleUID = "rule-id"
|
||||
const ruleGroup = "test-group"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
states StateTransitions
|
||||
ruleKey models.AlertRuleKeyWithGroup
|
||||
doNotSaveNormalState bool
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
name: "success case",
|
||||
states: StateTransitions{
|
||||
{
|
||||
State: &State{
|
||||
Labels: data.Labels{
|
||||
"label-1": "value-1",
|
||||
},
|
||||
LastEvaluationTime: time.Now(),
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
ruleKey: models.AlertRuleKeyWithGroup{
|
||||
AlertRuleKey: models.AlertRuleKey{
|
||||
OrgID: orgID,
|
||||
UID: ruleUID,
|
||||
},
|
||||
RuleGroup: ruleGroup,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockStore := new(FakeInstanceStore)
|
||||
persister := &SyncRuleStatePersister{
|
||||
log: log.New("test"),
|
||||
store: mockStore,
|
||||
}
|
||||
tracer := otel.Tracer("test")
|
||||
ctx, span := tracer.Start(ctx, "test-span")
|
||||
|
||||
instances := make([]models.AlertInstance, 0, len(tc.states))
|
||||
for _, s := range tc.states {
|
||||
key, err := s.GetAlertInstanceKey()
|
||||
require.NoError(t, err)
|
||||
instance := models.AlertInstance{
|
||||
AlertInstanceKey: key,
|
||||
Labels: models.InstanceLabels(s.Labels),
|
||||
CurrentState: models.InstanceStateType(s.State.State.String()),
|
||||
CurrentReason: s.StateReason,
|
||||
LastEvalTime: s.LastEvaluationTime,
|
||||
CurrentStateSince: s.StartsAt,
|
||||
CurrentStateEnd: s.EndsAt,
|
||||
ResolvedAt: s.ResolvedAt,
|
||||
LastSentAt: s.LastSentAt,
|
||||
ResultFingerprint: s.ResultFingerprint.String(),
|
||||
}
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
persister.Sync(ctx, span, tc.ruleKey, tc.states)
|
||||
|
||||
recordedCalls := mockStore.RecordedOps()
|
||||
require.Len(t, recordedCalls, 1)
|
||||
|
||||
for _, op := range recordedCalls {
|
||||
switch q := op.(type) {
|
||||
case FakeInstanceStoreOp:
|
||||
require.Equal(t, "SaveAlertInstancesForRule", q.Name)
|
||||
require.Equal(t, tc.ruleKey, q.Args[1])
|
||||
require.Equal(t, instances, q.Args[2])
|
||||
default:
|
||||
require.Fail(t, "unexpected call", "op: %v", op)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("no-op when store is nil", func(t *testing.T) {
|
||||
persister := &SyncRuleStatePersister{
|
||||
log: log.New("test"),
|
||||
}
|
||||
tracer := otel.Tracer("test")
|
||||
ctx := context.Background()
|
||||
ctx, span := tracer.Start(ctx, "test-span")
|
||||
ruleKey := models.AlertRuleKeyWithGroup{
|
||||
AlertRuleKey: models.AlertRuleKey{
|
||||
OrgID: orgID,
|
||||
UID: ruleUID,
|
||||
},
|
||||
RuleGroup: ruleGroup,
|
||||
}
|
||||
states := StateTransitions{
|
||||
{
|
||||
State: &State{
|
||||
Labels: data.Labels{
|
||||
"label-1": "value-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// There is no store, so no call to SaveAlertInstancesForRule or panic
|
||||
persister.Sync(ctx, span, ruleKey, states)
|
||||
})
|
||||
}
|
||||
@@ -59,6 +59,17 @@ func (f *FakeInstanceStore) DeleteAlertInstances(ctx context.Context, q ...model
|
||||
}
|
||||
|
||||
func (f *FakeInstanceStore) SaveAlertInstancesForRule(ctx context.Context, key models.AlertRuleKeyWithGroup, instances []models.AlertInstance) error {
|
||||
f.mtx.Lock()
|
||||
defer f.mtx.Unlock()
|
||||
|
||||
f.recordedOps = append(f.recordedOps, FakeInstanceStoreOp{
|
||||
Name: "SaveAlertInstancesForRule", Args: []any{
|
||||
ctx,
|
||||
key,
|
||||
instances,
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -487,14 +487,7 @@ func (c *K8sTestHelper) LoadYAMLOrJSON(body string) *unstructured.Unstructured {
|
||||
func (c *K8sTestHelper) createTestUsers(orgName string) OrgUsers {
|
||||
c.t.Helper()
|
||||
users := OrgUsers{
|
||||
Admin: c.CreateUser("admin", orgName, org.RoleAdmin, []resourcepermissions.SetResourcePermissionCommand{
|
||||
{
|
||||
Actions: []string{"dashboards:read", "dashboards:write", "dashboards:create", "dashboards:delete"},
|
||||
Resource: "dashboards",
|
||||
ResourceAttribute: "uid",
|
||||
ResourceID: "*",
|
||||
},
|
||||
}),
|
||||
Admin: c.CreateUser("admin", orgName, org.RoleAdmin, nil),
|
||||
Editor: c.CreateUser("editor", orgName, org.RoleEditor, nil),
|
||||
Viewer: c.CreateUser("viewer", orgName, org.RoleViewer, nil),
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ func (e *DataSourceHandler) newProcessCfg(query backend.DataQuery, queryContext
|
||||
}
|
||||
}
|
||||
|
||||
if qm.Format == dataQueryFormatTable && col == "timeend" {
|
||||
if qm.Format == dataQueryFormatTable && strings.EqualFold(col, "timeend") {
|
||||
qm.timeEndIndex = i
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -443,7 +443,7 @@ func (e *DataSourceHandler) newProcessCfg(query backend.DataQuery, queryContext
|
||||
}
|
||||
}
|
||||
|
||||
if qm.Format == dataQueryFormatTable && col == "timeend" {
|
||||
if qm.Format == dataQueryFormatTable && strings.EqualFold(col, "timeend") {
|
||||
qm.timeEndIndex = i
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -443,7 +443,7 @@ func (e *DataSourceHandler) newProcessCfg(query backend.DataQuery, queryContext
|
||||
}
|
||||
}
|
||||
|
||||
if qm.Format == dataQueryFormatTable && col == "timeend" {
|
||||
if qm.Format == dataQueryFormatTable && strings.EqualFold(col, "timeend") {
|
||||
qm.timeEndIndex = i
|
||||
continue
|
||||
}
|
||||
|
||||
File renamed without changes.
+15
-17
@@ -12,15 +12,15 @@
|
||||
// }
|
||||
// Name: test
|
||||
// Dimensions: 10 Fields by 3 Rows
|
||||
// +----------------------------------+------------------+--------------------+---------------------+-------------------+-----------------------------------------------------------------------------------------------------+-----------------+-----------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------+
|
||||
// | Name: traceID | Name: spanID | Name: parentSpanID | Name: operationName | Name: serviceName | Name: serviceTags | Name: startTime | Name: duration | Name: logs | Name: tags |
|
||||
// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: |
|
||||
// | Type: []string | Type: []string | Type: []*string | Type: []string | Type: []string | Type: []json.RawMessage | Type: []float64 | Type: []float64 | Type: []json.RawMessage | Type: []json.RawMessage |
|
||||
// +----------------------------------+------------------+--------------------+---------------------+-------------------+-----------------------------------------------------------------------------------------------------+-----------------+-----------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------+
|
||||
// | 000000000000007b00000000000001c8 | 0000000000000001 | null | span 1 | service 1 | [{"key":"ipv4","value":"1.0.0.1"},{"key":"port","value":42},{"key":"endpointType","value":"local"}] | 0.001 | 0.01 | [{"Timestamp":2,"Fields":[{"key":"annotation","value":"annotation text"}],"Name":""},{"Timestamp":6,"Fields":[{"key":"annotation","value":"annotation text 3"}],"Name":""}] | [{"key":"kind","value":"CLIENT"},{"key":"tag2","value":"val2"},{"key":"tag1","value":"val1"}] |
|
||||
// | 000000000000007b00000000000001c8 | 0000000000000002 | 0000000000000001 | span 2 | service 2 | [{"key":"ipv4","value":"1.0.0.1"},{"key":"endpointType","value":"local"}] | 0.004 | 0.005 | [] | [{"key":"error","value":true},{"key":"errorValue","value":"404"}] |
|
||||
// | 000000000000007b00000000000001c8 | 0000000000000003 | 0000000000000001 | span 3 | spanstore-jdbc | [{"key":"ipv6","value":"::1"},{"key":"endpointType","value":"remote"}] | 0.006 | 0.007 | [] | [] |
|
||||
// +----------------------------------+------------------+--------------------+---------------------+-------------------+-----------------------------------------------------------------------------------------------------+-----------------+-----------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------+
|
||||
// +----------------------------------+------------------+--------------------+---------------------+-------------------+-----------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------+
|
||||
// | Name: traceID | Name: spanID | Name: parentSpanID | Name: operationName | Name: serviceName | Name: serviceTags | Name: startTime | Name: duration | Name: logs | Name: tags |
|
||||
// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: |
|
||||
// | Type: []string | Type: []string | Type: []*string | Type: []string | Type: []string | Type: []json.RawMessage | Type: []float64 | Type: []float64 | Type: []json.RawMessage | Type: []json.RawMessage |
|
||||
// +----------------------------------+------------------+--------------------+---------------------+-------------------+-----------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------+
|
||||
// | 000000000000007b00000000000001c8 | 0000000000000001 | null | span 1 | service 1 | [{"key":"ipv4","value":"1.0.0.1"},{"key":"port","value":42},{"key":"endpointType","value":"local"}] | 0.001 | 0.01 | [{"Timestamp":2,"Fields":[{"key":"annotation","value":"annotation text"}]},{"Timestamp":6,"Fields":[{"key":"annotation","value":"annotation text 3"}]}] | [{"key":"kind","value":"CLIENT"},{"key":"tag1","value":"val1"},{"key":"tag2","value":"val2"}] |
|
||||
// | 000000000000007b00000000000001c8 | 0000000000000002 | 0000000000000001 | span 2 | service 2 | [{"key":"ipv4","value":"1.0.0.1"},{"key":"endpointType","value":"local"}] | 0.004 | 0.005 | [] | [{"key":"error","value":true},{"key":"errorValue","value":"404"}] |
|
||||
// | 000000000000007b00000000000001c8 | 0000000000000003 | 0000000000000001 | span 3 | spanstore-jdbc | [{"key":"ipv6","value":"::1"},{"key":"endpointType","value":"remote"}] | 0.006 | 0.007 | [] | [] |
|
||||
// +----------------------------------+------------------+--------------------+---------------------+-------------------+-----------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------+
|
||||
//
|
||||
//
|
||||
// 🌟 This was machine generated. Do not edit. 🌟
|
||||
@@ -196,8 +196,7 @@
|
||||
"key": "annotation",
|
||||
"value": "annotation text"
|
||||
}
|
||||
],
|
||||
"Name": ""
|
||||
]
|
||||
},
|
||||
{
|
||||
"Timestamp": 6,
|
||||
@@ -206,8 +205,7 @@
|
||||
"key": "annotation",
|
||||
"value": "annotation text 3"
|
||||
}
|
||||
],
|
||||
"Name": ""
|
||||
]
|
||||
}
|
||||
],
|
||||
[],
|
||||
@@ -219,13 +217,13 @@
|
||||
"key": "kind",
|
||||
"value": "CLIENT"
|
||||
},
|
||||
{
|
||||
"key": "tag2",
|
||||
"value": "val2"
|
||||
},
|
||||
{
|
||||
"key": "tag1",
|
||||
"value": "val1"
|
||||
},
|
||||
{
|
||||
"key": "tag2",
|
||||
"value": "val2"
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
@@ -22,7 +22,7 @@ import { transformSceneToSaveModel } from '../serialization/transformSceneToSave
|
||||
import { findVizPanelByKey } from '../utils/utils';
|
||||
|
||||
import { V1DashboardSerializer, V2DashboardSerializer } from './DashboardSceneSerializer';
|
||||
import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
|
||||
import { getPanelElement, transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
|
||||
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
@@ -559,8 +559,9 @@ describe('DashboardSceneSerializer', () => {
|
||||
editScene.state.panelRef.resolve().setState({ title: 'changed title' });
|
||||
|
||||
const result = dashboard.getDashboardChanges(false, true);
|
||||
const panelSaveModel = (result.changedSaveModel as DashboardV2Spec).elements['panel-1'].spec;
|
||||
expect(panelSaveModel.title).toBe('changed title');
|
||||
const panelSaveModel = getPanelElement(result.changedSaveModel as DashboardV2Spec, 'panel-1')!;
|
||||
|
||||
expect(panelSaveModel.spec.title).toBe('changed title');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+33
-14
@@ -38,7 +38,11 @@ import { getQueryRunnerFor } from '../utils/utils';
|
||||
import { validateVariable, validateVizPanel } from '../v2schema/test-helpers';
|
||||
|
||||
import { SnapshotVariable } from './custom-variables/SnapshotVariable';
|
||||
import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
|
||||
import {
|
||||
getLibraryPanelElement,
|
||||
getPanelElement,
|
||||
transformSaveModelSchemaV2ToScene,
|
||||
} from './transformSaveModelSchemaV2ToScene';
|
||||
import { transformCursorSynctoEnum } from './transformToV2TypesUtils';
|
||||
|
||||
const defaultDashboard: DashboardWithAccessInfo<DashboardV2Spec> = {
|
||||
@@ -217,29 +221,44 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
|
||||
// VizPanel
|
||||
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
|
||||
expect(vizPanels).toHaveLength(1);
|
||||
const vizPanel = vizPanels[0];
|
||||
validateVizPanel(vizPanel, dash);
|
||||
expect(vizPanels).toHaveLength(2);
|
||||
|
||||
// Layout
|
||||
const layout = scene.state.body as DefaultGridLayoutManager;
|
||||
expect(layout.state.grid.state.children.length).toBe(1);
|
||||
expect(layout.state.grid.state.children[0].state.key).toBe(`grid-item-${dash.elements['panel-1'].spec.id}`);
|
||||
|
||||
// Panel
|
||||
const panel = getPanelElement(dash, 'panel-1')!;
|
||||
expect(layout.state.grid.state.children.length).toBe(2);
|
||||
expect(layout.state.grid.state.children[0].state.key).toBe(`grid-item-${panel.spec.id}`);
|
||||
const gridLayoutItemSpec = dash.layout.spec.items[0].spec;
|
||||
expect(layout.state.grid.state.children[0].state.width).toBe(gridLayoutItemSpec.width);
|
||||
expect(layout.state.grid.state.children[0].state.height).toBe(gridLayoutItemSpec.height);
|
||||
expect(layout.state.grid.state.children[0].state.x).toBe(gridLayoutItemSpec.x);
|
||||
expect(layout.state.grid.state.children[0].state.y).toBe(gridLayoutItemSpec.y);
|
||||
const vizPanel = vizPanels.find((p) => p.state.key === 'panel-1')!;
|
||||
validateVizPanel(vizPanel, dash);
|
||||
|
||||
// Library Panel
|
||||
const libraryPanel = getLibraryPanelElement(dash, 'library-panel-1')!;
|
||||
expect(layout.state.grid.state.children[1].state.key).toBe(`grid-item-${libraryPanel.spec.uid}`);
|
||||
const libraryGridLayoutItemSpec = dash.layout.spec.items[1].spec;
|
||||
expect(layout.state.grid.state.children[1].state.width).toBe(libraryGridLayoutItemSpec.width);
|
||||
expect(layout.state.grid.state.children[1].state.height).toBe(libraryGridLayoutItemSpec.height);
|
||||
expect(layout.state.grid.state.children[1].state.x).toBe(libraryGridLayoutItemSpec.x);
|
||||
expect(layout.state.grid.state.children[1].state.y).toBe(libraryGridLayoutItemSpec.y);
|
||||
const vizLibraryPanel = vizPanels.find((p) => p.state.key === 'library-panel-1')!;
|
||||
validateVizPanel(vizLibraryPanel, dash);
|
||||
|
||||
// Transformations
|
||||
expect((vizPanel.state.$data as SceneDataTransformer)?.state.transformations[0]).toEqual(
|
||||
dash.elements['panel-1'].spec.data.spec.transformations[0].spec
|
||||
const panelWithTransformations = vizPanels.find((p) => p.state.key === 'panel-1')!;
|
||||
expect((panelWithTransformations.state.$data as SceneDataTransformer)?.state.transformations[0]).toEqual(
|
||||
getPanelElement(dash, 'panel-1')!.spec.data.spec.transformations[0].spec
|
||||
);
|
||||
});
|
||||
|
||||
it('should set panel ds if it is mixed DS', () => {
|
||||
const dashboard = cloneDeep(defaultDashboard);
|
||||
dashboard.spec.elements['panel-1'].spec.data.spec.queries.push({
|
||||
getPanelElement(dashboard.spec, 'panel-1')?.spec.data.spec.queries.push({
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId: 'A',
|
||||
@@ -260,14 +279,14 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
const scene = transformSaveModelSchemaV2ToScene(dashboard);
|
||||
|
||||
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
|
||||
expect(vizPanels.length).toBe(1);
|
||||
expect(vizPanels.length).toBe(2);
|
||||
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.type).toBe('mixed');
|
||||
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.uid).toBe(MIXED_DATASOURCE_NAME);
|
||||
});
|
||||
|
||||
it('should set panel ds as undefined if it is not mixed DS', () => {
|
||||
const dashboard = cloneDeep(defaultDashboard);
|
||||
dashboard.spec.elements['panel-1'].spec.data.spec.queries.push({
|
||||
getPanelElement(dashboard.spec, 'panel-1')?.spec.data.spec.queries.push({
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId: 'A',
|
||||
@@ -288,14 +307,14 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
const scene = transformSaveModelSchemaV2ToScene(dashboard);
|
||||
|
||||
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
|
||||
expect(vizPanels.length).toBe(1);
|
||||
expect(vizPanels.length).toBe(2);
|
||||
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set panel ds as mixed if one ds is undefined', () => {
|
||||
const dashboard = cloneDeep(defaultDashboard);
|
||||
|
||||
dashboard.spec.elements['panel-1'].spec.data.spec.queries.push({
|
||||
getPanelElement(dashboard.spec, 'panel-1')?.spec.data.spec.queries.push({
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId: 'A',
|
||||
@@ -312,7 +331,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
const scene = transformSaveModelSchemaV2ToScene(dashboard);
|
||||
|
||||
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
|
||||
expect(vizPanels.length).toBe(1);
|
||||
expect(vizPanels.length).toBe(2);
|
||||
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.type).toBe('mixed');
|
||||
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.uid).toBe(MIXED_DATASOURCE_NAME);
|
||||
});
|
||||
|
||||
+61
-9
@@ -46,6 +46,7 @@ import {
|
||||
defaultTextVariableKind,
|
||||
GroupByVariableKind,
|
||||
IntervalVariableKind,
|
||||
LibraryPanelKind,
|
||||
PanelKind,
|
||||
PanelQueryKind,
|
||||
QueryVariableKind,
|
||||
@@ -74,6 +75,7 @@ import { registerDashboardMacro } from '../scene/DashboardMacro';
|
||||
import { DashboardReloadBehavior } from '../scene/DashboardReloadBehavior';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { DashboardScopesFacade } from '../scene/DashboardScopesFacade';
|
||||
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
|
||||
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
|
||||
import { PanelNotices } from '../scene/PanelNotices';
|
||||
@@ -253,6 +255,18 @@ function createSceneGridLayoutForItems(dashboard: DashboardV2Spec): SceneGridIte
|
||||
repeatDirection: element.spec.repeat?.direction,
|
||||
maxPerRow: element.spec.repeat?.maxPerRow,
|
||||
});
|
||||
} else if (panel.kind === 'LibraryPanel') {
|
||||
const libraryPanel = buildLibraryPanel(panel);
|
||||
|
||||
return new DashboardGridItem({
|
||||
key: `grid-item-${panel.spec.uid}`,
|
||||
x: element.spec.x,
|
||||
y: element.spec.y,
|
||||
width: element.spec.width,
|
||||
height: element.spec.height,
|
||||
itemHeight: element.spec.height,
|
||||
body: libraryPanel,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unknown element kind: ${element.kind}`);
|
||||
}
|
||||
@@ -262,6 +276,45 @@ function createSceneGridLayoutForItems(dashboard: DashboardV2Spec): SceneGridIte
|
||||
});
|
||||
}
|
||||
|
||||
function buildLibraryPanel(panel: LibraryPanelKind): VizPanel {
|
||||
const titleItems: SceneObject[] = [];
|
||||
|
||||
if (config.featureToggles.angularDeprecationUI) {
|
||||
titleItems.push(new AngularDeprecation());
|
||||
}
|
||||
|
||||
titleItems.push(
|
||||
new VizPanelLinks({
|
||||
rawLinks: [],
|
||||
menu: new VizPanelLinksMenu({ $behaviors: [panelLinksBehavior] }),
|
||||
})
|
||||
);
|
||||
|
||||
titleItems.push(new PanelNotices());
|
||||
|
||||
const vizPanelState: VizPanelState = {
|
||||
key: panel.spec.uid,
|
||||
titleItems,
|
||||
$behaviors: [new LibraryPanelBehavior({ uid: panel.spec.uid, name: panel.spec.name })],
|
||||
extendPanelContext: setDashboardPanelContext,
|
||||
pluginId: LibraryPanelBehavior.LOADING_VIZ_PANEL_PLUGIN_ID,
|
||||
title: '',
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
};
|
||||
|
||||
if (!config.publicDashboardAccessToken) {
|
||||
vizPanelState.menu = new VizPanelMenu({
|
||||
$behaviors: [panelMenuBehavior],
|
||||
});
|
||||
}
|
||||
|
||||
return new VizPanel(vizPanelState);
|
||||
}
|
||||
|
||||
function buildVizPanel(panel: PanelKind): VizPanel {
|
||||
const titleItems: SceneObject[] = [];
|
||||
|
||||
@@ -299,15 +352,6 @@ function buildVizPanel(panel: PanelKind): VizPanel {
|
||||
// _UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel), //FIXME: Angular Migration
|
||||
};
|
||||
|
||||
// FIXME: Library Panel
|
||||
// if (panel.spec.libraryPanel) {
|
||||
// vizPanelState.$behaviors!.push(
|
||||
// new LibraryPanelBehavior({ uid: panel.spec.libraryPanel.uid, name: panel.spec.libraryPanel.name })
|
||||
// );
|
||||
// vizPanelState.pluginId = LibraryPanelBehavior.LOADING_VIZ_PANEL_PLUGIN_ID;
|
||||
// vizPanelState.$data = undefined;
|
||||
// }
|
||||
|
||||
if (!config.publicDashboardAccessToken) {
|
||||
vizPanelState.menu = new VizPanelMenu({
|
||||
$behaviors: [panelMenuBehavior],
|
||||
@@ -679,3 +723,11 @@ export function createSnapshotVariable(variable: TypedVariableModelV2): SceneVar
|
||||
});
|
||||
return snapshotVariable;
|
||||
}
|
||||
|
||||
export function getPanelElement(dashboard: DashboardV2Spec, elementName: string): PanelKind | undefined {
|
||||
return dashboard.elements[elementName].kind === 'Panel' ? dashboard.elements[elementName] : undefined;
|
||||
}
|
||||
|
||||
export function getLibraryPanelElement(dashboard: DashboardV2Spec, elementName: string): LibraryPanelKind | undefined {
|
||||
return dashboard.elements[elementName].kind === 'LibraryPanel' ? dashboard.elements[elementName] : undefined;
|
||||
}
|
||||
+44
-28
@@ -36,6 +36,8 @@ import {
|
||||
AdhocVariableKind,
|
||||
AnnotationQueryKind,
|
||||
DataLink,
|
||||
LibraryPanelKind,
|
||||
Element,
|
||||
RepeatOptions,
|
||||
} from '../../../../../packages/grafana-schema/src/schema/dashboard/v2alpha0';
|
||||
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
|
||||
@@ -45,6 +47,7 @@ import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
import {
|
||||
getLibraryPanelBehavior,
|
||||
getPanelIdForVizPanel,
|
||||
getQueryRunnerFor,
|
||||
getVizPanelKeyForPanelId,
|
||||
@@ -237,33 +240,46 @@ export function gridItemToGridLayoutItemKind(gridItem: DashboardGridItem, isSnap
|
||||
|
||||
function getElements(state: DashboardSceneState) {
|
||||
const panels = state.body.getVizPanels() ?? [];
|
||||
const panelsArray = panels.reduce((acc: PanelKind[], vizPanel: VizPanel) => {
|
||||
const elementSpec: PanelKind = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
id: getPanelIdForVizPanel(vizPanel),
|
||||
title: vizPanel.state.title,
|
||||
description: vizPanel.state.description ?? '',
|
||||
links: getPanelLinks(vizPanel),
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries: getVizPanelQueries(vizPanel),
|
||||
transformations: getVizPanelTransformations(vizPanel),
|
||||
queryOptions: getVizPanelQueryOptions(vizPanel),
|
||||
const panelsArray = panels.reduce((acc: Element[], vizPanel: VizPanel) => {
|
||||
if (isLibraryPanel(vizPanel)) {
|
||||
const behavior = getLibraryPanelBehavior(vizPanel)!;
|
||||
const elementSpec: LibraryPanelKind = {
|
||||
kind: 'LibraryPanel',
|
||||
spec: {
|
||||
name: behavior.state.name,
|
||||
uid: behavior.state.uid,
|
||||
},
|
||||
};
|
||||
acc.push(elementSpec);
|
||||
} else {
|
||||
const elementSpec: PanelKind = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
id: getPanelIdForVizPanel(vizPanel),
|
||||
title: vizPanel.state.title,
|
||||
description: vizPanel.state.description ?? '',
|
||||
links: getPanelLinks(vizPanel),
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries: getVizPanelQueries(vizPanel),
|
||||
transformations: getVizPanelTransformations(vizPanel),
|
||||
queryOptions: getVizPanelQueryOptions(vizPanel),
|
||||
},
|
||||
},
|
||||
vizConfig: {
|
||||
kind: vizPanel.state.pluginId,
|
||||
spec: {
|
||||
pluginVersion: vizPanel.state.pluginVersion ?? '',
|
||||
options: vizPanel.state.options,
|
||||
fieldConfig: (vizPanel.state.fieldConfig as FieldConfigSource) ?? defaultFieldConfigSource(),
|
||||
},
|
||||
},
|
||||
},
|
||||
vizConfig: {
|
||||
kind: vizPanel.state.pluginId,
|
||||
spec: {
|
||||
pluginVersion: vizPanel.state.pluginVersion ?? '',
|
||||
options: vizPanel.state.options,
|
||||
fieldConfig: (vizPanel.state.fieldConfig as FieldConfigSource) ?? defaultFieldConfigSource(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
acc.push(elementSpec);
|
||||
};
|
||||
acc.push(elementSpec);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
// create elements
|
||||
@@ -382,14 +398,14 @@ function getVizPanelQueryOptions(vizPanel: VizPanel): QueryOptionsSpec {
|
||||
return queryOptions;
|
||||
}
|
||||
|
||||
function createElements(panels: PanelKind[]): Record<string, PanelKind> {
|
||||
function createElements(panels: Element[]): Record<string, Element> {
|
||||
return panels.reduce(
|
||||
(acc, panel) => {
|
||||
const key = getVizPanelKeyForPanelId(panel.spec.id);
|
||||
const key = panel.kind === 'Panel' ? getVizPanelKeyForPanelId(panel.spec.id) : panel.spec.uid;
|
||||
acc[key] = panel;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, PanelKind>
|
||||
{} as Record<string, Element>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { VizPanelLinks } from '../scene/PanelLinks';
|
||||
import { TypedVariableModelV2 } from '../serialization/transformSaveModelSchemaV2ToScene';
|
||||
import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
|
||||
import { getLibraryPanelBehavior, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
|
||||
|
||||
type SceneVariableConstructor<T extends SceneVariableState, V extends SceneVariable<T>> = new (
|
||||
initialState: Partial<T>
|
||||
@@ -60,34 +61,44 @@ export function validateVariable<
|
||||
}
|
||||
|
||||
export function validateVizPanel(vizPanel: VizPanel, dash: DashboardV2Spec) {
|
||||
expect(vizPanel.state.title).toBe(dash.elements['panel-1'].spec.title);
|
||||
expect(vizPanel.state.description).toBe(dash.elements['panel-1'].spec.description);
|
||||
expect(vizPanel.state.pluginId).toBe(dash.elements['panel-1'].spec.vizConfig.kind);
|
||||
expect(vizPanel.state.pluginVersion).toBe(dash.elements['panel-1'].spec.vizConfig.spec.pluginVersion);
|
||||
expect(vizPanel.state.options).toEqual(dash.elements['panel-1'].spec.vizConfig.spec.options);
|
||||
expect(vizPanel.state.fieldConfig).toEqual(dash.elements['panel-1'].spec.vizConfig.spec.fieldConfig);
|
||||
expect(getPanelIdForVizPanel(vizPanel)).toBe(dash.elements['panel-1'].spec.id);
|
||||
expect(vizPanel.state.displayMode).toBe(dash.elements['panel-1'].spec.transparent ? 'transparent' : 'default');
|
||||
const panel = dash.elements[vizPanel.state.key!];
|
||||
|
||||
expect(vizPanel.state.$data).toBeInstanceOf(SceneDataTransformer);
|
||||
const dataTransformer = vizPanel.state.$data as SceneDataTransformer;
|
||||
expect(dataTransformer.state.transformations[0]).toEqual(
|
||||
dash.elements['panel-1'].spec.data.spec.transformations[0].spec
|
||||
);
|
||||
if (panel.kind === 'Panel') {
|
||||
expect(vizPanel.state.title).toBe(panel.spec.title);
|
||||
expect(vizPanel.state.description).toBe(panel.spec.description);
|
||||
expect(vizPanel.state.pluginId).toBe(panel.spec.vizConfig.kind);
|
||||
expect(vizPanel.state.pluginVersion).toBe(panel.spec.vizConfig.spec.pluginVersion);
|
||||
expect(vizPanel.state.options).toEqual(panel.spec.vizConfig.spec.options);
|
||||
expect(vizPanel.state.fieldConfig).toEqual(panel.spec.vizConfig.spec.fieldConfig);
|
||||
expect(getPanelIdForVizPanel(vizPanel)).toBe(panel.spec.id);
|
||||
expect(vizPanel.state.displayMode).toBe(panel.spec.transparent ? 'transparent' : 'default');
|
||||
|
||||
expect(dataTransformer.state.$data).toBeInstanceOf(SceneQueryRunner);
|
||||
const queryRunner = getQueryRunnerFor(vizPanel)!;
|
||||
expect(queryRunner).toBeInstanceOf(SceneQueryRunner);
|
||||
expect(queryRunner.state.queries).toEqual([
|
||||
{ datasource: { type: 'prometheus', uid: 'datasource1' }, expr: 'test-query', hide: false, refId: 'A' },
|
||||
]);
|
||||
expect(queryRunner.state.maxDataPoints).toBe(100);
|
||||
expect(queryRunner.state.cacheTimeout).toBe('1m');
|
||||
expect(queryRunner.state.queryCachingTTL).toBe(60);
|
||||
expect(queryRunner.state.minInterval).toBe('1m');
|
||||
const titleItems = vizPanel.state.titleItems as SceneObject[];
|
||||
const vizPanelLinks = titleItems[0] as VizPanelLinks;
|
||||
expect(vizPanelLinks.state.rawLinks).toHaveLength(dash.elements['panel-1'].spec.links.length);
|
||||
expect(vizPanelLinks.state.rawLinks).toEqual(dash.elements['panel-1'].spec.links);
|
||||
expect(queryRunner.state.dataLayerFilter?.panelId).toBe(dash.elements['panel-1'].spec.id);
|
||||
expect(vizPanel.state.$data).toBeInstanceOf(SceneDataTransformer);
|
||||
const dataTransformer = vizPanel.state.$data as SceneDataTransformer;
|
||||
expect(dataTransformer.state.transformations[0]).toEqual(panel.spec.data.spec.transformations[0].spec);
|
||||
|
||||
expect(dataTransformer.state.$data).toBeInstanceOf(SceneQueryRunner);
|
||||
const queryRunner = getQueryRunnerFor(vizPanel)!;
|
||||
expect(queryRunner).toBeInstanceOf(SceneQueryRunner);
|
||||
expect(queryRunner.state.queries).toEqual([
|
||||
{ datasource: { type: 'prometheus', uid: 'datasource1' }, expr: 'test-query', hide: false, refId: 'A' },
|
||||
]);
|
||||
expect(queryRunner.state.maxDataPoints).toBe(100);
|
||||
expect(queryRunner.state.cacheTimeout).toBe('1m');
|
||||
expect(queryRunner.state.queryCachingTTL).toBe(60);
|
||||
expect(queryRunner.state.minInterval).toBe('1m');
|
||||
const titleItems = vizPanel.state.titleItems as SceneObject[];
|
||||
const vizPanelLinks = titleItems[0] as VizPanelLinks;
|
||||
|
||||
expect(vizPanelLinks.state.rawLinks).toHaveLength(panel.spec.links.length);
|
||||
expect(vizPanelLinks.state.rawLinks).toEqual(panel.spec.links);
|
||||
expect(queryRunner.state.dataLayerFilter?.panelId).toBe(panel.spec.id);
|
||||
} else if (panel.kind === 'LibraryPanel') {
|
||||
expect(getLibraryPanelBehavior(vizPanel)?.state.name).toBe(panel.spec.name);
|
||||
expect(getLibraryPanelBehavior(vizPanel)?.state.uid).toBe(panel.spec.uid);
|
||||
|
||||
expect(vizPanel.state.pluginId).toBe(LibraryPanelBehavior.LOADING_VIZ_PANEL_PLUGIN_ID);
|
||||
} else {
|
||||
throw new Error('vizPanel is not a valid element kind');
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { CustomVariable, GroupByVariable } from '@grafana/scenes';
|
||||
import { LibraryPanel } from '@grafana/schema';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/examples';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import * as libpanels from 'app/features/library-panels/state/api';
|
||||
|
||||
import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene';
|
||||
import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2';
|
||||
@@ -43,6 +45,23 @@ jest.mock('@grafana/runtime', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const libraryPanel: LibraryPanel = {
|
||||
name: 'LibraryPanel A',
|
||||
uid: '111',
|
||||
type: 'table',
|
||||
model: {
|
||||
title: 'LibraryPanel A title',
|
||||
type: 'table',
|
||||
options: { showHeader: true },
|
||||
fieldConfig: { defaults: {}, overrides: [] },
|
||||
datasource: { uid: 'abcdef' },
|
||||
targets: [{ refId: 'A' }],
|
||||
},
|
||||
version: 1,
|
||||
};
|
||||
|
||||
jest.spyOn(libpanels, 'getLibraryPanel').mockResolvedValue(libraryPanel);
|
||||
|
||||
describe('V2 Transformers', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.groupByVariable = true;
|
||||
|
||||
@@ -281,6 +281,40 @@ describe('ResponseTransformers', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
panels: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'timeseries',
|
||||
title: 'Panel Title',
|
||||
gridPos: { x: 0, y: 0, w: 12, h: 8 },
|
||||
targets: [
|
||||
{
|
||||
refId: 'A',
|
||||
datasource: 'datasource1',
|
||||
expr: 'test-query',
|
||||
hide: false,
|
||||
},
|
||||
],
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'datasource1',
|
||||
},
|
||||
fieldConfig: { defaults: {}, overrides: [] },
|
||||
options: {},
|
||||
transparent: false,
|
||||
links: [],
|
||||
transformations: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'table',
|
||||
libraryPanel: {
|
||||
uid: 'library-panel-table',
|
||||
name: 'Table Panel as Library Panel',
|
||||
},
|
||||
gridPos: { x: 0, y: 8, w: 12, h: 8 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const dto: DashboardWithAccessInfo<DashboardDataDTO> = {
|
||||
@@ -317,6 +351,7 @@ describe('ResponseTransformers', () => {
|
||||
|
||||
const transformed = ResponseTransformers.ensureV2Response(dto);
|
||||
|
||||
// Metadata
|
||||
expect(transformed.apiVersion).toBe('v2alpha1');
|
||||
expect(transformed.kind).toBe('DashboardWithAccessInfo');
|
||||
expect(transformed.metadata.annotations?.[AnnoKeyCreatedBy]).toEqual('user1');
|
||||
@@ -327,6 +362,7 @@ describe('ResponseTransformers', () => {
|
||||
expect(transformed.metadata.annotations?.[AnnoKeyDashboardId]).toBe(123);
|
||||
expect(transformed.metadata.annotations?.[AnnoKeyDashboardGnetId]).toBe('something-like-a-uid');
|
||||
|
||||
// Spec
|
||||
const spec = transformed.spec;
|
||||
expect(spec.title).toBe(dashboardV1.title);
|
||||
expect(spec.description).toBe(dashboardV1.description);
|
||||
@@ -349,6 +385,90 @@ describe('ResponseTransformers', () => {
|
||||
expect(spec.timeSettings.weekStart).toBe(dashboardV1.weekStart);
|
||||
expect(spec.links).toEqual(dashboardV1.links);
|
||||
expect(spec.annotations).toEqual([]);
|
||||
|
||||
// Panel
|
||||
expect(spec.layout.spec.items).toHaveLength(2);
|
||||
expect(spec.layout.spec.items[0].spec).toEqual({
|
||||
element: {
|
||||
kind: 'ElementReference',
|
||||
name: '1',
|
||||
},
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 12,
|
||||
height: 8,
|
||||
});
|
||||
expect(spec.elements['1']).toEqual({
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
title: 'Panel Title',
|
||||
description: '',
|
||||
id: 1,
|
||||
links: [],
|
||||
vizConfig: {
|
||||
kind: 'timeseries',
|
||||
spec: {
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
options: {},
|
||||
pluginVersion: undefined,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
datasource: 'datasource1',
|
||||
hidden: false,
|
||||
query: {
|
||||
kind: 'prometheus',
|
||||
spec: {
|
||||
expr: 'test-query',
|
||||
},
|
||||
},
|
||||
refId: 'A',
|
||||
},
|
||||
},
|
||||
],
|
||||
queryOptions: {
|
||||
cacheTimeout: undefined,
|
||||
hideTimeOverride: undefined,
|
||||
interval: undefined,
|
||||
maxDataPoints: undefined,
|
||||
queryCachingTTL: undefined,
|
||||
timeFrom: undefined,
|
||||
timeShift: undefined,
|
||||
},
|
||||
transformations: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// Library Panel
|
||||
expect(spec.layout.spec.items[1].spec).toEqual({
|
||||
element: {
|
||||
kind: 'ElementReference',
|
||||
name: 'library-panel-table',
|
||||
},
|
||||
x: 0,
|
||||
y: 8,
|
||||
width: 12,
|
||||
height: 8,
|
||||
});
|
||||
expect(spec.elements['library-panel-table']).toEqual({
|
||||
kind: 'LibraryPanel',
|
||||
spec: {
|
||||
uid: 'library-panel-table',
|
||||
name: 'Table Panel as Library Panel',
|
||||
},
|
||||
});
|
||||
|
||||
// Variables
|
||||
validateVariablesV1ToV2(spec.variables[0], dashboardV1.templating?.list?.[0]);
|
||||
validateVariablesV1ToV2(spec.variables[1], dashboardV1.templating?.list?.[1]);
|
||||
validateVariablesV1ToV2(spec.variables[2], dashboardV1.templating?.list?.[2]);
|
||||
|
||||
@@ -254,56 +254,73 @@ function getElementsFromPanels(panels: Panel[]): [DashboardV2Spec['elements'], D
|
||||
|
||||
// iterate over panels
|
||||
for (const p of panels) {
|
||||
// FIXME: for now we should skip row panels
|
||||
if (p.type === 'row') {
|
||||
continue;
|
||||
}
|
||||
let elementName;
|
||||
|
||||
const queries = getPanelQueries(
|
||||
(p.targets as unknown as DataQuery[]) || [],
|
||||
p.datasource || getDefaultDatasource()
|
||||
);
|
||||
// LibraryPanelKind
|
||||
if (p.libraryPanel) {
|
||||
elementName = p.libraryPanel.uid;
|
||||
|
||||
const transformations = getPanelTransformations(p.transformations || []);
|
||||
|
||||
elements[p.id!] = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
title: p.title || '',
|
||||
description: p.description || '',
|
||||
vizConfig: {
|
||||
kind: p.type,
|
||||
spec: {
|
||||
fieldConfig: (p.fieldConfig as any) || defaultFieldConfigSource(),
|
||||
options: p.options as any,
|
||||
pluginVersion: p.pluginVersion!,
|
||||
},
|
||||
elements[elementName] = {
|
||||
kind: 'LibraryPanel',
|
||||
spec: {
|
||||
uid: p.libraryPanel.uid,
|
||||
name: p.libraryPanel.name,
|
||||
},
|
||||
links:
|
||||
p.links?.map<DataLink>((l) => ({
|
||||
title: l.title,
|
||||
url: l.url || '',
|
||||
targetBlank: l.targetBlank,
|
||||
})) || [],
|
||||
id: p.id!,
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries,
|
||||
transformations, // TODO[schema v2]: handle transformations
|
||||
queryOptions: {
|
||||
cacheTimeout: p.cacheTimeout,
|
||||
maxDataPoints: p.maxDataPoints,
|
||||
interval: p.interval,
|
||||
hideTimeOverride: p.hideTimeOverride,
|
||||
queryCachingTTL: p.queryCachingTTL,
|
||||
timeFrom: p.timeFrom,
|
||||
timeShift: p.timeShift,
|
||||
};
|
||||
// PanelKind
|
||||
} else {
|
||||
elementName = p.id!.toString();
|
||||
|
||||
// FIXME: for now we should skip row panels
|
||||
if (p.type === 'row') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queries = getPanelQueries(
|
||||
(p.targets as unknown as DataQuery[]) || [],
|
||||
p.datasource || getDefaultDatasource()
|
||||
);
|
||||
const transformations = getPanelTransformations(p.transformations || []);
|
||||
|
||||
elements[elementName] = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
title: p.title || '',
|
||||
description: p.description || '',
|
||||
vizConfig: {
|
||||
kind: p.type,
|
||||
spec: {
|
||||
fieldConfig: (p.fieldConfig as any) || defaultFieldConfigSource(),
|
||||
options: p.options as any,
|
||||
pluginVersion: p.pluginVersion!,
|
||||
},
|
||||
},
|
||||
links:
|
||||
p.links?.map<DataLink>((l) => ({
|
||||
title: l.title,
|
||||
url: l.url || '',
|
||||
targetBlank: l.targetBlank,
|
||||
})) || [],
|
||||
id: p.id!,
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries,
|
||||
transformations, // TODO[schema v2]: handle transformations
|
||||
queryOptions: {
|
||||
cacheTimeout: p.cacheTimeout,
|
||||
maxDataPoints: p.maxDataPoints,
|
||||
interval: p.interval,
|
||||
hideTimeOverride: p.hideTimeOverride,
|
||||
queryCachingTTL: p.queryCachingTTL,
|
||||
timeFrom: p.timeFrom,
|
||||
timeShift: p.timeShift,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
layout.spec.items.push({
|
||||
kind: 'GridLayoutItem',
|
||||
@@ -314,7 +331,7 @@ function getElementsFromPanels(panels: Panel[]): [DashboardV2Spec['elements'], D
|
||||
height: p.gridPos!.h,
|
||||
element: {
|
||||
kind: 'ElementReference',
|
||||
name: p.id!.toString(),
|
||||
name: elementName,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+6
-9
@@ -8,7 +8,6 @@ import { config, reportInteraction } from '@grafana/runtime';
|
||||
import {
|
||||
Card,
|
||||
EmptyState,
|
||||
HorizontalGroup,
|
||||
LinkButton,
|
||||
Pagination,
|
||||
Spinner,
|
||||
@@ -191,14 +190,12 @@ export const PublicDashboardListTable = () => {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Pagination
|
||||
onNavigate={setPage}
|
||||
currentPage={paginatedPublicDashboards.page}
|
||||
numberOfPages={paginatedPublicDashboards.totalPages}
|
||||
hideWhenSinglePage
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
<Pagination
|
||||
onNavigate={setPage}
|
||||
currentPage={paginatedPublicDashboards.page}
|
||||
numberOfPages={paginatedPublicDashboards.totalPages}
|
||||
hideWhenSinglePage
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('Team settings', () => {
|
||||
await userEvent.clear(screen.getByRole('textbox', { name: /Name/ }));
|
||||
await userEvent.type(screen.getByLabelText(/Email/i), 'team@test.com');
|
||||
// Submitting with userEvent doesn't work here
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Update' }));
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(await screen.findByText('Name is required')).toBeInTheDocument();
|
||||
await waitFor(() => expect(mockUpdate).not.toHaveBeenCalled());
|
||||
});
|
||||
@@ -58,7 +58,7 @@ describe('Team settings', () => {
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /Name/ }), 'New team');
|
||||
await userEvent.type(screen.getByLabelText(/Email/i), 'team@test.com');
|
||||
// Submitting with userEvent doesn't work here
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Update' }));
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith('New team', 'team@test.com'));
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { ConnectedProps, connect } from 'react-redux';
|
||||
|
||||
import { Input, Field, Button, FieldSet, Stack } from '@grafana/ui';
|
||||
import { Button, Field, FieldSet, Input, Stack } from '@grafana/ui';
|
||||
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
||||
import { useRoleOptions } from 'app/core/components/RolePicker/hooks';
|
||||
import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
|
||||
@@ -75,7 +75,7 @@ export const TeamSettings = ({ team, updateTeam }: Props) => {
|
||||
<Input {...register('email')} placeholder="team@email.com" type="email" id="email-input" />
|
||||
</Field>
|
||||
<Button type="submit" disabled={!canWriteTeamSettings}>
|
||||
Update
|
||||
Save
|
||||
</Button>
|
||||
</FieldSet>
|
||||
</form>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"@grafana/experimental": "2.1.6",
|
||||
"@grafana/lezer-logql": "0.2.6",
|
||||
"@grafana/lezer-traceql": "0.0.20",
|
||||
"@grafana/monaco-logql": "^0.0.7",
|
||||
"@grafana/monaco-logql": "^0.0.8",
|
||||
"@grafana/o11y-ds-frontend": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/schema": "workspace:*",
|
||||
|
||||
@@ -2049,6 +2049,12 @@
|
||||
"title": "Why host with Grafana?"
|
||||
}
|
||||
},
|
||||
"multicombobox": {
|
||||
"all": {
|
||||
"title": "All",
|
||||
"title-filtered": "All (filtered)"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"add-new-connections": {
|
||||
"title": "Add new connection"
|
||||
|
||||
@@ -2049,6 +2049,12 @@
|
||||
"title": "Ŵĥy ĥőşŧ ŵįŧĥ Ğřäƒäʼnä?"
|
||||
}
|
||||
},
|
||||
"multicombobox": {
|
||||
"all": {
|
||||
"title": "Åľľ",
|
||||
"title-filtered": "Åľľ (ƒįľŧęřęđ)"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"add-new-connections": {
|
||||
"title": "Åđđ ʼnęŵ čőʼnʼnęčŧįőʼn"
|
||||
|
||||
@@ -3007,7 +3007,7 @@ __metadata:
|
||||
"@grafana/experimental": "npm:2.1.6"
|
||||
"@grafana/lezer-logql": "npm:0.2.6"
|
||||
"@grafana/lezer-traceql": "npm:0.0.20"
|
||||
"@grafana/monaco-logql": "npm:^0.0.7"
|
||||
"@grafana/monaco-logql": "npm:^0.0.8"
|
||||
"@grafana/o11y-ds-frontend": "workspace:*"
|
||||
"@grafana/plugin-configs": "npm:11.5.0-pre"
|
||||
"@grafana/runtime": "workspace:*"
|
||||
@@ -3253,24 +3253,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/faro-core@npm:^1.12.2, @grafana/faro-core@npm:^1.3.6":
|
||||
version: 1.12.2
|
||||
resolution: "@grafana/faro-core@npm:1.12.2"
|
||||
"@grafana/faro-core@npm:^1.12.3, @grafana/faro-core@npm:^1.3.6":
|
||||
version: 1.12.3
|
||||
resolution: "@grafana/faro-core@npm:1.12.3"
|
||||
dependencies:
|
||||
"@opentelemetry/api": "npm:^1.9.0"
|
||||
"@opentelemetry/otlp-transformer": "npm:^0.53.0"
|
||||
checksum: 10/98b6d4dc5977fde257ee5968b4193ee9e683df67665f04e41053903796c250ca2d39bccefd61501908bfe500454faf452924714a613a7c84885e519cd5b598ce
|
||||
"@opentelemetry/otlp-transformer": "npm:^0.57.1"
|
||||
checksum: 10/616592a8e65c72be763f27baa3a44c228fc61aeff9cd6c895dac7b4adc24a836accc4766d574a3b5156896b3068c8d7df51c1d715679f3f1a1feef90dcaf82e3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/faro-web-sdk@npm:1.12.2, @grafana/faro-web-sdk@npm:^1.12.2, @grafana/faro-web-sdk@npm:^1.3.6":
|
||||
version: 1.12.2
|
||||
resolution: "@grafana/faro-web-sdk@npm:1.12.2"
|
||||
"@grafana/faro-web-sdk@npm:1.12.3, @grafana/faro-web-sdk@npm:^1.12.2, @grafana/faro-web-sdk@npm:^1.3.6":
|
||||
version: 1.12.3
|
||||
resolution: "@grafana/faro-web-sdk@npm:1.12.3"
|
||||
dependencies:
|
||||
"@grafana/faro-core": "npm:^1.12.2"
|
||||
"@grafana/faro-core": "npm:^1.12.3"
|
||||
ua-parser-js: "npm:^1.0.32"
|
||||
web-vitals: "npm:^4.0.1"
|
||||
checksum: 10/d8dde29c8c940f4850c0661190880837625977a2e6a500efcbe2ba299ee14c2a3189fc893ec1a0c6b6fcaa4057cf7f104b815000f0dbd181c68ec19e20789ba5
|
||||
checksum: 10/433b118ff5b8bc231e209902c20b8a645efe6efd9ecc4ef57947111fa076842becc9760b5bcc8957c1f04cd20c801241ba9cca0a6d4637b8aa2b034d7a7c1888
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3381,15 +3381,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/monaco-logql@npm:^0.0.7":
|
||||
version: 0.0.7
|
||||
resolution: "@grafana/monaco-logql@npm:0.0.7"
|
||||
peerDependencies:
|
||||
monaco-editor: ^0.32.1
|
||||
checksum: 10/a900e24912476997f30349e6ee24041c0cffebf6706d8b9203eb952f12c768fc0fd6be8d7abe86d02019a0d8f26b83d15ffd0fb09d97047307e2ba5b4ba43a1b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/monaco-logql@npm:^0.0.8":
|
||||
version: 0.0.8
|
||||
resolution: "@grafana/monaco-logql@npm:0.0.8"
|
||||
@@ -3510,7 +3501,7 @@ __metadata:
|
||||
"@grafana/data": "npm:11.5.0-pre"
|
||||
"@grafana/e2e-selectors": "npm:11.5.0-pre"
|
||||
"@grafana/experimental": "npm:2.1.6"
|
||||
"@grafana/faro-web-sdk": "npm:1.12.2"
|
||||
"@grafana/faro-web-sdk": "npm:1.12.3"
|
||||
"@grafana/runtime": "npm:11.5.0-pre"
|
||||
"@grafana/schema": "npm:11.5.0-pre"
|
||||
"@grafana/tsconfig": "npm:^2.0.0"
|
||||
@@ -5463,6 +5454,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/api-logs@npm:0.57.1":
|
||||
version: 0.57.1
|
||||
resolution: "@opentelemetry/api-logs@npm:0.57.1"
|
||||
dependencies:
|
||||
"@opentelemetry/api": "npm:^1.3.0"
|
||||
checksum: 10/4e06b34797f40245e8b51f52092cd74a44a5755a89bb80108428f7ef5490b8c812451fff3138d24d9b57e1f53a3b9815c40300dcf9852deacd64dad93990f736
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/api-metrics@npm:0.25.0":
|
||||
version: 0.25.0
|
||||
resolution: "@opentelemetry/api-metrics@npm:0.25.0"
|
||||
@@ -5472,7 +5472,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/api@npm:1.9.0, @opentelemetry/api@npm:^1.0.0, @opentelemetry/api@npm:^1.9.0":
|
||||
"@opentelemetry/api@npm:1.9.0, @opentelemetry/api@npm:^1.0.0, @opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.9.0":
|
||||
version: 1.9.0
|
||||
resolution: "@opentelemetry/api@npm:1.9.0"
|
||||
checksum: 10/a607f0eef971893c4f2ee2a4c2069aade6ec3e84e2a1f5c2aac19f65c5d9eeea41aa72db917c1029faafdd71789a1a040bdc18f40d63690e22ccae5d7070f194
|
||||
@@ -5511,7 +5511,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/core@npm:1.26.0, @opentelemetry/core@npm:^1.26.0":
|
||||
"@opentelemetry/core@npm:1.26.0":
|
||||
version: 1.26.0
|
||||
resolution: "@opentelemetry/core@npm:1.26.0"
|
||||
dependencies:
|
||||
@@ -5522,6 +5522,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/core@npm:1.30.1, @opentelemetry/core@npm:^1.26.0":
|
||||
version: 1.30.1
|
||||
resolution: "@opentelemetry/core@npm:1.30.1"
|
||||
dependencies:
|
||||
"@opentelemetry/semantic-conventions": "npm:1.28.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
checksum: 10/fa3df9619fdbf8f607132d72915849754b71c4c5f5f705b30c8c59b209abe97206decf25cb8ebafdbb6105a4baab2acddee47468cb9d0b67f1a8df96cebc3548
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/exporter-collector@npm:0.25.0":
|
||||
version: 0.25.0
|
||||
resolution: "@opentelemetry/exporter-collector@npm:0.25.0"
|
||||
@@ -5625,6 +5636,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/otlp-transformer@npm:^0.57.1":
|
||||
version: 0.57.1
|
||||
resolution: "@opentelemetry/otlp-transformer@npm:0.57.1"
|
||||
dependencies:
|
||||
"@opentelemetry/api-logs": "npm:0.57.1"
|
||||
"@opentelemetry/core": "npm:1.30.1"
|
||||
"@opentelemetry/resources": "npm:1.30.1"
|
||||
"@opentelemetry/sdk-logs": "npm:0.57.1"
|
||||
"@opentelemetry/sdk-metrics": "npm:1.30.1"
|
||||
"@opentelemetry/sdk-trace-base": "npm:1.30.1"
|
||||
protobufjs: "npm:^7.3.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ^1.3.0
|
||||
checksum: 10/49f77a22466faeda94cb87583fa71e8aa07ae3e218f320b877d48de37947df828d9f6c70e24a9ff03406198e687ce36d2b3c016936b20c77b9cb6e0bfce23573
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/resources@npm:0.25.0":
|
||||
version: 0.25.0
|
||||
resolution: "@opentelemetry/resources@npm:0.25.0"
|
||||
@@ -5637,7 +5665,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/resources@npm:1.26.0, @opentelemetry/resources@npm:^1.26.0":
|
||||
"@opentelemetry/resources@npm:1.26.0":
|
||||
version: 1.26.0
|
||||
resolution: "@opentelemetry/resources@npm:1.26.0"
|
||||
dependencies:
|
||||
@@ -5649,6 +5677,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/resources@npm:1.30.1, @opentelemetry/resources@npm:^1.26.0":
|
||||
version: 1.30.1
|
||||
resolution: "@opentelemetry/resources@npm:1.30.1"
|
||||
dependencies:
|
||||
"@opentelemetry/core": "npm:1.30.1"
|
||||
"@opentelemetry/semantic-conventions": "npm:1.28.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
checksum: 10/9b7544b639e8fee41315e2646615676ffb1020dba0f6c81e6ec1dd2daf5409fc6ce3d2b629bbd9cd32f85decc3a8bfa5dc8cc52bb72bd84c1777ca25b4301aa0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-logs@npm:0.53.0":
|
||||
version: 0.53.0
|
||||
resolution: "@opentelemetry/sdk-logs@npm:0.53.0"
|
||||
@@ -5662,6 +5702,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-logs@npm:0.57.1":
|
||||
version: 0.57.1
|
||||
resolution: "@opentelemetry/sdk-logs@npm:0.57.1"
|
||||
dependencies:
|
||||
"@opentelemetry/api-logs": "npm:0.57.1"
|
||||
"@opentelemetry/core": "npm:1.30.1"
|
||||
"@opentelemetry/resources": "npm:1.30.1"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.4.0 <1.10.0"
|
||||
checksum: 10/e1dc32ea4520436640b2aabfa81396c8977276ceebc9708a84d96742cc86866344d73b49d61dc6448984c741cc766c1653539caeacb9c4594c6d9f0e532840fc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-metrics-base@npm:0.25.0":
|
||||
version: 0.25.0
|
||||
resolution: "@opentelemetry/sdk-metrics-base@npm:0.25.0"
|
||||
@@ -5688,6 +5741,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-metrics@npm:1.30.1":
|
||||
version: 1.30.1
|
||||
resolution: "@opentelemetry/sdk-metrics@npm:1.30.1"
|
||||
dependencies:
|
||||
"@opentelemetry/core": "npm:1.30.1"
|
||||
"@opentelemetry/resources": "npm:1.30.1"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
checksum: 10/cfdbef083eab77ee62cf4d3f29508a0f444a2a2413554b2977632ea1e238fbd472c964b48e09eb48e2131f6cdc1957ff079838d53de5777207a041b594dd917a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-trace-base@npm:0.25.0":
|
||||
version: 0.25.0
|
||||
resolution: "@opentelemetry/sdk-trace-base@npm:0.25.0"
|
||||
@@ -5715,6 +5780,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-trace-base@npm:1.30.1":
|
||||
version: 1.30.1
|
||||
resolution: "@opentelemetry/sdk-trace-base@npm:1.30.1"
|
||||
dependencies:
|
||||
"@opentelemetry/core": "npm:1.30.1"
|
||||
"@opentelemetry/resources": "npm:1.30.1"
|
||||
"@opentelemetry/semantic-conventions": "npm:1.28.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
checksum: 10/3ba794622c9ff1d147b77fcd0c8547a6a1356edb5af884cf1d09838c71a004a044ea55d4c742b956e9247e46053583bdbda533836686b2f54ee1ecfc527254ff
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-trace-web@npm:1.26.0, @opentelemetry/sdk-trace-web@npm:^1.26.0":
|
||||
version: 1.26.0
|
||||
resolution: "@opentelemetry/sdk-trace-web@npm:1.26.0"
|
||||
@@ -9619,16 +9697,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@npm:*":
|
||||
version: 18.2.25
|
||||
resolution: "@types/react-dom@npm:18.2.25"
|
||||
dependencies:
|
||||
"@types/react": "npm:*"
|
||||
checksum: 10/0e45856a2fdbf09e74632b132b3af773c6b18fc2ab0bd04595c9f2bcc0bb04d5e732ac8156d145b712dedab7484a8fe9dce5cf720a5437b5d26099c7060c7ba4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@npm:18.3.5":
|
||||
"@types/react-dom@npm:*, @types/react-dom@npm:18.3.5":
|
||||
version: 18.3.5
|
||||
resolution: "@types/react-dom@npm:18.3.5"
|
||||
peerDependencies:
|
||||
@@ -9743,17 +9812,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:*":
|
||||
version: 18.3.3
|
||||
resolution: "@types/react@npm:18.3.3"
|
||||
dependencies:
|
||||
"@types/prop-types": "npm:*"
|
||||
csstype: "npm:^3.0.2"
|
||||
checksum: 10/68e203b7f1f91d6cf21f33fc7af9d6d228035a26c83f514981e54aa3da695d0ec6af10c277c6336de1dd76c4adbe9563f3a21f80c4462000f41e5f370b46e96c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:18.3.18":
|
||||
"@types/react@npm:*, @types/react@npm:18.3.18":
|
||||
version: 18.3.18
|
||||
resolution: "@types/react@npm:18.3.18"
|
||||
dependencies:
|
||||
|
||||
Reference in new issue
Block a user