Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c730b5b8b | ||
|
|
4aaf8ef714 | ||
|
|
5ff18c0802 | ||
|
|
0c8c886930 | ||
|
|
ba196958cd | ||
|
|
622c75af6d | ||
|
|
e983aac141 | ||
|
|
c42421e616 | ||
|
|
e4ba1c1a6d | ||
|
|
909ed02218 | ||
|
|
ad3763f04d | ||
|
|
ca6ab973b4 |
+22
-4
@@ -290,7 +290,7 @@
|
||||
],
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"placement": "right",
|
||||
"showLegend": true,
|
||||
"values": [
|
||||
"percent"
|
||||
@@ -304,7 +304,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": false,
|
||||
"showLegend": true,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -323,6 +323,15 @@
|
||||
}
|
||||
],
|
||||
"title": "Percent",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "^Backend-(.*)$",
|
||||
"renamePattern": "b-$1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
@@ -366,7 +375,7 @@
|
||||
],
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"placement": "right",
|
||||
"showLegend": true,
|
||||
"values": [
|
||||
"value"
|
||||
@@ -380,7 +389,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": false,
|
||||
"showLegend": true,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -399,6 +408,15 @@
|
||||
}
|
||||
],
|
||||
"title": "Value",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "(.*)",
|
||||
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
"legend": {
|
||||
"values": ["percent"],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
"placement": "right"
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
@@ -256,7 +256,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": false,
|
||||
"showLegend": true,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -272,6 +272,15 @@
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Percent",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "^Backend-(.*)$",
|
||||
"renamePattern": "b-$1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
@@ -311,7 +320,7 @@
|
||||
"legend": {
|
||||
"values": ["value"],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
"placement": "right"
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
@@ -319,7 +328,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": false,
|
||||
"showLegend": true,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -335,6 +344,15 @@
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Value",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "(.*)",
|
||||
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,6 +48,23 @@ scopes:
|
||||
operator: equals
|
||||
value: kids
|
||||
|
||||
# This scope appears in multiple places in the tree.
|
||||
# The defaultPath determines which path is shown when this scope is selected
|
||||
# (e.g., from a URL or programmatically), even if another path also links to it.
|
||||
shared-service:
|
||||
title: Shared Service
|
||||
# Path from the root node down to the direct scopeNode.
|
||||
# Node names are hierarchical (parent-child), so use the full names.
|
||||
# This points to: gdev-scopes > production > shared-service-prod
|
||||
defaultPath:
|
||||
- gdev-scopes
|
||||
- gdev-scopes-production
|
||||
- gdev-scopes-production-shared-service-prod
|
||||
filters:
|
||||
- key: service
|
||||
operator: equals
|
||||
value: shared
|
||||
|
||||
tree:
|
||||
gdev-scopes:
|
||||
title: gdev-scopes
|
||||
@@ -68,6 +85,13 @@ tree:
|
||||
nodeType: leaf
|
||||
linkId: app2
|
||||
linkType: scope
|
||||
# This node links to 'shared-service' scope.
|
||||
# The scope's defaultPath points here (production > gdev-scopes).
|
||||
shared-service-prod:
|
||||
title: Shared Service
|
||||
nodeType: leaf
|
||||
linkId: shared-service
|
||||
linkType: scope
|
||||
test-cases:
|
||||
title: Test cases
|
||||
nodeType: container
|
||||
@@ -83,6 +107,15 @@ tree:
|
||||
nodeType: leaf
|
||||
linkId: test-case-2
|
||||
linkType: scope
|
||||
# This node also links to the same 'shared-service' scope.
|
||||
# However, the scope's defaultPath points to the production path,
|
||||
# so selecting this scope will expand the tree to production > shared-service-prod.
|
||||
shared-service-test:
|
||||
title: Shared Service (also in Production)
|
||||
subTitle: defaultPath points to Production
|
||||
nodeType: leaf
|
||||
linkId: shared-service
|
||||
linkType: scope
|
||||
test-case-redirect:
|
||||
title: Test case with redirect
|
||||
nodeType: leaf
|
||||
|
||||
+16
-4
@@ -51,8 +51,9 @@ type Config struct {
|
||||
|
||||
// ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec
|
||||
type ScopeConfig struct {
|
||||
Title string `yaml:"title"`
|
||||
Filters []ScopeFilterConfig `yaml:"filters"`
|
||||
Title string `yaml:"title"`
|
||||
DefaultPath []string `yaml:"defaultPath,omitempty"`
|
||||
Filters []ScopeFilterConfig `yaml:"filters"`
|
||||
}
|
||||
|
||||
// ScopeFilterConfig is used for YAML parsing - converts to v0alpha1.ScopeFilter
|
||||
@@ -116,9 +117,20 @@ func convertScopeSpec(cfg ScopeConfig) v0alpha1.ScopeSpec {
|
||||
for i, f := range cfg.Filters {
|
||||
filters[i] = convertFilter(f)
|
||||
}
|
||||
|
||||
// Prefix defaultPath elements with the gdev prefix
|
||||
var defaultPath []string
|
||||
if len(cfg.DefaultPath) > 0 {
|
||||
defaultPath = make([]string, len(cfg.DefaultPath))
|
||||
for i, p := range cfg.DefaultPath {
|
||||
defaultPath[i] = prefix + "-" + p
|
||||
}
|
||||
}
|
||||
|
||||
return v0alpha1.ScopeSpec{
|
||||
Title: cfg.Title,
|
||||
Filters: filters,
|
||||
Title: cfg.Title,
|
||||
DefaultPath: defaultPath,
|
||||
Filters: filters,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,10 @@ JSON Body schema:
|
||||
- **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the folder under which the dashboard should be created.
|
||||
- **spec** – The dashboard json.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored.
|
||||
{{< /admonition >}}
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
@@ -521,6 +525,10 @@ JSON Body schema:
|
||||
- **metadata.annotations.grafana.app/message** - Optional field, to set a commit message for the version history.
|
||||
- **spec** – The dashboard json.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored.
|
||||
{{< /admonition >}}
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
|
||||
@@ -259,6 +259,10 @@ JSON Body schema:
|
||||
- **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the parent folder under which the folder should be created. Requires nested folders to be enabled.
|
||||
- **spec.title** – The title of the folder.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored.
|
||||
{{< /admonition >}}
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
@@ -337,6 +341,10 @@ JSON Body schema:
|
||||
- **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the parent folder under which the folder should be - update this to move the folder under a different parent folder. Requires nested folders to be enabled.
|
||||
- **spec.title** – The title of the folder.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored.
|
||||
{{< /admonition >}}
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
|
||||
@@ -51,6 +51,8 @@ test.describe('Dashboard keybindings with new layouts', { tag: ['@dashboards'] }
|
||||
|
||||
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible();
|
||||
|
||||
// Press Escape to close tooltip on the close button
|
||||
await page.keyboard.press('Escape');
|
||||
// Press Escape to close inspector
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ test.describe(
|
||||
|
||||
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible();
|
||||
|
||||
// Press Escape to close tooltip on the close button
|
||||
await page.keyboard.press('Escape');
|
||||
// Press Escape to close inspector
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
|
||||
@@ -82,9 +82,9 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
|
||||
await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100);
|
||||
|
||||
// click cell inspect, check that cell inspection pops open in the side as we'd expect.
|
||||
await loremIpsumCell.getByLabel('Inspect value').click();
|
||||
const loremIpsumText = await loremIpsumCell.textContent();
|
||||
expect(loremIpsumText).toBeDefined();
|
||||
await loremIpsumCell.getByLabel('Inspect value').click();
|
||||
await expect(page.getByRole('dialog').getByText(loremIpsumText!)).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -5340,6 +5340,7 @@ export type OrgUserDto = {
|
||||
};
|
||||
authLabels?: string[];
|
||||
avatarUrl?: string;
|
||||
created?: string;
|
||||
email?: string;
|
||||
isDisabled?: boolean;
|
||||
isExternallySynced?: boolean;
|
||||
@@ -6118,6 +6119,7 @@ export type ChangeUserPasswordCommand = {
|
||||
export type UserSearchHitDto = {
|
||||
authLabels?: string[];
|
||||
avatarUrl?: string;
|
||||
created?: string;
|
||||
email?: string;
|
||||
id?: number;
|
||||
isAdmin?: boolean;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { FloatingFocusManager, useFloating } from '@floating-ui/react';
|
||||
import RcDrawer from '@rc-component/drawer';
|
||||
import { useDialog } from '@react-aria/dialog';
|
||||
import { FocusScope } from '@react-aria/focus';
|
||||
import { useOverlay } from '@react-aria/overlays';
|
||||
import { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { ReactNode, useCallback, useEffect, useId, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@@ -81,17 +79,16 @@ export function Drawer({
|
||||
const styles = useStyles2(getStyles);
|
||||
const wrapperStyles = useStyles2(getWrapperStyles, size);
|
||||
const dragStyles = useStyles2(getDragStyles);
|
||||
const titleId = useId();
|
||||
|
||||
const overlayRef = React.useRef(null);
|
||||
const { dialogProps, titleProps } = useDialog({}, overlayRef);
|
||||
const { overlayProps } = useOverlay(
|
||||
{
|
||||
isDismissable: false,
|
||||
isOpen: true,
|
||||
onClose,
|
||||
const { context, refs } = useFloating({
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
overlayRef
|
||||
);
|
||||
});
|
||||
|
||||
// Adds body class while open so the toolbar nav can hide some actions while drawer is open
|
||||
useBodyClassWhileOpen();
|
||||
@@ -117,6 +114,8 @@ export function Drawer({
|
||||
minWidth,
|
||||
},
|
||||
}}
|
||||
aria-label={typeof title === 'string' ? selectors.components.Drawer.General.title(title) : undefined}
|
||||
aria-labelledby={typeof title !== 'string' ? titleId : undefined}
|
||||
width={''}
|
||||
motion={{
|
||||
motionAppear: true,
|
||||
@@ -129,18 +128,8 @@ export function Drawer({
|
||||
motionName: styles.maskMotion,
|
||||
}}
|
||||
>
|
||||
<FocusScope restoreFocus contain autoFocus>
|
||||
<div
|
||||
aria-label={
|
||||
typeof title === 'string'
|
||||
? selectors.components.Drawer.General.title(title)
|
||||
: selectors.components.Drawer.General.title('no title')
|
||||
}
|
||||
className={styles.container}
|
||||
{...overlayProps}
|
||||
{...dialogProps}
|
||||
ref={overlayRef}
|
||||
>
|
||||
<FloatingFocusManager context={context} modal>
|
||||
<div className={styles.container} ref={refs.setFloating}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={cx(dragStyles.dragHandleVertical, styles.resizer)}
|
||||
@@ -159,7 +148,7 @@ export function Drawer({
|
||||
</div>
|
||||
{typeof title === 'string' ? (
|
||||
<Stack direction="column">
|
||||
<Text element="h3" truncate {...titleProps}>
|
||||
<Text element="h3" truncate>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
@@ -169,13 +158,13 @@ export function Drawer({
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
title
|
||||
<div id={titleId}>{title}</div>
|
||||
)}
|
||||
{tabs && <div className={styles.tabsWrapper}>{tabs}</div>}
|
||||
</div>
|
||||
{!scrollableContent ? content : <ScrollContainer showScrollIndicators>{content}</ScrollContainer>}
|
||||
</div>
|
||||
</FocusScope>
|
||||
</FloatingFocusManager>
|
||||
</RcDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { useDialog } from '@react-aria/dialog';
|
||||
import { FocusScope } from '@react-aria/focus';
|
||||
import { OverlayContainer, useOverlay } from '@react-aria/overlays';
|
||||
import { PropsWithChildren, useRef, type JSX } from 'react';
|
||||
import * as React from 'react';
|
||||
import { FloatingFocusManager, useDismiss, useFloating, useInteractions, useRole } from '@floating-ui/react';
|
||||
import { OverlayContainer } from '@react-aria/overlays';
|
||||
import { PropsWithChildren, ReactNode, useId, type JSX } from 'react';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
@@ -66,23 +64,26 @@ export function Modal(props: PropsWithChildren<Props>) {
|
||||
trapFocus = true,
|
||||
} = props;
|
||||
const styles = useStyles2(getModalStyles);
|
||||
const titleId = useId();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle interacting outside the dialog and pressing
|
||||
// the Escape key to close the modal.
|
||||
const { overlayProps, underlayProps } = useOverlay(
|
||||
{ isKeyboardDismissDisabled: !closeOnEscape, isOpen, onClose: onDismiss },
|
||||
ref
|
||||
);
|
||||
|
||||
// Get props for the dialog and its title
|
||||
const { dialogProps, titleProps } = useDialog(
|
||||
{
|
||||
'aria-label': ariaLabel,
|
||||
const { context, refs } = useFloating({
|
||||
open: isOpen,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) {
|
||||
onDismiss?.();
|
||||
}
|
||||
},
|
||||
ref
|
||||
);
|
||||
});
|
||||
|
||||
const dismiss = useDismiss(context, {
|
||||
enabled: closeOnEscape,
|
||||
});
|
||||
|
||||
const role = useRole(context, {
|
||||
role: 'dialog',
|
||||
});
|
||||
|
||||
const { getFloatingProps } = useInteractions([dismiss, role]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -96,12 +97,17 @@ export function Modal(props: PropsWithChildren<Props>) {
|
||||
role="presentation"
|
||||
className={styles.modalBackdrop}
|
||||
onClick={onClickBackdrop || (closeOnBackdropClick ? onDismiss : undefined)}
|
||||
{...underlayProps}
|
||||
/>
|
||||
<FocusScope contain={trapFocus} autoFocus restoreFocus>
|
||||
<div className={cx(styles.modal, className)} ref={ref} {...overlayProps} {...dialogProps}>
|
||||
<FloatingFocusManager context={context} modal={trapFocus}>
|
||||
<div
|
||||
className={cx(styles.modal, className)}
|
||||
ref={refs.setFloating}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={typeof title === 'string' ? titleId : undefined}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
<div className={headerClass}>
|
||||
{typeof title === 'string' && <DefaultModalHeader {...props} title={title} id={titleProps.id} />}
|
||||
{typeof title === 'string' && <DefaultModalHeader {...props} title={title} id={titleId} />}
|
||||
{
|
||||
// FIXME: custom title components won't get an accessible title.
|
||||
// Do we really want to support them or shall we just limit this ModalTabsHeader?
|
||||
@@ -118,12 +124,12 @@ export function Modal(props: PropsWithChildren<Props>) {
|
||||
</div>
|
||||
<div className={cx(styles.modalContent, contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</FocusScope>
|
||||
</FloatingFocusManager>
|
||||
</OverlayContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalButtonRow({ leftItems, children }: { leftItems?: React.ReactNode; children: React.ReactNode }) {
|
||||
function ModalButtonRow({ leftItems, children }: { leftItems?: ReactNode; children: ReactNode }) {
|
||||
const styles = useStyles2(getModalStyles);
|
||||
|
||||
if (leftItems) {
|
||||
|
||||
@@ -75,5 +75,3 @@ return (
|
||||
</Toggletip>
|
||||
);
|
||||
```
|
||||
|
||||
<ArgTypes of={Toggletip} />
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '../Button/Button';
|
||||
import { Drawer } from '../Drawer/Drawer';
|
||||
import { Field } from '../Forms/Field';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Modal } from '../Modal/Modal';
|
||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
||||
import mdx from '../Toggletip/Toggletip.mdx';
|
||||
|
||||
@@ -133,4 +138,86 @@ LongContent.parameters = {
|
||||
},
|
||||
};
|
||||
|
||||
export const InsideDrawer: StoryFn<typeof Toggletip> = () => {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsDrawerOpen(true)}>Open Drawer</Button>
|
||||
{isDrawerOpen && (
|
||||
<Drawer title="Drawer with Toggletip" onClose={() => setIsDrawerOpen(false)}>
|
||||
<div>
|
||||
<p style={{ marginBottom: '16px' }}>This demonstrates using Toggletip inside a Drawer.</p>
|
||||
<Toggletip
|
||||
title="Interactive Form"
|
||||
content={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<Field label="Name">
|
||||
<Input placeholder="Enter your name" />
|
||||
</Field>
|
||||
<Button variant="primary" size="sm">
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
footer="Focus should work correctly within this Toggletip"
|
||||
placement="bottom-start"
|
||||
>
|
||||
<Button>Click to show Toggletip</Button>
|
||||
</Toggletip>
|
||||
</div>
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
InsideDrawer.parameters = {
|
||||
controls: {
|
||||
hideNoControlsWarning: true,
|
||||
exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'],
|
||||
},
|
||||
};
|
||||
|
||||
export const InsideModal: StoryFn<typeof Toggletip> = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsModalOpen(true)}>Open Modal</Button>
|
||||
<Modal title="Modal with Toggletip" isOpen={isModalOpen} onDismiss={() => setIsModalOpen(false)}>
|
||||
<div>
|
||||
<p style={{ marginBottom: '16px' }}>This demonstrates using Toggletip inside a Modal.</p>
|
||||
<Modal.ButtonRow>
|
||||
<Toggletip
|
||||
title="Interactive Form"
|
||||
content={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<Field label="Name">
|
||||
<Input placeholder="Enter your name" />
|
||||
</Field>
|
||||
<Button variant="primary" size="sm">
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
footer="Focus should work correctly within this Toggletip"
|
||||
placement="bottom-start"
|
||||
>
|
||||
<Button>Click to show Toggletip</Button>
|
||||
</Toggletip>
|
||||
</Modal.ButtonRow>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
InsideDrawer.parameters = {
|
||||
controls: {
|
||||
hideNoControlsWarning: true,
|
||||
exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'],
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface UsersIndicatorProps {
|
||||
* https://developers.grafana.com/ui/latest/index.html?path=/docs/iconography-usersindicator--docs
|
||||
*/
|
||||
export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const styles = useStyles2(getStyles, limit);
|
||||
if (!users.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -39,6 +39,9 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp
|
||||
className={styles.container}
|
||||
aria-label={t('grafana-ui.users-indicator.container-label', 'Users indicator container')}
|
||||
>
|
||||
{users.slice(0, limitReached ? limit : limit + 1).map((userView, idx, arr) => (
|
||||
<UserIcon key={userView.user.name} userView={userView} />
|
||||
))}
|
||||
{limitReached && (
|
||||
<UserIcon onClick={onClick} userView={{ user: { name: 'Extra users' } }} showTooltip={false}>
|
||||
{tooManyUsers
|
||||
@@ -47,26 +50,30 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp
|
||||
: `+${extraUsers}`}
|
||||
</UserIcon>
|
||||
)}
|
||||
{users
|
||||
.slice(0, limitReached ? limit : limit + 1)
|
||||
.reverse()
|
||||
.map((userView) => (
|
||||
<UserIcon key={userView.user.name} userView={userView} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
const getStyles = (theme: GrafanaTheme2, limit: number) => {
|
||||
return {
|
||||
container: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row-reverse',
|
||||
marginLeft: theme.spacing(1),
|
||||
isolation: 'isolate',
|
||||
|
||||
'& > button': {
|
||||
marginLeft: theme.spacing(-1), // Overlay the elements a bit on top of each other
|
||||
|
||||
// Ensure overlaying user icons are stacked correctly with z-index on each element
|
||||
...Object.fromEntries(
|
||||
Array.from({ length: limit }).map((_, idx) => [
|
||||
`&:nth-of-type(${idx + 1})`,
|
||||
{
|
||||
zIndex: limit - idx,
|
||||
},
|
||||
])
|
||||
),
|
||||
},
|
||||
}),
|
||||
dots: css({
|
||||
|
||||
@@ -119,6 +119,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
table: css({
|
||||
width: '100%',
|
||||
'th:first-child': {
|
||||
width: '100%',
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -134,6 +134,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
label: 'LegendLabelCell',
|
||||
maxWidth: 0,
|
||||
width: '100%',
|
||||
minWidth: theme.spacing(16),
|
||||
}),
|
||||
labelCellInner: css({
|
||||
label: 'LegendLabelCellInner',
|
||||
|
||||
@@ -44,7 +44,7 @@ var groupVersion = schema.GroupVersion{
|
||||
}
|
||||
|
||||
type APIBuilder struct {
|
||||
providerType string
|
||||
providerType setting.OpenFeatureProviderType
|
||||
url *url.URL
|
||||
insecure bool
|
||||
caFile string
|
||||
@@ -52,7 +52,7 @@ type APIBuilder struct {
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func NewAPIBuilder(providerType string, url *url.URL, insecure bool, caFile string, staticEvaluator featuremgmt.StaticFlagEvaluator) *APIBuilder {
|
||||
func NewAPIBuilder(providerType setting.OpenFeatureProviderType, url *url.URL, insecure bool, caFile string, staticEvaluator featuremgmt.StaticFlagEvaluator) *APIBuilder {
|
||||
return &APIBuilder{
|
||||
providerType: providerType,
|
||||
url: url,
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
alertingac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -25,7 +24,7 @@ var (
|
||||
)
|
||||
|
||||
type ReceiverService interface {
|
||||
GetReceiver(ctx context.Context, q ngmodels.GetReceiverQuery, user identity.Requester) (*ngmodels.Receiver, error)
|
||||
GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*ngmodels.Receiver, error)
|
||||
GetReceivers(ctx context.Context, q ngmodels.GetReceiversQuery, user identity.Requester) ([]*ngmodels.Receiver, error)
|
||||
CreateReceiver(ctx context.Context, r *ngmodels.Receiver, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
|
||||
UpdateReceiver(ctx context.Context, r *ngmodels.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
|
||||
@@ -116,22 +115,12 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name, err := legacy_storage.UidToName(uid)
|
||||
if err != nil {
|
||||
return nil, apierrors.NewNotFound(ResourceInfo.GroupResource(), uid)
|
||||
}
|
||||
q := ngmodels.GetReceiverQuery{
|
||||
OrgID: info.OrgID,
|
||||
Name: name,
|
||||
Decrypt: false,
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r, err := s.service.GetReceiver(ctx, q, user)
|
||||
r, err := s.service.GetReceiver(ctx, uid, false, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
// OpenFeatureConfig holds configuration for initializing OpenFeature
|
||||
type OpenFeatureConfig struct {
|
||||
// ProviderType is either "static", "features-service", or "ofrep"
|
||||
ProviderType string
|
||||
ProviderType setting.OpenFeatureProviderType
|
||||
// URL is the remote provider's URL (required for features-service + OFREP providers)
|
||||
URL *url.URL
|
||||
// HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers)
|
||||
@@ -98,7 +98,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
|
||||
}
|
||||
|
||||
func createProvider(
|
||||
providerType string,
|
||||
providerType setting.OpenFeatureProviderType,
|
||||
u *url.URL,
|
||||
staticFlags map[string]bool,
|
||||
httpClient *http.Client,
|
||||
|
||||
@@ -16,13 +16,6 @@ import (
|
||||
"github.com/grafana/alerting/receivers/schema"
|
||||
)
|
||||
|
||||
// GetReceiverQuery represents a query for a single receiver.
|
||||
type GetReceiverQuery struct {
|
||||
OrgID int64
|
||||
Name string
|
||||
Decrypt bool
|
||||
}
|
||||
|
||||
// GetReceiversQuery represents a query for receiver groups.
|
||||
type GetReceiversQuery struct {
|
||||
OrgID int64
|
||||
|
||||
@@ -378,3 +378,29 @@ func EncryptedReceivers(receivers []*definitions.PostableApiReceiver, encryptFn
|
||||
}
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// DecryptIntegrationSettings returns a function to decrypt integration settings.
|
||||
func DecryptIntegrationSettings(ctx context.Context, ss secretService) models.DecryptFn {
|
||||
return func(value string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
decrypted, err := ss.Decrypt(ctx, decoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decrypted), nil
|
||||
}
|
||||
}
|
||||
|
||||
// EncryptIntegrationSettings returns a function to encrypt integration settings.
|
||||
func EncryptIntegrationSettings(ctx context.Context, ss secretService) models.EncryptFn {
|
||||
return func(payload string) (string, error) {
|
||||
encrypted, err := ss.Encrypt(ctx, []byte(payload), secrets.WithoutScope())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
package notifier
|
||||
|
||||
import "github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"github.com/grafana/alerting/receivers/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
)
|
||||
|
||||
// WithPublicError sets the public message of an errutil error to the error message.
|
||||
func WithPublicError(err errutil.Error) error {
|
||||
err.PublicMessage = err.Error()
|
||||
return err
|
||||
}
|
||||
|
||||
// If provided error is errutil.Error it appends fields that caused the error to public payload
|
||||
func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error {
|
||||
var authzErr errutil.Error
|
||||
if !errors.As(err, &authzErr) {
|
||||
return err
|
||||
}
|
||||
if authzErr.PublicPayload == nil {
|
||||
authzErr.PublicPayload = map[string]interface{}{}
|
||||
}
|
||||
fields := make(map[string][]string, len(diff))
|
||||
for field, paths := range diff {
|
||||
fields[field] = make([]string, len(paths))
|
||||
for i, path := range paths {
|
||||
fields[field][i] = path.String()
|
||||
}
|
||||
slices.Sort(fields[field])
|
||||
}
|
||||
authzErr.PublicPayload["changed_protected_fields"] = fields
|
||||
return authzErr
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -133,30 +132,33 @@ func (rs *ReceiverService) loadProvenances(ctx context.Context, orgID int64) (ma
|
||||
return rs.provisioningStore.GetProvenances(ctx, orgID, (&models.Integration{}).ResourceType())
|
||||
}
|
||||
|
||||
// GetReceiver returns a receiver by name.
|
||||
// GetReceiver returns a receiver by its UID.
|
||||
// The receiver's secure settings are decrypted if requested and the user has access to do so.
|
||||
func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (*models.Receiver, error) {
|
||||
func (rs *ReceiverService) GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*models.Receiver, error) {
|
||||
if user == nil {
|
||||
return nil, errors.New("user is required")
|
||||
}
|
||||
ctx, span := rs.tracer.Start(ctx, "alerting.receivers.get", trace.WithAttributes(
|
||||
attribute.Int64("query_org_id", q.OrgID),
|
||||
attribute.String("query_name", q.Name),
|
||||
attribute.Bool("query_decrypt", q.Decrypt),
|
||||
attribute.Int64("query_org_id", user.GetOrgID()),
|
||||
attribute.String("query_uid", uid),
|
||||
attribute.Bool("query_decrypt", decrypt),
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
revision, err := rs.cfgStore.Get(ctx, q.OrgID)
|
||||
revision, err := rs.cfgStore.Get(ctx, user.GetOrgID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prov, err := rs.loadProvenances(ctx, q.OrgID)
|
||||
prov, err := rs.loadProvenances(ctx, user.GetOrgID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rcv, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name), prov)
|
||||
rcv, err := revision.GetReceiver(uid, prov)
|
||||
if err != nil {
|
||||
if errors.Is(err, legacy_storage.ErrReceiverNotFound) && rs.includeImported {
|
||||
imported := rs.getImportedReceivers(ctx, span, []string{legacy_storage.NameToUid(q.Name)}, revision)
|
||||
imported := rs.getImportedReceivers(ctx, span, []string{uid}, revision)
|
||||
if len(imported) > 0 {
|
||||
rcv = imported[0]
|
||||
}
|
||||
@@ -171,14 +173,14 @@ func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiver
|
||||
))
|
||||
|
||||
auth := rs.authz.AuthorizeReadDecrypted
|
||||
if !q.Decrypt {
|
||||
if !decrypt {
|
||||
auth = rs.authz.AuthorizeRead
|
||||
}
|
||||
if err := auth(ctx, user, rcv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if q.Decrypt {
|
||||
if decrypt {
|
||||
err := rcv.Decrypt(rs.decryptor(ctx))
|
||||
if err != nil {
|
||||
rs.log.FromContext(ctx).Warn("Failed to decrypt secure settings", "name", rcv.Name, "error", err)
|
||||
@@ -684,28 +686,12 @@ func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, i
|
||||
|
||||
// decryptor returns a models.DecryptFn that decrypts a secure setting. If decryption fails, the fallback value is used.
|
||||
func (rs *ReceiverService) decryptor(ctx context.Context) models.DecryptFn {
|
||||
return func(value string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
decrypted, err := rs.encryptionService.Decrypt(ctx, decoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decrypted), nil
|
||||
}
|
||||
return DecryptIntegrationSettings(ctx, rs.encryptionService)
|
||||
}
|
||||
|
||||
// encryptor creates an encrypt function that delegates to secrets.Service and returns the base64 encoded result.
|
||||
func (rs *ReceiverService) encryptor(ctx context.Context) models.EncryptFn {
|
||||
return func(payload string) (string, error) {
|
||||
s, err := rs.encryptionService.Encrypt(ctx, []byte(payload), secrets.WithoutScope())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(s), nil
|
||||
}
|
||||
return EncryptIntegrationSettings(ctx, rs.encryptionService)
|
||||
}
|
||||
|
||||
// checkOptimisticConcurrency checks if the existing receiver's version matches the desired version.
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"github.com/grafana/alerting/receivers/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
)
|
||||
|
||||
func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error {
|
||||
var authzErr errutil.Error
|
||||
if !errors.As(err, &authzErr) {
|
||||
return err
|
||||
}
|
||||
if authzErr.PublicPayload == nil {
|
||||
authzErr.PublicPayload = map[string]interface{}{}
|
||||
}
|
||||
fields := make(map[string][]string, len(diff))
|
||||
for field, paths := range diff {
|
||||
fields[field] = make([]string, len(paths))
|
||||
for i, path := range paths {
|
||||
fields[field][i] = path.String()
|
||||
}
|
||||
slices.Sort(fields[field])
|
||||
}
|
||||
authzErr.PublicPayload["changed_protected_fields"] = fields
|
||||
return authzErr
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
|
||||
|
||||
t.Run("service gets receiver from AM config", func(t *testing.T) {
|
||||
sut := createReceiverServiceSut(t, secretsService)
|
||||
recv, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser)
|
||||
recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "slack receiver", recv.Name)
|
||||
require.Len(t, recv.Integrations, 1)
|
||||
@@ -60,7 +60,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
|
||||
t.Run("service returns error when receiver does not exist", func(t *testing.T) {
|
||||
sut := createReceiverServiceSut(t, secretsService)
|
||||
|
||||
_, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser)
|
||||
_, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser)
|
||||
require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound)
|
||||
})
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
|
||||
t.Run("gets imported receivers", func(t *testing.T) {
|
||||
sut := createReceiverServiceSut(t, secretsService, withImportedIncluded)
|
||||
|
||||
recv, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser)
|
||||
recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.ResourceOriginImported, recv.Origin)
|
||||
assert.Equal(t, "receiver1", recv.Name)
|
||||
@@ -81,9 +81,9 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
|
||||
|
||||
t.Run("falls to only Grafana if cannot read imported receivers", func(t *testing.T) {
|
||||
sut := createReceiverServiceSut(t, secretsService, withImportedIncluded, withInvalidExtraConfig)
|
||||
_, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser)
|
||||
_, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser)
|
||||
require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound)
|
||||
_, err = sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser)
|
||||
_, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
})
|
||||
@@ -187,12 +187,6 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) {
|
||||
user: readUser,
|
||||
err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver",
|
||||
},
|
||||
{
|
||||
name: "service returns error if user is nil and decrypt is true",
|
||||
decrypt: true,
|
||||
user: nil,
|
||||
err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver",
|
||||
},
|
||||
{
|
||||
name: "service decrypts receivers with permission",
|
||||
decrypt: true,
|
||||
@@ -224,18 +218,16 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for _, o := range origin {
|
||||
for _, method := range getMethods {
|
||||
t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) {
|
||||
for _, method := range getMethods {
|
||||
t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) {
|
||||
for _, o := range origin {
|
||||
t.Run(fmt.Sprintf("%s %s (%s)", tc.name, method, o.origin), func(t *testing.T) {
|
||||
sut := createReceiverServiceSut(t, secretsService, o.opts...)
|
||||
|
||||
var res *models.Receiver
|
||||
var err error
|
||||
if method == "single" {
|
||||
q := singleQ(1, o.receiver)
|
||||
q.Decrypt = tc.decrypt
|
||||
res, err = sut.GetReceiver(context.Background(), q, tc.user)
|
||||
res, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(o.receiver), tc.decrypt, tc.user)
|
||||
} else {
|
||||
q := multiQ(1, o.receiver)
|
||||
q.Decrypt = tc.decrypt
|
||||
@@ -267,8 +259,8 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) {
|
||||
require.NotEqual(t, o.decryptedSettingValue, res.Integrations[0].SecureSettings[o.secureSettingKey])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,8 +404,7 @@ func TestReceiverService_Delete(t *testing.T) {
|
||||
// Ensure receiver saved to store is correct.
|
||||
name, err := legacy_storage.UidToName(tc.deleteUID)
|
||||
require.NoError(t, err)
|
||||
q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: name}
|
||||
_, err = sut.GetReceiver(context.Background(), q, writer)
|
||||
_, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(name), false, writer)
|
||||
assert.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound)
|
||||
|
||||
provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType())
|
||||
@@ -626,8 +617,7 @@ func TestReceiverService_Create(t *testing.T) {
|
||||
assert.Equal(t, tc.expectedCreate, *created)
|
||||
|
||||
// Ensure receiver saved to store is correct.
|
||||
q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true}
|
||||
stored, err := sut.GetReceiver(context.Background(), q, decryptUser)
|
||||
stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser)
|
||||
require.NoError(t, err)
|
||||
decrypted := models.CopyReceiverWith(tc.expectedCreate, models.ReceiverMuts.Decrypted(models.Base64Decrypt))
|
||||
decrypted.Version = tc.expectedCreate.Version // Version is calculated before decryption.
|
||||
@@ -931,8 +921,7 @@ func TestReceiverService_Update(t *testing.T) {
|
||||
assert.Equal(t, tc.expectedUpdate, *updated)
|
||||
|
||||
// Ensure receiver saved to store is correct.
|
||||
q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true}
|
||||
stored, err := sut.GetReceiver(context.Background(), q, decryptUser)
|
||||
stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser)
|
||||
require.NoError(t, err)
|
||||
decrypted := models.CopyReceiverWith(tc.expectedUpdate, models.ReceiverMuts.Decrypted(models.Base64Decrypt))
|
||||
decrypted.Version = tc.expectedUpdate.Version // Version is calculated before decryption.
|
||||
@@ -1054,7 +1043,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) {
|
||||
sut.ruleNotificationsStore = ruleStore
|
||||
|
||||
newReceiverName = "receiver1"
|
||||
actual, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer)
|
||||
actual, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, models.ResourceOriginImported, actual.Origin)
|
||||
require.Equal(t, newReceiverName, actual.Name)
|
||||
@@ -1067,7 +1056,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) {
|
||||
require.NotEqual(t, actual, recv)
|
||||
require.Equal(t, models.ResourceOriginGrafana, recv.Origin)
|
||||
|
||||
actual, err = sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer)
|
||||
actual, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, recv.Name, actual.Name)
|
||||
})
|
||||
@@ -1185,7 +1174,7 @@ func TestReceiverServiceAC_Read(t *testing.T) {
|
||||
return false
|
||||
}
|
||||
for _, recv := range allReceivers() {
|
||||
response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr)
|
||||
response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr)
|
||||
if isVisible(recv.UID) {
|
||||
require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name)
|
||||
assert.NotNil(t, response)
|
||||
@@ -1207,7 +1196,7 @@ func TestReceiverServiceAC_Read(t *testing.T) {
|
||||
}
|
||||
sut.authz = ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), true)
|
||||
for _, recv := range allReceivers() {
|
||||
response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr)
|
||||
response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr)
|
||||
if isVisibleInProvisioning(recv.UID) {
|
||||
require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name)
|
||||
assert.NotNil(t, response)
|
||||
@@ -1766,7 +1755,7 @@ func TestReceiverService_AccessControlMetadata(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
|
||||
r, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: 1, Name: "receiver1"}, admin)
|
||||
r, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, admin)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("should override metadata for imported receivers", func(t *testing.T) {
|
||||
@@ -1842,13 +1831,6 @@ func createEncryptedConfig(t *testing.T, secretService secretService, extraConfi
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
func singleQ(orgID int64, name string) models.GetReceiverQuery {
|
||||
return models.GetReceiverQuery{
|
||||
OrgID: orgID,
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func multiQ(orgID int64, names ...string) models.GetReceiversQuery {
|
||||
return models.GetReceiversQuery{
|
||||
OrgID: orgID,
|
||||
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
remoteClient "github.com/grafana/grafana/pkg/services/ngalert/remote/client"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/sender"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
"github.com/grafana/grafana/pkg/util/cmputil"
|
||||
)
|
||||
|
||||
@@ -57,6 +58,7 @@ func NoopAutogenFn(_ context.Context, _ log.Logger, _ int64, _ *apimodels.Postab
|
||||
}
|
||||
|
||||
type Crypto interface {
|
||||
Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error)
|
||||
Decrypt(ctx context.Context, payload []byte) ([]byte, error)
|
||||
DecryptExtraConfigs(ctx context.Context, config *apimodels.PostableUserConfig) error
|
||||
}
|
||||
@@ -289,20 +291,6 @@ func (am *Alertmanager) isDefaultConfiguration(configHash string) bool {
|
||||
return configHash == am.defaultConfigHash
|
||||
}
|
||||
|
||||
func decrypter(ctx context.Context, crypto Crypto) models.DecryptFn {
|
||||
return func(value string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
decrypted, err := crypto.Decrypt(ctx, decoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decrypted), nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildConfiguration takes a raw Alertmanager configuration and returns a config that the remote Alertmanager can use.
|
||||
// It parses the initial configuration, adds auto-generated routes, decrypts receivers, and merges the extra configs.
|
||||
func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, createdAtEpoch int64, autogenInvalidReceiverAction notifier.InvalidReceiversAction) (remoteClient.UserGrafanaConfig, error) {
|
||||
@@ -317,7 +305,7 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea
|
||||
}
|
||||
|
||||
// Decrypt the receivers in the configuration.
|
||||
decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, decrypter(ctx, am.crypto))
|
||||
decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto))
|
||||
if err != nil {
|
||||
return remoteClient.UserGrafanaConfig{}, fmt.Errorf("unable to decrypt receivers: %w", err)
|
||||
}
|
||||
@@ -619,7 +607,7 @@ func (am *Alertmanager) GetReceivers(ctx context.Context) ([]apimodels.Receiver,
|
||||
}
|
||||
|
||||
func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*alertingNotify.TestReceiversResult, int, error) {
|
||||
decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, decrypter(ctx, am.crypto))
|
||||
decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to decrypt receivers: %w", err)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/remote/client"
|
||||
ngfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/database"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/fakes"
|
||||
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
|
||||
@@ -298,13 +297,7 @@ func TestIntegrationApplyConfig(t *testing.T) {
|
||||
var c apimodels.PostableUserConfig
|
||||
require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &c))
|
||||
secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t)))
|
||||
encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
|
||||
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
})
|
||||
encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
|
||||
c.AlertmanagerConfig.Receivers = encryptedReceivers
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -462,13 +455,7 @@ func TestCompareAndSendConfiguration(t *testing.T) {
|
||||
// Create a config with correctly encrypted and encoded secrets.
|
||||
var inputCfg apimodels.PostableUserConfig
|
||||
require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg))
|
||||
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
|
||||
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
})
|
||||
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
|
||||
inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers
|
||||
require.NoError(t, err)
|
||||
testGrafanaConfigWithEncryptedSecret, err := json.Marshal(inputCfg)
|
||||
@@ -663,13 +650,7 @@ func Test_TestReceiversDecryptsSecureSettings(t *testing.T) {
|
||||
|
||||
var inputCfg apimodels.PostableUserConfig
|
||||
require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg))
|
||||
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
|
||||
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
})
|
||||
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
|
||||
inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1037,13 +1018,7 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) {
|
||||
{
|
||||
postableCfg, err := notifier.Load([]byte(testGrafanaConfigWithSecret))
|
||||
require.NoError(t, err)
|
||||
encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
|
||||
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
})
|
||||
encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
|
||||
postableCfg.AlertmanagerConfig.Receivers = encryptedReceivers
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ type OrgUserDTO struct {
|
||||
Role string `json:"role"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||
Updated time.Time `json:"-"`
|
||||
Created time.Time `json:"-"`
|
||||
Created time.Time `json:"created"`
|
||||
LastSeenAtAge string `json:"lastSeenAtAge"`
|
||||
AccessControl map[string]bool `json:"accessControl,omitempty"`
|
||||
IsDisabled bool `json:"isDisabled"`
|
||||
|
||||
@@ -146,6 +146,7 @@ type UserSearchHitDTO struct {
|
||||
LastSeenAtAge string `json:"lastSeenAtAge"`
|
||||
AuthLabels []string `json:"authLabels"`
|
||||
AuthModule AuthModuleConversion `json:"-"`
|
||||
Created time.Time `json:"created" xorm:"created"`
|
||||
}
|
||||
|
||||
type GetUserProfileQuery struct {
|
||||
|
||||
@@ -541,7 +541,7 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (*
|
||||
sess.Limit(query.Limit, offset)
|
||||
}
|
||||
|
||||
sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned")
|
||||
sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned", "u.created")
|
||||
|
||||
if len(query.SortOpts) > 0 {
|
||||
for i := range query.SortOpts {
|
||||
|
||||
@@ -5,15 +5,17 @@ import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type OpenFeatureProviderType string
|
||||
|
||||
const (
|
||||
StaticProviderType = "static"
|
||||
FeaturesServiceProviderType = "features-service"
|
||||
OFREPProviderType = "ofrep"
|
||||
StaticProviderType OpenFeatureProviderType = "static"
|
||||
FeaturesServiceProviderType OpenFeatureProviderType = "features-service"
|
||||
OFREPProviderType OpenFeatureProviderType = "ofrep"
|
||||
)
|
||||
|
||||
type OpenFeatureSettings struct {
|
||||
APIEnabled bool
|
||||
ProviderType string
|
||||
ProviderType OpenFeatureProviderType
|
||||
URL *url.URL
|
||||
TargetingKey string
|
||||
ContextAttrs map[string]string
|
||||
@@ -24,7 +26,27 @@ func (cfg *Cfg) readOpenFeatureSettings() error {
|
||||
|
||||
config := cfg.Raw.Section("feature_toggles.openfeature")
|
||||
cfg.OpenFeature.APIEnabled = config.Key("enable_api").MustBool(true)
|
||||
cfg.OpenFeature.ProviderType = config.Key("provider").MustString(StaticProviderType)
|
||||
|
||||
providerType := config.Key("provider").Validate(func(in string) string {
|
||||
if in == "" {
|
||||
return string(StaticProviderType)
|
||||
}
|
||||
|
||||
switch in {
|
||||
case string(StaticProviderType):
|
||||
return string(StaticProviderType)
|
||||
case string(FeaturesServiceProviderType):
|
||||
return string(FeaturesServiceProviderType)
|
||||
case string(OFREPProviderType):
|
||||
return string(OFREPProviderType)
|
||||
default:
|
||||
cfg.Logger.Warn("invalid provider type", "provider", in)
|
||||
cfg.Logger.Info("using static provider for openfeature")
|
||||
return string(StaticProviderType)
|
||||
}
|
||||
})
|
||||
|
||||
cfg.OpenFeature.ProviderType = OpenFeatureProviderType(providerType)
|
||||
strURL := config.Key("url").MustString("")
|
||||
|
||||
defaultTargetingKey := "default"
|
||||
|
||||
@@ -2,9 +2,12 @@ package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -67,6 +70,8 @@ func RunSQLStorageBackendCompatibilityTest(t *testing.T, newSqlBackend, newKvBac
|
||||
}{
|
||||
{"key_path generation", runTestIntegrationBackendKeyPathGeneration},
|
||||
{"sql backend fields compatibility", runTestSQLBackendFieldsCompatibility},
|
||||
{"cross backend consistency", runTestCrossBackendConsistency},
|
||||
{"concurrent operations stress", runTestConcurrentOperationsStress},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -111,38 +116,25 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri
|
||||
|
||||
// Create 3 resources
|
||||
for i := 1; i <= 3; i++ {
|
||||
key := &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: nsPrefix,
|
||||
Name: fmt.Sprintf("test-playlist-%d", i),
|
||||
folder := ""
|
||||
if i == 2 {
|
||||
folder = "test-folder" // Resource 2 has folder annotation
|
||||
}
|
||||
|
||||
// Create resource JSON with folder annotation for resource 2
|
||||
resourceJSON := fmt.Sprintf(`{
|
||||
"apiVersion": "playlist.grafana.app/v0alpha1",
|
||||
"kind": "Playlist",
|
||||
"metadata": {
|
||||
"name": "test-playlist-%d",
|
||||
"namespace": "%s",
|
||||
"uid": "test-uid-%d"%s
|
||||
},
|
||||
"spec": {
|
||||
"title": "My Test Playlist %d"
|
||||
}
|
||||
}`, i, nsPrefix, i, getAnnotationsJSON(i == 2), i)
|
||||
opts := PlaylistResourceOptions{
|
||||
Name: fmt.Sprintf("test-playlist-%d", i),
|
||||
Namespace: nsPrefix,
|
||||
UID: fmt.Sprintf("test-uid-%d", i),
|
||||
Generation: 1,
|
||||
Title: fmt.Sprintf("My Test Playlist %d", i),
|
||||
Folder: folder,
|
||||
}
|
||||
|
||||
// Create the resource using server.Create
|
||||
created, err := server.Create(ctx, &resourcepb.CreateRequest{
|
||||
Key: key,
|
||||
Value: []byte(resourceJSON),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, created.Error)
|
||||
require.Greater(t, created.ResourceVersion, int64(0))
|
||||
created := createPlaylistResource(t, server, ctx, opts)
|
||||
currentRVs[i-1] = created.ResourceVersion
|
||||
|
||||
// Verify created resource key_path (with folder for resource 2)
|
||||
key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i))
|
||||
if i == 2 {
|
||||
verifyKeyPath(t, db, ctx, key, "created", created.ResourceVersion, "test-folder")
|
||||
} else {
|
||||
@@ -152,39 +144,25 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri
|
||||
|
||||
// Update the 3 resources
|
||||
for i := 1; i <= 3; i++ {
|
||||
key := &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: nsPrefix,
|
||||
Name: fmt.Sprintf("test-playlist-%d", i),
|
||||
folder := ""
|
||||
if i == 2 {
|
||||
folder = "test-folder" // Resource 2 has folder annotation
|
||||
}
|
||||
|
||||
// Create updated resource JSON with folder annotation for resource 2
|
||||
updatedResourceJSON := fmt.Sprintf(`{
|
||||
"apiVersion": "playlist.grafana.app/v0alpha1",
|
||||
"kind": "Playlist",
|
||||
"metadata": {
|
||||
"name": "test-playlist-%d",
|
||||
"namespace": "%s",
|
||||
"uid": "test-uid-%d"%s
|
||||
},
|
||||
"spec": {
|
||||
"title": "My Updated Playlist %d"
|
||||
}
|
||||
}`, i, nsPrefix, i, getAnnotationsJSON(i == 2), i)
|
||||
opts := PlaylistResourceOptions{
|
||||
Name: fmt.Sprintf("test-playlist-%d", i),
|
||||
Namespace: nsPrefix,
|
||||
UID: fmt.Sprintf("test-uid-%d", i),
|
||||
Generation: 2,
|
||||
Title: fmt.Sprintf("My Updated Playlist %d", i),
|
||||
Folder: folder,
|
||||
}
|
||||
|
||||
// Update the resource using server.Update
|
||||
updated, err := server.Update(ctx, &resourcepb.UpdateRequest{
|
||||
Key: key,
|
||||
Value: []byte(updatedResourceJSON),
|
||||
ResourceVersion: currentRVs[i-1], // Use the resource version returned by previous operation
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, updated.Error)
|
||||
require.Greater(t, updated.ResourceVersion, currentRVs[i-1])
|
||||
updated := updatePlaylistResource(t, server, ctx, opts, currentRVs[i-1])
|
||||
currentRVs[i-1] = updated.ResourceVersion // Update to the latest resource version
|
||||
|
||||
// Verify updated resource key_path (with folder for resource 2)
|
||||
key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i))
|
||||
if i == 2 {
|
||||
verifyKeyPath(t, db, ctx, key, "updated", updated.ResourceVersion, "test-folder")
|
||||
} else {
|
||||
@@ -194,22 +172,11 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri
|
||||
|
||||
// Delete the 3 resources
|
||||
for i := 1; i <= 3; i++ {
|
||||
key := &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: nsPrefix,
|
||||
Name: fmt.Sprintf("test-playlist-%d", i),
|
||||
}
|
||||
|
||||
// Delete the resource using server.Delete
|
||||
deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{
|
||||
Key: key,
|
||||
ResourceVersion: currentRVs[i-1], // Use the resource version from previous operation
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, deleted.ResourceVersion, currentRVs[i-1])
|
||||
name := fmt.Sprintf("test-playlist-%d", i)
|
||||
deleted := deletePlaylistResource(t, server, ctx, nsPrefix, name, currentRVs[i-1])
|
||||
|
||||
// Verify deleted resource key_path (with folder for resource 2)
|
||||
key := createPlaylistKey(nsPrefix, name)
|
||||
if i == 2 {
|
||||
verifyKeyPath(t, db, ctx, key, "deleted", deleted.ResourceVersion, "test-folder")
|
||||
} else {
|
||||
@@ -276,17 +243,6 @@ func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resource
|
||||
require.Equal(t, expectedActionCode, actualAction)
|
||||
}
|
||||
|
||||
// getAnnotationsJSON returns the annotations JSON string for the folder annotation if needed
|
||||
func getAnnotationsJSON(withFolder bool) string {
|
||||
if withFolder {
|
||||
return `,
|
||||
"annotations": {
|
||||
"grafana.app/folder": "test-folder"
|
||||
}`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runTestSQLBackendFieldsCompatibility tests that KV backend with RvManager populates all SQL backend legacy fields
|
||||
func runTestSQLBackendFieldsCompatibility(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) {
|
||||
ctx := testutil.NewDefaultTestContext(t)
|
||||
@@ -349,76 +305,35 @@ func runSQLBackendFieldsTest(t *testing.T, backend resource.StorageBackend, name
|
||||
|
||||
// Create 3 resources
|
||||
for i, res := range resources {
|
||||
key := &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: namespace,
|
||||
Name: res.name,
|
||||
// Create the resource using helper function
|
||||
opts := PlaylistResourceOptions{
|
||||
Name: res.name,
|
||||
Namespace: namespace,
|
||||
UID: fmt.Sprintf("test-uid-%d", i+1),
|
||||
Generation: 1,
|
||||
Title: fmt.Sprintf("Test Playlist %d", i+1),
|
||||
Folder: res.folder,
|
||||
}
|
||||
|
||||
// Create resource JSON with folder annotation and generation=1 for creates
|
||||
resourceJSON := fmt.Sprintf(`{
|
||||
"apiVersion": "playlist.grafana.app/v0alpha1",
|
||||
"kind": "Playlist",
|
||||
"metadata": {
|
||||
"name": "%s",
|
||||
"namespace": "%s",
|
||||
"uid": "test-uid-%d",
|
||||
"generation": 1%s
|
||||
},
|
||||
"spec": {
|
||||
"title": "Test Playlist %d"
|
||||
}
|
||||
}`, res.name, namespace, i+1, getAnnotationsJSON(res.folder != ""), i+1)
|
||||
|
||||
// Create the resource
|
||||
created, err := server.Create(ctx, &resourcepb.CreateRequest{
|
||||
Key: key,
|
||||
Value: []byte(resourceJSON),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, created.Error)
|
||||
require.Greater(t, created.ResourceVersion, int64(0))
|
||||
|
||||
created := createPlaylistResource(t, server, ctx, opts)
|
||||
// Store the resource version
|
||||
resourceVersions[i] = append(resourceVersions[i], created.ResourceVersion)
|
||||
}
|
||||
|
||||
// Update 3 resources
|
||||
for i, res := range resources {
|
||||
key := &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: namespace,
|
||||
Name: res.name,
|
||||
// Update the resource using helper function
|
||||
opts := PlaylistResourceOptions{
|
||||
Name: res.name,
|
||||
Namespace: namespace,
|
||||
UID: fmt.Sprintf("test-uid-%d", i+1),
|
||||
Generation: 2,
|
||||
Title: fmt.Sprintf("Updated Test Playlist %d", i+1),
|
||||
Folder: res.folder,
|
||||
}
|
||||
|
||||
// Update resource JSON with generation=2 for updates
|
||||
resourceJSON := fmt.Sprintf(`{
|
||||
"apiVersion": "playlist.grafana.app/v0alpha1",
|
||||
"kind": "Playlist",
|
||||
"metadata": {
|
||||
"name": "%s",
|
||||
"namespace": "%s",
|
||||
"uid": "test-uid-%d",
|
||||
"generation": 2%s
|
||||
},
|
||||
"spec": {
|
||||
"title": "Updated Test Playlist %d"
|
||||
}
|
||||
}`, res.name, namespace, i+1, getAnnotationsJSON(res.folder != ""), i+1)
|
||||
|
||||
// Update the resource using the current resource version
|
||||
currentRV := resourceVersions[i][len(resourceVersions[i])-1]
|
||||
updated, err := server.Update(ctx, &resourcepb.UpdateRequest{
|
||||
Key: key,
|
||||
Value: []byte(resourceJSON),
|
||||
ResourceVersion: currentRV,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, updated.Error)
|
||||
require.Greater(t, updated.ResourceVersion, currentRV)
|
||||
|
||||
updated := updatePlaylistResource(t, server, ctx, opts, currentRV)
|
||||
// Store the new resource version
|
||||
resourceVersions[i] = append(resourceVersions[i], updated.ResourceVersion)
|
||||
}
|
||||
@@ -714,3 +629,627 @@ func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, res
|
||||
// But it shouldn't be too much higher (within a reasonable range)
|
||||
require.LessOrEqual(t, record.ResourceVersion, maxRV+100, "resource_version shouldn't be much higher than expected")
|
||||
}
|
||||
|
||||
// runTestCrossBackendConsistency tests basic consistency between SQL and KV backends (lightweight)
|
||||
func runTestCrossBackendConsistency(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) {
|
||||
ctx := testutil.NewDefaultTestContext(t)
|
||||
|
||||
// Create storage servers from both backends
|
||||
sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{
|
||||
Backend: sqlBackend,
|
||||
AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{
|
||||
Backend: kvBackend,
|
||||
AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create isolated namespaces for each test phase
|
||||
sqlNamespace := nsPrefix + "-concurrent-sql"
|
||||
kvNamespace := nsPrefix + "-concurrent-kv"
|
||||
|
||||
t.Run("Write to SQL, Read from Both", func(t *testing.T) {
|
||||
runWriteToOneReadFromBoth(t, sqlServer, kvServer, sqlNamespace+"-writeSQL", ctx, "sql")
|
||||
})
|
||||
|
||||
t.Run("Write to KV, Read from Both", func(t *testing.T) {
|
||||
runWriteToOneReadFromBoth(t, kvServer, sqlServer, kvNamespace+"-writeKV", ctx, "kv")
|
||||
})
|
||||
|
||||
t.Run("Resource Version Consistency", func(t *testing.T) {
|
||||
runResourceVersionConsistencyTest(t, sqlServer, kvServer, nsPrefix+"-rv-consistency", ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// runTestConcurrentOperationsStress tests heavy concurrent operations between SQL and KV backends
|
||||
func runTestConcurrentOperationsStress(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) {
|
||||
// Skip on SQLite due to concurrency limitations
|
||||
if db.DriverName() == "sqlite3" {
|
||||
t.Skip("Skipping concurrent operations stress test on SQLite")
|
||||
}
|
||||
|
||||
ctx := testutil.NewDefaultTestContext(t)
|
||||
|
||||
// Create storage servers from both backends
|
||||
sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{
|
||||
Backend: sqlBackend,
|
||||
AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{
|
||||
Backend: kvBackend,
|
||||
AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create isolated namespace for mixed operations
|
||||
mixedNamespace := nsPrefix + "-concurrent-mixed"
|
||||
|
||||
// do a single create using the sql backend to initialize the resource_version table
|
||||
// without this, both backend may try to insert the same group+resource to the resource_version which breaks the
|
||||
// tests
|
||||
initNamespace := mixedNamespace + "-init"
|
||||
initOpts := PlaylistResourceOptions{
|
||||
Name: "init-resource",
|
||||
Namespace: initNamespace,
|
||||
UID: "init-uid",
|
||||
Generation: 1,
|
||||
Title: "Init Resource",
|
||||
Folder: "",
|
||||
}
|
||||
createPlaylistResource(t, sqlServer, ctx, initOpts)
|
||||
|
||||
// Heavy Mixed Concurrent Operations
|
||||
t.Run("Mixed Concurrent Operations", func(t *testing.T) {
|
||||
runMixedConcurrentOperations(t, sqlServer, kvServer, mixedNamespace, ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// runWriteToOneReadFromBoth writes resources to one backend then reads from both to verify consistency
|
||||
func runWriteToOneReadFromBoth(t *testing.T, writeServer, readServer resource.ResourceServer, namespace string, ctx context.Context, writerBackend string) {
|
||||
// Create 5 test resources
|
||||
resourceNames := []string{
|
||||
fmt.Sprintf("resource-%s-1", writerBackend),
|
||||
fmt.Sprintf("resource-%s-2", writerBackend),
|
||||
fmt.Sprintf("resource-%s-3", writerBackend),
|
||||
fmt.Sprintf("resource-%s-4", writerBackend),
|
||||
fmt.Sprintf("resource-%s-5", writerBackend),
|
||||
}
|
||||
|
||||
createdResourceVersions := make([]int64, len(resourceNames))
|
||||
|
||||
// Write all resources to the write backend
|
||||
for i, resourceName := range resourceNames {
|
||||
key := &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: namespace,
|
||||
Name: resourceName,
|
||||
}
|
||||
|
||||
resourceJSON := fmt.Sprintf(`{
|
||||
"apiVersion": "playlist.grafana.app/v0alpha1",
|
||||
"kind": "Playlist",
|
||||
"metadata": {
|
||||
"name": "%s",
|
||||
"namespace": "%s",
|
||||
"uid": "test-uid-%d",
|
||||
"generation": 1
|
||||
},
|
||||
"spec": {
|
||||
"title": "Concurrent Test Playlist %d"
|
||||
}
|
||||
}`, resourceName, namespace, i+1, i+1)
|
||||
|
||||
created, err := writeServer.Create(ctx, &resourcepb.CreateRequest{
|
||||
Key: key,
|
||||
Value: []byte(resourceJSON),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, created.Error)
|
||||
require.Greater(t, created.ResourceVersion, int64(0))
|
||||
createdResourceVersions[i] = created.ResourceVersion
|
||||
}
|
||||
|
||||
// Add a small delay to ensure data propagates
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Read from both backends and compare payloads
|
||||
for _, resourceName := range resourceNames {
|
||||
key := &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: namespace,
|
||||
Name: resourceName,
|
||||
}
|
||||
|
||||
// Read from write backend
|
||||
writeResp, err := writeServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
|
||||
require.NoError(t, err, "Failed to read %s from write backend", resourceName)
|
||||
require.Nil(t, writeResp.Error, "Read error from write backend %s: %s", resourceName, writeResp.Error)
|
||||
require.Greater(t, writeResp.ResourceVersion, int64(0), "Invalid resource version for %s on write backend", resourceName)
|
||||
|
||||
// Read from read backend
|
||||
readResp, err := readServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
|
||||
require.NoError(t, err, "Failed to read %s from read backend", resourceName)
|
||||
require.Nil(t, readResp.Error, "Read error from read backend %s: %s", resourceName, readResp.Error)
|
||||
require.Greater(t, readResp.ResourceVersion, int64(0), "Invalid resource version for %s on read backend", resourceName)
|
||||
|
||||
// Validate that both backends return identical payload content
|
||||
require.JSONEq(t, string(writeResp.Value), string(readResp.Value),
|
||||
"Payload mismatch for resource %s between write and read backends.\nWrite backend: %s\nRead backend: %s",
|
||||
resourceName, string(writeResp.Value), string(readResp.Value))
|
||||
|
||||
// Validate that both backends return equivalent resource versions using rvmanager compatibility check
|
||||
// Note: rvmanager.IsRvEqual expects snowflake format as first parameter, so we check both orderings
|
||||
require.True(t, rvmanager.IsRvEqual(writeResp.ResourceVersion, readResp.ResourceVersion) || rvmanager.IsRvEqual(readResp.ResourceVersion, writeResp.ResourceVersion),
|
||||
"Resource version mismatch for resource %s between backends.\nWrite backend (%s): %d\nRead backend (%s): %d",
|
||||
resourceName, writerBackend, writeResp.ResourceVersion, getOtherBackendName(writerBackend), readResp.ResourceVersion)
|
||||
|
||||
t.Logf("✓ Resource %s: payload and resource version (%d) consistency verified between %s (write) and %s (read) backends",
|
||||
resourceName, writeResp.ResourceVersion, writerBackend, getOtherBackendName(writerBackend))
|
||||
}
|
||||
|
||||
// Verify List consistency between backends
|
||||
verifyListConsistencyBetweenServers(t, writeServer, readServer, namespace, len(resourceNames))
|
||||
}
|
||||
|
||||
// getOtherBackendName returns the complementary backend name
|
||||
func getOtherBackendName(backend string) string {
|
||||
if backend == "sql" {
|
||||
return "kv"
|
||||
}
|
||||
return "sql"
|
||||
}
|
||||
|
||||
// runMixedConcurrentOperations runs different operations simultaneously on both backends
|
||||
func runMixedConcurrentOperations(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 20)
|
||||
startBarrier := make(chan struct{})
|
||||
|
||||
// Use higher operation counts to ensure concurrency
|
||||
opCounts := BackendOperationCounts{
|
||||
Creates: 25,
|
||||
Updates: 15,
|
||||
Deletes: 10,
|
||||
}
|
||||
|
||||
// SQL backend operations
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-startBarrier // Wait for signal to start
|
||||
if err := runBackendOperationsWithCounts(ctx, sqlServer, namespace+"-sql", "sql", opCounts); err != nil {
|
||||
errors <- fmt.Errorf("SQL backend operations failed: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// KV backend operations
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-startBarrier // Wait for signal to start
|
||||
if err := runBackendOperationsWithCounts(ctx, kvServer, namespace+"-kv", "kv", opCounts); err != nil {
|
||||
errors <- fmt.Errorf("KV backend operations failed: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start both goroutines simultaneously
|
||||
close(startBarrier)
|
||||
|
||||
// Wait for operations to complete with timeout
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Operations completed
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Timeout waiting for mixed concurrent operations")
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
close(errors)
|
||||
for err := range errors {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Allow some time for data propagation
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Calculate expected remaining resources based on operation counts
|
||||
expectedRemaining := opCounts.Creates - opCounts.Deletes // Creates - Deletes = Remaining
|
||||
|
||||
// Verify consistency of resources created by SQL backend operations
|
||||
// Note: Skip resource version checking since these are separate operations on different backends
|
||||
verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-sql", expectedRemaining, false)
|
||||
|
||||
// Verify consistency of resources created by KV backend operations
|
||||
// Note: Skip resource version checking since these are separate operations on different backends
|
||||
verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-kv", expectedRemaining, false)
|
||||
}
|
||||
|
||||
// BackendOperationCounts defines how many operations of each type to perform
|
||||
type BackendOperationCounts struct {
|
||||
Creates int
|
||||
Updates int
|
||||
Deletes int
|
||||
}
|
||||
|
||||
// runBackendOperationsWithCounts performs configurable create, update, delete operations on a backend
|
||||
func runBackendOperationsWithCounts(ctx context.Context, server resource.ResourceServer, namespace, backendType string, counts BackendOperationCounts) error {
|
||||
// Create resources
|
||||
resourceVersions := make([]int64, counts.Creates)
|
||||
for i := 1; i <= counts.Creates; i++ {
|
||||
key := &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: namespace,
|
||||
Name: fmt.Sprintf("resource-%s-%d", backendType, i),
|
||||
}
|
||||
|
||||
resourceJSON := fmt.Sprintf(`{
|
||||
"apiVersion": "playlist.grafana.app/v0alpha1",
|
||||
"kind": "Playlist",
|
||||
"metadata": {
|
||||
"name": "resource-%s-%d",
|
||||
"namespace": "%s",
|
||||
"uid": "test-uid-%s-%d",
|
||||
"generation": 1
|
||||
},
|
||||
"spec": {
|
||||
"title": "Mixed Test Playlist %s %d"
|
||||
}
|
||||
}`, backendType, i, namespace, backendType, i, backendType, i)
|
||||
|
||||
created, err := server.Create(ctx, &resourcepb.CreateRequest{
|
||||
Key: key,
|
||||
Value: []byte(resourceJSON),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create resource %d: %w", i, err)
|
||||
}
|
||||
if created.Error != nil {
|
||||
return fmt.Errorf("create error for resource %d: %s", i, created.Error.Message)
|
||||
}
|
||||
resourceVersions[i-1] = created.ResourceVersion
|
||||
}
|
||||
|
||||
// Update resources (only update as many as we have, limited by creates and updates count)
|
||||
updateCount := counts.Updates
|
||||
if updateCount > counts.Creates {
|
||||
updateCount = counts.Creates // Can't update more resources than we created
|
||||
}
|
||||
for i := 1; i <= updateCount; i++ {
|
||||
key := &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: namespace,
|
||||
Name: fmt.Sprintf("resource-%s-%d", backendType, i),
|
||||
}
|
||||
|
||||
updatedJSON := fmt.Sprintf(`{
|
||||
"apiVersion": "playlist.grafana.app/v0alpha1",
|
||||
"kind": "Playlist",
|
||||
"metadata": {
|
||||
"name": "resource-%s-%d",
|
||||
"namespace": "%s",
|
||||
"uid": "test-uid-%s-%d",
|
||||
"generation": 2
|
||||
},
|
||||
"spec": {
|
||||
"title": "Updated Mixed Test Playlist %s %d"
|
||||
}
|
||||
}`, backendType, i, namespace, backendType, i, backendType, i)
|
||||
|
||||
updated, err := server.Update(ctx, &resourcepb.UpdateRequest{
|
||||
Key: key,
|
||||
Value: []byte(updatedJSON),
|
||||
ResourceVersion: resourceVersions[i-1],
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update resource %d: %w", i, err)
|
||||
}
|
||||
if updated.Error != nil {
|
||||
return fmt.Errorf("update error for resource %d: %s", i, updated.Error.Message)
|
||||
}
|
||||
resourceVersions[i-1] = updated.ResourceVersion
|
||||
}
|
||||
|
||||
// Delete resources (only delete as many as we have, limited by creates and deletes count)
|
||||
deleteCount := counts.Deletes
|
||||
if deleteCount > updateCount {
|
||||
deleteCount = updateCount // Can only delete resources that were updated (have latest RV)
|
||||
}
|
||||
for i := 1; i <= deleteCount; i++ {
|
||||
key := &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: namespace,
|
||||
Name: fmt.Sprintf("resource-%s-%d", backendType, i),
|
||||
}
|
||||
|
||||
deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{
|
||||
Key: key,
|
||||
ResourceVersion: resourceVersions[i-1], // Use the resource version from updates
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete resource %d: %w", i, err)
|
||||
}
|
||||
if deleted.Error != nil {
|
||||
return fmt.Errorf("delete error for resource %d: %s", i, deleted.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runResourceVersionConsistencyTest verifies resource version handling across backends
|
||||
func runResourceVersionConsistencyTest(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) {
|
||||
// Create a resource on SQL backend
|
||||
opts := PlaylistResourceOptions{
|
||||
Name: "rv-test-resource",
|
||||
Namespace: namespace,
|
||||
UID: "test-uid-rv",
|
||||
Generation: 1,
|
||||
Title: "RV Test Playlist",
|
||||
Folder: "", // No folder
|
||||
}
|
||||
|
||||
createPlaylistResource(t, sqlServer, ctx, opts)
|
||||
|
||||
// Allow data to propagate
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Read from KV backend to get the same resource
|
||||
key := createPlaylistKey(namespace, "rv-test-resource")
|
||||
kvRead, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, kvRead.Error)
|
||||
// Note: Resource versions may differ between backends, but content should be the same
|
||||
require.Greater(t, kvRead.ResourceVersion, int64(0), "KV backend should return a valid resource version")
|
||||
|
||||
// Read from SQL backend to compare content
|
||||
sqlReadInitial, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, sqlReadInitial.Error)
|
||||
require.JSONEq(t, string(sqlReadInitial.Value), string(kvRead.Value), "Both backends should return the same initial content")
|
||||
|
||||
// Update via KV backend
|
||||
updateOpts := PlaylistResourceOptions{
|
||||
Name: "rv-test-resource",
|
||||
Namespace: namespace,
|
||||
UID: "test-uid-rv",
|
||||
Generation: 2,
|
||||
Title: "Updated RV Test Playlist",
|
||||
Folder: "", // No folder
|
||||
}
|
||||
|
||||
updatePlaylistResource(t, kvServer, ctx, updateOpts, kvRead.ResourceVersion)
|
||||
|
||||
// Allow data to propagate
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Read from SQL backend to verify consistency
|
||||
sqlRead, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, sqlRead.Error)
|
||||
// Note: Resource versions may differ, but content should be consistent
|
||||
require.Greater(t, sqlRead.ResourceVersion, int64(0), "SQL backend should return a valid resource version")
|
||||
|
||||
// Verify both backends return the same content - we need to read from KV again to get the Value
|
||||
kvReadAfterUpdate, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, kvReadAfterUpdate.Error)
|
||||
require.JSONEq(t, string(kvReadAfterUpdate.Value), string(sqlRead.Value), "Both backends should return the same updated content")
|
||||
}
|
||||
|
||||
// verifyListConsistencyBetweenServers verifies that both servers return consistent list results
|
||||
func verifyListConsistencyBetweenServers(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int) {
|
||||
verifyListConsistencyBetweenServersWithRVCheck(t, server1, server2, namespace, expectedCount, true)
|
||||
}
|
||||
|
||||
// verifyListConsistencyBetweenServersWithRVCheck verifies list consistency with optional resource version checking
|
||||
func verifyListConsistencyBetweenServersWithRVCheck(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int, checkResourceVersions bool) {
|
||||
ctx := testutil.NewDefaultTestContext(t)
|
||||
|
||||
// Get lists from both servers
|
||||
list1, err := server1.List(ctx, &resourcepb.ListRequest{
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: namespace,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, list1.Error)
|
||||
|
||||
list2, err := server2.List(ctx, &resourcepb.ListRequest{
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: namespace,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, list2.Error)
|
||||
|
||||
// Create maps for easier comparison by extracting names from JSON
|
||||
items1 := make(map[string]*resourcepb.ResourceWrapper)
|
||||
for _, item := range list1.Items {
|
||||
itemNamespace := extractResourceNamespaceFromJSON(t, item.Value)
|
||||
if itemNamespace == namespace { // Only compare items from our exact namespace
|
||||
name := extractResourceNameFromJSON(t, item.Value)
|
||||
items1[name] = item
|
||||
}
|
||||
}
|
||||
|
||||
items2 := make(map[string]*resourcepb.ResourceWrapper)
|
||||
for _, item := range list2.Items {
|
||||
itemNamespace := extractResourceNamespaceFromJSON(t, item.Value)
|
||||
if itemNamespace == namespace { // Only compare items from our exact namespace
|
||||
name := extractResourceNameFromJSON(t, item.Value)
|
||||
items2[name] = item
|
||||
}
|
||||
}
|
||||
|
||||
// Verify counts match after filtering by namespace
|
||||
require.Equal(t, expectedCount, len(items1), "Server 1 should return expected count after filtering")
|
||||
require.Equal(t, expectedCount, len(items2), "Server 2 should return expected count after filtering")
|
||||
require.Equal(t, len(items1), len(items2), "Both servers should return same count after filtering")
|
||||
|
||||
// Verify all items exist in both lists with same content and resource version
|
||||
for name, item1 := range items1 {
|
||||
item2, exists := items2[name]
|
||||
require.True(t, exists, "Item %s should exist in both lists", name)
|
||||
require.Greater(t, item1.ResourceVersion, int64(0), "Item1 should have valid resource version for %s", name)
|
||||
require.Greater(t, item2.ResourceVersion, int64(0), "Item2 should have valid resource version for %s", name)
|
||||
require.JSONEq(t, string(item1.Value), string(item2.Value), "Content should match for %s", name)
|
||||
|
||||
// Validate that both backends return equivalent resource versions using rvmanager compatibility check
|
||||
if checkResourceVersions {
|
||||
require.True(t, rvmanager.IsRvEqual(item1.ResourceVersion, item2.ResourceVersion) || rvmanager.IsRvEqual(item2.ResourceVersion, item1.ResourceVersion),
|
||||
"Resource version mismatch for item %s between backends. Item1: %d, Item2: %d", name, item1.ResourceVersion, item2.ResourceVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractResourceNameFromJSON extracts the resource name from JSON metadata
|
||||
func extractResourceNameFromJSON(t *testing.T, jsonData []byte) string {
|
||||
var obj map[string]interface{}
|
||||
err := json.Unmarshal(jsonData, &obj)
|
||||
require.NoError(t, err, "Failed to unmarshal JSON")
|
||||
|
||||
metadata, ok := obj["metadata"].(map[string]interface{})
|
||||
require.True(t, ok, "metadata field not found or not an object")
|
||||
|
||||
name, ok := metadata["name"].(string)
|
||||
require.True(t, ok, "name field not found or not a string")
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// extractResourceNamespaceFromJSON extracts the resource namespace from JSON metadata
|
||||
func extractResourceNamespaceFromJSON(t *testing.T, jsonData []byte) string {
|
||||
var obj map[string]interface{}
|
||||
err := json.Unmarshal(jsonData, &obj)
|
||||
require.NoError(t, err, "Failed to unmarshal JSON")
|
||||
|
||||
metadata, ok := obj["metadata"].(map[string]interface{})
|
||||
require.True(t, ok, "metadata field not found or not an object")
|
||||
|
||||
namespace, ok := metadata["namespace"].(string)
|
||||
require.True(t, ok, "namespace field not found or not a string")
|
||||
|
||||
return namespace
|
||||
}
|
||||
|
||||
// PlaylistResourceOptions defines options for creating test playlist resources
|
||||
type PlaylistResourceOptions struct {
|
||||
Name string
|
||||
Namespace string
|
||||
UID string
|
||||
Generation int
|
||||
Title string
|
||||
Folder string // optional - empty string means no folder
|
||||
}
|
||||
|
||||
// createPlaylistJSON creates standardized JSON for playlist resources
|
||||
func createPlaylistJSON(opts PlaylistResourceOptions) []byte {
|
||||
folderAnnotation := ""
|
||||
if opts.Folder != "" {
|
||||
folderAnnotation = fmt.Sprintf(`,
|
||||
"annotations": {
|
||||
"grafana.app/folder": "%s"
|
||||
}`, opts.Folder)
|
||||
}
|
||||
|
||||
jsonStr := fmt.Sprintf(`{
|
||||
"apiVersion": "playlist.grafana.app/v0alpha1",
|
||||
"kind": "Playlist",
|
||||
"metadata": {
|
||||
"name": "%s",
|
||||
"namespace": "%s",
|
||||
"uid": "%s",
|
||||
"generation": %d%s
|
||||
},
|
||||
"spec": {
|
||||
"title": "%s"
|
||||
}
|
||||
}`, opts.Name, opts.Namespace, opts.UID, opts.Generation, folderAnnotation, opts.Title)
|
||||
|
||||
return []byte(jsonStr)
|
||||
}
|
||||
|
||||
// createPlaylistKey creates standardized ResourceKey for playlist resources
|
||||
func createPlaylistKey(namespace, name string) *resourcepb.ResourceKey {
|
||||
return &resourcepb.ResourceKey{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: namespace,
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// createPlaylistResource creates a playlist resource using the server with consistent error handling
|
||||
func createPlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions) *resourcepb.CreateResponse {
|
||||
t.Helper()
|
||||
key := createPlaylistKey(opts.Namespace, opts.Name)
|
||||
value := createPlaylistJSON(opts)
|
||||
|
||||
created, err := server.Create(ctx, &resourcepb.CreateRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, created.Error)
|
||||
require.Greater(t, created.ResourceVersion, int64(0))
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
// updatePlaylistResource updates a playlist resource using the server with consistent error handling
|
||||
func updatePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions, resourceVersion int64) *resourcepb.UpdateResponse {
|
||||
t.Helper()
|
||||
key := createPlaylistKey(opts.Namespace, opts.Name)
|
||||
value := createPlaylistJSON(opts)
|
||||
|
||||
updated, err := server.Update(ctx, &resourcepb.UpdateRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
ResourceVersion: resourceVersion,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, updated.Error)
|
||||
require.Greater(t, updated.ResourceVersion, int64(0)) // Just check it's positive, not necessarily greater than input
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
// deletePlaylistResource deletes a playlist resource using the server with consistent error handling
|
||||
func deletePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, namespace, name string, resourceVersion int64) *resourcepb.DeleteResponse {
|
||||
t.Helper()
|
||||
key := createPlaylistKey(namespace, name)
|
||||
|
||||
deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{
|
||||
Key: key,
|
||||
ResourceVersion: resourceVersion,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, deleted.Error)
|
||||
require.Greater(t, deleted.ResourceVersion, int64(0))
|
||||
|
||||
return deleted
|
||||
}
|
||||
|
||||
Generated
+8
@@ -6002,6 +6002,10 @@
|
||||
"avatarUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"created": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -8903,6 +8907,10 @@
|
||||
"avatarUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"created": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
Generated
+8
@@ -18489,6 +18489,10 @@
|
||||
"avatarUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"created": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -23410,6 +23414,10 @@
|
||||
"avatarUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"created": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -115,12 +115,15 @@ export const OrgUsersTable = ({
|
||||
{
|
||||
id: 'lastSeenAtAge',
|
||||
header: 'Last active',
|
||||
cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => {
|
||||
cell: ({ cell: { value }, row: { original } }: Cell<'lastSeenAtAge'>) => {
|
||||
// If lastSeenAt is before created, user has never logged in
|
||||
const neverLoggedIn =
|
||||
original.lastSeenAt && original.created && new Date(original.lastSeenAt) < new Date(original.created);
|
||||
return (
|
||||
<>
|
||||
{value && (
|
||||
<>
|
||||
{value === '10 years' ? (
|
||||
{neverLoggedIn ? (
|
||||
<Text color={'disabled'}>
|
||||
<Trans i18nKey="admin.org-uers.last-seen-never">Never</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -135,12 +135,19 @@ export const UsersTable = ({
|
||||
content: 'Time since user was seen using Grafana',
|
||||
iconName: 'question-circle',
|
||||
},
|
||||
cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => {
|
||||
cell: ({
|
||||
cell: { value },
|
||||
row: {
|
||||
original: { lastSeenAt, created },
|
||||
},
|
||||
}: Cell<'lastSeenAtAge'>) => {
|
||||
// The user has never logged in if lastSeenAt is before its creation date.
|
||||
const neverLoggedIn = lastSeenAt && created && new Date(lastSeenAt) < new Date(created);
|
||||
return (
|
||||
<>
|
||||
{value && (
|
||||
<>
|
||||
{value === '10 years' ? (
|
||||
{neverLoggedIn ? (
|
||||
<Text color={'disabled'}>
|
||||
<Trans i18nKey="admin.users-table.last-seen-never">Never</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -79,6 +79,9 @@ describe('new receiver', () => {
|
||||
// click test
|
||||
await user.click(ui.testContactPoint.get());
|
||||
|
||||
// close the modal
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
// we shouldn't be testing implementation details but when the request is successful
|
||||
// it can't seem to assert on the success toast
|
||||
await user.click(ui.saveContactButton.get());
|
||||
|
||||
@@ -71,6 +71,7 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
|
||||
reportInteraction(INTERACTION_EVENT_NAME, {
|
||||
item: INTERACTION_ITEM.SELECT_PANEL_PLUGIN,
|
||||
plugin_id: pluginId,
|
||||
from_suggestions: options.fromSuggestions ?? false,
|
||||
});
|
||||
|
||||
// clear custom options
|
||||
@@ -236,6 +237,7 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
|
||||
onClose={model.onToggleVizPicker}
|
||||
data={data}
|
||||
showBackButton={config.featureToggles.newVizSuggestions ? hasPickedViz || !isNewPanel : true}
|
||||
isNewPanel={isNewPanel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface Props {
|
||||
editPreview: VizPanel;
|
||||
onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void;
|
||||
onClose: () => void;
|
||||
isNewPanel?: boolean;
|
||||
}
|
||||
|
||||
const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }> => {
|
||||
@@ -42,7 +43,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }>
|
||||
: [allVisualizationsTab, suggestionsTab];
|
||||
};
|
||||
|
||||
export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton }: Props) {
|
||||
export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton, isNewPanel }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
|
||||
const filterId = useId();
|
||||
@@ -83,6 +84,16 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose
|
||||
[setListMode]
|
||||
);
|
||||
|
||||
const handleBackButtonClick = useCallback(() => {
|
||||
reportInteraction(INTERACTION_EVENT_NAME, {
|
||||
item: INTERACTION_ITEM.BACK_BUTTON,
|
||||
tab: VisualizationSelectPaneTab[listMode],
|
||||
creator_team: 'grafana_plugins_catalog',
|
||||
schema_version: '1.0.0',
|
||||
});
|
||||
onClose();
|
||||
}, [listMode, onClose]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<TabsBar className={styles.tabs} hideBorder={true}>
|
||||
@@ -114,7 +125,7 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose
|
||||
variant="secondary"
|
||||
icon="arrow-left"
|
||||
data-testid={selectors.components.PanelEditor.toggleVizPicker}
|
||||
onClick={onClose}
|
||||
onClick={handleBackButtonClick}
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
|
||||
</Button>
|
||||
@@ -136,6 +147,7 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose
|
||||
editPreview={editPreview}
|
||||
data={data}
|
||||
searchQuery={searchQuery}
|
||||
isNewPanel={isNewPanel}
|
||||
/>
|
||||
)}
|
||||
{listMode === VisualizationSelectPaneTab.Visualizations && (
|
||||
|
||||
@@ -4,4 +4,5 @@ export const INTERACTION_ITEM = {
|
||||
SELECT_PANEL_PLUGIN: 'select_panel_plugin',
|
||||
CHANGE_TAB: 'change_tab', // for ref - PanelVizTypePicker
|
||||
SEARCH: 'search', // for ref - PanelVizTypePicker
|
||||
BACK_BUTTON: 'back_button', // for ref - PanelVizTypePicker
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, testWithFeatureToggles } from 'test/test-utils';
|
||||
import { render, testWithFeatureToggles, userEvent, waitFor } from 'test/test-utils';
|
||||
|
||||
import { ExpressionQuery, ExpressionQueryType } from '../../types';
|
||||
|
||||
@@ -72,12 +72,12 @@ describe('SqlExpr', () => {
|
||||
const refIds = [{ value: 'A' }];
|
||||
const query = { refId: 'expr1', type: 'sql', expression: '' } as ExpressionQuery;
|
||||
|
||||
await act(async () => {
|
||||
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
|
||||
});
|
||||
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
|
||||
|
||||
// Verify onChange was called
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify essential SQL structure without exact string matching
|
||||
const updatedQuery = onChange.mock.calls[0][0];
|
||||
@@ -90,19 +90,12 @@ describe('SqlExpr', () => {
|
||||
const existingExpression = 'SELECT 1 AS foo';
|
||||
const query = { refId: 'expr1', type: 'sql', expression: existingExpression } as ExpressionQuery;
|
||||
|
||||
await act(async () => {
|
||||
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
|
||||
});
|
||||
|
||||
// Check if onChange was called
|
||||
if (onChange.mock.calls.length > 0) {
|
||||
// If called, ensure it didn't change the expression value
|
||||
const updatedQuery = onChange.mock.calls[0][0];
|
||||
expect(updatedQuery.expression).toBe(existingExpression);
|
||||
}
|
||||
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
|
||||
|
||||
// The SQLEditor should receive the existing expression
|
||||
expect(query.expression).toBe(existingExpression);
|
||||
await waitFor(() => {
|
||||
expect(query.expression).toBe(existingExpression);
|
||||
});
|
||||
});
|
||||
|
||||
it('adds alerting format when alerting prop is true', async () => {
|
||||
@@ -110,40 +103,12 @@ describe('SqlExpr', () => {
|
||||
const refIds = [{ value: 'A' }];
|
||||
const query = { refId: 'expr1', type: 'sql' } as ExpressionQuery;
|
||||
|
||||
await act(async () => {
|
||||
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} alerting queries={[]} />);
|
||||
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} alerting queries={[]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const updatedQuery = onChange.mock.calls[0][0];
|
||||
expect(updatedQuery.format).toBe('alerting');
|
||||
});
|
||||
|
||||
const updatedQuery = onChange.mock.calls[0][0];
|
||||
expect(updatedQuery.format).toBe('alerting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SqlExpr with GenAI features', () => {
|
||||
const defaultProps: SqlExprProps = {
|
||||
onChange: jest.fn(),
|
||||
refIds: [{ value: 'A' }],
|
||||
query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` },
|
||||
queries: [],
|
||||
};
|
||||
|
||||
it('renders suggestions drawer when isDrawerOpen is true', async () => {
|
||||
const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions');
|
||||
useSQLSuggestions.mockImplementation(() => ({
|
||||
isDrawerOpen: true,
|
||||
suggestions: ['suggestion1', 'suggestion2'],
|
||||
}));
|
||||
|
||||
const { findByTestId } = render(<SqlExpr {...defaultProps} />);
|
||||
expect(await findByTestId('suggestions-drawer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders explanation drawer when isExplanationOpen is true', async () => {
|
||||
const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations');
|
||||
useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true }));
|
||||
|
||||
const { findByTestId } = render(<SqlExpr {...defaultProps} />);
|
||||
expect(await findByTestId('explanation-drawer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,10 +131,10 @@ describe('Schema Inspector feature toggle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders panel open by default', () => {
|
||||
const { getByText } = render(<SqlExpr {...defaultProps} />);
|
||||
it('renders panel open by default', async () => {
|
||||
const { findByText } = render(<SqlExpr {...defaultProps} />);
|
||||
|
||||
expect(getByText('No schema information available')).toBeInTheDocument();
|
||||
expect(await findByText('No schema information available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes panel and shows reopen button when close button clicked', async () => {
|
||||
@@ -178,7 +143,7 @@ describe('Schema Inspector feature toggle', () => {
|
||||
expect(queryByText('No schema information available')).toBeInTheDocument();
|
||||
|
||||
const closeButton = getByText('Schema inspector');
|
||||
await act(async () => fireEvent.click(closeButton));
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
expect(queryByText('No schema information available')).not.toBeInTheDocument();
|
||||
expect(await findByText('Schema inspector')).toBeInTheDocument();
|
||||
@@ -188,12 +153,12 @@ describe('Schema Inspector feature toggle', () => {
|
||||
const { queryByText, getByText } = render(<SqlExpr {...defaultProps} />);
|
||||
|
||||
const closeButton = getByText('Schema inspector');
|
||||
await act(async () => fireEvent.click(closeButton));
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
expect(queryByText('No schema information available')).not.toBeInTheDocument();
|
||||
|
||||
const reopenButton = getByText('Schema inspector');
|
||||
await act(async () => fireEvent.click(reopenButton));
|
||||
await userEvent.click(reopenButton);
|
||||
|
||||
expect(queryByText('No schema information available')).toBeInTheDocument();
|
||||
});
|
||||
@@ -233,3 +198,33 @@ describe('Schema Inspector feature toggle', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SqlExpr with GenAI features', () => {
|
||||
const defaultProps: SqlExprProps = {
|
||||
onChange: jest.fn(),
|
||||
refIds: [{ value: 'A' }],
|
||||
query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` },
|
||||
queries: [],
|
||||
};
|
||||
|
||||
it('renders suggestions drawer when isDrawerOpen is true', async () => {
|
||||
// TODO this inline require breaks future tests - do it differently!
|
||||
const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions');
|
||||
useSQLSuggestions.mockImplementation(() => ({
|
||||
isDrawerOpen: true,
|
||||
suggestions: ['suggestion1', 'suggestion2'],
|
||||
}));
|
||||
|
||||
const { findByTestId } = render(<SqlExpr {...defaultProps} />);
|
||||
expect(await findByTestId('suggestions-drawer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders explanation drawer when isExplanationOpen is true', async () => {
|
||||
// TODO this inline require breaks future tests - do it differently!
|
||||
const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations');
|
||||
useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true }));
|
||||
|
||||
const { findByTestId } = render(<SqlExpr {...defaultProps} />);
|
||||
expect(await findByTestId('explanation-drawer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+10
-8
@@ -340,14 +340,16 @@ describe('LibraryPanelsSearch', () => {
|
||||
await user.click(screen.getAllByRole('button', { name: 'Delete' })[1]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getLibraryPanelsSpy).toHaveBeenCalledWith({
|
||||
searchString: '',
|
||||
folderFilterUIDs: ['wfTJJL5Wz'],
|
||||
page: 1,
|
||||
typeFilter: [],
|
||||
sortDirection: undefined,
|
||||
perPage: 40,
|
||||
})
|
||||
expect(getLibraryPanelsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
searchString: '',
|
||||
folderFilterUIDs: ['wfTJJL5Wz'],
|
||||
page: 1,
|
||||
typeFilter: [],
|
||||
sortDirection: undefined,
|
||||
perPage: 40,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getAllSuggestions } from '../../suggestions/getAllSuggestions';
|
||||
import { hasData } from '../../suggestions/utils';
|
||||
|
||||
import { VisualizationSuggestionCard } from './VisualizationSuggestionCard';
|
||||
import { VizSuggestionsInteractions, PANEL_STATES, type PanelState } from './interactions';
|
||||
import { VizTypeChangeDetails } from './types';
|
||||
|
||||
export interface Props {
|
||||
@@ -30,6 +31,7 @@ export interface Props {
|
||||
data?: PanelData;
|
||||
panel?: PanelModel;
|
||||
searchQuery?: string;
|
||||
isNewPanel?: boolean;
|
||||
}
|
||||
|
||||
const useSuggestions = (data: PanelData | undefined, searchQuery: string | undefined) => {
|
||||
@@ -62,7 +64,7 @@ const useSuggestions = (data: PanelData | undefined, searchQuery: string | undef
|
||||
return { value: filteredValue, loading, error, retry };
|
||||
};
|
||||
|
||||
export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery }: Props) {
|
||||
export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery, isNewPanel }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { value: result, loading, error, retry } = useSuggestions(data, searchQuery);
|
||||
@@ -75,6 +77,18 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s
|
||||
const isNewVizSuggestionsEnabled = config.featureToggles.newVizSuggestions;
|
||||
const isUnconfiguredPanel = panel?.type === UNCONFIGURED_PANEL_PLUGIN_ID;
|
||||
|
||||
const panelState = useMemo((): PanelState => {
|
||||
if (isUnconfiguredPanel) {
|
||||
return PANEL_STATES.UNCONFIGURED_PANEL;
|
||||
}
|
||||
|
||||
if (isNewPanel) {
|
||||
return PANEL_STATES.NEW_PANEL;
|
||||
}
|
||||
|
||||
return PANEL_STATES.EXISTING_PANEL;
|
||||
}, [isUnconfiguredPanel, isNewPanel]);
|
||||
|
||||
const suggestionsByVizType = useMemo(() => {
|
||||
const meta = getAllPanelPluginMeta();
|
||||
const record: Record<string, PanelPluginMeta> = {};
|
||||
@@ -96,22 +110,36 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s
|
||||
}, [suggestions]);
|
||||
|
||||
const applySuggestion = useCallback(
|
||||
(suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => {
|
||||
(suggestion: PanelPluginVisualizationSuggestion, isPreview: boolean, isAutoSelected = false) => {
|
||||
if (isPreview) {
|
||||
VizSuggestionsInteractions.suggestionPreviewed({
|
||||
pluginId: suggestion.pluginId,
|
||||
suggestionName: suggestion.name,
|
||||
panelState,
|
||||
isAutoSelected,
|
||||
});
|
||||
|
||||
setSuggestionHash(suggestion.hash);
|
||||
} else {
|
||||
VizSuggestionsInteractions.suggestionAccepted({
|
||||
pluginId: suggestion.pluginId,
|
||||
suggestionName: suggestion.name,
|
||||
panelState,
|
||||
});
|
||||
}
|
||||
|
||||
onChange(
|
||||
{
|
||||
pluginId: suggestion.pluginId,
|
||||
options: suggestion.options,
|
||||
fieldConfig: suggestion.fieldConfig,
|
||||
withModKey: isPreview,
|
||||
fromSuggestions: true,
|
||||
},
|
||||
isPreview ? editPreview : undefined
|
||||
);
|
||||
|
||||
if (isPreview) {
|
||||
setSuggestionHash(suggestion.hash);
|
||||
}
|
||||
},
|
||||
[onChange, editPreview]
|
||||
[onChange, editPreview, panelState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -124,7 +152,7 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s
|
||||
// the previously selected suggestion is no longer present in the list.
|
||||
const newFirstCardHash = suggestions?.[0]?.hash ?? null;
|
||||
if (firstCardHash !== newFirstCardHash || suggestions.every((s) => s.hash !== suggestionHash)) {
|
||||
applySuggestion(suggestions[0], true);
|
||||
applySuggestion(suggestions[0], true, true);
|
||||
setFirstCardHash(newFirstCardHash);
|
||||
return;
|
||||
}
|
||||
@@ -243,7 +271,7 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s
|
||||
suggestion={suggestion}
|
||||
width={width}
|
||||
tabIndex={index}
|
||||
onClick={() => applySuggestion(suggestion)}
|
||||
onClick={() => applySuggestion(suggestion, false)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
|
||||
export const PANEL_STATES = {
|
||||
UNCONFIGURED_PANEL: 'unconfigured_panel',
|
||||
NEW_PANEL: 'new_panel',
|
||||
EXISTING_PANEL: 'existing_panel',
|
||||
} as const;
|
||||
|
||||
export type PanelState = (typeof PANEL_STATES)[keyof typeof PANEL_STATES];
|
||||
|
||||
export const VizSuggestionsInteractions = {
|
||||
suggestionPreviewed: (properties: {
|
||||
pluginId: string;
|
||||
suggestionName: string;
|
||||
panelState: PanelState;
|
||||
isAutoSelected?: boolean;
|
||||
}) => {
|
||||
reportVizSuggestionsInteraction('suggestion_previewed', properties);
|
||||
},
|
||||
|
||||
suggestionAccepted: (properties: { pluginId: string; suggestionName: string; panelState: PanelState }) => {
|
||||
reportVizSuggestionsInteraction('suggestion_accepted', properties);
|
||||
},
|
||||
};
|
||||
|
||||
const reportVizSuggestionsInteraction = (name: string, properties?: Record<string, unknown>) => {
|
||||
reportInteraction(`grafana_viz_suggestions_${name}`, properties);
|
||||
};
|
||||
@@ -5,4 +5,5 @@ export interface VizTypeChangeDetails {
|
||||
options?: Record<string, unknown>;
|
||||
fieldConfig?: FieldConfigSource;
|
||||
withModKey?: boolean;
|
||||
fromSuggestions?: boolean;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
VisualizationSuggestion,
|
||||
VisualizationSuggestionScore,
|
||||
} from '@grafana/data';
|
||||
import { ReduceDataOptions } from '@grafana/schema';
|
||||
import { LegendDisplayMode, ReduceDataOptions, VizLegendOptions } from '@grafana/schema';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -62,3 +62,15 @@ export function defaultNumericVizOptions(
|
||||
export function hasData(data?: PanelData): boolean {
|
||||
return Boolean(data && data.series && data.series.length > 0 && data.series.some((frame) => frame.length > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Hidden legend config for previewing suggestion cards.
|
||||
* This should only be used in previewModifier.
|
||||
*/
|
||||
export const SUGGESTIONS_LEGEND_OPTIONS: VizLegendOptions = {
|
||||
calcs: [],
|
||||
displayMode: LegendDisplayMode.Hidden,
|
||||
placement: 'right',
|
||||
showLegend: false,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { getBackendSrv, config } from '@grafana/runtime';
|
||||
|
||||
import { ScopesApiClient } from './ScopesApiClient';
|
||||
|
||||
// Mock the runtime dependencies
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
getBackendSrv: jest.fn(),
|
||||
config: {
|
||||
featureToggles: {
|
||||
useMultipleScopeNodesEndpoint: true,
|
||||
useScopeSingleNodeEndpoint: true,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/api-clients', () => ({
|
||||
getAPIBaseURL: jest.fn().mockReturnValue('/apis/scope.grafana.app/v0alpha1'),
|
||||
}));
|
||||
|
||||
describe('ScopesApiClient', () => {
|
||||
let apiClient: ScopesApiClient;
|
||||
let mockBackendSrv: jest.Mocked<{ get: jest.Mock }>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockBackendSrv = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
(getBackendSrv as jest.Mock).mockReturnValue(mockBackendSrv);
|
||||
apiClient = new ScopesApiClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchMultipleScopeNodes', () => {
|
||||
it('should fetch multiple nodes by names', async () => {
|
||||
const mockNodes = [
|
||||
{
|
||||
metadata: { name: 'node-1' },
|
||||
spec: { nodeType: 'container', title: 'Node 1', parentName: '' },
|
||||
},
|
||||
{
|
||||
metadata: { name: 'node-2' },
|
||||
spec: { nodeType: 'leaf', title: 'Node 2', parentName: 'node-1' },
|
||||
},
|
||||
];
|
||||
|
||||
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
|
||||
|
||||
const result = await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2']);
|
||||
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||
names: ['node-1', 'node-2'],
|
||||
});
|
||||
expect(result).toEqual(mockNodes);
|
||||
});
|
||||
|
||||
it('should return empty array when names array is empty', async () => {
|
||||
const result = await apiClient.fetchMultipleScopeNodes([]);
|
||||
|
||||
expect(mockBackendSrv.get).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when feature toggle is disabled', async () => {
|
||||
config.featureToggles.useMultipleScopeNodesEndpoint = false;
|
||||
|
||||
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
|
||||
|
||||
expect(mockBackendSrv.get).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([]);
|
||||
|
||||
// Restore feature toggle
|
||||
config.featureToggles.useMultipleScopeNodesEndpoint = true;
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
mockBackendSrv.get.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle response with no items field', async () => {
|
||||
mockBackendSrv.get.mockResolvedValue({});
|
||||
|
||||
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle response with null items', async () => {
|
||||
mockBackendSrv.get.mockResolvedValue({ items: null });
|
||||
|
||||
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle large arrays of node names', async () => {
|
||||
const names = Array.from({ length: 100 }, (_, i) => `node-${i}`);
|
||||
const mockNodes = names.map((name) => ({
|
||||
metadata: { name },
|
||||
spec: { nodeType: 'leaf', title: name, parentName: '' },
|
||||
}));
|
||||
|
||||
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
|
||||
|
||||
const result = await apiClient.fetchMultipleScopeNodes(names);
|
||||
|
||||
expect(result).toEqual(mockNodes);
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||
names,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through node names exactly as provided', async () => {
|
||||
const names = ['node-with-special-chars_123', 'node.with.dots', 'node-with-dashes'];
|
||||
mockBackendSrv.get.mockResolvedValue({ items: [] });
|
||||
|
||||
await apiClient.fetchMultipleScopeNodes(names);
|
||||
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||
names,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchScopeNode', () => {
|
||||
it('should fetch a single scope node by ID', async () => {
|
||||
const mockNode = {
|
||||
metadata: { name: 'test-node' },
|
||||
spec: { nodeType: 'leaf', title: 'Test Node', parentName: 'parent' },
|
||||
};
|
||||
|
||||
mockBackendSrv.get.mockResolvedValue(mockNode);
|
||||
|
||||
const result = await apiClient.fetchScopeNode('test-node');
|
||||
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopenodes/test-node');
|
||||
expect(result).toEqual(mockNode);
|
||||
});
|
||||
|
||||
it('should return undefined when feature toggle is disabled', async () => {
|
||||
config.featureToggles.useScopeSingleNodeEndpoint = false;
|
||||
|
||||
const result = await apiClient.fetchScopeNode('test-node');
|
||||
|
||||
expect(mockBackendSrv.get).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
// Restore feature toggle
|
||||
config.featureToggles.useScopeSingleNodeEndpoint = true;
|
||||
});
|
||||
|
||||
it('should return undefined on API error', async () => {
|
||||
mockBackendSrv.get.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
const result = await apiClient.fetchScopeNode('non-existent');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchNodes', () => {
|
||||
it('should fetch nodes with parent filter', async () => {
|
||||
const mockNodes = [
|
||||
{
|
||||
metadata: { name: 'child-1' },
|
||||
spec: { nodeType: 'leaf', title: 'Child 1', parentName: 'parent' },
|
||||
},
|
||||
];
|
||||
|
||||
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
|
||||
|
||||
const result = await apiClient.fetchNodes({ parent: 'parent' });
|
||||
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||
parent: 'parent',
|
||||
query: undefined,
|
||||
limit: 1000,
|
||||
});
|
||||
expect(result).toEqual(mockNodes);
|
||||
});
|
||||
|
||||
it('should fetch nodes with query filter', async () => {
|
||||
const mockNodes = [
|
||||
{
|
||||
metadata: { name: 'matching-node' },
|
||||
spec: { nodeType: 'leaf', title: 'Matching Node', parentName: '' },
|
||||
},
|
||||
];
|
||||
|
||||
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
|
||||
|
||||
const result = await apiClient.fetchNodes({ query: 'matching' });
|
||||
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||
parent: undefined,
|
||||
query: 'matching',
|
||||
limit: 1000,
|
||||
});
|
||||
expect(result).toEqual(mockNodes);
|
||||
});
|
||||
|
||||
it('should respect custom limit', async () => {
|
||||
mockBackendSrv.get.mockResolvedValue({ items: [] });
|
||||
|
||||
await apiClient.fetchNodes({ limit: 50 });
|
||||
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||
parent: undefined,
|
||||
query: undefined,
|
||||
limit: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid limit (too small)', async () => {
|
||||
await expect(apiClient.fetchNodes({ limit: 0 })).rejects.toThrow('Limit must be between 1 and 10000');
|
||||
});
|
||||
|
||||
it('should throw error for invalid limit (too large)', async () => {
|
||||
await expect(apiClient.fetchNodes({ limit: 10001 })).rejects.toThrow('Limit must be between 1 and 10000');
|
||||
});
|
||||
|
||||
it('should use default limit of 1000 when not specified', async () => {
|
||||
mockBackendSrv.get.mockResolvedValue({ items: [] });
|
||||
|
||||
await apiClient.fetchNodes({});
|
||||
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||
parent: undefined,
|
||||
query: undefined,
|
||||
limit: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array on API error', async () => {
|
||||
mockBackendSrv.get.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const result = await apiClient.fetchNodes({ parent: 'test' });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchScope', () => {
|
||||
it('should fetch a scope by name', async () => {
|
||||
const mockScope = {
|
||||
metadata: { name: 'test-scope' },
|
||||
spec: {
|
||||
title: 'Test Scope',
|
||||
filters: [],
|
||||
},
|
||||
};
|
||||
|
||||
mockBackendSrv.get.mockResolvedValue(mockScope);
|
||||
|
||||
const result = await apiClient.fetchScope('test-scope');
|
||||
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopes/test-scope');
|
||||
expect(result).toEqual(mockScope);
|
||||
});
|
||||
|
||||
it('should return undefined on error', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
mockBackendSrv.get.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
const result = await apiClient.fetchScope('non-existent');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should log error to console', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
const error = new Error('Not found');
|
||||
mockBackendSrv.get.mockRejectedValue(error);
|
||||
|
||||
await apiClient.fetchScope('non-existent');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(error);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchMultipleScopes', () => {
|
||||
it('should fetch multiple scopes in parallel', async () => {
|
||||
const mockScopes = [
|
||||
{
|
||||
metadata: { name: 'scope-1' },
|
||||
spec: { title: 'Scope 1', filters: [] },
|
||||
},
|
||||
{
|
||||
metadata: { name: 'scope-2' },
|
||||
spec: { title: 'Scope 2', filters: [] },
|
||||
},
|
||||
];
|
||||
|
||||
mockBackendSrv.get.mockResolvedValueOnce(mockScopes[0]).mockResolvedValueOnce(mockScopes[1]);
|
||||
|
||||
const result = await apiClient.fetchMultipleScopes(['scope-1', 'scope-2']);
|
||||
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual(mockScopes);
|
||||
});
|
||||
|
||||
it('should filter out undefined scopes', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
const mockScope = {
|
||||
metadata: { name: 'scope-1' },
|
||||
spec: { title: 'Scope 1', filters: [] },
|
||||
};
|
||||
|
||||
mockBackendSrv.get.mockResolvedValueOnce(mockScope).mockRejectedValueOnce(new Error('Not found'));
|
||||
|
||||
const result = await apiClient.fetchMultipleScopes(['scope-1', 'non-existent']);
|
||||
|
||||
expect(result).toEqual([mockScope]);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return empty array when no scopes provided', async () => {
|
||||
const result = await apiClient.fetchMultipleScopes([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockBackendSrv.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance considerations', () => {
|
||||
it('should make single batched request with fetchMultipleScopeNodes', async () => {
|
||||
mockBackendSrv.get.mockResolvedValue({ items: [] });
|
||||
|
||||
await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2', 'node-3', 'node-4', 'node-5']);
|
||||
|
||||
// Should make exactly 1 API call
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should make N sequential requests with fetchScopeNode (old pattern)', async () => {
|
||||
mockBackendSrv.get.mockResolvedValue({
|
||||
metadata: { name: 'test' },
|
||||
spec: { nodeType: 'leaf', title: 'Test', parentName: '' },
|
||||
});
|
||||
|
||||
// Simulate old pattern of fetching nodes one by one
|
||||
await Promise.all([
|
||||
apiClient.fetchScopeNode('node-1'),
|
||||
apiClient.fetchScopeNode('node-2'),
|
||||
apiClient.fetchScopeNode('node-3'),
|
||||
apiClient.fetchScopeNode('node-4'),
|
||||
apiClient.fetchScopeNode('node-5'),
|
||||
]);
|
||||
|
||||
// Should make 5 separate API calls
|
||||
expect(mockBackendSrv.get).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { ScopeSpecFilter } from '@grafana/data';
|
||||
import { LocationService } from '@grafana/runtime';
|
||||
|
||||
import { ScopesService } from './ScopesService';
|
||||
@@ -16,8 +17,20 @@ describe('ScopesService', () => {
|
||||
let locationService: jest.Mocked<LocationService>;
|
||||
let selectorStateSubscription:
|
||||
| ((
|
||||
state: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> },
|
||||
prevState: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> }
|
||||
state: {
|
||||
appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>;
|
||||
scopes?: Record<
|
||||
string,
|
||||
{ metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } }
|
||||
>;
|
||||
},
|
||||
prevState: {
|
||||
appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>;
|
||||
scopes?: Record<
|
||||
string,
|
||||
{ metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } }
|
||||
>;
|
||||
}
|
||||
) => void)
|
||||
| undefined;
|
||||
let dashboardsStateSubscription:
|
||||
@@ -274,9 +287,11 @@ describe('ScopesService', () => {
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
|
||||
scopes: {},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -298,9 +313,11 @@ describe('ScopesService', () => {
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1', parentNodeId: 'parent1' }],
|
||||
scopes: {},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -320,9 +337,11 @@ describe('ScopesService', () => {
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node2' }],
|
||||
scopes: {},
|
||||
},
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -344,9 +363,11 @@ describe('ScopesService', () => {
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1' }],
|
||||
scopes: {},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -370,15 +391,171 @@ describe('ScopesService', () => {
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
|
||||
scopes: {},
|
||||
},
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('defaultPath support', () => {
|
||||
it('should extract scope_node from defaultPath when available', () => {
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'old-node' }],
|
||||
scopes: {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
defaultPath: ['', 'parent-node', 'correct-node'],
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
// Should use 'correct-node' from defaultPath, not 'old-node' from appliedScopes
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
scopes: ['scope1'],
|
||||
scope_node: 'correct-node',
|
||||
scope_parent: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to scopeNodeId when defaultPath is not available', () => {
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }],
|
||||
scopes: {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
// Should fallback to scopeNodeId from appliedScopes
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
scopes: ['scope1'],
|
||||
scope_node: 'fallback-node',
|
||||
scope_parent: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty defaultPath gracefully', () => {
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }],
|
||||
scopes: {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
defaultPath: [],
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
// Should fallback to scopeNodeId when defaultPath is empty
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
scopes: ['scope1'],
|
||||
scope_node: 'fallback-node',
|
||||
scope_parent: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect changes in defaultPath-derived scopeNodeId', () => {
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1' }],
|
||||
scopes: {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
defaultPath: ['', 'parent', 'new-node'],
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1' }],
|
||||
scopes: {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
defaultPath: ['', 'parent', 'old-node'],
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Should detect the change in defaultPath-derived scopeNodeId
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
scopes: ['scope1'],
|
||||
scope_node: 'new-node',
|
||||
scope_parent: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should write navigation_scope to URL when navigationScope changes', () => {
|
||||
if (!dashboardsStateSubscription) {
|
||||
throw new Error('dashboardsStateSubscription not set');
|
||||
@@ -622,6 +799,30 @@ describe('ScopesService', () => {
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should use defaultPath for scope_node when enabling scopes', () => {
|
||||
selectorService.state.appliedScopes = [{ scopeId: 'scope1', scopeNodeId: 'old-node' }];
|
||||
selectorService.state.scopes = {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
defaultPath: ['', 'parent', 'correct-node-from-defaultPath'],
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
service.setEnabled(true);
|
||||
|
||||
// Should use defaultPath instead of scopeNodeId from appliedScopes
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope_node: 'correct-node-from-defaultPath',
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('back/forward navigation handling', () => {
|
||||
|
||||
@@ -151,12 +151,26 @@ export class ScopesService implements ScopesContextValue {
|
||||
// Update the URL based on change in the scopes state
|
||||
this.subscriptions.push(
|
||||
selectorService.subscribeToState((state, prevState) => {
|
||||
const oldScopeNodeId = prevState.appliedScopes[0]?.scopeNodeId;
|
||||
const newScopeNodeId = state.appliedScopes[0]?.scopeNodeId;
|
||||
|
||||
const oldScopeNames = prevState.appliedScopes.map((scope) => scope.scopeId);
|
||||
const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId);
|
||||
|
||||
// Extract scopeNodeId from defaultPath when available
|
||||
const getScopeNodeId = (appliedScopes: typeof state.appliedScopes, scopes: typeof state.scopes) => {
|
||||
const firstScope = appliedScopes[0];
|
||||
if (!firstScope) {
|
||||
return undefined;
|
||||
}
|
||||
const scope = scopes[firstScope.scopeId];
|
||||
// Prefer defaultPath when available
|
||||
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
|
||||
return scope.spec.defaultPath[scope.spec.defaultPath.length - 1];
|
||||
}
|
||||
return firstScope.scopeNodeId;
|
||||
};
|
||||
|
||||
const oldScopeNodeId = getScopeNodeId(prevState.appliedScopes, prevState.scopes);
|
||||
const newScopeNodeId = getScopeNodeId(state.appliedScopes, state.scopes);
|
||||
|
||||
const scopesChanged = !isEqual(oldScopeNames, newScopeNames);
|
||||
const scopeNodeChanged = oldScopeNodeId !== newScopeNodeId;
|
||||
|
||||
@@ -230,7 +244,7 @@ export class ScopesService implements ScopesContextValue {
|
||||
if (this.state.enabled !== enabled) {
|
||||
this.updateState({ enabled });
|
||||
if (enabled) {
|
||||
const scopeNodeId = this.selectorService.state.appliedScopes[0]?.scopeNodeId;
|
||||
const scopeNodeId = this.getScopeNodeIdForUrl();
|
||||
this.locationService.partial(
|
||||
{
|
||||
scopes: this.selectorService.state.appliedScopes.map((s) => s.scopeId),
|
||||
@@ -243,6 +257,29 @@ export class ScopesService implements ScopesContextValue {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the scopeNodeId for URL syncing, preferring defaultPath when available.
|
||||
* When a scope has defaultPath, that is the source of truth for the node ID.
|
||||
* @private
|
||||
*/
|
||||
private getScopeNodeIdForUrl(): string | undefined {
|
||||
const firstScope = this.selectorService.state.appliedScopes[0];
|
||||
if (!firstScope) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scope = this.selectorService.state.scopes[firstScope.scopeId];
|
||||
|
||||
// Prefer scopeNodeId from defaultPath if available (most reliable source)
|
||||
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
|
||||
// Extract scopeNodeId from the last element of defaultPath
|
||||
return scope.spec.defaultPath[scope.spec.defaultPath.length - 1];
|
||||
}
|
||||
|
||||
// Fallback to next in priority order: scopeNodeId from appliedScopes
|
||||
return firstScope.scopeNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns observable that emits when relevant parts of the selectorService state change.
|
||||
* @private
|
||||
|
||||
@@ -31,13 +31,33 @@ export function ScopesInput({
|
||||
onInputClick,
|
||||
onRemoveAllClick,
|
||||
}: ScopesInputProps) {
|
||||
const scopeNodeId = appliedScopes[0]?.scopeNodeId;
|
||||
const firstScope = appliedScopes[0];
|
||||
const scope = scopes[firstScope?.scopeId];
|
||||
const styles = useStyles2(getStyles);
|
||||
const parentNodeIdFromRecentScopes = appliedScopes[0]?.parentNodeId; // This is only set from recent scopes TODO: remove after recent scopes refactor
|
||||
|
||||
// Prefer scopeNodeId from defaultPath if available (most reliable source)
|
||||
let scopeNodeId: string | undefined;
|
||||
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
|
||||
// Extract scopeNodeId from the last element of defaultPath
|
||||
scopeNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 1];
|
||||
} else {
|
||||
// Fallback to next in priority order: scopeNodeId from appliedScopes
|
||||
scopeNodeId = firstScope?.scopeNodeId;
|
||||
}
|
||||
|
||||
const { node: scopeNode, isLoading: scopeNodeLoading } = useScopeNode(scopeNodeId);
|
||||
|
||||
// Get parent from scope node if available, otherwise fallback to parent
|
||||
const parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes;
|
||||
// Prefer parentNodeId from defaultPath if available
|
||||
let parentNodeId: string | undefined;
|
||||
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 1) {
|
||||
// Extract parentNodeId from the second-to-last element of defaultPath
|
||||
parentNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 2];
|
||||
} else {
|
||||
// Fallback to parent from scope node or recent scopes
|
||||
const parentNodeIdFromRecentScopes = firstScope?.parentNodeId;
|
||||
parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes;
|
||||
}
|
||||
|
||||
const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId);
|
||||
|
||||
// Prioritize scope node subtitle over parent node title
|
||||
@@ -99,16 +119,31 @@ export function ScopesInput({
|
||||
);
|
||||
}
|
||||
|
||||
const getScopesPath = (appliedScopes: SelectedScope[], nodes: NodesMap) => {
|
||||
const getScopesPath = (
|
||||
appliedScopes: SelectedScope[],
|
||||
nodes: NodesMap,
|
||||
defaultPath?: string[]
|
||||
): string[] | undefined => {
|
||||
let nicePath: string[] | undefined;
|
||||
|
||||
if (appliedScopes.length > 0 && appliedScopes[0].scopeNodeId) {
|
||||
let path = getPathOfNode(appliedScopes[0].scopeNodeId, nodes);
|
||||
// Get reed of empty root section and the actual scope node
|
||||
path = path.slice(1, -1);
|
||||
if (appliedScopes.length > 0) {
|
||||
const firstScope = appliedScopes[0];
|
||||
|
||||
// We may not have all the nodes in path loaded
|
||||
nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p);
|
||||
// Prefer defaultPath from scope metadata
|
||||
if (defaultPath && defaultPath.length > 1) {
|
||||
// Get all nodes except the last one (which is the scope itself)
|
||||
const pathNodeIds = defaultPath.slice(0, -1);
|
||||
nicePath = pathNodeIds.map((nodeId) => nodes[nodeId]?.spec.title).filter((title) => title);
|
||||
}
|
||||
// Fallback to walking the node tree
|
||||
else if (firstScope.scopeNodeId) {
|
||||
let path = getPathOfNode(firstScope.scopeNodeId, nodes);
|
||||
// Get rid of empty root section and the actual scope node
|
||||
path = path.slice(1, -1);
|
||||
|
||||
// We may not have all the nodes in path loaded
|
||||
nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p);
|
||||
}
|
||||
}
|
||||
|
||||
return nicePath;
|
||||
@@ -127,7 +162,9 @@ function ScopesTooltip({ nodes, scopes, appliedScopes, onRemoveAllClick, disable
|
||||
return t('scopes.selector.input.tooltip', 'Select scope');
|
||||
}
|
||||
|
||||
const nicePath = getScopesPath(appliedScopes, nodes);
|
||||
const firstScope = appliedScopes[0];
|
||||
const scope = scopes[firstScope?.scopeId];
|
||||
const nicePath = getScopesPath(appliedScopes, nodes, scope?.spec.defaultPath);
|
||||
const scopeNames = appliedScopes.map((s) => {
|
||||
if (s.scopeNodeId) {
|
||||
return nodes[s.scopeNodeId]?.spec.title || s.scopeNodeId;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ import {
|
||||
treeNodeAtPath,
|
||||
} from './scopesTreeUtils';
|
||||
import { NodesMap, RecentScope, RecentScopeSchema, ScopeSchema, ScopesMap, SelectedScope, TreeNode } from './types';
|
||||
|
||||
export const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
|
||||
|
||||
export interface ScopesSelectorServiceState {
|
||||
@@ -101,22 +102,74 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
}
|
||||
};
|
||||
|
||||
private getNodePath = async (scopeNodeId: string): Promise<ScopeNode[]> => {
|
||||
private getNodePath = async (scopeNodeId: string, visited: Set<string> = new Set()): Promise<ScopeNode[]> => {
|
||||
// Protect against circular references
|
||||
if (visited.has(scopeNodeId)) {
|
||||
console.error('Circular reference detected in node path', scopeNodeId);
|
||||
return [];
|
||||
}
|
||||
|
||||
const node = await this.getScopeNode(scopeNodeId);
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Add current node to visited set
|
||||
const newVisited = new Set(visited);
|
||||
newVisited.add(scopeNodeId);
|
||||
|
||||
const parentPath =
|
||||
node.spec.parentName && node.spec.parentName !== '' ? await this.getNodePath(node.spec.parentName) : [];
|
||||
node.spec.parentName && node.spec.parentName !== ''
|
||||
? await this.getNodePath(node.spec.parentName, newVisited)
|
||||
: [];
|
||||
|
||||
return [...parentPath, node];
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the path to a scope node, preferring defaultPath from scope metadata.
|
||||
* This is the single source of truth for path resolution.
|
||||
*
|
||||
* TODO: Consider making this public and exposing via a hook to avoid duplication
|
||||
* with getScopesPath in ScopesInput.tsx
|
||||
*
|
||||
* @param scopeId - The scope ID to get the path for
|
||||
* @param scopeNodeId - Optional scope node ID to fall back to if no defaultPath
|
||||
* @returns Promise resolving to array of ScopeNode objects representing the path
|
||||
*/
|
||||
private async getPathForScope(scopeId: string, scopeNodeId?: string): Promise<ScopeNode[]> {
|
||||
// 1. Check if scope has defaultPath (preferred method)
|
||||
const scope = this.state.scopes[scopeId];
|
||||
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
|
||||
// Batch fetch all nodes in defaultPath
|
||||
return await this.getScopeNodes(scope.spec.defaultPath);
|
||||
}
|
||||
|
||||
// 2. Fall back to calculating path from scopeNodeId
|
||||
if (scopeNodeId) {
|
||||
return await this.getNodePath(scopeNodeId);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public resolvePathToRoot = async (
|
||||
scopeNodeId: string,
|
||||
tree: TreeNode
|
||||
tree: TreeNode,
|
||||
scopeId?: string
|
||||
): Promise<{ path: ScopeNode[]; tree: TreeNode }> => {
|
||||
const nodePath = await this.getNodePath(scopeNodeId);
|
||||
let nodePath: ScopeNode[];
|
||||
|
||||
// Check if scope has defaultPath for optimized resolution
|
||||
const scope = scopeId ? this.state.scopes[scopeId] : undefined;
|
||||
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
|
||||
// Use batch-fetched defaultPath (most efficient)
|
||||
nodePath = await this.getPathForScope(scopeId!, scopeNodeId);
|
||||
} else {
|
||||
// Fall back to node-based path resolution
|
||||
nodePath = await this.getNodePath(scopeNodeId);
|
||||
}
|
||||
|
||||
const newTree = insertPathNodesIntoTree(tree, nodePath);
|
||||
|
||||
this.updateState({ tree: newTree });
|
||||
@@ -207,16 +260,39 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
}
|
||||
|
||||
const newTree = modifyTreeNodeAtPath(this.state.tree, path, (treeNode) => {
|
||||
// Set parent query only when filtering within existing children
|
||||
treeNode.children = {};
|
||||
// Preserve existing children that have nested structure (from insertPathNodesIntoTree)
|
||||
const existingChildren = treeNode.children || {};
|
||||
const childrenToPreserve: Record<string, TreeNode> = {};
|
||||
|
||||
// Keep children that have a children property (object, not undefined)
|
||||
// This includes both empty objects {} (from path insertion) and populated ones
|
||||
for (const [key, child] of Object.entries(existingChildren)) {
|
||||
// Preserve if children is an object (not undefined)
|
||||
if (child.children !== undefined && typeof child.children === 'object') {
|
||||
childrenToPreserve[key] = child;
|
||||
}
|
||||
}
|
||||
|
||||
// Start with preserved children, then add/update with fetched children
|
||||
treeNode.children = { ...childrenToPreserve };
|
||||
|
||||
for (const node of childNodes) {
|
||||
treeNode.children[node.metadata.name] = {
|
||||
expanded: false,
|
||||
scopeNodeId: node.metadata.name,
|
||||
// Only set query on tree nodes if parent already has children (filtering vs first expansion). This is used for saerch highlighting.
|
||||
query: query || '',
|
||||
children: undefined,
|
||||
};
|
||||
// If this child was preserved, merge with fetched data
|
||||
if (childrenToPreserve[node.metadata.name]) {
|
||||
treeNode.children[node.metadata.name] = {
|
||||
...childrenToPreserve[node.metadata.name],
|
||||
// Update query but keep nested children
|
||||
query: query || '',
|
||||
};
|
||||
} else {
|
||||
// New child from API
|
||||
treeNode.children[node.metadata.name] = {
|
||||
expanded: false,
|
||||
scopeNodeId: node.metadata.name,
|
||||
query: query || '',
|
||||
children: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Set loaded to true if node is a container
|
||||
treeNode.childrenLoaded = true;
|
||||
@@ -356,16 +432,54 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
}
|
||||
|
||||
const newScopesState = { ...this.state.scopes };
|
||||
|
||||
// Validate API response is an array
|
||||
if (!Array.isArray(fetchedScopes)) {
|
||||
console.error('Expected fetchedScopes to be an array, got:', typeof fetchedScopes);
|
||||
this.updateState({ scopes: newScopesState, loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const scope of fetchedScopes) {
|
||||
newScopesState[scope.metadata.name] = scope;
|
||||
}
|
||||
|
||||
// If not provided, try to get the parent from the scope node
|
||||
// When selected from recent scopes, we don't have access to the scope node (if it hasn't been loaded), but we do have access to the parent node from local storage.
|
||||
const parentNodeId = scopes[0]?.parentNodeId ?? scopeNode?.spec.parentName;
|
||||
const parentNode = parentNodeId ? this.state.nodes[parentNodeId] : undefined;
|
||||
// Pre-fetch the first scope's defaultPath to improve performance
|
||||
// This makes the selector open instantly since all nodes are already cached
|
||||
// We only need the first scope since that's what's used for expansion
|
||||
const firstScope = fetchedScopes[0];
|
||||
if (firstScope?.spec.defaultPath && firstScope.spec.defaultPath.length > 0) {
|
||||
// Deduplicate and filter out already cached nodes
|
||||
const uniqueNodeIds = [...new Set(firstScope.spec.defaultPath)];
|
||||
const nodesToFetch = uniqueNodeIds.filter((nodeId) => !this.state.nodes[nodeId]);
|
||||
|
||||
this.addRecentScopes(fetchedScopes, parentNode, scopes[0]?.scopeNodeId);
|
||||
if (nodesToFetch.length > 0) {
|
||||
await this.getScopeNodes(nodesToFetch);
|
||||
}
|
||||
}
|
||||
|
||||
// Get scopeNode and parentNode, preferring defaultPath as the source of truth
|
||||
let parentNode: ScopeNode | undefined;
|
||||
let scopeNodeId: string | undefined;
|
||||
|
||||
if (firstScope?.spec.defaultPath && firstScope.spec.defaultPath.length > 1) {
|
||||
// Extract from defaultPath (most reliable source)
|
||||
// defaultPath format: ['', 'parent-id', 'scope-node-id', ...]
|
||||
scopeNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 1];
|
||||
const parentNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 2];
|
||||
|
||||
scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined;
|
||||
parentNode = parentNodeId && parentNodeId !== '' ? this.state.nodes[parentNodeId] : undefined;
|
||||
} else {
|
||||
// Fallback to next in priority order
|
||||
scopeNodeId = scopes[0]?.scopeNodeId;
|
||||
scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined;
|
||||
|
||||
const parentNodeId = scopes[0]?.parentNodeId ?? scopeNode?.spec.parentName;
|
||||
parentNode = parentNodeId ? this.state.nodes[parentNodeId] : undefined;
|
||||
}
|
||||
|
||||
this.addRecentScopes(fetchedScopes, parentNode, scopeNodeId);
|
||||
this.updateState({ scopes: newScopesState, loading: false });
|
||||
}
|
||||
};
|
||||
@@ -375,7 +489,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
// Check if we are currently on an active scope navigation
|
||||
const currentPath = locationService.getLocation().pathname;
|
||||
const activeScopeNavigation = this.dashboardsService.state.scopeNavigations.find((s) => {
|
||||
if (!('url' in s.spec) || typeof s.spec.url !== 'string') {
|
||||
if (!('url' in s.spec)) {
|
||||
return false;
|
||||
}
|
||||
return isCurrentPath(currentPath, s.spec.url);
|
||||
@@ -386,7 +500,6 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
!activeScopeNavigation &&
|
||||
scopeNode &&
|
||||
scopeNode.spec.redirectPath &&
|
||||
typeof scopeNode.spec.redirectPath === 'string' &&
|
||||
// Don't redirect if we're already on the target path
|
||||
!isCurrentPath(currentPath, scopeNode.spec.redirectPath)
|
||||
) {
|
||||
@@ -402,7 +515,6 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
if (
|
||||
firstScopeNavigation &&
|
||||
'url' in firstScopeNavigation.spec &&
|
||||
typeof firstScopeNavigation.spec.url === 'string' &&
|
||||
// Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support
|
||||
firstScopeNavigation.spec.url.includes('/d/') &&
|
||||
// Don't redirect if we're already on the target path
|
||||
@@ -462,13 +574,11 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
const recentScopes = parseScopesFromLocalStorage(content);
|
||||
|
||||
// Load parent nodes for recent scopes
|
||||
const parentNodes = Object.fromEntries(
|
||||
return Object.fromEntries(
|
||||
recentScopes
|
||||
.map((scopes) => [scopes[0]?.parentNode?.metadata?.name, scopes[0]?.parentNode])
|
||||
.filter(([key, parentNode]) => parentNode !== undefined && key !== undefined)
|
||||
);
|
||||
|
||||
return parentNodes;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -499,40 +609,42 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
let newTree = closeNodes(this.state.tree);
|
||||
|
||||
if (this.state.selectedScopes.length && this.state.selectedScopes[0].scopeNodeId) {
|
||||
let path = getPathOfNode(this.state.selectedScopes[0].scopeNodeId, this.state.nodes);
|
||||
|
||||
// Get node at path, and request it's children if they don't exist yet
|
||||
let nodeAtPath = treeNodeAtPath(newTree, path);
|
||||
|
||||
// In the cases where nodes are not in the tree yet
|
||||
if (!nodeAtPath) {
|
||||
try {
|
||||
const result = await this.resolvePathToRoot(this.state.selectedScopes[0].scopeNodeId, newTree);
|
||||
newTree = result.tree;
|
||||
// Update path to use the resolved path since nodes have been fetched
|
||||
path = result.path.map((n) => n.metadata.name);
|
||||
path.unshift('');
|
||||
nodeAtPath = treeNodeAtPath(newTree, path);
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve path to root', error);
|
||||
}
|
||||
}
|
||||
|
||||
// We have resolved to root, which means the parent node should be available
|
||||
let parentPath = path.slice(0, -1);
|
||||
let parentNodeAtPath = treeNodeAtPath(newTree, parentPath);
|
||||
|
||||
if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) {
|
||||
// This will update the tree with the children
|
||||
const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, '');
|
||||
newTree = newTreeWithChildren;
|
||||
}
|
||||
|
||||
// Expand the nodes to the selected scope - must be done after loading children
|
||||
try {
|
||||
newTree = expandNodes(newTree, parentPath);
|
||||
// Get the path for the selected scope, preferring defaultPath from scope metadata
|
||||
const pathNodes = await this.getPathForScope(
|
||||
this.state.selectedScopes[0].scopeId,
|
||||
this.state.selectedScopes[0].scopeNodeId
|
||||
);
|
||||
|
||||
if (pathNodes.length > 0) {
|
||||
// Convert to string path
|
||||
const stringPath = pathNodes.map((n) => n.metadata.name);
|
||||
stringPath.unshift(''); // Add root segment
|
||||
|
||||
// Check if nodes are in tree
|
||||
let nodeAtPath = treeNodeAtPath(newTree, stringPath);
|
||||
|
||||
// If nodes aren't in tree yet, insert them
|
||||
if (!nodeAtPath) {
|
||||
newTree = insertPathNodesIntoTree(newTree, pathNodes);
|
||||
// Update state so loadNodeChildren can see the inserted nodes
|
||||
this.updateState({ tree: newTree });
|
||||
}
|
||||
|
||||
// Load children of the parent node if needed to show all siblings
|
||||
const parentPath = stringPath.slice(0, -1);
|
||||
const parentNodeAtPath = treeNodeAtPath(newTree, parentPath);
|
||||
|
||||
if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) {
|
||||
const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, '');
|
||||
newTree = newTreeWithChildren;
|
||||
}
|
||||
|
||||
// Expand the nodes to show the selected scope
|
||||
newTree = expandNodes(newTree, parentPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to expand nodes', error);
|
||||
console.error('Failed to expand to selected scope', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,9 +692,14 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
// Get nodes that are not in the cache
|
||||
const nodesToFetch = scopeNodeNames.filter((name) => !nodesMap[name]);
|
||||
|
||||
const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch);
|
||||
for (const node of nodes) {
|
||||
nodesMap[node.metadata.name] = node;
|
||||
if (nodesToFetch.length > 0) {
|
||||
const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch);
|
||||
// Handle case where API returns undefined or non-array
|
||||
if (Array.isArray(nodes)) {
|
||||
for (const node of nodes) {
|
||||
nodesMap[node.metadata.name] = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newNodes = { ...this.state.nodes, ...nodesMap };
|
||||
|
||||
@@ -127,17 +127,27 @@ export const insertPathNodesIntoTree = (tree: TreeNode, path: ScopeNode[]) => {
|
||||
if (!childNodeName) {
|
||||
console.warn('Failed to insert full path into tree. Did not find child to' + stringPath[index]);
|
||||
treeNode.childrenLoaded = treeNode.childrenLoaded ?? false;
|
||||
return treeNode;
|
||||
return;
|
||||
}
|
||||
// Create node if it doesn't exist
|
||||
if (!treeNode.children[childNodeName]) {
|
||||
treeNode.children[childNodeName] = {
|
||||
expanded: false,
|
||||
scopeNodeId: childNodeName,
|
||||
query: '',
|
||||
children: {},
|
||||
childrenLoaded: false,
|
||||
};
|
||||
} else {
|
||||
// Node exists, ensure it has children object for nested insertion
|
||||
if (treeNode.children[childNodeName].children === undefined) {
|
||||
treeNode.children[childNodeName] = {
|
||||
...treeNode.children[childNodeName],
|
||||
children: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
treeNode.children[childNodeName] = {
|
||||
expanded: false,
|
||||
scopeNodeId: childNodeName,
|
||||
query: '',
|
||||
children: undefined,
|
||||
childrenLoaded: false,
|
||||
};
|
||||
treeNode.childrenLoaded = treeNode.childrenLoaded ?? false;
|
||||
return treeNode;
|
||||
});
|
||||
}
|
||||
return newTree;
|
||||
|
||||
@@ -105,7 +105,6 @@ describe('Selector', () => {
|
||||
// Lowercase because we don't have any backend that returns the correct case, then it falls back to the value in the URL
|
||||
expectScopesSelectorValue('grafana');
|
||||
await openSelector();
|
||||
//screen.debug(undefined, 100000);
|
||||
expectResultApplicationsGrafanaSelected();
|
||||
|
||||
jest.spyOn(locationService, 'getLocation').mockRestore();
|
||||
@@ -133,11 +132,12 @@ describe('Selector', () => {
|
||||
expectRecentScope('Grafana Applications');
|
||||
expectRecentScope('Grafana, Mimir Applications');
|
||||
await selectRecentScope('Grafana Applications');
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expectScopesSelectorValue('Grafana');
|
||||
|
||||
await openSelector();
|
||||
// Close to root node so we can see the recent scopes
|
||||
// Collapse tree to root level to see recent scopes section
|
||||
await expandResultApplications();
|
||||
|
||||
await expandRecentScopes();
|
||||
@@ -156,8 +156,8 @@ describe('Selector', () => {
|
||||
await applyScopes();
|
||||
|
||||
await openSelector();
|
||||
// Close to root node so we can try to see the recent scopes
|
||||
await expandResultApplications();
|
||||
// Tree expands to show selected scope, so recent scopes are not visible
|
||||
// (recent scopes only show at root level with tree collapsed)
|
||||
expectRecentScopeNotPresentInDocument();
|
||||
});
|
||||
|
||||
@@ -175,6 +175,7 @@ describe('Selector', () => {
|
||||
await applyScopes();
|
||||
|
||||
// Deselect all scopes
|
||||
await hoverSelector();
|
||||
await clearSelector();
|
||||
|
||||
// Recent scopes should still be available
|
||||
@@ -197,6 +198,7 @@ describe('Selector', () => {
|
||||
await selectResultApplicationsMimir();
|
||||
await applyScopes();
|
||||
|
||||
await hoverSelector();
|
||||
await clearSelector();
|
||||
|
||||
// Check recent scopes are updated
|
||||
|
||||
@@ -47,7 +47,7 @@ const type = async (selector: () => HTMLInputElement, value: string) => {
|
||||
export const updateScopes = async (service: ScopesService, scopes: string[]) =>
|
||||
act(async () => service.changeScopes(scopes));
|
||||
export const openSelector = async () => click(getSelectorInput);
|
||||
export const hoverSelector = async () => fireEvent.mouseOver(getSelectorInput());
|
||||
export const hoverSelector = async () => userEvent.hover(getSelectorInput());
|
||||
export const clearSelector = async () => click(getSelectorClear);
|
||||
export const applyScopes = async () => {
|
||||
await click(getSelectorApply);
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
VisualizationSuggestionsSupplier,
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { LegendDisplayMode } from '@grafana/schema';
|
||||
import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
|
||||
import { defaultNumericVizOptions, SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils';
|
||||
|
||||
import { PieChartLabels, Options, PieChartType } from './panelcfg.gen';
|
||||
|
||||
@@ -16,12 +15,13 @@ const withDefaults = (suggestion: VisualizationSuggestion<Options>): Visualizati
|
||||
defaultsDeep(suggestion, {
|
||||
options: {
|
||||
displayLabels: [PieChartLabels.Percent],
|
||||
legend: {
|
||||
calcs: [],
|
||||
displayMode: LegendDisplayMode.Hidden,
|
||||
placement: 'right',
|
||||
values: [],
|
||||
showLegend: false,
|
||||
},
|
||||
cardOptions: {
|
||||
previewModifier: (s) => {
|
||||
s.options!.legend = {
|
||||
...SUGGESTIONS_LEGEND_OPTIONS,
|
||||
values: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
} satisfies VisualizationSuggestion<Options>);
|
||||
|
||||
@@ -10,15 +10,9 @@ import {
|
||||
VisualizationSuggestionsSupplier,
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import {
|
||||
GraphDrawStyle,
|
||||
GraphFieldConfig,
|
||||
GraphGradientMode,
|
||||
LegendDisplayMode,
|
||||
LineInterpolation,
|
||||
StackingMode,
|
||||
} from '@grafana/schema';
|
||||
import { GraphDrawStyle, GraphFieldConfig, GraphGradientMode, LineInterpolation, StackingMode } from '@grafana/schema';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils';
|
||||
|
||||
import { Options } from './panelcfg.gen';
|
||||
|
||||
@@ -29,14 +23,6 @@ const withDefaults = (
|
||||
suggestion: VisualizationSuggestion<Options, GraphFieldConfig>
|
||||
): VisualizationSuggestion<Options, GraphFieldConfig> =>
|
||||
defaultsDeep(suggestion, {
|
||||
options: {
|
||||
legend: {
|
||||
calcs: [],
|
||||
displayMode: LegendDisplayMode.Hidden,
|
||||
placement: 'right',
|
||||
showLegend: false,
|
||||
},
|
||||
},
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
custom: {},
|
||||
@@ -46,6 +32,7 @@ const withDefaults = (
|
||||
cardOptions: {
|
||||
previewModifier: (s) => {
|
||||
s.options!.disableKeyboardEvents = true;
|
||||
s.options!.legend = SUGGESTIONS_LEGEND_OPTIONS;
|
||||
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
|
||||
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Field, FieldType, PanelPlugin, VisualizationSuggestionScore } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { GraphDrawStyle } from '@grafana/schema';
|
||||
import { commonOptionsBuilder, LegendDisplayMode } from '@grafana/ui';
|
||||
import { commonOptionsBuilder } from '@grafana/ui';
|
||||
import { optsWithHideZeros } from '@grafana/ui/internal';
|
||||
import { SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils';
|
||||
|
||||
import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config';
|
||||
|
||||
@@ -49,14 +50,6 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
|
||||
return [
|
||||
{
|
||||
score: VisualizationSuggestionScore.Good,
|
||||
options: {
|
||||
legend: {
|
||||
calcs: [],
|
||||
displayMode: LegendDisplayMode.Hidden,
|
||||
placement: 'right',
|
||||
showLegend: false,
|
||||
},
|
||||
},
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
custom: {},
|
||||
@@ -65,6 +58,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
|
||||
},
|
||||
cardOptions: {
|
||||
previewModifier: (s) => {
|
||||
s.options!.legend = SUGGESTIONS_LEGEND_OPTIONS;
|
||||
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
|
||||
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Role } from './accessControl';
|
||||
export interface OrgUser extends WithAccessControlMetadata {
|
||||
avatarUrl: string;
|
||||
email: string;
|
||||
created?: string;
|
||||
lastSeenAt: string;
|
||||
lastSeenAtAge: string;
|
||||
login: string;
|
||||
@@ -49,6 +50,7 @@ export interface UserDTO extends WithAccessControlMetadata {
|
||||
theme?: string;
|
||||
avatarUrl?: string;
|
||||
orgId?: number;
|
||||
created?: string;
|
||||
lastSeenAt?: string;
|
||||
lastSeenAtAge?: string;
|
||||
licensedRole?: string;
|
||||
|
||||
Generated
+8
@@ -8018,6 +8018,10 @@
|
||||
"avatarUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"created": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12938,6 +12942,10 @@
|
||||
"avatarUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"created": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user